v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav

This commit is contained in:
2026-02-10 18:52:42 -03:00
parent e8f4788a33
commit ecdd7546d3
33 changed files with 2105 additions and 309 deletions

View File

@@ -0,0 +1,78 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
export default function AchievementsPage() {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const { hydrate } = useAuthStore();
const router = useRouter();
useEffect(() => {
hydrate();
if (!localStorage.getItem('token')) { router.push('/login'); return; }
api.achievements().then(setData).finally(() => setLoading(false));
}, []);
if (loading) return <div className="min-h-screen bg-dark flex items-center justify-center text-gray-400">Carregando...</div>;
const achievements = data?.achievements || [];
const unlocked = achievements.filter((a: any) => a.unlocked).length;
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-2 text-center">🏆 Conquistas</h1>
<p className="text-center text-gray-400 text-sm mb-6">
{unlocked}/{achievements.length} desbloqueadas
</p>
{/* Progress bar */}
<div className="glass rounded-2xl p-4 mb-6">
<div className="flex justify-between text-xs mb-2">
<span className="text-gray-400">Progresso</span>
<span className="text-primary">{Math.round((unlocked / Math.max(achievements.length, 1)) * 100)}%</span>
</div>
<div className="h-3 bg-dark rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-primary to-accent rounded-full transition-all duration-1000"
style={{ width: `${(unlocked / Math.max(achievements.length, 1)) * 100}%` }} />
</div>
</div>
<div className="space-y-3">
{achievements.map((a: any) => {
const progress = Math.min(a.progress / a.target, 1);
return (
<div key={a.id}
className={`glass rounded-2xl p-5 transition-all ${a.unlocked ? 'border border-primary/20' : 'opacity-60'}`}>
<div className="flex items-center gap-4">
<span className={`text-4xl ${a.unlocked ? '' : 'grayscale'}`}>{a.emoji}</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-bold text-sm">{a.name}</h3>
{a.unlocked && <span className="text-xs text-primary"></span>}
</div>
<p className="text-gray-400 text-xs">{a.description}</p>
<div className="mt-2 h-1.5 bg-dark rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all duration-1000 ${a.unlocked ? 'bg-primary' : 'bg-gray-600'}`}
style={{ width: `${progress * 100}%` }} />
</div>
<p className="text-gray-500 text-[10px] mt-1">{a.progress}/{a.target}</p>
</div>
</div>
{a.unlocked && a.unlocked_at && (
<p className="text-gray-600 text-[10px] mt-2 text-right">
Desbloqueada em {new Date(a.unlocked_at).toLocaleDateString('pt-BR')}
</p>
)}
</div>
);
})}
</div>
<BottomNav />
</div>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
export default function ComparePage() {
const [scans, setScans] = useState<any[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [comparison, setComparison] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [comparing, setComparing] = useState(false);
const { hydrate } = useAuthStore();
const router = useRouter();
useEffect(() => {
hydrate();
if (!localStorage.getItem('token')) { router.push('/login'); return; }
api.history().then(setScans).finally(() => setLoading(false));
}, []);
const toggleSelect = (id: number) => {
setSelected(prev => {
if (prev.includes(id)) return prev.filter(x => x !== id);
if (prev.length >= 4) return prev;
return [...prev, id];
});
};
const handleCompare = async () => {
if (selected.length < 2) return;
setComparing(true);
try {
const data = await api.compare(selected);
setComparison(data);
} catch (e) {}
setComparing(false);
};
const getScoreColor = (s: number) => s >= 70 ? '#10B981' : s >= 50 ? '#EAB308' : s >= 30 ? '#F97316' : '#EF4444';
if (loading) return <div className="min-h-screen bg-dark flex items-center justify-center text-gray-400">Carregando...</div>;
// Comparison result view
if (comparison) {
const products = comparison.products || [];
const bestScore = Math.max(...products.map((p: any) => p.score));
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<button onClick={() => setComparison(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar</button>
<h1 className="text-xl font-bold mb-6 text-center"> Comparação</h1>
{/* Score comparison */}
<div className="glass rounded-2xl p-5 mb-4">
<h3 className="text-sm font-semibold mb-4">Score</h3>
{products.map((p: any) => (
<div key={p.scan_id} className="mb-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-300 truncate max-w-[60%]">{p.product_name}</span>
<span className="font-bold" style={{ color: getScoreColor(p.score) }}>
{p.score} {p.score === bestScore ? '👑' : ''}
</span>
</div>
<div className="h-3 bg-dark rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-1000"
style={{ width: `${p.score}%`, backgroundColor: getScoreColor(p.score) }} />
</div>
</div>
))}
</div>
{/* Nutrition comparison */}
<div className="glass rounded-2xl p-5 mb-4">
<h3 className="text-sm font-semibold mb-4">📊 Nutrição</h3>
{['calorias', 'acucar', 'gordura_total', 'sodio', 'fibras', 'proteinas'].map(key => {
const label = { calorias: 'Calorias', acucar: 'Açúcar', gordura_total: 'Gordura', sodio: 'Sódio', fibras: 'Fibras', proteinas: 'Proteínas' }[key] || key;
return (
<div key={key} className="mb-3">
<p className="text-xs text-gray-400 mb-1">{label}</p>
{products.map((p: any) => (
<div key={p.scan_id} className="flex justify-between text-xs mb-0.5">
<span className="text-gray-500 truncate max-w-[50%]">{p.product_name}</span>
<span className="text-gray-300">{p.nutrition?.[key] || 'N/A'}</span>
</div>
))}
</div>
);
})}
</div>
{/* Verdict */}
<div className="glass rounded-2xl p-5">
<h3 className="text-sm font-semibold mb-3">🏆 Veredito</h3>
{products.sort((a: any, b: any) => b.score - a.score).map((p: any, i: number) => (
<div key={p.scan_id} className="flex items-center gap-3 mb-2">
<span className="text-lg">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '4⃣'}</span>
<span className="text-sm flex-1">{p.product_name}</span>
<span className="font-bold" style={{ color: getScoreColor(p.score) }}>{p.score}</span>
</div>
))}
</div>
<BottomNav />
</div>
);
}
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-2 text-center"> Comparar</h1>
<p className="text-center text-gray-400 text-sm mb-6">Selecione 2-4 produtos do histórico</p>
{selected.length >= 2 && (
<button onClick={handleCompare} disabled={comparing}
className="w-full bg-primary text-dark py-4 rounded-2xl font-bold text-lg mb-4 hover:bg-primary-dark transition">
{comparing ? 'Comparando...' : `Comparar ${selected.length} produtos`}
</button>
)}
<div className="space-y-2">
{scans.map(s => {
const isSelected = selected.includes(s.id);
return (
<button key={s.id} onClick={() => toggleSelect(s.id)}
className={`w-full text-left p-4 rounded-xl flex items-center justify-between transition-all ${isSelected
? 'bg-primary/10 border border-primary/30'
: 'bg-dark-light border border-transparent hover:border-white/10'}`}>
<div className="flex items-center gap-3">
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${isSelected ? 'border-primary bg-primary' : 'border-gray-600'}`}>
{isSelected && <span className="text-dark text-xs font-bold"></span>}
</div>
<div>
<p className="font-medium text-sm">{s.product_name || s.barcode}</p>
{s.brand && <p className="text-gray-500 text-xs">{s.brand}</p>}
</div>
</div>
<span className="text-lg font-black" style={{ color: getScoreColor(s.score) }}>{s.score}</span>
</button>
);
})}
</div>
{scans.length === 0 && (
<div className="text-center py-16 text-gray-500">
<p className="text-4xl mb-3"></p>
<p>Escaneie produtos primeiro para poder comparar</p>
</div>
)}
<BottomNav />
</div>
);
}

View File

@@ -4,12 +4,14 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
export default function HistoryPage() {
const [scans, setScans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [addedToList, setAddedToList] = useState(false);
const { hydrate } = useAuthStore();
const router = useRouter();
@@ -20,27 +22,34 @@ export default function HistoryPage() {
}, []);
const openDetail = async (id: number) => {
setDetailLoading(true);
try {
const data = await api.scanDetail(id);
setDetail(data);
} catch { }
setDetailLoading(true); setAddedToList(false);
try { setDetail(await api.scanDetail(id)); } catch { }
setDetailLoading(false);
};
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 = (s: number) => s >= 71 ? '#10B981' : s >= 51 ? '#EAB308' : s >= 31 ? '#F97316' : '#EF4444';
const getScoreClass = (s: number) => s >= 71 ? 'text-green-400' : s >= 51 ? 'text-yellow-400' : s >= 31 ? 'text-orange-400' : 'text-red-400';
const getScoreLabel = (s: number) => {
if (s >= 90) return { label: 'Excelente', emoji: '🌟' };
if (s >= 70) return { label: 'Bom', emoji: '✅' };
if (s >= 50) return { label: 'Regular', emoji: '⚠️' };
if (s >= 30) return { label: 'Ruim', emoji: '🔶' };
return { label: 'Péssimo', emoji: '🚫' };
};
const getClassIcon = (c: string) => c === 'good' ? '🟢' : c === 'warning' ? '🟡' : '🔴';
const getClassColor = (c: string) => c === 'good' ? 'text-green-400' : c === 'warning' ? 'text-yellow-400' : 'text-red-400';
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';
@@ -62,15 +71,16 @@ export default function HistoryPage() {
);
};
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 handleShare = () => {
if (!detail) return;
const url = `${window.location.origin}/api/scan/${detail.id}/share`;
if (navigator.share) navigator.share({ title: `ALETHEIA: ${detail.product_name}`, text: `Score: ${detail.score}/100`, url });
else { navigator.clipboard.writeText(url); alert('Link copiado!'); }
};
const handleAddToList = async () => {
if (!detail) return;
try { await api.addToShoppingList(detail.product_name || 'Produto', detail.barcode); setAddedToList(true); } catch {}
};
// Detail view
@@ -79,15 +89,25 @@ export default function HistoryPage() {
const dashArray = detail.score * 3.267 + ' 326.7';
const nutrition = detail.nutrition || {};
const recipe = detail.recipe;
const allergenAlerts = detail.allergen_alerts || [];
const substitutions = detail.substitutions || [];
return (
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
<button onClick={() => setDetail(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar ao Histórico</button>
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<button onClick={() => setDetail(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar</button>
{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>
)}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold mb-1">{detail.product_name || 'Produto'}</h2>
{detail.brand && <p className="text-gray-500 text-sm">{detail.brand}</p>}
{detail.category && <p className="text-gray-600 text-xs mt-1">{detail.category}</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" />
@@ -103,120 +123,108 @@ export default function HistoryPage() {
<span className="text-lg">{getScoreLabel(detail.score).emoji}</span>
<span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(detail.score).label}</span>
</div>
<div className="flex justify-center gap-3 mt-3">
{detail.nutri_score && detail.nutri_score !== 'unknown' && <span className="text-xs bg-dark-light px-3 py-1 rounded-full">Nutri-Score: <b className="uppercase">{detail.nutri_score}</b></span>}
{detail.nova_group && <span className="text-xs bg-dark-light px-3 py-1 rounded-full">NOVA: <b>{detail.nova_group}</b></span>}
</div>
</div>
<div className="bg-dark-light rounded-2xl p-4 mb-4">
<h3 className="font-semibold text-sm mb-2" style={{ color }}>
{getScoreLabel(detail.score).emoji} Por que é {getScoreLabel(detail.score).label}?
</h3>
<p className="text-gray-300 text-sm leading-relaxed">{detail.summary}</p>
<p className="text-gray-500 text-xs mt-2 italic">{getScoreLabel(detail.score).desc}</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>
<h3 className="font-semibold mb-3 text-sm">📊 Nutrição</h3>
{detail.nutrition_verdict && <p className="text-gray-400 text-xs mb-3 italic">{detail.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">
{detail.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>
{detail.positives.map((p: string, i: number) => (
<p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>
))}
{detail.positives.map((p: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>)}
</div>
)}
{detail.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>
{detail.negatives.map((n: string, i: number) => (
<p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>
))}
{detail.negatives.map((n: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>)}
</div>
)}
</div>
{/* Ingredients */}
{detail.ingredients?.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold mb-3 text-sm">📋 Ingredientes</h3>
<div className="space-y-2">
{detail.ingredients.map((ing: any, i: number) => (
<div key={i} className="bg-dark-light rounded-xl p-3">
<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>{getClassIcon(ing.classification)}</span>
<span className={'font-medium text-sm ' + getClassColor(ing.classification)}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''}
<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.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>
)}
{/* 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">🍳 {detail.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>
<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>
<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>
{substitutions?.length > 0 && detail.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>
{substitutions.map((sub: any, i: number) => (
<div key={i} className="bg-dark/40 rounded-xl p-3 mb-2">
<div className="flex justify-between">
<span className="font-medium text-sm text-green-400">{sub.name}</span>
{sub.estimated_score && <span className="text-xs text-green-400">~{sub.estimated_score}</span>}
</div>
<p className="text-gray-400 text-xs mt-1">{sub.reason}</p>
</div>
)}
))}
</div>
)}
<p className="text-center text-gray-600 text-xs">
{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">🍳 {detail.score > 70 ? 'Receita' : '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>
{recipe.ingredients_list && recipe.ingredients_list.map((ing: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2"> {ing}</p>)}
{recipe.steps && recipe.steps.map((step: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2 mt-1">{i+1}. {step}</p>)}
</div>
)}
<div className="flex gap-2 mb-2">
<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'}`}>
{addedToList ? '✓ Na lista' : '🛒 Adicionar'}
</button>
</div>
<p className="text-center text-gray-600 text-xs mt-4">
Escaneado em {new Date(detail.scanned_at).toLocaleString('pt-BR')}
</p>
<BottomNav />
</div>
);
}
return (
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
<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">
<Link href="/scan" className="text-gray-400 hover:text-white"> Voltar</Link>
<Link href="/scan" className="text-gray-400 hover:text-white"> Scan</Link>
<span className="font-bold tracking-wider text-primary">Histórico</span>
<div />
<Link href="/compare" className="text-gray-400 text-sm hover:text-primary"> Comparar</Link>
</nav>
{loading ? (
@@ -247,12 +255,11 @@ export default function HistoryPage() {
{detailLoading && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="text-center">
<div className="animate-spin text-4xl mb-4">👁</div>
<p className="text-gray-300">Carregando detalhes...</p>
</div>
<div className="animate-spin text-4xl">👁</div>
</div>
)}
<BottomNav />
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
const ALLERGY_OPTIONS = ['Glúten', 'Lactose', 'Amendoim', 'Soja', 'Ovo', 'Frutos do Mar', 'Nozes', 'Corantes', 'Conservantes'];
const HEALTH_PROFILES = [
{ value: 'normal', label: '🧑 Normal', desc: 'Sem restrições' },
{ value: 'crianca', label: '👶 Criança', desc: 'Mais rigoroso com ultraprocessados' },
{ value: 'gestante', label: '🤰 Gestante', desc: 'Alerta cafeína e conservantes' },
{ value: 'diabetico', label: '💉 Diabético', desc: 'Foco em açúcares e carboidratos' },
{ value: 'hipertenso', label: '❤️ Hipertenso', desc: 'Foco em sódio' },
];
export default function ProfilePage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [profile, setProfile] = useState<any>(null);
const [allergies, setAllergies] = useState<string[]>([]);
const [healthProfile, setHealthProfile] = useState('normal');
const [saved, setSaved] = useState(false);
const { user, hydrate, logout } = useAuthStore();
const router = useRouter();
useEffect(() => {
hydrate();
if (!localStorage.getItem('token')) { router.push('/login'); return; }
api.getProfile().then(p => {
setProfile(p);
setAllergies(p.allergies || []);
setHealthProfile(p.health_profile || 'normal');
}).finally(() => setLoading(false));
}, []);
const toggleAllergy = (a: string) => {
setAllergies(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a]);
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
try {
await api.updateProfile({ allergies, health_profile: healthProfile });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {}
setSaving(false);
};
const handleLogout = () => {
logout();
router.push('/login');
};
if (loading) return <div className="min-h-screen bg-dark flex items-center justify-center text-gray-400">Carregando...</div>;
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">Meu Perfil</h1>
{/* User info */}
<div className="glass rounded-2xl p-6 mb-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center text-2xl font-bold">
{profile?.name?.charAt(0) || '?'}
</div>
<div>
<h2 className="font-bold text-lg">{profile?.name}</h2>
<p className="text-gray-400 text-sm">{profile?.email}</p>
{profile?.is_premium && (
<span className="text-xs text-accent bg-accent/10 px-2 py-0.5 rounded-full border border-accent/20"> Premium</span>
)}
</div>
</div>
</div>
{/* Health Profile */}
<div className="glass rounded-2xl p-6 mb-6">
<h3 className="font-semibold mb-4 text-sm">🏥 Perfil de Saúde</h3>
<p className="text-gray-400 text-xs mb-3">A IA adaptará alertas ao seu perfil</p>
<div className="space-y-2">
{HEALTH_PROFILES.map(hp => (
<button key={hp.value} onClick={() => { setHealthProfile(hp.value); setSaved(false); }}
className={`w-full text-left p-3 rounded-xl transition-all ${healthProfile === hp.value
? 'bg-primary/10 border border-primary/30'
: 'bg-white/5 border border-white/5 hover:border-white/10'}`}>
<span className="font-medium text-sm">{hp.label}</span>
<p className="text-gray-500 text-xs">{hp.desc}</p>
</button>
))}
</div>
</div>
{/* Allergies */}
<div className="glass rounded-2xl p-6 mb-6">
<h3 className="font-semibold mb-4 text-sm"> Alergias e Intolerâncias</h3>
<p className="text-gray-400 text-xs mb-3">Ingredientes perigosos serão destacados nos scans</p>
<div className="flex flex-wrap gap-2">
{ALLERGY_OPTIONS.map(a => (
<button key={a} onClick={() => toggleAllergy(a)}
className={`px-4 py-2 rounded-full text-sm transition-all ${allergies.includes(a)
? 'bg-red-500/20 border border-red-500/40 text-red-400'
: 'bg-white/5 border border-white/10 text-gray-400 hover:border-white/20'}`}>
{allergies.includes(a) ? '✓ ' : ''}{a}
</button>
))}
</div>
</div>
{/* Save */}
<button onClick={handleSave} disabled={saving}
className={`w-full py-4 rounded-2xl font-bold text-lg transition-all mb-4 ${saved
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-primary text-dark hover:bg-primary-dark'}`}>
{saving ? 'Salvando...' : saved ? '✓ Salvo!' : 'Salvar Alterações'}
</button>
{/* Logout */}
<button onClick={handleLogout} className="w-full py-3 rounded-xl bg-white/5 text-gray-400 hover:text-red-400 transition">
Sair da Conta
</button>
<BottomNav />
</div>
);
}

View File

@@ -3,6 +3,7 @@ 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() {
@@ -10,7 +11,12 @@ export default function ScanPage() {
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);
@@ -23,8 +29,7 @@ export default function ScanPage() {
}, []);
const startScanner = async () => {
setScanning(true);
setError('');
setScanning(true); setError('');
try {
const { Html5Qrcode } = await import('html5-qrcode');
const scanner = new Html5Qrcode('scanner-view');
@@ -32,36 +37,51 @@ export default function ScanPage() {
await scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 150 } },
(decodedText) => {
scanner.stop().catch(() => {});
setScanning(false);
handleScan(decodedText);
},
(decodedText) => { scanner.stop().catch(() => {}); setScanning(false); handleScan(decodedText); },
() => {}
);
} catch (err) {
setScanning(false);
setError('Não foi possível acessar a câmera. Use o código manual.');
}
} catch { setScanning(false); setError('Não foi possível acessar a câmera.'); }
};
const stopScanner = () => {
scannerRef.current?.stop().catch(() => {});
setScanning(false);
};
const stopScanner = () => { scannerRef.current?.stop().catch(() => {}); setScanning(false); };
const handleScan = async (barcode: string) => {
setLoading(true);
setError('');
setResult(null);
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) {
setError(err.message);
} finally {
setLoading(false);
}
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) => {
@@ -72,23 +92,19 @@ export default function ScanPage() {
return { label: 'Péssimo', emoji: '🚫', desc: 'Muito prejudicial à saúde' };
};
const getScoreColor = (score: number) => {
if (score >= 71) return '#10B981';
if (score >= 51) return '#EAB308';
if (score >= 31) return '#F97316';
return '#EF4444';
};
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 getClassColor = (c: string) => {
if (c === 'good') return 'text-green-400';
if (c === 'warning') return 'text-yellow-400';
return 'text-red-400';
};
const getClassIcon = (c: string) => {
if (c === 'good') return '🟢';
if (c === 'warning') return '🟡';
return '🔴';
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) => {
@@ -112,26 +128,34 @@ export default function ScanPage() {
);
};
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'; // inverted: more fiber = better
if (nutrient === 'proteinas') return num > 10 ? 'low' : num > 3 ? 'mid' : 'high'; // inverted
if (nutrient === 'calorias') return num > 300 ? 'high' : num > 150 ? 'mid' : 'low';
return 'mid';
};
// 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 max-w-lg mx-auto">
<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 */}
@@ -153,30 +177,18 @@ export default function ScanPage() {
<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>
{result.nutri_score && result.nutri_score !== 'unknown' && (
<div className="flex justify-center gap-3 mt-3">
<span className="text-xs bg-dark-light px-3 py-1 rounded-full">Nutri-Score: <b className="uppercase">{result.nutri_score}</b></span>
{result.nova_group && <span className="text-xs bg-dark-light px-3 py-1 rounded-full">NOVA: <b>{result.nova_group}</b></span>}
</div>
)}
</div>
{/* Why this score */}
{/* Summary */}
<div className="bg-dark-light rounded-2xl p-4 mb-4">
<h3 className="font-semibold text-sm mb-2" style={{ color }}>
{getScoreLabel(result.score).emoji} Por que é {getScoreLabel(result.score).label}?
</h3>
<p className="text-gray-300 text-sm leading-relaxed">{result.summary}</p>
<p className="text-gray-500 text-xs mt-2 italic">{getScoreLabel(result.score).desc}</p>
</div>
{/* Nutrition Table */}
{/* 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>
)}
{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))}
@@ -193,17 +205,13 @@ export default function ScanPage() {
{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>
))}
{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>
))}
{result.negatives.map((n: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>)}
</div>
)}
</div>
@@ -214,11 +222,12 @@ export default function ScanPage() {
<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">
<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>{getClassIcon(ing.classification)}</span>
<span className={'font-medium text-sm ' + getClassColor(ing.classification)}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''}
<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>
@@ -229,6 +238,27 @@ export default function ScanPage() {
</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">
@@ -239,65 +269,47 @@ export default function ScanPage() {
{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>
<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>
<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>
{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>
)}
{/* Score Legend */}
<div className="bg-dark-light rounded-2xl p-4 mb-4">
<h3 className="font-semibold mb-3 text-sm">📏 O que significa o Score?</h3>
<div className="space-y-2">
<div className="flex items-center gap-2"><span className="w-8 text-center">🌟</span><div className="flex-1"><div className="flex justify-between"><span className="text-green-400 text-xs font-bold">90-100 Excelente</span></div><div className="h-1 bg-green-500 rounded-full mt-0.5" style={{width:'100%'}} /></div></div>
<div className="flex items-center gap-2"><span className="w-8 text-center"></span><div className="flex-1"><div className="flex justify-between"><span className="text-green-300 text-xs font-bold">70-89 Bom</span></div><div className="h-1 bg-green-400 rounded-full mt-0.5" style={{width:'80%'}} /></div></div>
<div className="flex items-center gap-2"><span className="w-8 text-center"></span><div className="flex-1"><div className="flex justify-between"><span className="text-yellow-400 text-xs font-bold">50-69 Regular</span></div><div className="h-1 bg-yellow-500 rounded-full mt-0.5" style={{width:'60%'}} /></div></div>
<div className="flex items-center gap-2"><span className="w-8 text-center">🔶</span><div className="flex-1"><div className="flex justify-between"><span className="text-orange-400 text-xs font-bold">30-49 Ruim</span></div><div className="h-1 bg-orange-500 rounded-full mt-0.5" style={{width:'40%'}} /></div></div>
<div className="flex items-center gap-2"><span className="w-8 text-center">🚫</span><div className="flex-1"><div className="flex justify-between"><span className="text-red-400 text-xs font-bold">0-29 Péssimo</span></div><div className="h-1 bg-red-500 rounded-full mt-0.5" style={{width:'20%'}} /></div></div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<button onClick={() => {
if (navigator.share) {
navigator.share({ title: 'Aletheia: ' + result.product_name, text: 'Score: ' + result.score + '/100 - ' + result.summary, url: window.location.href });
}
}} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">
📤 Compartilhar
</button>
<button onClick={() => setResult(null)} className="flex-1 bg-dark-light text-white font-bold py-3 rounded-xl">
📷 Novo Scan
<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 max-w-lg mx-auto">
<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="/history" className="text-gray-400 text-sm hover:text-white">Histórico</Link>
<Link href="/premium" className="text-primary text-sm font-semibold">Premium</Link>
<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>
@@ -307,68 +319,83 @@ export default function ScanPage() {
</div>
{error && <div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-xl mb-4">{error}</div>}
{loading && (
<div className="text-center py-20">
<div className="animate-spin text-4xl mb-4">👁</div>
<p className="text-gray-400">Analisando produto...</p>
<p className="text-gray-600 text-xs mt-2">Nossa IA está analisando cada ingrediente</p>
{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>
)}
{!loading && (
{(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 && (
<>
{/* Camera Scanner */}
<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 Scanner
</button>
<button onClick={stopScanner} className="w-full bg-red-500/20 text-red-400 py-3 rounded-xl font-semibold">Parar</button>
</div>
) : (
<button onClick={startScanner} className="w-full bg-primary text-dark py-6 rounded-2xl font-bold text-xl hover:bg-primary-dark transition transform hover:scale-[1.02] active:scale-95">
📷 Escanear Código de Barras
</button>
<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>
{/* Manual Input */}
<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 o código</span></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">
<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>
className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50">Buscar</button>
</div>
{/* Quick Demo */}
<div className="mt-8">
<p className="text-gray-500 text-sm mb-3">🧪 Teste com produtos demo:</p>
<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' },
{ name: 'Oreo', code: '7622300830151' },
].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>
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>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
export default function ShoppingPage() {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [newItem, setNewItem] = useState('');
const [adding, setAdding] = useState(false);
const { hydrate } = useAuthStore();
const router = useRouter();
useEffect(() => {
hydrate();
if (!localStorage.getItem('token')) { router.push('/login'); return; }
loadItems();
}, []);
const loadItems = () => {
api.shoppingList().then(setItems).finally(() => setLoading(false));
};
const addItem = async () => {
if (!newItem.trim()) return;
setAdding(true);
try {
const item = await api.addToShoppingList(newItem.trim());
setItems(prev => [item, ...prev]);
setNewItem('');
} catch (e) {}
setAdding(false);
};
const deleteItem = async (id: number) => {
try {
await api.deleteShoppingItem(id);
setItems(prev => prev.filter(i => i.id !== id));
} catch (e) {}
};
const toggleItem = async (id: number) => {
try {
const res = await api.toggleShoppingItem(id);
setItems(prev => prev.map(i => i.id === id ? { ...i, checked: res.checked } : i));
} catch (e) {}
};
if (loading) return <div className="min-h-screen bg-dark flex items-center justify-center text-gray-400">Carregando...</div>;
const unchecked = items.filter(i => !i.checked);
const checked = items.filter(i => i.checked);
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">🛒 Lista de Compras</h1>
{/* Add item */}
<div className="flex gap-2 mb-6">
<input type="text" value={newItem} onChange={e => setNewItem(e.target.value)}
placeholder="Adicionar produto..."
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' && addItem()} />
<button onClick={addItem} disabled={adding || !newItem.trim()}
className="bg-primary text-dark px-5 py-3 rounded-xl font-bold disabled:opacity-50">+</button>
</div>
{items.length === 0 ? (
<div className="text-center py-16">
<p className="text-4xl mb-3">🛒</p>
<p className="text-gray-500">Lista vazia</p>
<p className="text-gray-600 text-xs mt-2">Adicione produtos acima ou pelo resultado do scan</p>
</div>
) : (
<>
{/* Active items */}
{unchecked.length > 0 && (
<div className="space-y-2 mb-6">
{unchecked.map(item => (
<div key={item.id} className="glass rounded-xl p-4 flex items-center gap-3">
<button onClick={() => toggleItem(item.id)}
className="w-6 h-6 rounded-full border-2 border-gray-600 hover:border-primary transition flex-shrink-0" />
<span className="flex-1 text-sm">{item.product_name}</span>
<button onClick={() => deleteItem(item.id)} className="text-gray-600 hover:text-red-400 text-lg">×</button>
</div>
))}
</div>
)}
{/* Checked items */}
{checked.length > 0 && (
<div>
<h3 className="text-xs text-gray-500 mb-2">Comprados ({checked.length})</h3>
<div className="space-y-2">
{checked.map(item => (
<div key={item.id} className="glass rounded-xl p-4 flex items-center gap-3 opacity-50">
<button onClick={() => toggleItem(item.id)}
className="w-6 h-6 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-dark text-xs font-bold"></span>
</button>
<span className="flex-1 text-sm line-through text-gray-500">{item.product_name}</span>
<button onClick={() => deleteItem(item.id)} className="text-gray-600 hover:text-red-400 text-lg">×</button>
</div>
))}
</div>
</div>
)}
</>
)}
<BottomNav />
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
export default function StatsPage() {
const [stats, setStats] = useState<any>(null);
const [evolution, setEvolution] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const { hydrate } = useAuthStore();
const router = useRouter();
useEffect(() => {
hydrate();
if (!localStorage.getItem('token')) { router.push('/login'); return; }
Promise.all([api.stats(), api.evolution()]).then(([s, e]) => {
setStats(s);
setEvolution(e.evolution || []);
}).finally(() => setLoading(false));
}, []);
const getScoreColor = (s: number) => s >= 70 ? '#10B981' : s >= 50 ? '#EAB308' : s >= 30 ? '#F97316' : '#EF4444';
if (loading) return <div className="min-h-screen bg-dark flex items-center justify-center text-gray-400">Carregando...</div>;
const maxEvo = Math.max(...evolution.map(e => e.avg_score), 100);
return (
<div className="min-h-screen bg-dark px-4 py-6 pb-24 max-w-lg mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">📊 Estatísticas</h1>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="glass rounded-2xl p-4 text-center">
<div className="text-3xl font-black text-primary">{stats?.total_scans || 0}</div>
<div className="text-xs text-gray-400 mt-1">Total Scans</div>
</div>
<div className="glass rounded-2xl p-4 text-center">
<div className="text-3xl font-black" style={{ color: getScoreColor(stats?.avg_score || 0) }}>
{stats?.avg_score || 0}
</div>
<div className="text-xs text-gray-400 mt-1">Score Médio</div>
</div>
<div className="glass rounded-2xl p-4 text-center">
<div className="text-3xl font-black text-accent">{stats?.monthly_scans || 0}</div>
<div className="text-xs text-gray-400 mt-1">Este Mês</div>
</div>
</div>
{/* Evolution Chart */}
{evolution.length > 1 && (
<div className="glass rounded-2xl p-5 mb-6">
<h3 className="font-semibold text-sm mb-4">📈 Evolução Semanal</h3>
<div className="flex items-end gap-1 h-32">
{evolution.map((e, i) => {
const height = (e.avg_score / maxEvo) * 100;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-400">{Math.round(e.avg_score)}</span>
<div className="w-full rounded-t-lg transition-all duration-500"
style={{ height: `${height}%`, backgroundColor: getScoreColor(e.avg_score), minHeight: '4px' }} />
<span className="text-[9px] text-gray-500">{e.week}</span>
</div>
);
})}
</div>
</div>
)}
{/* Best Products */}
{stats?.best?.length > 0 && (
<div className="glass rounded-2xl p-5 mb-6">
<h3 className="font-semibold text-sm mb-3">🏆 Melhores do Mês</h3>
<div className="space-y-2">
{stats.best.slice(0, 5).map((p: any, i: number) => (
<div key={p.id} className="flex items-center justify-between bg-white/5 rounded-xl p-3">
<div className="flex items-center gap-3">
<span className="text-lg">{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '•'}</span>
<div>
<p className="text-sm font-medium">{p.product_name || 'Produto'}</p>
<p className="text-xs text-gray-500">{p.brand || ''}</p>
</div>
</div>
<span className="text-lg font-black" style={{ color: getScoreColor(p.score) }}>{p.score}</span>
</div>
))}
</div>
</div>
)}
{/* Worst Products */}
{stats?.worst?.length > 0 && (
<div className="glass rounded-2xl p-5 mb-6">
<h3 className="font-semibold text-sm mb-3"> Piores do Mês</h3>
<div className="space-y-2">
{stats.worst.slice(0, 5).map((p: any, i: number) => (
<div key={p.id} className="flex items-center justify-between bg-white/5 rounded-xl p-3">
<div className="flex items-center gap-3">
<span className="text-lg">{i === 0 ? '💀' : i === 1 ? '☠️' : '⚠️'}</span>
<div>
<p className="text-sm font-medium">{p.product_name || 'Produto'}</p>
<p className="text-xs text-gray-500">{p.brand || ''}</p>
</div>
</div>
<span className="text-lg font-black" style={{ color: getScoreColor(p.score) }}>{p.score}</span>
</div>
))}
</div>
</div>
)}
{!stats?.total_scans && (
<div className="text-center py-10 text-gray-500">
<p className="text-4xl mb-3">📊</p>
<p>Escaneie produtos para ver estatísticas</p>
</div>
)}
<BottomNav />
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const NAV_ITEMS = [
{ href: '/scan', label: 'Scan', icon: '📷' },
{ href: '/history', label: 'Histórico', icon: '📋' },
{ href: '/stats', label: 'Stats', icon: '📊' },
{ href: '/shopping', label: 'Lista', icon: '🛒' },
{ href: '/profile', label: 'Perfil', icon: '👤' },
];
export default function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 backdrop-blur-xl bg-dark-card/80 border-t border-white/5 safe-bottom">
<div className="max-w-lg mx-auto flex justify-around py-2">
{NAV_ITEMS.map(item => {
const active = pathname === item.href || (item.href !== '/' && pathname?.startsWith(item.href));
return (
<Link key={item.href} href={item.href}
className={`flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-xl transition-all ${active ? 'text-primary' : 'text-gray-500 hover:text-gray-300'}`}>
<span className={`text-xl ${active ? 'scale-110' : ''} transition-transform`}>{item.icon}</span>
<span className="text-[10px] font-medium">{item.label}</span>
{active && <div className="w-1 h-1 rounded-full bg-primary" />}
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -22,8 +22,36 @@ export const api = {
login: (data: { email: string; password: string }) =>
request('/api/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => request('/api/auth/me'),
getProfile: () => request('/api/auth/profile'),
updateProfile: (data: { allergies?: string[]; health_profile?: string; name?: string }) =>
request('/api/auth/profile', { method: 'PUT', body: JSON.stringify(data) }),
scan: (barcode: string) =>
request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }),
scanPhoto: async (file: File) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_URL}/api/scan/photo`, { method: 'POST', headers, body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Erro ao analisar foto' }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
},
history: () => request("/api/history"),
scanDetail: (id: number) => request(`/api/history/${id}`),
compare: (scan_ids: number[]) =>
request('/api/compare', { method: 'POST', body: JSON.stringify({ scan_ids }) }),
stats: () => request('/api/stats'),
evolution: () => request('/api/stats/evolution'),
achievements: () => request('/api/achievements'),
shoppingList: () => request('/api/shopping-list'),
addToShoppingList: (product_name: string, barcode?: string) =>
request('/api/shopping-list', { method: 'POST', body: JSON.stringify({ product_name, barcode }) }),
deleteShoppingItem: (id: number) =>
request(`/api/shopping-list/${id}`, { method: 'DELETE' }),
toggleShoppingItem: (id: number) =>
request(`/api/shopping-list/${id}/toggle`, { method: 'PUT' }),
};