Files
aletheia/frontend/src/app/scan/page.tsx

402 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
import Link from 'next/link';
export default function ScanPage() {
const [scanning, setScanning] = useState(false);
const [manualCode, setManualCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [notFound, setNotFound] = useState(false);
const [photoLoading, setPhotoLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [addedToList, setAddedToList] = useState(false);
const [newBadgeBanner, setNewBadgeBanner] = useState<string[]>([]);
const photoInputRef = useRef<HTMLInputElement>(null);
const { user, hydrate } = useAuthStore();
const router = useRouter();
const scannerRef = useRef<any>(null);
const scannerDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
hydrate();
const token = localStorage.getItem('token');
if (!token) router.push('/login');
}, []);
const startScanner = async () => {
setScanning(true); setError('');
try {
const { Html5Qrcode } = await import('html5-qrcode');
const scanner = new Html5Qrcode('scanner-view');
scannerRef.current = scanner;
await scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 150 } },
(decodedText) => { scanner.stop().catch(() => {}); setScanning(false); handleScan(decodedText); },
() => {}
);
} catch { setScanning(false); setError('Não foi possível acessar a câmera.'); }
};
const stopScanner = () => { scannerRef.current?.stop().catch(() => {}); setScanning(false); };
const handleScan = async (barcode: string) => {
setLoading(true); setError(''); setNotFound(false); setResult(null); setAddedToList(false);
try {
const data = await api.scan(barcode);
setResult(data);
if (data.new_badges?.length) { setNewBadgeBanner(data.new_badges); setTimeout(() => setNewBadgeBanner([]), 5000); }
} catch (err: any) {
if (err.message.includes('não encontrado')) setNotFound(true);
else setError(err.message);
} finally { setLoading(false); }
};
const handlePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return;
setPhotoLoading(true); setError(''); setNotFound(false);
try {
const data = await api.scanPhoto(file);
setResult(data);
if (data.new_badges?.length) { setNewBadgeBanner(data.new_badges); setTimeout(() => setNewBadgeBanner([]), 5000); }
} catch (err: any) { setError(err.message); }
finally { setPhotoLoading(false); }
};
const handleShare = () => {
if (!result) return;
const shareUrl = `${window.location.origin}/api/scan/${result.id}/share`;
const shareData = { title: `ALETHEIA: ${result.product_name}`, text: `Score: ${result.score}/100 - ${result.summary}`, url: shareUrl };
if (navigator.share) { navigator.share(shareData).catch(() => {}); }
else { navigator.clipboard.writeText(shareUrl); alert('Link copiado!'); }
};
const handleAddToList = async () => {
if (!result) return;
try {
await api.addToShoppingList(result.product_name || 'Produto', result.barcode);
setAddedToList(true);
} catch (e) {}
};
const getScoreLabel = (s: number) => {
if (s >= 90) return { label: 'Excelente', emoji: '🌟', desc: 'Alimento natural e saudável' };
if (s >= 70) return { label: 'Bom', emoji: '✅', desc: 'Saudável, com poucos aditivos' };
if (s >= 50) return { label: 'Regular', emoji: '⚠️', desc: 'Processado, consumir com moderação' };
if (s >= 30) return { label: 'Ruim', emoji: '🔶', desc: 'Ultraprocessado, vários aditivos' };
return { label: 'Péssimo', emoji: '🚫', desc: 'Muito prejudicial à saúde' };
};
const getScoreColor = (score: number) => score >= 71 ? '#10B981' : score >= 51 ? '#EAB308' : score >= 31 ? '#F97316' : '#EF4444';
const getClassColor = (c: string) => c === 'good' ? 'text-green-400' : c === 'warning' ? 'text-yellow-400' : 'text-red-400';
const getClassIcon = (c: string) => c === 'good' ? '🟢' : c === 'warning' ? '🟡' : '🔴';
const guessLevel = (nutrient: string, val: string) => {
const num = parseFloat(val) || 0;
if (nutrient === 'acucar') return num > 15 ? 'high' : num > 5 ? 'mid' : 'low';
if (nutrient === 'gordura_total' || nutrient === 'gordura_saturada') return num > 10 ? 'high' : num > 3 ? 'mid' : 'low';
if (nutrient === 'sodio') return num > 400 ? 'high' : num > 120 ? 'mid' : 'low';
if (nutrient === 'fibras') return num > 5 ? 'low' : num > 2 ? 'mid' : 'high';
if (nutrient === 'proteinas') return num > 10 ? 'low' : num > 3 ? 'mid' : 'high';
if (nutrient === 'calorias') return num > 300 ? 'high' : num > 150 ? 'mid' : 'low';
return 'mid';
};
const getNutritionBar = (label: string, value: string, level: string) => {
const barColor = level === 'low' ? 'bg-green-500' : level === 'mid' ? 'bg-yellow-500' : 'bg-red-500';
const pillColor = level === 'low' ? 'text-green-400 bg-green-500/10' : level === 'mid' ? 'text-yellow-400 bg-yellow-500/10' : 'text-red-400 bg-red-500/10';
const pillText = level === 'low' ? 'Baixo' : level === 'mid' ? 'Médio' : 'Alto';
const width = level === 'low' ? '25%' : level === 'mid' ? '55%' : '85%';
return (
<div key={label} className="mb-2">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-300">{label}</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">{value}</span>
<span className={'px-2 py-0.5 rounded-full text-[10px] ' + pillColor}>{pillText}</span>
</div>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div className={'h-full rounded-full transition-all duration-1000 ' + barColor} style={{ width }} />
</div>
</div>
);
};
// Result view
if (result) {
const color = getScoreColor(result.score);
const dashArray = result.score * 3.267 + ' 326.7';
const nutrition = result.nutrition || {};
const recipe = result.recipe;
const allergenAlerts = result.allergen_alerts || [];
const substitutions = result.substitutions || [];
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
{/* New badge banner */}
{newBadgeBanner.length > 0 && (
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-primary/20 to-accent/20 backdrop-blur-xl border-b border-primary/20 p-4 text-center animate-fade-up">
<p className="text-sm">🏆 Nova conquista: <span className="font-bold text-primary">{newBadgeBanner.join(', ')}</span></p>
</div>
)}
{/* Allergen Alert Banner */}
{allergenAlerts.length > 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-2xl p-4 mb-4 animate-pulse">
<h3 className="font-bold text-red-400 text-sm mb-2"> ALERTA DE ALÉRGENOS!</h3>
{allergenAlerts.map((a: any, i: number) => (
<p key={i} className="text-red-300 text-xs">🔴 <b>{a.ingredient}</b> contém <b>{a.allergy}</b></p>
))}
</div>
)}
<button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white"> Novo Scan</button>
{/* Score */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold mb-1">{result.product_name || 'Produto'}</h2>
{result.brand && <p className="text-gray-500 text-sm">{result.brand}</p>}
<div className="relative w-36 h-36 mx-auto mt-4">
<svg viewBox="0 0 120 120" className="w-full h-full -rotate-90">
<circle cx="60" cy="60" r="52" fill="none" stroke="#374151" strokeWidth="10" />
<circle cx="60" cy="60" r="52" fill="none" stroke={color} strokeWidth="10"
strokeDasharray={dashArray} strokeLinecap="round" className="transition-all duration-1000" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-black" style={{ color }}>{result.score}</span>
<span className="text-gray-500 text-xs">/100</span>
</div>
</div>
<div className="mt-3 bg-dark-light rounded-2xl px-4 py-3 inline-block">
<span className="text-lg">{getScoreLabel(result.score).emoji}</span>
<span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(result.score).label}</span>
</div>
</div>
{/* Summary */}
<div className="bg-dark-light rounded-2xl p-4 mb-4">
<p className="text-gray-300 text-sm leading-relaxed">{result.summary}</p>
</div>
{/* Nutrition */}
{Object.keys(nutrition).length > 0 && (
<div className="bg-dark-light rounded-2xl p-4 mb-4">
<h3 className="font-semibold mb-3 text-sm">📊 Informações Nutricionais</h3>
{result.nutrition_verdict && <p className="text-gray-400 text-xs mb-3 italic">{result.nutrition_verdict}</p>}
{nutrition.calorias && getNutritionBar('Calorias', nutrition.calorias, guessLevel('calorias', nutrition.calorias))}
{nutrition.acucar && getNutritionBar('Açúcar', nutrition.acucar, guessLevel('acucar', nutrition.acucar))}
{nutrition.gordura_total && getNutritionBar('Gordura Total', nutrition.gordura_total, guessLevel('gordura_total', nutrition.gordura_total))}
{nutrition.gordura_saturada && getNutritionBar('Gordura Saturada', nutrition.gordura_saturada, guessLevel('gordura_saturada', nutrition.gordura_saturada))}
{nutrition.sodio && getNutritionBar('Sódio', nutrition.sodio, guessLevel('sodio', nutrition.sodio))}
{nutrition.carboidratos && getNutritionBar('Carboidratos', nutrition.carboidratos, guessLevel('carboidratos', nutrition.carboidratos))}
{nutrition.fibras && getNutritionBar('Fibras', nutrition.fibras, guessLevel('fibras', nutrition.fibras))}
{nutrition.proteinas && getNutritionBar('Proteínas', nutrition.proteinas, guessLevel('proteinas', nutrition.proteinas))}
</div>
)}
{/* Positives & Negatives */}
<div className="grid grid-cols-2 gap-3 mb-4">
{result.positives?.length > 0 && (
<div className="bg-green-500/5 border border-green-500/20 rounded-xl p-3">
<h3 className="font-semibold text-green-400 text-xs mb-2"> Positivos</h3>
{result.positives.map((p: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>)}
</div>
)}
{result.negatives?.length > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-3">
<h3 className="font-semibold text-red-400 text-xs mb-2"> Negativos</h3>
{result.negatives.map((n: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>)}
</div>
)}
</div>
{/* Ingredients */}
{result.ingredients?.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold mb-3 text-sm">📋 Ingredientes</h3>
<div className="space-y-2">
{result.ingredients.map((ing: any, i: number) => (
<div key={i} className={`bg-dark-light rounded-xl p-3 ${ing.is_allergen ? 'border-2 border-red-500/50 animate-pulse' : ''}`}>
<div className="flex items-center gap-2 mb-1">
<span>{ing.is_allergen ? '🚨' : getClassIcon(ing.classification)}</span>
<span className={`font-medium text-sm ${ing.is_allergen ? 'text-red-400 font-bold' : getClassColor(ing.classification)}`}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ` (${ing.popular_name})` : ''}
{ing.is_allergen && ' ⚠️ ALÉRGENO'}
</span>
</div>
<p className="text-gray-400 text-xs ml-6">{ing.explanation}</p>
<p className="text-gray-500 text-xs ml-6 italic">{ing.reason}</p>
</div>
))}
</div>
</div>
)}
{/* Substitutions */}
{substitutions?.length > 0 && result.score < 50 && (
<div className="bg-gradient-to-br from-green-500/10 to-primary/10 border border-green-500/20 rounded-2xl p-4 mb-4">
<h3 className="font-semibold text-sm mb-3">🔄 Alternativas Mais Saudáveis</h3>
<div className="space-y-3">
{substitutions.map((sub: any, i: number) => (
<div key={i} className="bg-dark/40 rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-green-400">{sub.name}</span>
{sub.estimated_score && (
<span className="text-xs bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full">~{sub.estimated_score}</span>
)}
</div>
{sub.brand && <p className="text-gray-500 text-xs">{sub.brand}</p>}
<p className="text-gray-400 text-xs mt-1">{sub.reason}</p>
</div>
))}
</div>
</div>
)}
{/* Recipe */}
{recipe && (
<div className="bg-gradient-to-br from-primary/10 to-accent/10 border border-primary/20 rounded-2xl p-4 mb-4">
<h3 className="font-semibold mb-2 text-sm">🍳 {result.score > 70 ? 'Receita com este produto' : 'Alternativa Saudável'}</h3>
<h4 className="text-primary font-bold mb-1">{recipe.title}</h4>
<p className="text-gray-400 text-xs mb-3">{recipe.description}</p>
<div className="flex gap-3 mb-3">
{recipe.prep_time && <span className="text-xs bg-dark/50 px-2 py-1 rounded-lg"> {recipe.prep_time}</span>}
{recipe.calories && <span className="text-xs bg-dark/50 px-2 py-1 rounded-lg">🔥 {recipe.calories}</span>}
</div>
{recipe.ingredients_list && (
<div className="mb-3">
<p className="text-xs font-semibold text-gray-300 mb-1">Ingredientes:</p>
{recipe.ingredients_list.map((ing: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2"> {ing}</p>)}
</div>
)}
{recipe.steps && (
<div className="mb-3">
<p className="text-xs font-semibold text-gray-300 mb-1">Preparo:</p>
{recipe.steps.map((step: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2 mb-1">{i + 1}. {step}</p>)}
</div>
)}
{recipe.tip && <div className="bg-dark/30 rounded-lg p-2 mt-2"><p className="text-primary text-xs">💡 {recipe.tip}</p></div>}
</div>
)}
{/* Actions */}
<div className="flex gap-2 mb-4">
<button onClick={handleShare} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">📤 Compartilhar</button>
<button onClick={handleAddToList} disabled={addedToList}
className={`flex-1 py-3 rounded-xl font-bold ${addedToList ? 'bg-green-500/20 text-green-400' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}>
{addedToList ? '✓ Na lista' : '🛒 Adicionar'}
</button>
</div>
<button onClick={() => setResult(null)} className="w-full bg-dark-light text-white font-bold py-3 rounded-xl">📷 Novo Scan</button>
<BottomNav />
</div>
);
}
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<nav className="flex items-center justify-between mb-8">
<div className="flex items-center gap-2">
<span className="text-xl">👁</span>
<span className="font-bold tracking-wider text-primary">ALETHEIA</span>
</div>
<div className="flex gap-3">
<Link href="/compare" className="text-gray-400 text-sm hover:text-white">Comparar</Link>
<Link href="/achievements" className="text-gray-400 text-sm hover:text-white">🏆</Link>
</div>
</nav>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Escanear Produto</h1>
<p className="text-gray-400 text-sm">Aponte a câmera para o código de barras</p>
</div>
{error && <div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-xl mb-4">{error}</div>}
{notFound && !photoLoading && (
<div className="bg-orange-500/10 border border-orange-500/30 rounded-2xl p-6 mb-6 text-center">
<span className="text-4xl mb-3 block">🔍</span>
<h3 className="font-bold text-orange-400 mb-2">Produto não encontrado</h3>
<p className="text-gray-400 text-sm mb-4">Tire uma foto do <b>rótulo</b> e nossa IA analisa.</p>
<input type="file" accept="image/*" capture="environment" ref={photoInputRef} onChange={handlePhoto} className="hidden" />
<button onClick={() => photoInputRef.current?.click()}
className="w-full bg-gradient-to-r from-orange-500 to-amber-500 text-white py-4 rounded-xl font-bold text-lg">
📷 Fotografar Rótulo
</button>
<button onClick={() => setNotFound(false)} className="text-gray-500 text-xs mt-3 underline">Tentar outro código</button>
</div>
)}
{(photoLoading || loading) && (
<div className="text-center py-20">
<div className="animate-spin text-4xl mb-4">{photoLoading ? '📷' : '👁️'}</div>
<p className="text-gray-400">{photoLoading ? 'Analisando foto...' : 'Analisando produto...'}</p>
</div>
)}
{!loading && !notFound && !photoLoading && (
<>
<div className="mb-6">
{scanning ? (
<div>
<div id="scanner-view" ref={scannerDivRef} className="rounded-2xl overflow-hidden mb-4" />
<button onClick={stopScanner} className="w-full bg-red-500/20 text-red-400 py-3 rounded-xl font-semibold">Parar</button>
</div>
) : (
<div className="space-y-3">
<button onClick={startScanner} className="w-full bg-primary text-dark py-6 rounded-2xl font-bold text-xl hover:bg-primary-dark transition">
📷 Escanear Código de Barras
</button>
<div>
<input type="file" accept="image/*" capture="environment" ref={photoInputRef} onChange={handlePhoto} className="hidden" />
<button onClick={() => photoInputRef.current?.click()}
className="w-full bg-accent/20 text-accent py-4 rounded-2xl font-bold text-lg hover:bg-accent/30 transition">
📸 Fotografar Rótulo
</button>
</div>
</div>
)}
</div>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-700" /></div>
<div className="relative flex justify-center"><span className="bg-dark px-4 text-gray-500 text-sm">ou digite</span></div>
</div>
<div className="flex gap-2 mb-8">
<input type="text" placeholder="Ex: 7891000100103" value={manualCode} onChange={e => setManualCode(e.target.value)}
className="flex-1 bg-dark-light rounded-xl px-4 py-3 text-white placeholder-gray-500 outline-none focus:ring-2 focus:ring-primary"
onKeyDown={e => e.key === 'Enter' && manualCode && handleScan(manualCode)} />
<button onClick={() => manualCode && handleScan(manualCode)} disabled={!manualCode}
className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50">Buscar</button>
</div>
<div>
<p className="text-gray-500 text-sm mb-3">🧪 Teste rápido:</p>
<div className="flex flex-wrap gap-2">
{[
{ name: 'Coca-Cola', code: '7894900011517' },
{ name: 'Nescau', code: '7891000379691' },
{ name: 'Miojo', code: '7891079000212' },
{ name: 'Aveia', code: '7894321219820' },
].map(p => (
<button key={p.code} onClick={() => handleScan(p.code)}
className="bg-dark-light text-gray-300 px-3 py-1.5 rounded-lg text-xs hover:bg-gray-600 transition">{p.name}</button>
))}
</div>
</div>
</>
)}
<BottomNav />
</div>
);
}