diff --git a/backend/app/integrations/openai_client.py b/backend/app/integrations/openai_client.py index 928d519..8a2aac8 100644 --- a/backend/app/integrations/openai_client.py +++ b/backend/app/integrations/openai_client.py @@ -19,7 +19,7 @@ Responda SEMPRE em JSON válido com esta estrutura exata: "proteinas": "", "carboidratos": "" }, - "nutrition_verdict": "", + "nutrition_verdict": "", "ingredients": [ { "name": "", @@ -31,32 +31,50 @@ Responda SEMPRE em JSON válido com esta estrutura exata: ], "recipe": { "title": "", - "description": "", + "description": "", "prep_time": "", - "calories": "", - "ingredients_list": ["", "", ...], - "steps": ["", "", ...], - "tip": "" - } + "calories": "", + "ingredients_list": ["", ...], + "steps": ["", ...], + "tip": "" + }, + "substitutions": [ + { + "name": "", + "brand": "", + "reason": "", + "estimated_score": + } + ] } +REGRAS PARA SUBSTITUIÇÕES: +- SOMENTE inclua "substitutions" se o score for < 50 +- Sugira 3 produtos REAIS brasileiros que sejam alternativas mais saudáveis +- Se score >= 50, retorne "substitutions": null + 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 +- Se score > 70: sugira receita usando o produto +- Se score <= 70: sugira alternativa saudável Critérios para o score: -- 90-100: Alimento natural, minimamente processado, sem aditivos -- 70-89: Bom, com poucos aditivos ou processamento leve -- 50-69: Médio, processado mas aceitável com moderação -- 30-49: Ruim, ultraprocessado com vários aditivos -- 0-29: Péssimo, alto em açúcar/sódio/gordura trans, muitos aditivos +- 90-100: Natural, minimamente processado +- 70-89: Bom, poucos aditivos +- 50-69: Médio, processado mas aceitável +- 30-49: Ruim, ultraprocessado +- 0-29: Péssimo, alto em açúcar/sódio/gordura trans -Use os dados nutricionais fornecidos quando disponíveis. Estime quando não disponíveis. -Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos. -Seja direto e honesto. Use linguagem simples.""" +Use linguagem simples e direta.""" -async def analyze_product(product_data: dict) -> dict: +HEALTH_PROFILE_PROMPTS = { + "normal": "", + "crianca": "\n⚠️ PERFIL: CRIANÇA. Alerte sobre: excesso de açúcar, corantes artificiais, cafeína, sódio alto. Seja mais rigoroso com ultraprocessados.", + "gestante": "\n⚠️ PERFIL: GESTANTE. Alerte sobre: cafeína, adoçantes artificiais, sódio excessivo, conservantes. Priorize folato, ferro, cálcio.", + "diabetico": "\n⚠️ PERFIL: DIABÉTICO. Alerte sobre: açúcares, carboidratos refinados, índice glicêmico alto. Valorize fibras e proteínas.", + "hipertenso": "\n⚠️ PERFIL: HIPERTENSO. Alerte sobre: sódio (>400mg é ALTO), glutamato monossódico, conservantes com sódio. Limite: <2g sódio/dia.", +} + +async def analyze_product(product_data: dict, user_context: dict = None) -> dict: if not settings.OPENAI_API_KEY: return _mock_analysis(product_data) @@ -65,21 +83,30 @@ async def analyze_product(product_data: dict) -> dict: nutrition_info = product_data.get('nutrition', {}) nutrition_str = json.dumps(nutrition_info, ensure_ascii=False) if nutrition_info else 'Não disponível' + system = SYSTEM_PROMPT + extra = "" + if user_context: + hp = user_context.get("health_profile", "normal") + system += HEALTH_PROFILE_PROMPTS.get(hp, "") + allergies = user_context.get("allergies", []) + if allergies: + extra = f"\n\n⚠️ ALERGIAS DO USUÁRIO: {', '.join(allergies)}. Destaque QUALQUER ingrediente que possa conter esses alérgenos." + user_msg = f"""Produto: {product_data.get('name', 'Desconhecido')} Marca: {product_data.get('brand', '')} Categoria: {product_data.get('category', '')} Ingredientes: {product_data.get('ingredients_text', 'Não disponível')} Nutri-Score: {product_data.get('nutri_score', 'N/A')} NOVA: {product_data.get('nova_group', 'N/A')} -Dados Nutricionais: {nutrition_str} +Dados Nutricionais: {nutrition_str}{extra} -Analise este produto com informações nutricionais detalhadas e sugira uma receita.""" +Analise este produto completo.""" try: resp = await client.chat.completions.create( model=settings.OPENAI_MODEL, messages=[ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": system}, {"role": "user", "content": user_msg} ], response_format={"type": "json_object"}, @@ -115,5 +142,74 @@ def _mock_analysis(product_data: dict) -> dict: "nutrition": {}, "nutrition_verdict": "", "ingredients": [], - "recipe": None + "recipe": None, + "substitutions": None, } + + +PHOTO_PROMPT = """Analise esta foto de um produto alimentar/suplemento. A foto pode mostrar o rótulo, tabela nutricional, ingredientes ou embalagem. + +IMPORTANTE: Mesmo que a foto esteja parcial, TENTE extrair o máximo de informações. NUNCA retorne erro se conseguir identificar o produto. + +Responda em JSON com este formato: +{ + "product_name": "", + "brand": "", + "category": "", + "ingredients_text": "", + "nutri_score": "", + "nova_group": <1-4 ou null>, + "score": , + "summary": "", + "positives": ["..."], + "negatives": ["..."], + "nutrition": {"calorias":"...","acucar":"...","gordura_total":"...","gordura_saturada":"...","sodio":"...","carboidratos":"...","fibras":"...","proteinas":"..."}, + "nutrition_verdict": "", + "ingredients": [{"name":"...","popular_name":"...","explanation":"...","classification":"good|warning|bad","reason":"..."}], + "recipe": {"title":"...","description":"...","prep_time":"...","calories":"...","ingredients_list":["..."],"steps":["..."],"tip":"..."}, + "substitutions": null +} + +Se score < 50, inclua "substitutions" com 3 alternativas reais brasileiras. +SOMENTE retorne {"error": "mensagem"} se NÃO for alimento.""" + +async def analyze_photo(b64_image: str, user_context: dict = None) -> dict: + if not settings.OPENAI_API_KEY: + return None + + client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + + system = "Voce eh um nutricionista brasileiro expert em analise de rotulos alimentares." + if user_context: + hp = user_context.get("health_profile", "normal") + system += HEALTH_PROFILE_PROMPTS.get(hp, "") + + prompt = PHOTO_PROMPT + if user_context and user_context.get("allergies"): + prompt += f"\n\n⚠️ ALERGIAS: {', '.join(user_context['allergies'])}. Destaque alérgenos!" + + for detail_level in ["low", "auto"]: + try: + resp = await client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_image}", "detail": detail_level}} + ]} + ], + response_format={"type": "json_object"}, + temperature=0.3, + timeout=30, + max_tokens=1500, + ) + result = json.loads(resp.choices[0].message.content) + if result.get("error"): + continue + return result + except Exception as e: + print(f"OpenAI photo error (detail={detail_level}): {e}") + continue + + return None diff --git a/backend/app/main.py b/backend/app/main.py index ff81252..5d9fa09 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,19 +1,24 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager -from app.database import init_db +from app.database import init_db, async_session from app.routers import auth, scan +from app.routers import compare, profile, stats, achievements, shopping, share +from app.services.achievements import seed_achievements @asynccontextmanager async def lifespan(app: FastAPI): await init_db() + # Seed achievements + async with async_session() as db: + await seed_achievements(db) yield -app = FastAPI(title="Aletheia API", version="0.1.0", lifespan=lifespan) +app = FastAPI(title="Aletheia API", version="0.2.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3080", "http://127.0.0.1:3080"], + allow_origins=["http://localhost:3080", "http://127.0.0.1:3080", "http://198.199.84.130:3080", "*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -21,7 +26,13 @@ app.add_middleware( app.include_router(auth.router) app.include_router(scan.router) +app.include_router(compare.router) +app.include_router(profile.router) +app.include_router(stats.router) +app.include_router(achievements.router) +app.include_router(shopping.router) +app.include_router(share.router) @app.get("/api/health") async def health(): - return {"status": "ok", "app": "Aletheia API v0.1"} + return {"status": "ok", "app": "Aletheia API v0.2"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a2c9e3c..c815f21 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,5 @@ from app.models.user import User from app.models.product import Product from app.models.scan import Scan - -__all__ = ["User", "Product", "Scan"] +from app.models.achievement import Achievement, UserAchievement +from app.models.shopping_list import ShoppingItem diff --git a/backend/app/models/achievement.py b/backend/app/models/achievement.py new file mode 100644 index 0000000..6285fea --- /dev/null +++ b/backend/app/models/achievement.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from datetime import datetime, timezone +from app.database import Base + +class Achievement(Base): + __tablename__ = "achievements" + id = Column(Integer, primary_key=True, index=True) + code = Column(String, unique=True, nullable=False) + name = Column(String, nullable=False) + description = Column(String, nullable=False) + emoji = Column(String, default="🏆") + target = Column(Integer, default=1) # number needed to unlock + +class UserAchievement(Base): + __tablename__ = "user_achievements" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False) + unlocked_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/shopping_list.py b/backend/app/models/shopping_list.py new file mode 100644 index 0000000..135e287 --- /dev/null +++ b/backend/app/models/shopping_list.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from datetime import datetime, timezone +from app.database import Base + +class ShoppingItem(Base): + __tablename__ = "shopping_list" + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_name = Column(String, nullable=False) + barcode = Column(String, nullable=True) + checked = Column(Boolean, default=False) + added_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index efc9599..b4bc164 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text from datetime import datetime, timezone from app.database import Base @@ -9,4 +9,6 @@ class User(Base): name = Column(String, nullable=False) password_hash = Column(String, nullable=False) is_premium = Column(Boolean, default=False) + allergies = Column(Text, default="[]") # JSON array of allergies + health_profile = Column(String, default="normal") # normal, crianca, gestante, diabetico, hipertenso created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/achievements.py b/backend/app/routers/achievements.py new file mode 100644 index 0000000..3e1df98 --- /dev/null +++ b/backend/app/routers/achievements.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.database import get_db +from app.models.user import User +from app.models.scan import Scan +from app.models.achievement import Achievement, UserAchievement +from app.utils.security import get_current_user + +router = APIRouter(prefix="/api", tags=["achievements"]) + +@router.get("/achievements") +async def get_achievements(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + # Get all achievements + res = await db.execute(select(Achievement).order_by(Achievement.id)) + all_achievements = res.scalars().all() + + # Get user unlocked + unlocked_res = await db.execute( + select(UserAchievement).where(UserAchievement.user_id == user.id) + ) + unlocked = {ua.achievement_id: ua.unlocked_at for ua in unlocked_res.scalars().all()} + + # Get progress counts + total_scans_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id)) + total_scans = total_scans_res.scalar() or 0 + + photo_scans_res = await db.execute( + select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.barcode == "PHOTO") + ) + photo_scans = photo_scans_res.scalar() or 0 + + progress_map = { + "first_scan": min(total_scans, 1), + "detective": min(total_scans, 10), + "expert": min(total_scans, 50), + "master": min(total_scans, 100), + "photographer": min(photo_scans, 5), + "comparator": 0, # tracked separately + } + + result = [] + for a in all_achievements: + is_unlocked = a.id in unlocked + result.append({ + "id": a.id, + "code": a.code, + "name": a.name, + "description": a.description, + "emoji": a.emoji, + "target": a.target, + "progress": progress_map.get(a.code, 0), + "unlocked": is_unlocked, + "unlocked_at": unlocked[a.id].isoformat() if is_unlocked else None, + }) + + return {"achievements": result, "total_scans": total_scans} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0e723e5..42f1e0e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +import json from app.database import get_db from app.models.user import User from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse @@ -8,6 +9,14 @@ from app.utils.security import hash_password, verify_password, create_access_tok router = APIRouter(prefix="/api/auth", tags=["auth"]) +def user_dict(user: User) -> dict: + return { + "id": user.id, "email": user.email, "name": user.name, + "is_premium": user.is_premium, + "allergies": json.loads(user.allergies or "[]"), + "health_profile": user.health_profile or "normal", + } + @router.post("/register", response_model=TokenResponse) async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): existing = await db.execute(select(User).where(User.email == req.email)) @@ -20,10 +29,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): await db.refresh(user) token = create_access_token({"sub": str(user.id)}) - return TokenResponse( - access_token=token, - user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium} - ) + return TokenResponse(access_token=token, user=user_dict(user)) @router.post("/login", response_model=TokenResponse) async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): @@ -33,11 +39,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=401, detail="Email ou senha incorretos") token = create_access_token({"sub": str(user.id)}) - return TokenResponse( - access_token=token, - user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium} - ) + return TokenResponse(access_token=token, user=user_dict(user)) -@router.get("/me", response_model=UserResponse) +@router.get("/me") async def me(user: User = Depends(get_current_user)): - return UserResponse(id=user.id, email=user.email, name=user.name, is_premium=user.is_premium) + return user_dict(user) diff --git a/backend/app/routers/compare.py b/backend/app/routers/compare.py new file mode 100644 index 0000000..8b84243 --- /dev/null +++ b/backend/app/routers/compare.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import json +from app.database import get_db +from app.models.user import User +from app.models.scan import Scan +from app.models.product import Product +from app.utils.security import get_current_user +from app.services.achievements import check_achievements + +router = APIRouter(prefix="/api", tags=["compare"]) + +@router.post("/compare") +async def compare_products(data: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + scan_ids = data.get("scan_ids", []) + if len(scan_ids) < 2 or len(scan_ids) > 4: + raise HTTPException(status_code=400, detail="Selecione entre 2 e 4 produtos") + + results = [] + for sid in scan_ids: + res = await db.execute(select(Scan).where(Scan.id == sid, Scan.user_id == user.id)) + scan = res.scalar_one_or_none() + if not scan: + raise HTTPException(status_code=404, detail=f"Scan {sid} não encontrado") + + analysis = json.loads(scan.analysis_json or '{}') + prod_res = await db.execute(select(Product).where(Product.barcode == scan.barcode)) + product = prod_res.scalar_one_or_none() + + results.append({ + "scan_id": scan.id, + "product_name": scan.product_name, + "brand": scan.brand, + "score": scan.score, + "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", []), + "nutrition": analysis.get("nutrition", {}), + "nutrition_verdict": analysis.get("nutrition_verdict", ""), + }) + + # Check comparison achievement + await check_achievements(user.id, db, action="compare") + + return {"products": results} diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py new file mode 100644 index 0000000..d736300 --- /dev/null +++ b/backend/app/routers/profile.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import List, Optional +import json +from app.database import get_db +from app.models.user import User +from app.utils.security import get_current_user + +router = APIRouter(prefix="/api/auth", tags=["profile"]) + +class ProfileUpdate(BaseModel): + allergies: Optional[List[str]] = None + health_profile: Optional[str] = None + name: Optional[str] = None + +VALID_PROFILES = ["normal", "crianca", "gestante", "diabetico", "hipertenso"] + +@router.put("/profile") +async def update_profile(data: ProfileUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + if data.allergies is not None: + user.allergies = json.dumps(data.allergies) + if data.health_profile is not None: + if data.health_profile not in VALID_PROFILES: + raise HTTPException(status_code=400, detail=f"Perfil inválido. Use: {', '.join(VALID_PROFILES)}") + user.health_profile = data.health_profile + if data.name is not None: + user.name = data.name + + await db.commit() + await db.refresh(user) + + return { + "id": user.id, + "email": user.email, + "name": user.name, + "is_premium": user.is_premium, + "allergies": json.loads(user.allergies or "[]"), + "health_profile": user.health_profile or "normal", + } + +@router.get("/profile") +async def get_profile(user: User = Depends(get_current_user)): + return { + "id": user.id, + "email": user.email, + "name": user.name, + "is_premium": user.is_premium, + "allergies": json.loads(user.allergies or "[]"), + "health_profile": user.health_profile or "normal", + } diff --git a/backend/app/routers/scan.py b/backend/app/routers/scan.py index 0ac0bf6..5a368ea 100644 --- a/backend/app/routers/scan.py +++ b/backend/app/routers/scan.py @@ -1,6 +1,6 @@ +from fastapi import APIRouter, Depends, HTTPException, File, UploadFile import json -from datetime import datetime, timezone, date -from fastapi import APIRouter, Depends, HTTPException +from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.database import get_db @@ -10,15 +10,21 @@ from app.models.scan import Scan from app.schemas.scan import ScanRequest, ScanResult, ScanHistoryItem from app.utils.security import get_current_user from app.integrations.open_food_facts import fetch_product -from app.integrations.openai_client import analyze_product +from app.integrations.openai_client import analyze_product, analyze_photo from app.config import settings from app.services.seed import SEED_PRODUCTS +from app.services.achievements import check_achievements router = APIRouter(prefix="/api", tags=["scan"]) +def get_user_context(user: User) -> dict: + """Build user context for AI analysis.""" + allergies = json.loads(user.allergies or "[]") + health_profile = user.health_profile or "normal" + return {"allergies": allergies, "health_profile": health_profile} + @router.post("/scan", response_model=ScanResult) async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): - # Rate limit check if not user.is_premium: today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) result = await db.execute( @@ -28,7 +34,6 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), if count >= settings.FREE_SCAN_LIMIT: raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido. Faça upgrade para Premium!") - # Check local cache result = await db.execute(select(Product).where(Product.barcode == req.barcode)) product = result.scalar_one_or_none() @@ -43,12 +48,10 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), "image_url": product.image_url, } else: - # Check seed data if req.barcode in SEED_PRODUCTS: product_data = SEED_PRODUCTS[req.barcode].copy() source = "seed" else: - # Fetch from Open Food Facts product_data = await fetch_product(req.barcode) source = "open_food_facts" @@ -66,8 +69,38 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), if not product_data: raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.") - # AI Analysis - analysis = await analyze_product(product_data) + user_context = get_user_context(user) + analysis = await analyze_product(product_data, user_context=user_context) + + # Add allergen alerts + allergies = json.loads(user.allergies or "[]") + allergen_alerts = [] + if allergies and analysis.get("ingredients"): + for ing in analysis["ingredients"]: + ing_name = (ing.get("name", "") + " " + ing.get("popular_name", "")).lower() + for allergy in allergies: + allergy_lower = allergy.lower() + # Map common allergy names to ingredient keywords + allergy_keywords = { + "glúten": ["glúten", "trigo", "centeio", "cevada", "aveia", "farinha de trigo", "wheat", "gluten"], + "lactose": ["lactose", "leite", "soro de leite", "whey", "caseína", "lácteo", "milk", "dairy"], + "amendoim": ["amendoim", "peanut"], + "soja": ["soja", "lecitina de soja", "soy"], + "ovo": ["ovo", "albumina", "egg"], + "frutos do mar": ["camarão", "peixe", "lagosta", "caranguejo", "marisco", "fish", "shrimp"], + "nozes": ["nozes", "castanha", "amêndoa", "avelã", "nuts", "almond"], + } + keywords = allergy_keywords.get(allergy_lower, [allergy_lower]) + for kw in keywords: + if kw in ing_name: + allergen_alerts.append({ + "ingredient": ing.get("name", ""), + "allergy": allergy, + }) + ing["is_allergen"] = True + break + + analysis["allergen_alerts"] = allergen_alerts # Save scan scan = Scan( @@ -77,8 +110,12 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), ) db.add(scan) await db.commit() + + # Check achievements + new_badges = await check_achievements(user.id, db, action="scan") return ScanResult( + id=scan.id, barcode=req.barcode, product_name=product_data.get("name"), brand=product_data.get("brand"), @@ -94,6 +131,9 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), nutrition=analysis.get("nutrition"), nutrition_verdict=analysis.get("nutrition_verdict"), recipe=analysis.get("recipe"), + substitutions=analysis.get("substitutions"), + allergen_alerts=allergen_alerts, + new_badges=new_badges, source=source, ) @@ -108,37 +148,6 @@ async def get_history(user: User = Depends(get_current_user), db: AsyncSession = brand=s.brand, score=s.score, scanned_at=s.scanned_at ) 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( @@ -170,4 +179,88 @@ async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), "nutrition": analysis.get("nutrition", {}), "nutrition_verdict": analysis.get("nutrition_verdict", ""), "recipe": analysis.get("recipe"), + "substitutions": analysis.get("substitutions"), + "allergen_alerts": analysis.get("allergen_alerts", []), + } + +@router.post("/scan/photo") +async def scan_photo(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), file: UploadFile = File(...)): + if not user.is_premium: + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + result = await db.execute( + select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= today_start) + ) + count = result.scalar() + if count >= settings.FREE_SCAN_LIMIT: + raise HTTPException(status_code=429, detail=f"Limite de {settings.FREE_SCAN_LIMIT} scans/dia atingido.") + + contents = await file.read() + if len(contents) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Imagem muito grande. Máximo 10MB.") + + import base64 + from PIL import Image + import io + try: + img = Image.open(io.BytesIO(contents)) + img = img.convert("RGB") + max_dim = 1024 + if max(img.size) > max_dim: + ratio = max_dim / max(img.size) + img = img.resize((int(img.size[0]*ratio), int(img.size[1]*ratio)), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85) + b64 = base64.b64encode(buf.getvalue()).decode() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Imagem inválida: {str(e)}") + + user_context = get_user_context(user) + analysis = await analyze_photo(b64, user_context=user_context) + + if not analysis: + raise HTTPException(status_code=422, detail="Não foi possível analisar a imagem. Tente uma foto mais nítida do rótulo.") + + scan = Scan( + user_id=user.id, barcode="PHOTO", + product_name=analysis.get("product_name", "Produto (foto)"), + brand=analysis.get("brand", ""), score=analysis.get("score", 50), + summary=analysis.get("summary", ""), analysis_json=json.dumps(analysis), + ) + db.add(scan) + + if analysis.get("product_name"): + new_product = Product( + barcode="PHOTO_" + str(hash(b64[:100]))[-8:], + name=analysis.get("product_name"), brand=analysis.get("brand", ""), + category=analysis.get("category", ""), ingredients_text=analysis.get("ingredients_text", ""), + nutri_score=analysis.get("nutri_score"), nova_group=analysis.get("nova_group"), + nutrition_json=json.dumps(analysis.get("nutrition", {})), + ) + db.add(new_product) + + await db.commit() + + new_badges = await check_achievements(user.id, db, action="scan") + + return { + "id": scan.id, + "barcode": "PHOTO", + "product_name": analysis.get("product_name", "Produto (foto)"), + "brand": analysis.get("brand", ""), + "category": analysis.get("category", ""), + "image_url": None, + "score": analysis.get("score", 50), + "summary": analysis.get("summary", ""), + "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"), + "substitutions": analysis.get("substitutions"), + "allergen_alerts": analysis.get("allergen_alerts", []), + "nutri_score": analysis.get("nutri_score"), + "nova_group": analysis.get("nova_group"), + "new_badges": new_badges, + "source": "photo", } diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py new file mode 100644 index 0000000..a164fe2 --- /dev/null +++ b/backend/app/routers/share.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import json +from app.database import get_db +from app.models.scan import Scan +from app.models.product import Product + +router = APIRouter(prefix="/api", tags=["share"]) + +@router.get("/scan/{scan_id}/share", response_class=HTMLResponse) +async def share_scan(scan_id: int, db: AsyncSession = Depends(get_db)): + res = await db.execute(select(Scan).where(Scan.id == scan_id)) + scan = res.scalar_one_or_none() + if not scan: + raise HTTPException(status_code=404, detail="Scan não encontrado") + + analysis = json.loads(scan.analysis_json or '{}') + score = scan.score or 0 + color = '#10B981' if score >= 70 else '#EAB308' if score >= 50 else '#F97316' if score >= 30 else '#EF4444' + label = 'Excelente' if score >= 90 else 'Bom' if score >= 70 else 'Regular' if score >= 50 else 'Ruim' if score >= 30 else 'Péssimo' + + positives = ''.join(f'
  • ✅ {p}
  • ' for p in analysis.get("positives", [])) + negatives = ''.join(f'
  • ❌ {n}
  • ' for n in analysis.get("negatives", [])) + + html = f""" + + + + +ALETHEIA - {scan.product_name} + +
    +
    {score}/100
    +

    {scan.product_name or 'Produto'}

    +

    {scan.brand or ''}

    +
    {label}
    +

    {scan.summary or ''}

    +{f'
      {positives}
    ' if positives else ''} +{f'
      {negatives}
    ' if negatives else ''} +
    + +""" + return HTMLResponse(content=html) diff --git a/backend/app/routers/shopping.py b/backend/app/routers/shopping.py new file mode 100644 index 0000000..a640fee --- /dev/null +++ b/backend/app/routers/shopping.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import Optional +from app.database import get_db +from app.models.user import User +from app.models.shopping_list import ShoppingItem +from app.utils.security import get_current_user + +router = APIRouter(prefix="/api", tags=["shopping"]) + +class ShoppingAdd(BaseModel): + product_name: str + barcode: Optional[str] = None + +@router.post("/shopping-list") +async def add_to_list(data: ShoppingAdd, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + item = ShoppingItem(user_id=user.id, product_name=data.product_name, barcode=data.barcode) + db.add(item) + await db.commit() + await db.refresh(item) + return {"id": item.id, "product_name": item.product_name, "barcode": item.barcode, "checked": item.checked, "added_at": item.added_at.isoformat()} + +@router.get("/shopping-list") +async def get_list(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + res = await db.execute( + select(ShoppingItem).where(ShoppingItem.user_id == user.id).order_by(ShoppingItem.added_at.desc()) + ) + items = res.scalars().all() + return [{"id": i.id, "product_name": i.product_name, "barcode": i.barcode, "checked": i.checked, + "added_at": i.added_at.isoformat()} for i in items] + +@router.delete("/shopping-list/{item_id}") +async def delete_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id)) + item = res.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Item não encontrado") + await db.delete(item) + await db.commit() + return {"ok": True} + +@router.put("/shopping-list/{item_id}/toggle") +async def toggle_item(item_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + res = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id, ShoppingItem.user_id == user.id)) + item = res.scalar_one_or_none() + if not item: + raise HTTPException(status_code=404, detail="Item não encontrado") + item.checked = not item.checked + await db.commit() + return {"id": item.id, "checked": item.checked} diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..4ce45e9 --- /dev/null +++ b/backend/app/routers/stats.py @@ -0,0 +1,87 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc, asc +from datetime import datetime, timezone, timedelta +from app.database import get_db +from app.models.user import User +from app.models.scan import Scan +from app.utils.security import get_current_user + +router = APIRouter(prefix="/api", tags=["stats"]) + +@router.get("/stats") +async def get_stats(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + now = datetime.now(timezone.utc) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + # Total scans + total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user.id)) + total_scans = total_res.scalar() or 0 + + # Average score + avg_res = await db.execute(select(func.avg(Scan.score)).where(Scan.user_id == user.id)) + avg_score = round(avg_res.scalar() or 0, 1) + + # Monthly scans + monthly_res = await db.execute( + select(func.count(Scan.id)).where(Scan.user_id == user.id, Scan.scanned_at >= month_start) + ) + monthly_scans = monthly_res.scalar() or 0 + + # Top 10 best this month + best_res = await db.execute( + select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start) + .order_by(desc(Scan.score)).limit(10) + ) + best = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score, + "scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in best_res.scalars().all()] + + # Top 10 worst this month + worst_res = await db.execute( + select(Scan).where(Scan.user_id == user.id, Scan.scanned_at >= month_start) + .order_by(asc(Scan.score)).limit(10) + ) + worst = [{"id": s.id, "product_name": s.product_name, "brand": s.brand, "score": s.score, + "scanned_at": s.scanned_at.isoformat() if s.scanned_at else None} for s in worst_res.scalars().all()] + + return { + "total_scans": total_scans, + "avg_score": avg_score, + "monthly_scans": monthly_scans, + "best": best, + "worst": worst, + } + +@router.get("/stats/evolution") +async def get_evolution(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + now = datetime.now(timezone.utc) + three_months_ago = now - timedelta(days=90) + + result = await db.execute( + select(Scan.scanned_at, Scan.score) + .where(Scan.user_id == user.id, Scan.scanned_at >= three_months_ago) + .order_by(Scan.scanned_at) + ) + scans = result.all() + + # Group by week + weeks = {} + for scanned_at, score in scans: + # ISO week + week_key = scanned_at.strftime("%Y-W%W") + week_start = scanned_at - timedelta(days=scanned_at.weekday()) + if week_key not in weeks: + weeks[week_key] = {"week": week_start.strftime("%d/%m"), "scores": [], "count": 0} + weeks[week_key]["scores"].append(score) + weeks[week_key]["count"] += 1 + + evolution = [] + for key in sorted(weeks.keys()): + w = weeks[key] + evolution.append({ + "week": w["week"], + "avg_score": round(sum(w["scores"]) / len(w["scores"]), 1), + "count": w["count"], + }) + + return {"evolution": evolution} diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index eb3144b..27d59ce 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -8,9 +8,10 @@ class ScanRequest(BaseModel): class IngredientAnalysis(BaseModel): name: str popular_name: Optional[str] = None - explanation: str - classification: str - reason: str + explanation: str = "" + classification: str = "warning" + reason: str = "" + is_allergen: Optional[bool] = False class RecipeInfo(BaseModel): title: Optional[str] = None @@ -21,7 +22,18 @@ class RecipeInfo(BaseModel): steps: Optional[List[str]] = None tip: Optional[str] = None +class SubstitutionItem(BaseModel): + name: str + brand: Optional[str] = None + reason: str = "" + estimated_score: Optional[int] = None + +class AllergenAlert(BaseModel): + ingredient: str + allergy: str + class ScanResult(BaseModel): + id: Optional[int] = None barcode: str product_name: Optional[str] = None brand: Optional[str] = None @@ -31,12 +43,15 @@ class ScanResult(BaseModel): summary: str positives: List[str] negatives: List[str] - ingredients: List[IngredientAnalysis] + ingredients: List[Any] # Allow flexible ingredient format nutrition: Optional[Dict[str, Any]] = None nutrition_verdict: Optional[str] = None - recipe: Optional[RecipeInfo] = None + recipe: Optional[Any] = None nutri_score: Optional[str] = None nova_group: Optional[int] = None + substitutions: Optional[List[Any]] = None + allergen_alerts: Optional[List[Any]] = None + new_badges: Optional[List[str]] = None source: str = "open_food_facts" class ScanHistoryItem(BaseModel): diff --git a/backend/app/services/achievements.py b/backend/app/services/achievements.py new file mode 100644 index 0000000..c7bdc94 --- /dev/null +++ b/backend/app/services/achievements.py @@ -0,0 +1,59 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.models.achievement import Achievement, UserAchievement +from app.models.scan import Scan + +ACHIEVEMENT_DEFS = [ + {"code": "first_scan", "name": "Primeiro Scan", "description": "Escaneie seu primeiro produto", "emoji": "🎯", "target": 1}, + {"code": "detective", "name": "Detetive de Rótulos", "description": "Escaneie 10 produtos", "emoji": "🔍", "target": 10}, + {"code": "expert", "name": "Expert", "description": "Escaneie 50 produtos", "emoji": "🧠", "target": 50}, + {"code": "master", "name": "Master", "description": "Escaneie 100 produtos", "emoji": "👑", "target": 100}, + {"code": "photographer", "name": "Fotógrafo", "description": "Analise 5 produtos por foto", "emoji": "📸", "target": 5}, + {"code": "comparator", "name": "Comparador", "description": "Compare produtos 3 vezes", "emoji": "⚖️", "target": 3}, +] + +async def seed_achievements(db: AsyncSession): + for a_def in ACHIEVEMENT_DEFS: + res = await db.execute(select(Achievement).where(Achievement.code == a_def["code"])) + if not res.scalar_one_or_none(): + db.add(Achievement(**a_def)) + await db.commit() + +async def check_achievements(user_id: int, db: AsyncSession, action: str = "scan"): + """Check and unlock achievements after an action.""" + # Get all achievements + res = await db.execute(select(Achievement)) + all_achievements = {a.code: a for a in res.scalars().all()} + + # Get already unlocked + unlocked_res = await db.execute(select(UserAchievement).where(UserAchievement.user_id == user_id)) + unlocked_ids = {ua.achievement_id for ua in unlocked_res.scalars().all()} + + # Count scans + total_res = await db.execute(select(func.count(Scan.id)).where(Scan.user_id == user_id)) + total_scans = total_res.scalar() or 0 + + photo_res = await db.execute( + select(func.count(Scan.id)).where(Scan.user_id == user_id, Scan.barcode == "PHOTO") + ) + photo_scans = photo_res.scalar() or 0 + + checks = { + "first_scan": total_scans >= 1, + "detective": total_scans >= 10, + "expert": total_scans >= 50, + "master": total_scans >= 100, + "photographer": photo_scans >= 5, + } + + new_unlocked = [] + for code, condition in checks.items(): + if code in all_achievements and all_achievements[code].id not in unlocked_ids and condition: + ua = UserAchievement(user_id=user_id, achievement_id=all_achievements[code].id) + db.add(ua) + new_unlocked.append(all_achievements[code].name) + + if new_unlocked: + await db.commit() + + return new_unlocked diff --git a/docs/ARQUITETURA-TECNICA.pdf b/docs/ARQUITETURA-TECNICA.pdf index 75bea33..d4ef553 100644 Binary files a/docs/ARQUITETURA-TECNICA.pdf and b/docs/ARQUITETURA-TECNICA.pdf differ diff --git a/docs/MANUAL-PRODUTO.pdf b/docs/MANUAL-PRODUTO.pdf index 78a86cd..de8d1ec 100644 Binary files a/docs/MANUAL-PRODUTO.pdf and b/docs/MANUAL-PRODUTO.pdf differ diff --git a/docs/MANUAL-TECNICO.pdf b/docs/MANUAL-TECNICO.pdf index ef2e771..0a13020 100644 Binary files a/docs/MANUAL-TECNICO.pdf and b/docs/MANUAL-TECNICO.pdf differ diff --git a/docs/MANUAL-VENDAS.pdf b/docs/MANUAL-VENDAS.pdf index f6818ed..8780e01 100644 Binary files a/docs/MANUAL-VENDAS.pdf and b/docs/MANUAL-VENDAS.pdf differ diff --git a/docs/generate-pdfs.py b/docs/generate-pdfs.py new file mode 100644 index 0000000..8191355 --- /dev/null +++ b/docs/generate-pdfs.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import subprocess, base64, os, sys +from pathlib import Path + +os.chdir(Path(__file__).parent) + +# Load logo +logo_path = Path("../frontend/public/icons/icon-192.png") +logo_b64 = base64.b64encode(logo_path.read_bytes()).decode() + +# Load template +template = Path("pdf-template.html").read_text() + +from datetime import datetime +date_str = datetime.now().strftime("%d/%m/%Y") + +docs = { + "MANUAL-PRODUTO": "Manual do Produto", + "MANUAL-VENDAS": "Manual de Vendas", + "MANUAL-TECNICO": "Manual Técnico", + "ARQUITETURA-TECNICA": "Arquitetura Técnica", +} + +for doc, title in docs.items(): + md_file = f"{doc}.md" + pdf_file = f"{doc}.pdf" + + if not os.path.exists(md_file): + print(f" ⚠️ {md_file} not found, skipping") + continue + + print(f"Generating {pdf_file}...") + + # Convert MD to HTML + result = subprocess.run( + ["pandoc", md_file, "--from", "markdown", "--to", "html"], + capture_output=True, text=True + ) + body_html = result.stdout + + # Build cover + cover = f'''
    + +

    ALETHEIA

    +
    SCANNER NUTRICIONAL COM IA
    +
    {title}
    +
    Versão 1.0 — {date_str}
    +
    "A verdade sobre o que você come"
    +
    +''' + + full_body = cover + "\n" + body_html + full_html = template.replace("$body$", full_body) + + tmp_html = f"/tmp/{doc}-full.html" + Path(tmp_html).write_text(full_html, encoding="utf-8") + + # Generate PDF + result = subprocess.run([ + "wkhtmltopdf", + "--page-size", "A4", + "--margin-top", "20mm", + "--margin-bottom", "20mm", + "--margin-left", "20mm", + "--margin-right", "20mm", + "--enable-local-file-access", + "--print-media-type", + "--encoding", "utf-8", + "--quiet", + tmp_html, pdf_file + ], capture_output=True, text=True) + + if os.path.exists(pdf_file): + size = os.path.getsize(pdf_file) + print(f" ✅ {pdf_file} ({size/1024:.0f}KB)") + else: + print(f" ❌ Failed: {result.stderr[:200]}") + +print("\nDone! 🎉") diff --git a/docs/generate-pdfs.sh b/docs/generate-pdfs.sh new file mode 100755 index 0000000..ad2a91c --- /dev/null +++ b/docs/generate-pdfs.sh @@ -0,0 +1,66 @@ +#!/bin/bash +cd "$(dirname "$0")" + +LOGO_B64=$(base64 -w0 ../frontend/public/icons/icon-192.png) +TEMPLATE="pdf-template.html" +DATE=$(date +"%d/%m/%Y") + +declare -A TITLES=( + ["MANUAL-PRODUTO"]="Manual do Produto" + ["MANUAL-VENDAS"]="Manual de Vendas" + ["MANUAL-TECNICO"]="Manual Técnico" + ["ARQUITETURA-TECNICA"]="Arquitetura Técnica" +) + +for doc in MANUAL-PRODUTO MANUAL-VENDAS MANUAL-TECNICO ARQUITETURA-TECNICA; do + TITLE="${TITLES[$doc]}" + echo "Generating $doc.pdf..." + + # Create cover + content HTML + COVER="
    + +

    ALETHEIA

    +
    SCANNER NUTRICIONAL COM IA
    +
    $TITLE
    +
    Versão 1.0 — $DATE
    +
    \"A verdade sobre o que você come\"
    +
    +
    +
    + + ALETHEIA +
    + $TITLE +
    " + + # Convert MD to HTML body + BODY=$(pandoc "$doc.md" --from markdown --to html 2>/dev/null) + + # Build full HTML + FULL_HTML=$(cat "$TEMPLATE" | sed "s|\\\$body\\\$|$COVER\n$BODY|") + + # Write temp HTML + echo "$FULL_HTML" > "/tmp/${doc}-full.html" + + # Generate PDF with wkhtmltopdf + wkhtmltopdf \ + --page-size A4 \ + --margin-top 20mm \ + --margin-bottom 20mm \ + --margin-left 20mm \ + --margin-right 20mm \ + --enable-local-file-access \ + --print-media-type \ + --encoding utf-8 \ + --quiet \ + "/tmp/${doc}-full.html" "$doc.pdf" 2>/dev/null + + if [ $? -eq 0 ]; then + SIZE=$(du -h "$doc.pdf" | cut -f1) + echo " ✅ $doc.pdf ($SIZE)" + else + echo " ❌ Failed $doc.pdf" + fi +done + +echo "Done!" diff --git a/docs/pdf-template.html b/docs/pdf-template.html new file mode 100644 index 0000000..d130d85 --- /dev/null +++ b/docs/pdf-template.html @@ -0,0 +1,247 @@ + + + + + + + +$body$ + + diff --git a/frontend/src/app/achievements/page.tsx b/frontend/src/app/achievements/page.tsx new file mode 100644 index 0000000..da059c5 --- /dev/null +++ b/frontend/src/app/achievements/page.tsx @@ -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(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
    Carregando...
    ; + + const achievements = data?.achievements || []; + const unlocked = achievements.filter((a: any) => a.unlocked).length; + + return ( +
    +

    🏆 Conquistas

    +

    + {unlocked}/{achievements.length} desbloqueadas +

    + + {/* Progress bar */} +
    +
    + Progresso + {Math.round((unlocked / Math.max(achievements.length, 1)) * 100)}% +
    +
    +
    +
    +
    + +
    + {achievements.map((a: any) => { + const progress = Math.min(a.progress / a.target, 1); + return ( +
    +
    + {a.emoji} +
    +
    +

    {a.name}

    + {a.unlocked && } +
    +

    {a.description}

    +
    +
    +
    +

    {a.progress}/{a.target}

    +
    +
    + {a.unlocked && a.unlocked_at && ( +

    + Desbloqueada em {new Date(a.unlocked_at).toLocaleDateString('pt-BR')} +

    + )} +
    + ); + })} +
    + + +
    + ); +} diff --git a/frontend/src/app/compare/page.tsx b/frontend/src/app/compare/page.tsx new file mode 100644 index 0000000..27773ef --- /dev/null +++ b/frontend/src/app/compare/page.tsx @@ -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([]); + const [selected, setSelected] = useState([]); + const [comparison, setComparison] = useState(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
    Carregando...
    ; + + // Comparison result view + if (comparison) { + const products = comparison.products || []; + const bestScore = Math.max(...products.map((p: any) => p.score)); + + return ( +
    + +

    ⚖️ Comparação

    + + {/* Score comparison */} +
    +

    Score

    + {products.map((p: any) => ( +
    +
    + {p.product_name} + + {p.score} {p.score === bestScore ? '👑' : ''} + +
    +
    +
    +
    +
    + ))} +
    + + {/* Nutrition comparison */} +
    +

    📊 Nutrição

    + {['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 ( +
    +

    {label}

    + {products.map((p: any) => ( +
    + {p.product_name} + {p.nutrition?.[key] || 'N/A'} +
    + ))} +
    + ); + })} +
    + + {/* Verdict */} +
    +

    🏆 Veredito

    + {products.sort((a: any, b: any) => b.score - a.score).map((p: any, i: number) => ( +
    + {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '4️⃣'} + {p.product_name} + {p.score} +
    + ))} +
    + + +
    + ); + } + + return ( +
    +

    ⚖️ Comparar

    +

    Selecione 2-4 produtos do histórico

    + + {selected.length >= 2 && ( + + )} + +
    + {scans.map(s => { + const isSelected = selected.includes(s.id); + return ( + + ); + })} +
    + + {scans.length === 0 && ( +
    +

    ⚖️

    +

    Escaneie produtos primeiro para poder comparar

    +
    + )} + + +
    + ); +} diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx index c60226b..1c8e08d 100644 --- a/frontend/src/app/history/page.tsx +++ b/frontend/src/app/history/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(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 ( -
    - +
    + + {allergenAlerts.length > 0 && ( +
    +

    ⚠️ ALERTA DE ALÉRGENOS!

    + {allergenAlerts.map((a: any, i: number) => ( +

    🔴 {a.ingredient} — contém {a.allergy}

    + ))} +
    + )} +

    {detail.product_name || 'Produto'}

    {detail.brand &&

    {detail.brand}

    } - {detail.category &&

    {detail.category}

    }
    @@ -103,120 +123,108 @@ export default function HistoryPage() { {getScoreLabel(detail.score).emoji} {getScoreLabel(detail.score).label}
    -
    - {detail.nutri_score && detail.nutri_score !== 'unknown' && Nutri-Score: {detail.nutri_score}} - {detail.nova_group && NOVA: {detail.nova_group}} -
    -

    - {getScoreLabel(detail.score).emoji} Por que é {getScoreLabel(detail.score).label}? -

    {detail.summary}

    -

    {getScoreLabel(detail.score).desc}

    - {/* Nutrition */} {Object.keys(nutrition).length > 0 && (
    -

    📊 Informações Nutricionais

    +

    📊 Nutrição

    {detail.nutrition_verdict &&

    {detail.nutrition_verdict}

    } {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))}
    )} - {/* Positives & Negatives */}
    {detail.positives?.length > 0 && (

    ✅ Positivos

    - {detail.positives.map((p: string, i: number) => ( -

    • {p}

    - ))} + {detail.positives.map((p: string, i: number) =>

    • {p}

    )}
    )} {detail.negatives?.length > 0 && (

    ❌ Negativos

    - {detail.negatives.map((n: string, i: number) => ( -

    • {n}

    - ))} + {detail.negatives.map((n: string, i: number) =>

    • {n}

    )}
    )}
    - {/* Ingredients */} {detail.ingredients?.length > 0 && (

    📋 Ingredientes

    {detail.ingredients.map((ing: any, i: number) => ( -
    +
    - {getClassIcon(ing.classification)} - - {ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''} + {ing.is_allergen ? '🚨' : getClassIcon(ing.classification)} + + {ing.name}{ing.is_allergen && ' ⚠️ ALÉRGENO'}

    {ing.explanation}

    -

    {ing.reason}

    ))}
    )} - {/* Recipe */} - {recipe && ( -
    -

    🍳 {detail.score > 70 ? 'Receita com este produto' : 'Alternativa Saudável'}

    -

    {recipe.title}

    -

    {recipe.description}

    -
    - {recipe.prep_time && ⏱ {recipe.prep_time}} - {recipe.calories && 🔥 {recipe.calories}} -
    -
    -

    Ingredientes:

    - {recipe.ingredients_list?.map((ing: string, i: number) => ( -

    • {ing}

    - ))} -
    -
    -

    Preparo:

    - {recipe.steps?.map((step: string, i: number) => ( -

    {i + 1}. {step}

    - ))} -
    - {recipe.tip && ( -
    -

    💡 {recipe.tip}

    + {substitutions?.length > 0 && detail.score < 50 && ( +
    +

    🔄 Alternativas Mais Saudáveis

    + {substitutions.map((sub: any, i: number) => ( +
    +
    + {sub.name} + {sub.estimated_score && ~{sub.estimated_score}} +
    +

    {sub.reason}

    - )} + ))}
    )} -

    + {recipe && ( +

    +

    🍳 {detail.score > 70 ? 'Receita' : 'Alternativa Saudável'}

    +

    {recipe.title}

    +

    {recipe.description}

    + {recipe.ingredients_list && recipe.ingredients_list.map((ing: string, i: number) =>

    • {ing}

    )} + {recipe.steps && recipe.steps.map((step: string, i: number) =>

    {i+1}. {step}

    )} +
    + )} + +
    + + +
    + +

    Escaneado em {new Date(detail.scanned_at).toLocaleString('pt-BR')}

    + +
    ); } return ( -
    +
    {loading ? ( @@ -247,12 +255,11 @@ export default function HistoryPage() { {detailLoading && (
    -
    -
    👁️
    -

    Carregando detalhes...

    -
    +
    👁️
    )} + +
    ); } diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx new file mode 100644 index 0000000..56b98e1 --- /dev/null +++ b/frontend/src/app/profile/page.tsx @@ -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(null); + const [allergies, setAllergies] = useState([]); + 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
    Carregando...
    ; + + return ( +
    +

    Meu Perfil

    + + {/* User info */} +
    +
    +
    + {profile?.name?.charAt(0) || '?'} +
    +
    +

    {profile?.name}

    +

    {profile?.email}

    + {profile?.is_premium && ( + ⭐ Premium + )} +
    +
    +
    + + {/* Health Profile */} +
    +

    🏥 Perfil de Saúde

    +

    A IA adaptará alertas ao seu perfil

    +
    + {HEALTH_PROFILES.map(hp => ( + + ))} +
    +
    + + {/* Allergies */} +
    +

    ⚠️ Alergias e Intolerâncias

    +

    Ingredientes perigosos serão destacados nos scans

    +
    + {ALLERGY_OPTIONS.map(a => ( + + ))} +
    +
    + + {/* Save */} + + + {/* Logout */} + + + +
    + ); +} diff --git a/frontend/src/app/scan/page.tsx b/frontend/src/app/scan/page.tsx index 767582e..dc3f701 100644 --- a/frontend/src/app/scan/page.tsx +++ b/frontend/src/app/scan/page.tsx @@ -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(null); + const [addedToList, setAddedToList] = useState(false); + const [newBadgeBanner, setNewBadgeBanner] = useState([]); + const photoInputRef = useRef(null); const { user, hydrate } = useAuthStore(); const router = useRouter(); const scannerRef = useRef(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) => { + 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 ( -
    +
    + {/* New badge banner */} + {newBadgeBanner.length > 0 && ( +
    +

    🏆 Nova conquista: {newBadgeBanner.join(', ')}

    +
    + )} + + {/* Allergen Alert Banner */} + {allergenAlerts.length > 0 && ( +
    +

    ⚠️ ALERTA DE ALÉRGENOS!

    + {allergenAlerts.map((a: any, i: number) => ( +

    🔴 {a.ingredient} — contém {a.allergy}

    + ))} +
    + )} + {/* Score */} @@ -153,30 +177,18 @@ export default function ScanPage() { {getScoreLabel(result.score).emoji} {getScoreLabel(result.score).label}
    - {result.nutri_score && result.nutri_score !== 'unknown' && ( -
    - Nutri-Score: {result.nutri_score} - {result.nova_group && NOVA: {result.nova_group}} -
    - )}
    - {/* Why this score */} + {/* Summary */}
    -

    - {getScoreLabel(result.score).emoji} Por que é {getScoreLabel(result.score).label}? -

    {result.summary}

    -

    {getScoreLabel(result.score).desc}

    - {/* Nutrition Table */} + {/* Nutrition */} {Object.keys(nutrition).length > 0 && (

    📊 Informações Nutricionais

    - {result.nutrition_verdict && ( -

    {result.nutrition_verdict}

    - )} + {result.nutrition_verdict &&

    {result.nutrition_verdict}

    } {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 && (

    ✅ Positivos

    - {result.positives.map((p: string, i: number) => ( -

    • {p}

    - ))} + {result.positives.map((p: string, i: number) =>

    • {p}

    )}
    )} {result.negatives?.length > 0 && (

    ❌ Negativos

    - {result.negatives.map((n: string, i: number) => ( -

    • {n}

    - ))} + {result.negatives.map((n: string, i: number) =>

    • {n}

    )}
    )}
    @@ -214,11 +222,12 @@ export default function ScanPage() {

    📋 Ingredientes

    {result.ingredients.map((ing: any, i: number) => ( -
    +
    - {getClassIcon(ing.classification)} - - {ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''} + {ing.is_allergen ? '🚨' : getClassIcon(ing.classification)} + + {ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ` (${ing.popular_name})` : ''} + {ing.is_allergen && ' ⚠️ ALÉRGENO'}

    {ing.explanation}

    @@ -229,6 +238,27 @@ export default function ScanPage() {
    )} + {/* Substitutions */} + {substitutions?.length > 0 && result.score < 50 && ( +
    +

    🔄 Alternativas Mais Saudáveis

    +
    + {substitutions.map((sub: any, i: number) => ( +
    +
    + {sub.name} + {sub.estimated_score && ( + ~{sub.estimated_score} + )} +
    + {sub.brand &&

    {sub.brand}

    } +

    {sub.reason}

    +
    + ))} +
    +
    + )} + {/* Recipe */} {recipe && (
    @@ -239,65 +269,47 @@ export default function ScanPage() { {recipe.prep_time && ⏱ {recipe.prep_time}} {recipe.calories && 🔥 {recipe.calories}}
    -
    -

    Ingredientes:

    - {recipe.ingredients_list?.map((ing: string, i: number) => ( -

    • {ing}

    - ))} -
    -
    -

    Preparo:

    - {recipe.steps?.map((step: string, i: number) => ( -

    {i + 1}. {step}

    - ))} -
    - {recipe.tip && ( -
    -

    💡 {recipe.tip}

    + {recipe.ingredients_list && ( +
    +

    Ingredientes:

    + {recipe.ingredients_list.map((ing: string, i: number) =>

    • {ing}

    )}
    )} + {recipe.steps && ( +
    +

    Preparo:

    + {recipe.steps.map((step: string, i: number) =>

    {i + 1}. {step}

    )} +
    + )} + {recipe.tip &&

    💡 {recipe.tip}

    }
    )} - {/* Score Legend */} -
    -

    📏 O que significa o Score?

    -
    -
    🌟
    90-100 Excelente
    -
    70-89 Bom
    -
    ⚠️
    50-69 Regular
    -
    🔶
    30-49 Ruim
    -
    🚫
    0-29 Péssimo
    -
    -
    - {/* Actions */} -
    - - +
    + + +
    ); } return ( -
    +
    @@ -307,68 +319,83 @@ export default function ScanPage() {
    {error &&
    {error}
    } - {loading && ( -
    -
    👁️
    -

    Analisando produto...

    -

    Nossa IA está analisando cada ingrediente

    + + {notFound && !photoLoading && ( +
    + 🔍 +

    Produto não encontrado

    +

    Tire uma foto do rótulo e nossa IA analisa.

    + + +
    )} - {!loading && ( + {(photoLoading || loading) && ( +
    +
    {photoLoading ? '📷' : '👁️'}
    +

    {photoLoading ? 'Analisando foto...' : 'Analisando produto...'}

    +
    + )} + + {!loading && !notFound && !photoLoading && ( <> - {/* Camera Scanner */}
    {scanning ? (
    - +
    ) : ( - +
    + +
    + + +
    +
    )}
    - {/* Manual Input */}
    -
    ou digite o código
    +
    ou digite
    -
    +
    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)} /> + className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50">Buscar
    - {/* Quick Demo */} -
    -

    🧪 Teste com produtos demo:

    +
    +

    🧪 Teste rápido:

    {[ { name: 'Coca-Cola', code: '7894900011517' }, { name: 'Nescau', code: '7891000379691' }, { name: 'Miojo', code: '7891079000212' }, { name: 'Aveia', code: '7894321219820' }, - { name: 'Oreo', code: '7622300830151' }, ].map(p => ( + className="bg-dark-light text-gray-300 px-3 py-1.5 rounded-lg text-xs hover:bg-gray-600 transition">{p.name} ))}
    )} + +
    ); } diff --git a/frontend/src/app/shopping/page.tsx b/frontend/src/app/shopping/page.tsx new file mode 100644 index 0000000..49f0f60 --- /dev/null +++ b/frontend/src/app/shopping/page.tsx @@ -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([]); + 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
    Carregando...
    ; + + const unchecked = items.filter(i => !i.checked); + const checked = items.filter(i => i.checked); + + return ( +
    +

    🛒 Lista de Compras

    + + {/* Add item */} +
    + 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()} /> + +
    + + {items.length === 0 ? ( +
    +

    🛒

    +

    Lista vazia

    +

    Adicione produtos acima ou pelo resultado do scan

    +
    + ) : ( + <> + {/* Active items */} + {unchecked.length > 0 && ( +
    + {unchecked.map(item => ( +
    + +
    + ))} +
    + )} + + {/* Checked items */} + {checked.length > 0 && ( +
    +

    Comprados ({checked.length})

    +
    + {checked.map(item => ( +
    + + {item.product_name} + +
    + ))} +
    +
    + )} + + )} + + +
    + ); +} diff --git a/frontend/src/app/stats/page.tsx b/frontend/src/app/stats/page.tsx new file mode 100644 index 0000000..3ef3b49 --- /dev/null +++ b/frontend/src/app/stats/page.tsx @@ -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(null); + const [evolution, setEvolution] = useState([]); + 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
    Carregando...
    ; + + const maxEvo = Math.max(...evolution.map(e => e.avg_score), 100); + + return ( +
    +

    📊 Estatísticas

    + + {/* Summary Cards */} +
    +
    +
    {stats?.total_scans || 0}
    +
    Total Scans
    +
    +
    +
    + {stats?.avg_score || 0} +
    +
    Score Médio
    +
    +
    +
    {stats?.monthly_scans || 0}
    +
    Este Mês
    +
    +
    + + {/* Evolution Chart */} + {evolution.length > 1 && ( +
    +

    📈 Evolução Semanal

    +
    + {evolution.map((e, i) => { + const height = (e.avg_score / maxEvo) * 100; + return ( +
    + {Math.round(e.avg_score)} +
    + {e.week} +
    + ); + })} +
    +
    + )} + + {/* Best Products */} + {stats?.best?.length > 0 && ( +
    +

    🏆 Melhores do Mês

    +
    + {stats.best.slice(0, 5).map((p: any, i: number) => ( +
    +
    + {i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '•'} +
    +

    {p.product_name || 'Produto'}

    +

    {p.brand || ''}

    +
    +
    + {p.score} +
    + ))} +
    +
    + )} + + {/* Worst Products */} + {stats?.worst?.length > 0 && ( +
    +

    ⚠️ Piores do Mês

    +
    + {stats.worst.slice(0, 5).map((p: any, i: number) => ( +
    +
    + {i === 0 ? '💀' : i === 1 ? '☠️' : '⚠️'} +
    +

    {p.product_name || 'Produto'}

    +

    {p.brand || ''}

    +
    +
    + {p.score} +
    + ))} +
    +
    + )} + + {!stats?.total_scans && ( +
    +

    📊

    +

    Escaneie produtos para ver estatísticas

    +
    + )} + + +
    + ); +} diff --git a/frontend/src/components/BottomNav.tsx b/frontend/src/components/BottomNav.tsx new file mode 100644 index 0000000..c189bc1 --- /dev/null +++ b/frontend/src/components/BottomNav.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8854caa..9c3db6d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 = {}; + 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' }), }; diff --git a/migrate.sql b/migrate.sql new file mode 100644 index 0000000..f94fb30 --- /dev/null +++ b/migrate.sql @@ -0,0 +1,39 @@ +-- Migration: Add new columns and tables for Aletheia v0.2 +-- Safe: uses IF NOT EXISTS / ADD COLUMN IF NOT EXISTS (PostgreSQL 11+) + +-- User profile fields +ALTER TABLE users ADD COLUMN IF NOT EXISTS allergies TEXT DEFAULT '[]'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS health_profile VARCHAR DEFAULT 'normal'; + +-- Achievements table +CREATE TABLE IF NOT EXISTS achievements ( + id SERIAL PRIMARY KEY, + code VARCHAR UNIQUE NOT NULL, + name VARCHAR NOT NULL, + description VARCHAR NOT NULL, + emoji VARCHAR DEFAULT '🏆', + target INTEGER DEFAULT 1 +); + +-- User achievements +CREATE TABLE IF NOT EXISTS user_achievements ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) NOT NULL, + achievement_id INTEGER REFERENCES achievements(id) NOT NULL, + unlocked_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Shopping list +CREATE TABLE IF NOT EXISTS shopping_list ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) NOT NULL, + product_name VARCHAR NOT NULL, + barcode VARCHAR, + checked BOOLEAN DEFAULT FALSE, + added_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id); +CREATE INDEX IF NOT EXISTS idx_shopping_list_user ON shopping_list(user_id); +CREATE INDEX IF NOT EXISTS idx_scans_user_date ON scans(user_id, scanned_at);