🏗️ Código fonte completo: PostgreSQL, nutrição, receita, score labels, PWA fixes

This commit is contained in:
2026-02-10 15:19:39 -03:00
parent ccef350294
commit 532cfe46e9
13 changed files with 517 additions and 55 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
.next/
venv/
__pycache__/
*.db
*.pyc
.env
dist/

View File

@@ -9,6 +9,17 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
"summary": "<resumo em 2-3 frases para leigo, em português>", "summary": "<resumo em 2-3 frases para leigo, em português>",
"positives": ["<ponto positivo 1>", ...], "positives": ["<ponto positivo 1>", ...],
"negatives": ["<ponto negativo 1>", ...], "negatives": ["<ponto negativo 1>", ...],
"nutrition": {
"calorias": "<valor por porção, ex: 139 kcal>",
"gordura_total": "<valor, ex: 8g>",
"gordura_saturada": "<valor, ex: 3g>",
"acucar": "<valor, ex: 37g>",
"sodio": "<valor, ex: 14mg>",
"fibras": "<valor, ex: 0g>",
"proteinas": "<valor, ex: 0g>",
"carboidratos": "<valor, ex: 35g>"
},
"nutrition_verdict": "<frase curta sobre o perfil nutricional, ex: Alto em açúcar, zero fibras>",
"ingredients": [ "ingredients": [
{ {
"name": "<nome no rótulo>", "name": "<nome no rótulo>",
@@ -17,9 +28,23 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
"classification": "<good|warning|bad>", "classification": "<good|warning|bad>",
"reason": "<motivo da classificação, 1 frase>" "reason": "<motivo da classificação, 1 frase>"
} }
] ],
"recipe": {
"title": "<nome da receita saudável>",
"description": "<descrição curta, 1-2 frases>",
"prep_time": "<tempo de preparo>",
"calories": "<calorias aproximadas da receita>",
"ingredients_list": ["<ingrediente 1>", "<ingrediente 2>", ...],
"steps": ["<passo 1>", "<passo 2>", ...],
"tip": "<dica nutricional relacionada>"
}
} }
Para a receita:
- Se o produto for SAUDÁVEL (score > 70): sugira uma receita usando o produto
- Se o produto for RUIM (score <= 70): sugira uma alternativa saudável que substitua o produto
- A receita deve ser simples, rápida e brasileira quando possível
Critérios para o score: Critérios para o score:
- 90-100: Alimento natural, minimamente processado, sem aditivos - 90-100: Alimento natural, minimamente processado, sem aditivos
- 70-89: Bom, com poucos aditivos ou processamento leve - 70-89: Bom, com poucos aditivos ou processamento leve
@@ -27,6 +52,7 @@ Critérios para o score:
- 30-49: Ruim, ultraprocessado com vários aditivos - 30-49: Ruim, ultraprocessado com vários aditivos
- 0-29: Péssimo, alto em açúcar/sódio/gordura trans, muitos aditivos - 0-29: Péssimo, alto em açúcar/sódio/gordura trans, muitos aditivos
Use os dados nutricionais fornecidos quando disponíveis. Estime quando não disponíveis.
Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos. Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos.
Seja direto e honesto. Use linguagem simples.""" Seja direto e honesto. Use linguagem simples."""
@@ -36,14 +62,18 @@ async def analyze_product(product_data: dict) -> dict:
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
nutrition_info = product_data.get('nutrition', {})
nutrition_str = json.dumps(nutrition_info, ensure_ascii=False) if nutrition_info else 'Não disponível'
user_msg = f"""Produto: {product_data.get('name', 'Desconhecido')} user_msg = f"""Produto: {product_data.get('name', 'Desconhecido')}
Marca: {product_data.get('brand', '')} Marca: {product_data.get('brand', '')}
Categoria: {product_data.get('category', '')} Categoria: {product_data.get('category', '')}
Ingredientes: {product_data.get('ingredients_text', 'Não disponível')} Ingredientes: {product_data.get('ingredients_text', 'Não disponível')}
Nutri-Score: {product_data.get('nutri_score', 'N/A')} Nutri-Score: {product_data.get('nutri_score', 'N/A')}
NOVA: {product_data.get('nova_group', 'N/A')} NOVA: {product_data.get('nova_group', 'N/A')}
Dados Nutricionais: {nutrition_str}
Analise este produto.""" Analise este produto com informações nutricionais detalhadas e sugira uma receita."""
try: try:
resp = await client.chat.completions.create( resp = await client.chat.completions.create(
@@ -54,7 +84,7 @@ Analise este produto."""
], ],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
temperature=0.3, temperature=0.3,
timeout=15, timeout=30,
) )
return json.loads(resp.choices[0].message.content) return json.loads(resp.choices[0].message.content)
except Exception as e: except Exception as e:
@@ -82,5 +112,8 @@ def _mock_analysis(product_data: dict) -> dict:
"summary": f"Análise baseada em regras para {product_data.get('name', 'este produto')}. Configure OPENAI_API_KEY para análise completa com IA.", "summary": f"Análise baseada em regras para {product_data.get('name', 'este produto')}. Configure OPENAI_API_KEY para análise completa com IA.",
"positives": ["Dados nutricionais disponíveis"], "positives": ["Dados nutricionais disponíveis"],
"negatives": ["Análise IA indisponível - usando fallback"], "negatives": ["Análise IA indisponível - usando fallback"],
"ingredients": [] "nutrition": {},
"nutrition_verdict": "",
"ingredients": [],
"recipe": None
} }

View File

@@ -14,4 +14,4 @@ class Product(Base):
nova_group = Column(Integer) nova_group = Column(Integer)
nutrition_json = Column(Text) # JSON string nutrition_json = Column(Text) # JSON string
image_url = Column(String) image_url = Column(String)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -12,4 +12,4 @@ class Scan(Base):
score = Column(Integer) score = Column(Integer)
summary = Column(Text) summary = Column(Text)
analysis_json = Column(Text) # Full AI analysis as JSON analysis_json = Column(Text) # Full AI analysis as JSON
scanned_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) scanned_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -9,4 +9,4 @@ class User(Base):
name = Column(String, nullable=False) name = Column(String, nullable=False)
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=False)
is_premium = Column(Boolean, default=False) is_premium = Column(Boolean, default=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -91,6 +91,9 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
ingredients=analysis.get("ingredients", []), ingredients=analysis.get("ingredients", []),
nutri_score=product_data.get("nutri_score"), nutri_score=product_data.get("nutri_score"),
nova_group=product_data.get("nova_group"), nova_group=product_data.get("nova_group"),
nutrition=analysis.get("nutrition"),
nutrition_verdict=analysis.get("nutrition_verdict"),
recipe=analysis.get("recipe"),
source=source, source=source,
) )
@@ -104,3 +107,67 @@ async def get_history(user: User = Depends(get_current_user), db: AsyncSession =
id=s.id, barcode=s.barcode, product_name=s.product_name, id=s.id, barcode=s.barcode, product_name=s.product_name,
brand=s.brand, score=s.score, scanned_at=s.scanned_at brand=s.brand, score=s.score, scanned_at=s.scanned_at
) for s in scans] ) for s in scans]
@router.get("/history/{scan_id}")
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Scan).where(Scan.id == scan_id, Scan.user_id == user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=404, detail="Scan não encontrado")
analysis = json.loads(scan.analysis_json or '{}')
# Also get product info
prod_result = await db.execute(select(Product).where(Product.barcode == scan.barcode))
product = prod_result.scalar_one_or_none()
return {
"id": scan.id,
"barcode": scan.barcode,
"product_name": scan.product_name,
"brand": scan.brand,
"score": scan.score,
"summary": scan.summary,
"scanned_at": scan.scanned_at.isoformat() if scan.scanned_at else None,
"category": product.category if product else None,
"image_url": product.image_url if product else None,
"nutri_score": product.nutri_score if product else None,
"nova_group": product.nova_group if product else None,
"positives": analysis.get("positives", []),
"negatives": analysis.get("negatives", []),
"ingredients": analysis.get("ingredients", []),
}
@router.get("/history/{scan_id}")
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Scan).where(Scan.id == scan_id, Scan.user_id == user.id)
)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(status_code=404, detail="Scan não encontrado")
analysis = json.loads(scan.analysis_json or '{}')
prod_result = await db.execute(select(Product).where(Product.barcode == scan.barcode))
product = prod_result.scalar_one_or_none()
return {
"id": scan.id,
"barcode": scan.barcode,
"product_name": scan.product_name,
"brand": scan.brand,
"score": scan.score,
"summary": scan.summary,
"scanned_at": scan.scanned_at.isoformat() if scan.scanned_at else None,
"category": product.category if product else None,
"image_url": product.image_url if product else None,
"nutri_score": product.nutri_score if product else None,
"nova_group": product.nova_group if product else None,
"positives": analysis.get("positives", []),
"negatives": analysis.get("negatives", []),
"ingredients": analysis.get("ingredients", []),
"nutrition": analysis.get("nutrition", {}),
"nutrition_verdict": analysis.get("nutrition_verdict", ""),
"recipe": analysis.get("recipe"),
}

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List, Any, Dict
from datetime import datetime from datetime import datetime
class ScanRequest(BaseModel): class ScanRequest(BaseModel):
@@ -9,9 +9,18 @@ class IngredientAnalysis(BaseModel):
name: str name: str
popular_name: Optional[str] = None popular_name: Optional[str] = None
explanation: str explanation: str
classification: str # "good", "warning", "bad" classification: str
reason: str reason: str
class RecipeInfo(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
prep_time: Optional[str] = None
calories: Optional[str] = None
ingredients_list: Optional[List[str]] = None
steps: Optional[List[str]] = None
tip: Optional[str] = None
class ScanResult(BaseModel): class ScanResult(BaseModel):
barcode: str barcode: str
product_name: Optional[str] = None product_name: Optional[str] = None
@@ -23,6 +32,9 @@ class ScanResult(BaseModel):
positives: List[str] positives: List[str]
negatives: List[str] negatives: List[str]
ingredients: List[IngredientAnalysis] ingredients: List[IngredientAnalysis]
nutrition: Optional[Dict[str, Any]] = None
nutrition_verdict: Optional[str] = None
recipe: Optional[RecipeInfo] = None
nutri_score: Optional[str] = None nutri_score: Optional[str] = None
nova_group: Optional[int] = None nova_group: Optional[int] = None
source: str = "open_food_facts" source: str = "open_food_facts"

View File

@@ -3,22 +3,35 @@
"short_name": "ALETHEIA", "short_name": "ALETHEIA",
"description": "Escaneie rótulos de alimentos com IA", "description": "Escaneie rótulos de alimentos com IA",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"theme_color": "#1A7A4C", "theme_color": "#1A7A4C",
"background_color": "#1A7A4C", "background_color": "#0A0A0F",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any"
}, },
{ {
"src": "/icons/icon-512.png", "src": "/icons/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'aletheia-v1'; const CACHE_NAME = 'aletheia-v4';
const STATIC_ASSETS = ['/', '/manifest.json', '/icons/icon-192.png', '/icons/icon-512.png']; const STATIC_ASSETS = ['/', '/manifest.json', '/icons/icon-192.png', '/icons/icon-512.png'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
@@ -19,6 +19,7 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
if (event.request.url.includes('/api/')) return;
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)
.then((response) => { .then((response) => {

View File

@@ -8,6 +8,8 @@ import { useAuthStore } from '@/stores/authStore';
export default function HistoryPage() { export default function HistoryPage() {
const [scans, setScans] = useState<any[]>([]); const [scans, setScans] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
const { hydrate } = useAuthStore(); const { hydrate } = useAuthStore();
const router = useRouter(); const router = useRouter();
@@ -17,7 +19,197 @@ export default function HistoryPage() {
api.history().then(setScans).catch(() => {}).finally(() => setLoading(false)); api.history().then(setScans).catch(() => {}).finally(() => setLoading(false));
}, []); }, []);
const getScoreColor = (s: number) => s >= 71 ? 'text-green-400' : s >= 51 ? 'text-yellow-400' : s >= 31 ? 'text-orange-400' : 'text-red-400'; const openDetail = async (id: number) => {
setDetailLoading(true);
try {
const data = await api.scanDetail(id);
setDetail(data);
} 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 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 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>
);
};
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';
};
// Detail view
if (detail) {
const color = getScoreColor(detail.score);
const dashArray = detail.score * 3.267 + ' 326.7';
const nutrition = detail.nutrition || {};
const recipe = detail.recipe;
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="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" />
<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 }}>{detail.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(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>
{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>
))}
</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>
))}
</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 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>
</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>
</div>
)}
</div>
)}
<p className="text-center text-gray-600 text-xs">
Escaneado em {new Date(detail.scanned_at).toLocaleString('pt-BR')}
</p>
</div>
);
}
return ( 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 max-w-lg mx-auto">
@@ -37,17 +229,30 @@ export default function HistoryPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{scans.map(s => ( {scans.map(s => (
<div key={s.id} className="bg-dark-light rounded-xl p-4 flex items-center justify-between"> <button key={s.id} onClick={() => openDetail(s.id)}
className="w-full bg-dark-light rounded-xl p-4 flex items-center justify-between hover:bg-gray-800 transition text-left">
<div> <div>
<p className="font-medium text-sm">{s.product_name || s.barcode}</p> <p className="font-medium text-sm">{s.product_name || s.barcode}</p>
{s.brand && <p className="text-gray-500 text-xs">{s.brand}</p>} {s.brand && <p className="text-gray-500 text-xs">{s.brand}</p>}
<p className="text-gray-600 text-xs mt-1">{new Date(s.scanned_at).toLocaleDateString('pt-BR')}</p> <p className="text-gray-600 text-xs mt-1">{new Date(s.scanned_at).toLocaleDateString('pt-BR')}</p>
</div> </div>
<span className={`text-2xl font-black ${getScoreColor(s.score)}`}>{s.score}</span> <div className="flex items-center gap-2">
</div> <span className={'text-2xl font-black ' + getScoreClass(s.score)}>{s.score}</span>
<span className="text-gray-600"></span>
</div>
</button>
))} ))}
</div> </div>
)} )}
{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>
)}
</div> </div>
); );
} }

View File

@@ -6,7 +6,7 @@ import { ServiceWorkerRegister } from '@/components/ServiceWorkerRegister'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'ALETHEIA — A verdade sobre o que você come', title: 'ALETHEIA — A verdade sobre o que você come',
description: 'Escaneie qualquer produto e nossa IA revela o que a indústria alimentícia esconde nos rótulos.', description: 'Escaneie qualquer produto e nossa IA revela o que a indústria alimentícia esconde nos rótulos.',
manifest: '/manifest.json', // manifest moved to head link
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: 'black-translucent', statusBarStyle: 'black-translucent',
@@ -28,7 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return ( return (
<html lang="pt-BR" className="dark"> <html lang="pt-BR" className="dark">
<head> <head>
<link rel="apple-touch-icon" href="/icons/icon-192.png" /> <link rel="manifest" href="/manifest.json" /><link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head> </head>
<body className="bg-dark text-white min-h-screen antialiased"> <body className="bg-dark text-white min-h-screen antialiased">
{children} {children}

View File

@@ -64,6 +64,14 @@ export default function ScanPage() {
} }
}; };
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) => { const getScoreColor = (score: number) => {
if (score >= 71) return '#10B981'; if (score >= 71) return '#10B981';
if (score >= 51) return '#EAB308'; if (score >= 51) return '#EAB308';
@@ -83,64 +91,134 @@ export default function ScanPage() {
return '🔴'; return '🔴';
}; };
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>
);
};
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 // Result view
if (result) { if (result) {
const color = getScoreColor(result.score); const color = getScoreColor(result.score);
const dashArray = result.score * 3.267 + ' 326.7';
const nutrition = result.nutrition || {};
const recipe = result.recipe;
return ( 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 max-w-lg mx-auto">
<button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar</button> <button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white"> Novo Scan</button>
{/* Score */} {/* Score */}
<div className="text-center mb-8"> <div className="text-center mb-6">
<h2 className="text-lg font-semibold mb-1">{result.product_name || 'Produto'}</h2> <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>} {result.brand && <p className="text-gray-500 text-sm">{result.brand}</p>}
<div className="relative w-40 h-40 mx-auto mt-6"> <div className="relative w-36 h-36 mx-auto mt-4">
<svg viewBox="0 0 120 120" className="w-full h-full -rotate-90"> <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="#374151" strokeWidth="10" />
<circle cx="60" cy="60" r="52" fill="none" stroke={color} strokeWidth="10" <circle cx="60" cy="60" r="52" fill="none" stroke={color} strokeWidth="10"
strokeDasharray={`${result.score * 3.267} 326.7`} strokeLinecap="round" className="transition-all duration-1000" /> strokeDasharray={dashArray} strokeLinecap="round" className="transition-all duration-1000" />
</svg> </svg>
<div className="absolute inset-0 flex flex-col items-center justify-center"> <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-4xl font-black" style={{ color }}>{result.score}</span>
<span className="text-gray-500 text-sm">/100</span> <span className="text-gray-500 text-xs">/100</span>
</div> </div>
</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>
{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> </div>
{/* Summary */} {/* Why this score */}
<div className="bg-dark-light rounded-2xl p-4 mb-4"> <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-300 text-sm leading-relaxed">{result.summary}</p>
<p className="text-gray-500 text-xs mt-2 italic">{getScoreLabel(result.score).desc}</p>
</div> </div>
{/* Nutrition Table */}
{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 */} {/* Positives & Negatives */}
{result.positives?.length > 0 && ( <div className="grid grid-cols-2 gap-3 mb-4">
<div className="mb-4"> {result.positives?.length > 0 && (
<h3 className="font-semibold text-green-400 mb-2"> Positivos</h3> <div className="bg-green-500/5 border border-green-500/20 rounded-xl p-3">
{result.positives.map((p: string, i: number) => ( <h3 className="font-semibold text-green-400 text-xs mb-2"> Positivos</h3>
<p key={i} className="text-gray-300 text-sm ml-4 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> ))}
)} </div>
{result.negatives?.length > 0 && ( )}
<div className="mb-4"> {result.negatives?.length > 0 && (
<h3 className="font-semibold text-red-400 mb-2"> Negativos</h3> <div className="bg-red-500/5 border border-red-500/20 rounded-xl p-3">
{result.negatives.map((n: string, i: number) => ( <h3 className="font-semibold text-red-400 text-xs mb-2"> Negativos</h3>
<p key={i} className="text-gray-300 text-sm ml-4 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>
)}
</div>
{/* Ingredients */} {/* Ingredients */}
{result.ingredients?.length > 0 && ( {result.ingredients?.length > 0 && (
<div className="mb-6"> <div className="mb-4">
<h3 className="font-semibold mb-3">📋 Ingredientes</h3> <h3 className="font-semibold mb-3 text-sm">📋 Ingredientes</h3>
<div className="space-y-2"> <div className="space-y-2">
{result.ingredients.map((ing: any, i: number) => ( {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">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span>{getClassIcon(ing.classification)}</span> <span>{getClassIcon(ing.classification)}</span>
<span className={`font-medium text-sm ${getClassColor(ing.classification)}`}> <span className={'font-medium text-sm ' + getClassColor(ing.classification)}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ` (${ing.popular_name})` : ''} {ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''}
</span> </span>
</div> </div>
<p className="text-gray-400 text-xs ml-6">{ing.explanation}</p> <p className="text-gray-400 text-xs ml-6">{ing.explanation}</p>
@@ -151,11 +229,53 @@ export default function ScanPage() {
</div> </div>
)} )}
{/* Share */} {/* 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>
<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>
</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"> <div className="flex gap-3">
<button onClick={() => { <button onClick={() => {
if (navigator.share) { if (navigator.share) {
navigator.share({ title: `Aletheia: ${result.product_name}`, text: `Score: ${result.score}/100 - ${result.summary}`, url: window.location.href }); 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"> }} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">
📤 Compartilhar 📤 Compartilhar
@@ -191,6 +311,7 @@ export default function ScanPage() {
<div className="text-center py-20"> <div className="text-center py-20">
<div className="animate-spin text-4xl mb-4">👁</div> <div className="animate-spin text-4xl mb-4">👁</div>
<p className="text-gray-400">Analisando produto...</p> <p className="text-gray-400">Analisando produto...</p>
<p className="text-gray-600 text-xs mt-2">Nossa IA está analisando cada ingrediente</p>
</div> </div>
)} )}
@@ -220,7 +341,8 @@ export default function ScanPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<input type="text" placeholder="Ex: 7891000100103" value={manualCode} onChange={e => setManualCode(e.target.value)} <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" /> 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} <button onClick={() => manualCode && handleScan(manualCode)} disabled={!manualCode}
className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50"> className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50">
Buscar Buscar
@@ -232,11 +354,11 @@ export default function ScanPage() {
<p className="text-gray-500 text-sm mb-3">🧪 Teste com produtos demo:</p> <p className="text-gray-500 text-sm mb-3">🧪 Teste com produtos demo:</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
{ name: 'Coca-Cola', code: '7891000100103' }, { name: 'Coca-Cola', code: '7894900011517' },
{ name: 'Nescau', code: '7891000053508' }, { name: 'Nescau', code: '7891000379691' },
{ name: 'Miojo', code: '7891000305232' }, { name: 'Miojo', code: '7891079000212' },
{ name: 'Aveia', code: '7891000362006' }, { name: 'Aveia', code: '7894321219820' },
{ name: 'Oreo', code: '7622300830236' }, { name: 'Oreo', code: '7622300830151' },
].map(p => ( ].map(p => (
<button key={p.code} onClick={() => handleScan(p.code)} <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"> className="bg-dark-light text-gray-300 px-3 py-1.5 rounded-lg text-xs hover:bg-gray-600 transition">

View File

@@ -1,4 +1,4 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8090'; const API_URL = '';
async function request(path: string, options: RequestInit = {}) { async function request(path: string, options: RequestInit = {}) {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
@@ -24,5 +24,6 @@ export const api = {
me: () => request('/api/auth/me'), me: () => request('/api/auth/me'),
scan: (barcode: string) => scan: (barcode: string) =>
request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }), request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }),
history: () => request('/api/history'), history: () => request("/api/history"),
scanDetail: (id: number) => request(`/api/history/${id}`),
}; };