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

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

View File

@@ -19,7 +19,7 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
"proteinas": "<valor, ex: 0g>", "proteinas": "<valor, ex: 0g>",
"carboidratos": "<valor, ex: 35g>" "carboidratos": "<valor, ex: 35g>"
}, },
"nutrition_verdict": "<frase curta sobre o perfil nutricional, ex: Alto em açúcar, zero fibras>", "nutrition_verdict": "<frase curta sobre o perfil nutricional>",
"ingredients": [ "ingredients": [
{ {
"name": "<nome no rótulo>", "name": "<nome no rótulo>",
@@ -31,32 +31,50 @@ Responda SEMPRE em JSON válido com esta estrutura exata:
], ],
"recipe": { "recipe": {
"title": "<nome da receita saudável>", "title": "<nome da receita saudável>",
"description": "<descrição curta, 1-2 frases>", "description": "<descrição curta>",
"prep_time": "<tempo de preparo>", "prep_time": "<tempo de preparo>",
"calories": "<calorias aproximadas da receita>", "calories": "<calorias aproximadas>",
"ingredients_list": ["<ingrediente 1>", "<ingrediente 2>", ...], "ingredients_list": ["<ingrediente 1>", ...],
"steps": ["<passo 1>", "<passo 2>", ...], "steps": ["<passo 1>", ...],
"tip": "<dica nutricional relacionada>" "tip": "<dica nutricional>"
},
"substitutions": [
{
"name": "<nome do produto alternativo>",
"brand": "<marca sugerida ou genérica>",
"reason": "<por que é melhor, 1 frase>",
"estimated_score": <int 0-100>
} }
]
} }
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: Para a receita:
- Se o produto for SAUDÁVEL (score > 70): sugira uma receita usando o produto - Se score > 70: sugira receita usando o produto
- Se o produto for RUIM (score <= 70): sugira uma alternativa saudável que substitua o produto - Se score <= 70: sugira alternativa saudável
- 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: Natural, minimamente processado
- 70-89: Bom, com poucos aditivos ou processamento leve - 70-89: Bom, poucos aditivos
- 50-69: Médio, processado mas aceitável com moderação - 50-69: Médio, processado mas aceitável
- 30-49: Ruim, ultraprocessado com vários aditivos - 30-49: Ruim, ultraprocessado
- 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
Use os dados nutricionais fornecidos quando disponíveis. Estime quando não disponíveis. Use linguagem simples e direta."""
Considere Nutri-Score, classificação NOVA, e ingredientes problemáticos.
Seja direto e honesto. Use linguagem simples."""
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: if not settings.OPENAI_API_KEY:
return _mock_analysis(product_data) return _mock_analysis(product_data)
@@ -65,21 +83,30 @@ async def analyze_product(product_data: dict) -> dict:
nutrition_info = product_data.get('nutrition', {}) nutrition_info = product_data.get('nutrition', {})
nutrition_str = json.dumps(nutrition_info, ensure_ascii=False) if nutrition_info else 'Não disponível' 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')} 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} Dados Nutricionais: {nutrition_str}{extra}
Analise este produto com informações nutricionais detalhadas e sugira uma receita.""" Analise este produto completo."""
try: try:
resp = await client.chat.completions.create( resp = await client.chat.completions.create(
model=settings.OPENAI_MODEL, model=settings.OPENAI_MODEL,
messages=[ messages=[
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": system},
{"role": "user", "content": user_msg} {"role": "user", "content": user_msg}
], ],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
@@ -115,5 +142,74 @@ def _mock_analysis(product_data: dict) -> dict:
"nutrition": {}, "nutrition": {},
"nutrition_verdict": "", "nutrition_verdict": "",
"ingredients": [], "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": "<nome>",
"brand": "<marca>",
"category": "<categoria>",
"ingredients_text": "<ingredientes>",
"nutri_score": "<a-e ou null>",
"nova_group": <1-4 ou null>,
"score": <int 0-100>,
"summary": "<resumo 2-3 frases>",
"positives": ["..."],
"negatives": ["..."],
"nutrition": {"calorias":"...","acucar":"...","gordura_total":"...","gordura_saturada":"...","sodio":"...","carboidratos":"...","fibras":"...","proteinas":"..."},
"nutrition_verdict": "<frase curta>",
"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

View File

@@ -1,19 +1,24 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager 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 auth, scan
from app.routers import compare, profile, stats, achievements, shopping, share
from app.services.achievements import seed_achievements
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
# Seed achievements
async with async_session() as db:
await seed_achievements(db)
yield 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( app.add_middleware(
CORSMiddleware, 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_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -21,7 +26,13 @@ app.add_middleware(
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(scan.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") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "app": "Aletheia API v0.1"} return {"status": "ok", "app": "Aletheia API v0.2"}

View File

@@ -1,5 +1,5 @@
from app.models.user import User from app.models.user import User
from app.models.product import Product from app.models.product import Product
from app.models.scan import Scan from app.models.scan import Scan
from app.models.achievement import Achievement, UserAchievement
__all__ = ["User", "Product", "Scan"] from app.models.shopping_list import ShoppingItem

View File

@@ -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))

View File

@@ -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))

View File

@@ -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 datetime import datetime, timezone
from app.database import Base from app.database import Base
@@ -9,4 +9,6 @@ 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)
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)) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -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}

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
import json
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserResponse 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"]) 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) @router.post("/register", response_model=TokenResponse)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == req.email)) 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) await db.refresh(user)
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
return TokenResponse( return TokenResponse(access_token=token, user=user_dict(user))
access_token=token,
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
)
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): 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") raise HTTPException(status_code=401, detail="Email ou senha incorretos")
token = create_access_token({"sub": str(user.id)}) token = create_access_token({"sub": str(user.id)})
return TokenResponse( return TokenResponse(access_token=token, user=user_dict(user))
access_token=token,
user={"id": user.id, "email": user.email, "name": user.name, "is_premium": user.is_premium}
)
@router.get("/me", response_model=UserResponse) @router.get("/me")
async def me(user: User = Depends(get_current_user)): 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)

View File

@@ -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}

View File

@@ -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",
}

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
import json import json
from datetime import datetime, timezone, date from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.database import get_db 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.schemas.scan import ScanRequest, ScanResult, ScanHistoryItem
from app.utils.security import get_current_user from app.utils.security import get_current_user
from app.integrations.open_food_facts import fetch_product 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.config import settings
from app.services.seed import SEED_PRODUCTS from app.services.seed import SEED_PRODUCTS
from app.services.achievements import check_achievements
router = APIRouter(prefix="/api", tags=["scan"]) 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) @router.post("/scan", response_model=ScanResult)
async def scan_product(req: ScanRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): 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: if not user.is_premium:
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute( 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: 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!") 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)) result = await db.execute(select(Product).where(Product.barcode == req.barcode))
product = result.scalar_one_or_none() 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, "image_url": product.image_url,
} }
else: else:
# Check seed data
if req.barcode in SEED_PRODUCTS: if req.barcode in SEED_PRODUCTS:
product_data = SEED_PRODUCTS[req.barcode].copy() product_data = SEED_PRODUCTS[req.barcode].copy()
source = "seed" source = "seed"
else: else:
# Fetch from Open Food Facts
product_data = await fetch_product(req.barcode) product_data = await fetch_product(req.barcode)
source = "open_food_facts" source = "open_food_facts"
@@ -66,8 +69,38 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
if not product_data: if not product_data:
raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.") raise HTTPException(status_code=404, detail="Produto não encontrado. Tente inserir manualmente.")
# AI Analysis user_context = get_user_context(user)
analysis = await analyze_product(product_data) 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 # Save scan
scan = Scan( scan = Scan(
@@ -78,7 +111,11 @@ async def scan_product(req: ScanRequest, user: User = Depends(get_current_user),
db.add(scan) db.add(scan)
await db.commit() await db.commit()
# Check achievements
new_badges = await check_achievements(user.id, db, action="scan")
return ScanResult( return ScanResult(
id=scan.id,
barcode=req.barcode, barcode=req.barcode,
product_name=product_data.get("name"), product_name=product_data.get("name"),
brand=product_data.get("brand"), 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=analysis.get("nutrition"),
nutrition_verdict=analysis.get("nutrition_verdict"), nutrition_verdict=analysis.get("nutrition_verdict"),
recipe=analysis.get("recipe"), recipe=analysis.get("recipe"),
substitutions=analysis.get("substitutions"),
allergen_alerts=allergen_alerts,
new_badges=new_badges,
source=source, 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 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}") @router.get("/history/{scan_id}")
async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def get_scan_detail(scan_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute( 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": analysis.get("nutrition", {}),
"nutrition_verdict": analysis.get("nutrition_verdict", ""), "nutrition_verdict": analysis.get("nutrition_verdict", ""),
"recipe": analysis.get("recipe"), "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",
} }

View File

@@ -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'<li style="color:#10B981">✅ {p}</li>' for p in analysis.get("positives", []))
negatives = ''.join(f'<li style="color:#EF4444">❌ {n}</li>' for n in analysis.get("negatives", []))
html = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta property="og:title" content="ALETHEIA: {scan.product_name} - Score {score}/100">
<meta property="og:description" content="{scan.summary or ''}">
<title>ALETHEIA - {scan.product_name}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#0A0E17;color:white;font-family:Inter,system-ui,sans-serif;padding:20px;max-width:500px;margin:0 auto}}
.card{{background:#111827;border-radius:20px;padding:24px;margin-bottom:16px;border:1px solid rgba(255,255,255,0.05)}}
.score{{width:120px;height:120px;border-radius:50%;border:6px solid {color};display:flex;align-items:center;justify-content:center;margin:0 auto 16px;flex-direction:column}}
.score span{{font-size:36px;font-weight:900;color:{color}}}
.score small{{font-size:12px;color:#9CA3AF}}
h1{{font-size:20px;text-align:center;margin-bottom:4px}}
h2{{font-size:14px;color:#9CA3AF;text-align:center;margin-bottom:16px}}
.label{{display:inline-block;padding:4px 16px;border-radius:20px;font-weight:700;font-size:14px;color:{color};background:{color}15;text-align:center;margin:0 auto 20px;display:block;width:fit-content}}
ul{{list-style:none;padding:0}}li{{font-size:13px;margin-bottom:6px;color:#D1D5DB}}
.logo{{text-align:center;margin-top:24px;font-size:12px;color:#6B7280}}
.logo b{{background:linear-gradient(135deg,#00D4AA,#7C3AED);-webkit-background-clip:text;-webkit-text-fill-color:transparent}}
</style></head><body>
<div class="card">
<div class="score"><span>{score}</span><small>/100</small></div>
<h1>{scan.product_name or 'Produto'}</h1>
<h2>{scan.brand or ''}</h2>
<div class="label">{label}</div>
<p style="font-size:13px;color:#D1D5DB;text-align:center;margin-bottom:16px">{scan.summary or ''}</p>
{f'<ul>{positives}</ul>' if positives else ''}
{f'<ul style="margin-top:12px">{negatives}</ul>' if negatives else ''}
</div>
<div class="logo">Analisado por <b>ALETHEIA</b> — A verdade sobre o que você come</div>
</body></html>"""
return HTMLResponse(content=html)

View File

@@ -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}

View File

@@ -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}

View File

@@ -8,9 +8,10 @@ class ScanRequest(BaseModel):
class IngredientAnalysis(BaseModel): class IngredientAnalysis(BaseModel):
name: str name: str
popular_name: Optional[str] = None popular_name: Optional[str] = None
explanation: str explanation: str = ""
classification: str classification: str = "warning"
reason: str reason: str = ""
is_allergen: Optional[bool] = False
class RecipeInfo(BaseModel): class RecipeInfo(BaseModel):
title: Optional[str] = None title: Optional[str] = None
@@ -21,7 +22,18 @@ class RecipeInfo(BaseModel):
steps: Optional[List[str]] = None steps: Optional[List[str]] = None
tip: Optional[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): class ScanResult(BaseModel):
id: Optional[int] = None
barcode: str barcode: str
product_name: Optional[str] = None product_name: Optional[str] = None
brand: Optional[str] = None brand: Optional[str] = None
@@ -31,12 +43,15 @@ class ScanResult(BaseModel):
summary: str summary: str
positives: List[str] positives: List[str]
negatives: List[str] negatives: List[str]
ingredients: List[IngredientAnalysis] ingredients: List[Any] # Allow flexible ingredient format
nutrition: Optional[Dict[str, Any]] = None nutrition: Optional[Dict[str, Any]] = None
nutrition_verdict: Optional[str] = None nutrition_verdict: Optional[str] = None
recipe: Optional[RecipeInfo] = None recipe: Optional[Any] = None
nutri_score: Optional[str] = None nutri_score: Optional[str] = None
nova_group: Optional[int] = 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" source: str = "open_food_facts"
class ScanHistoryItem(BaseModel): class ScanHistoryItem(BaseModel):

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

85
docs/generate-pdfs.py Normal file
View File

@@ -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'''<div class="cover">
<img src="data:image/png;base64,{logo_b64}" class="cover-logo" />
<h1>ALETHEIA</h1>
<div class="subtitle">SCANNER NUTRICIONAL COM IA</div>
<div class="doc-title">{title}</div>
<div class="version">Versão 1.0 — {date_str}</div>
<div class="tagline">"A verdade sobre o que você come"</div>
</div>
<div class="page-header">
<div style="display:flex;align-items:center;gap:10px">
<img src="data:image/png;base64,{logo_b64}" />
<span class="brand">ALETHEIA</span>
</div>
<span class="doc-type">{title}</span>
</div>'''
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! 🎉")

66
docs/generate-pdfs.sh Executable file
View File

@@ -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="<div class=\"cover\">
<img src=\"data:image/png;base64,$LOGO_B64\" class=\"cover-logo\" />
<h1>ALETHEIA</h1>
<div class=\"subtitle\">SCANNER NUTRICIONAL COM IA</div>
<div class=\"doc-title\">$TITLE</div>
<div class=\"version\">Versão 1.0 — $DATE</div>
<div class=\"tagline\">\"A verdade sobre o que você come\"</div>
</div>
<div class=\"page-header\">
<div style=\"display:flex;align-items:center;gap:10px\">
<img src=\"data:image/png;base64,$LOGO_B64\" />
<span class=\"brand\">ALETHEIA</span>
</div>
<span class=\"doc-type\">$TITLE</span>
</div>"
# 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!"

247
docs/pdf-template.html Normal file
View File

@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
:root {
--primary: #00D4AA;
--accent: #7C3AED;
--dark: #0A0E17;
--dark2: #111827;
--gray: #9CA3AF;
--white: #F9FAFB;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
color: #1F2937;
line-height: 1.7;
font-size: 11pt;
}
/* COVER PAGE */
.cover {
page-break-after: always;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0A0E17 0%, #111827 40%, #0A0E17 100%);
color: white;
text-align: center;
position: relative;
overflow: hidden;
}
.cover::before {
content: '';
position: absolute;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0,212,170,0.15) 0%, transparent 70%);
top: 10%;
right: -10%;
}
.cover::after {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(124,58,237,0.1) 0%, transparent 70%);
bottom: 10%;
left: -5%;
}
.cover-logo {
width: 120px;
height: 120px;
border-radius: 24px;
margin-bottom: 30px;
box-shadow: 0 0 60px rgba(0,212,170,0.3);
position: relative;
z-index: 1;
}
.cover h1 {
font-size: 48pt;
font-weight: 800;
letter-spacing: 8px;
background: linear-gradient(135deg, #00D4AA 0%, #7C3AED 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.cover .subtitle {
font-size: 14pt;
color: #9CA3AF;
font-weight: 300;
letter-spacing: 3px;
margin-bottom: 50px;
position: relative;
z-index: 1;
}
.cover .doc-title {
font-size: 22pt;
font-weight: 600;
color: white;
padding: 15px 40px;
border: 2px solid rgba(0,212,170,0.4);
border-radius: 12px;
backdrop-filter: blur(10px);
background: rgba(0,212,170,0.05);
position: relative;
z-index: 1;
}
.cover .version {
margin-top: 40px;
font-size: 10pt;
color: #6B7280;
position: relative;
z-index: 1;
}
.cover .tagline {
font-size: 11pt;
color: #00D4AA;
font-style: italic;
margin-top: 10px;
position: relative;
z-index: 1;
}
/* HEADER */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 0;
border-bottom: 2px solid #00D4AA;
margin-bottom: 30px;
}
.page-header img {
height: 32px;
border-radius: 6px;
}
.page-header .brand {
font-size: 10pt;
font-weight: 700;
letter-spacing: 4px;
color: #0A0E17;
}
.page-header .doc-type {
font-size: 8pt;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 2px;
}
/* CONTENT */
h1 { font-size: 24pt; font-weight: 800; color: #0A0E17; margin: 30px 0 15px; }
h2 { font-size: 16pt; font-weight: 700; color: #0A0E17; margin: 25px 0 12px; border-left: 4px solid #00D4AA; padding-left: 12px; }
h3 { font-size: 13pt; font-weight: 600; color: #374151; margin: 20px 0 8px; }
p { margin-bottom: 10px; color: #374151; }
ul, ol { margin: 10px 0 10px 25px; color: #374151; }
li { margin-bottom: 5px; }
strong { color: #0A0E17; }
code {
background: #F3F4F6;
padding: 2px 6px;
border-radius: 4px;
font-size: 9pt;
color: #7C3AED;
}
pre {
background: #0A0E17;
color: #00D4AA;
padding: 15px;
border-radius: 8px;
font-size: 9pt;
margin: 10px 0;
overflow-x: auto;
}
blockquote {
border-left: 4px solid #00D4AA;
background: rgba(0,212,170,0.05);
padding: 12px 15px;
margin: 10px 0;
border-radius: 0 8px 8px 0;
color: #374151;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
font-size: 10pt;
}
th {
background: #0A0E17;
color: #00D4AA;
padding: 10px 12px;
text-align: left;
font-weight: 600;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #E5E7EB;
}
tr:nth-child(even) { background: #F9FAFB; }
/* FOOTER */
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px 40px;
font-size: 8pt;
color: #9CA3AF;
display: flex;
justify-content: space-between;
border-top: 1px solid #E5E7EB;
}
/* HIGHLIGHT BOXES */
.info-box {
background: linear-gradient(135deg, rgba(0,212,170,0.08), rgba(124,58,237,0.05));
border: 1px solid rgba(0,212,170,0.2);
border-radius: 10px;
padding: 15px;
margin: 15px 0;
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.cover { height: 100vh; }
}
</style>
</head>
<body>
$body$
</body>
</html>

View File

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

View File

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

View File

@@ -4,12 +4,14 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
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 [detail, setDetail] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [addedToList, setAddedToList] = useState(false);
const { hydrate } = useAuthStore(); const { hydrate } = useAuthStore();
const router = useRouter(); const router = useRouter();
@@ -20,27 +22,34 @@ export default function HistoryPage() {
}, []); }, []);
const openDetail = async (id: number) => { const openDetail = async (id: number) => {
setDetailLoading(true); setDetailLoading(true); setAddedToList(false);
try { try { setDetail(await api.scanDetail(id)); } catch { }
const data = await api.scanDetail(id);
setDetail(data);
} catch { }
setDetailLoading(false); 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 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 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 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 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 getNutritionBar = (label: string, value: string, level: string) => {
const barColor = level === 'low' ? 'bg-green-500' : level === 'mid' ? 'bg-yellow-500' : 'bg-red-500'; 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 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 handleShare = () => {
const num = parseFloat(val) || 0; if (!detail) return;
if (nutrient === 'acucar') return num > 15 ? 'high' : num > 5 ? 'mid' : 'low'; const url = `${window.location.origin}/api/scan/${detail.id}/share`;
if (nutrient === 'gordura_total' || nutrient === 'gordura_saturada') return num > 10 ? 'high' : num > 3 ? 'mid' : 'low'; if (navigator.share) navigator.share({ title: `ALETHEIA: ${detail.product_name}`, text: `Score: ${detail.score}/100`, url });
if (nutrient === 'sodio') return num > 400 ? 'high' : num > 120 ? 'mid' : 'low'; else { navigator.clipboard.writeText(url); alert('Link copiado!'); }
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'; const handleAddToList = async () => {
return 'mid'; if (!detail) return;
try { await api.addToShoppingList(detail.product_name || 'Produto', detail.barcode); setAddedToList(true); } catch {}
}; };
// Detail view // Detail view
@@ -79,15 +89,25 @@ export default function HistoryPage() {
const dashArray = detail.score * 3.267 + ' 326.7'; const dashArray = detail.score * 3.267 + ' 326.7';
const nutrition = detail.nutrition || {}; const nutrition = detail.nutrition || {};
const recipe = detail.recipe; const recipe = detail.recipe;
const allergenAlerts = detail.allergen_alerts || [];
const substitutions = detail.substitutions || [];
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 pb-24 max-w-lg mx-auto">
<button onClick={() => setDetail(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar ao Histórico</button> <button onClick={() => setDetail(null)} className="text-gray-400 mb-4 hover:text-white"> Voltar</button>
{allergenAlerts.length > 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-2xl p-4 mb-4 animate-pulse">
<h3 className="font-bold text-red-400 text-sm mb-2"> ALERTA DE ALÉRGENOS!</h3>
{allergenAlerts.map((a: any, i: number) => (
<p key={i} className="text-red-300 text-xs">🔴 <b>{a.ingredient}</b> contém <b>{a.allergy}</b></p>
))}
</div>
)}
<div className="text-center mb-6"> <div className="text-center mb-6">
<h2 className="text-lg font-semibold mb-1">{detail.product_name || 'Produto'}</h2> <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.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"> <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" />
@@ -103,120 +123,108 @@ export default function HistoryPage() {
<span className="text-lg">{getScoreLabel(detail.score).emoji}</span> <span className="text-lg">{getScoreLabel(detail.score).emoji}</span>
<span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(detail.score).label}</span> <span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(detail.score).label}</span>
</div> </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>
<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(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-300 text-sm leading-relaxed">{detail.summary}</p>
<p className="text-gray-500 text-xs mt-2 italic">{getScoreLabel(detail.score).desc}</p>
</div> </div>
{/* Nutrition */}
{Object.keys(nutrition).length > 0 && ( {Object.keys(nutrition).length > 0 && (
<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 mb-3 text-sm">📊 Informações Nutricionais</h3> <h3 className="font-semibold mb-3 text-sm">📊 Nutrição</h3>
{detail.nutrition_verdict && <p className="text-gray-400 text-xs mb-3 italic">{detail.nutrition_verdict}</p>} {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.calorias && getNutritionBar('Calorias', nutrition.calorias, guessLevel('calorias', nutrition.calorias))}
{nutrition.acucar && getNutritionBar('Açúcar', nutrition.acucar, guessLevel('acucar', nutrition.acucar))} {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_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.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.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.fibras && getNutritionBar('Fibras', nutrition.fibras, guessLevel('fibras', nutrition.fibras))}
{nutrition.proteinas && getNutritionBar('Proteínas', nutrition.proteinas, guessLevel('proteinas', nutrition.proteinas))} {nutrition.proteinas && getNutritionBar('Proteínas', nutrition.proteinas, guessLevel('proteinas', nutrition.proteinas))}
</div> </div>
)} )}
{/* Positives & Negatives */}
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
{detail.positives?.length > 0 && ( {detail.positives?.length > 0 && (
<div className="bg-green-500/5 border border-green-500/20 rounded-xl p-3"> <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> <h3 className="font-semibold text-green-400 text-xs mb-2"> Positivos</h3>
{detail.positives.map((p: string, i: number) => ( {detail.positives.map((p: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>)}
<p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>
))}
</div> </div>
)} )}
{detail.negatives?.length > 0 && ( {detail.negatives?.length > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-3"> <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> <h3 className="font-semibold text-red-400 text-xs mb-2"> Negativos</h3>
{detail.negatives.map((n: string, i: number) => ( {detail.negatives.map((n: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>)}
<p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>
))}
</div> </div>
)} )}
</div> </div>
{/* Ingredients */}
{detail.ingredients?.length > 0 && ( {detail.ingredients?.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<h3 className="font-semibold mb-3 text-sm">📋 Ingredientes</h3> <h3 className="font-semibold mb-3 text-sm">📋 Ingredientes</h3>
<div className="space-y-2"> <div className="space-y-2">
{detail.ingredients.map((ing: any, i: number) => ( {detail.ingredients.map((ing: any, i: number) => (
<div key={i} className="bg-dark-light rounded-xl p-3"> <div key={i} className={`bg-dark-light rounded-xl p-3 ${ing.is_allergen ? 'border-2 border-red-500/50 animate-pulse' : ''}`}>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span>{getClassIcon(ing.classification)}</span> <span>{ing.is_allergen ? '🚨' : getClassIcon(ing.classification)}</span>
<span className={'font-medium text-sm ' + getClassColor(ing.classification)}> <span className={`font-medium text-sm ${ing.is_allergen ? 'text-red-400 font-bold' : getClassColor(ing.classification)}`}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''} {ing.name}{ing.is_allergen && ' ⚠️ ALÉRGENO'}
</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>
<p className="text-gray-500 text-xs ml-6 italic">{ing.reason}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Recipe */} {substitutions?.length > 0 && detail.score < 50 && (
<div className="bg-gradient-to-br from-green-500/10 to-primary/10 border border-green-500/20 rounded-2xl p-4 mb-4">
<h3 className="font-semibold text-sm mb-3">🔄 Alternativas Mais Saudáveis</h3>
{substitutions.map((sub: any, i: number) => (
<div key={i} className="bg-dark/40 rounded-xl p-3 mb-2">
<div className="flex justify-between">
<span className="font-medium text-sm text-green-400">{sub.name}</span>
{sub.estimated_score && <span className="text-xs text-green-400">~{sub.estimated_score}</span>}
</div>
<p className="text-gray-400 text-xs mt-1">{sub.reason}</p>
</div>
))}
</div>
)}
{recipe && ( {recipe && (
<div className="bg-gradient-to-br from-primary/10 to-accent/10 border border-primary/20 rounded-2xl p-4 mb-4"> <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> <h3 className="font-semibold mb-2 text-sm">🍳 {detail.score > 70 ? 'Receita' : 'Alternativa Saudável'}</h3>
<h4 className="text-primary font-bold mb-1">{recipe.title}</h4> <h4 className="text-primary font-bold mb-1">{recipe.title}</h4>
<p className="text-gray-400 text-xs mb-3">{recipe.description}</p> <p className="text-gray-400 text-xs mb-3">{recipe.description}</p>
<div className="flex gap-3 mb-3"> {recipe.ingredients_list && recipe.ingredients_list.map((ing: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2"> {ing}</p>)}
{recipe.prep_time && <span className="text-xs bg-dark/50 px-2 py-1 rounded-lg"> {recipe.prep_time}</span>} {recipe.steps && recipe.steps.map((step: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2 mt-1">{i+1}. {step}</p>)}
{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> </div>
)} )}
<p className="text-center text-gray-600 text-xs"> <div className="flex gap-2 mb-2">
<button onClick={handleShare} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">📤 Compartilhar</button>
<button onClick={handleAddToList} disabled={addedToList}
className={`flex-1 py-3 rounded-xl font-bold ${addedToList ? 'bg-green-500/20 text-green-400' : 'bg-accent/20 text-accent'}`}>
{addedToList ? '✓ Na lista' : '🛒 Adicionar'}
</button>
</div>
<p className="text-center text-gray-600 text-xs mt-4">
Escaneado em {new Date(detail.scanned_at).toLocaleString('pt-BR')} Escaneado em {new Date(detail.scanned_at).toLocaleString('pt-BR')}
</p> </p>
<BottomNav />
</div> </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 pb-24 max-w-lg mx-auto">
<nav className="flex items-center justify-between mb-8"> <nav className="flex items-center justify-between mb-8">
<Link href="/scan" className="text-gray-400 hover:text-white"> Voltar</Link> <Link href="/scan" className="text-gray-400 hover:text-white"> Scan</Link>
<span className="font-bold tracking-wider text-primary">Histórico</span> <span className="font-bold tracking-wider text-primary">Histórico</span>
<div /> <Link href="/compare" className="text-gray-400 text-sm hover:text-primary"> Comparar</Link>
</nav> </nav>
{loading ? ( {loading ? (
@@ -247,12 +255,11 @@ export default function HistoryPage() {
{detailLoading && ( {detailLoading && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <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">👁</div>
<div className="animate-spin text-4xl mb-4">👁</div>
<p className="text-gray-300">Carregando detalhes...</p>
</div>
</div> </div>
)} )}
<BottomNav />
</div> </div>
); );
} }

View File

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

View File

@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import BottomNav from '@/components/BottomNav';
import Link from 'next/link'; import Link from 'next/link';
export default function ScanPage() { export default function ScanPage() {
@@ -10,7 +11,12 @@ export default function ScanPage() {
const [manualCode, setManualCode] = useState(''); const [manualCode, setManualCode] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notFound, setNotFound] = useState(false);
const [photoLoading, setPhotoLoading] = useState(false);
const [result, setResult] = useState<any>(null); const [result, setResult] = useState<any>(null);
const [addedToList, setAddedToList] = useState(false);
const [newBadgeBanner, setNewBadgeBanner] = useState<string[]>([]);
const photoInputRef = useRef<HTMLInputElement>(null);
const { user, hydrate } = useAuthStore(); const { user, hydrate } = useAuthStore();
const router = useRouter(); const router = useRouter();
const scannerRef = useRef<any>(null); const scannerRef = useRef<any>(null);
@@ -23,8 +29,7 @@ export default function ScanPage() {
}, []); }, []);
const startScanner = async () => { const startScanner = async () => {
setScanning(true); setScanning(true); setError('');
setError('');
try { try {
const { Html5Qrcode } = await import('html5-qrcode'); const { Html5Qrcode } = await import('html5-qrcode');
const scanner = new Html5Qrcode('scanner-view'); const scanner = new Html5Qrcode('scanner-view');
@@ -32,36 +37,51 @@ export default function ScanPage() {
await scanner.start( await scanner.start(
{ facingMode: 'environment' }, { facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 150 } }, { fps: 10, qrbox: { width: 250, height: 150 } },
(decodedText) => { (decodedText) => { scanner.stop().catch(() => {}); setScanning(false); handleScan(decodedText); },
scanner.stop().catch(() => {});
setScanning(false);
handleScan(decodedText);
},
() => {} () => {}
); );
} catch (err) { } catch { setScanning(false); setError('Não foi possível acessar a câmera.'); }
setScanning(false);
setError('Não foi possível acessar a câmera. Use o código manual.');
}
}; };
const stopScanner = () => { const stopScanner = () => { scannerRef.current?.stop().catch(() => {}); setScanning(false); };
scannerRef.current?.stop().catch(() => {});
setScanning(false);
};
const handleScan = async (barcode: string) => { const handleScan = async (barcode: string) => {
setLoading(true); setLoading(true); setError(''); setNotFound(false); setResult(null); setAddedToList(false);
setError('');
setResult(null);
try { try {
const data = await api.scan(barcode); const data = await api.scan(barcode);
setResult(data); setResult(data);
if (data.new_badges?.length) { setNewBadgeBanner(data.new_badges); setTimeout(() => setNewBadgeBanner([]), 5000); }
} catch (err: any) { } catch (err: any) {
setError(err.message); if (err.message.includes('não encontrado')) setNotFound(true);
} finally { else setError(err.message);
setLoading(false); } finally { setLoading(false); }
} };
const handlePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return;
setPhotoLoading(true); setError(''); setNotFound(false);
try {
const data = await api.scanPhoto(file);
setResult(data);
if (data.new_badges?.length) { setNewBadgeBanner(data.new_badges); setTimeout(() => setNewBadgeBanner([]), 5000); }
} catch (err: any) { setError(err.message); }
finally { setPhotoLoading(false); }
};
const handleShare = () => {
if (!result) return;
const shareUrl = `${window.location.origin}/api/scan/${result.id}/share`;
const shareData = { title: `ALETHEIA: ${result.product_name}`, text: `Score: ${result.score}/100 - ${result.summary}`, url: shareUrl };
if (navigator.share) { navigator.share(shareData).catch(() => {}); }
else { navigator.clipboard.writeText(shareUrl); alert('Link copiado!'); }
};
const handleAddToList = async () => {
if (!result) return;
try {
await api.addToShoppingList(result.product_name || 'Produto', result.barcode);
setAddedToList(true);
} catch (e) {}
}; };
const getScoreLabel = (s: number) => { const getScoreLabel = (s: number) => {
@@ -72,23 +92,19 @@ export default function ScanPage() {
return { label: 'Péssimo', emoji: '🚫', desc: 'Muito prejudicial à saúde' }; return { label: 'Péssimo', emoji: '🚫', desc: 'Muito prejudicial à saúde' };
}; };
const getScoreColor = (score: number) => { const getScoreColor = (score: number) => score >= 71 ? '#10B981' : score >= 51 ? '#EAB308' : score >= 31 ? '#F97316' : '#EF4444';
if (score >= 71) return '#10B981'; const getClassColor = (c: string) => c === 'good' ? 'text-green-400' : c === 'warning' ? 'text-yellow-400' : 'text-red-400';
if (score >= 51) return '#EAB308'; const getClassIcon = (c: string) => c === 'good' ? '🟢' : c === 'warning' ? '🟡' : '🔴';
if (score >= 31) return '#F97316';
return '#EF4444';
};
const getClassColor = (c: string) => { const guessLevel = (nutrient: string, val: string) => {
if (c === 'good') return 'text-green-400'; const num = parseFloat(val) || 0;
if (c === 'warning') return 'text-yellow-400'; if (nutrient === 'acucar') return num > 15 ? 'high' : num > 5 ? 'mid' : 'low';
return 'text-red-400'; 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';
const getClassIcon = (c: string) => { if (nutrient === 'proteinas') return num > 10 ? 'low' : num > 3 ? 'mid' : 'high';
if (c === 'good') return '🟢'; if (nutrient === 'calorias') return num > 300 ? 'high' : num > 150 ? 'mid' : 'low';
if (c === 'warning') return '🟡'; return 'mid';
return '🔴';
}; };
const getNutritionBar = (label: string, value: string, level: string) => { 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 // 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 dashArray = result.score * 3.267 + ' 326.7';
const nutrition = result.nutrition || {}; const nutrition = result.nutrition || {};
const recipe = result.recipe; const recipe = result.recipe;
const allergenAlerts = result.allergen_alerts || [];
const substitutions = result.substitutions || [];
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 pb-24 max-w-lg mx-auto">
{/* New badge banner */}
{newBadgeBanner.length > 0 && (
<div className="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-primary/20 to-accent/20 backdrop-blur-xl border-b border-primary/20 p-4 text-center animate-fade-up">
<p className="text-sm">🏆 Nova conquista: <span className="font-bold text-primary">{newBadgeBanner.join(', ')}</span></p>
</div>
)}
{/* Allergen Alert Banner */}
{allergenAlerts.length > 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-2xl p-4 mb-4 animate-pulse">
<h3 className="font-bold text-red-400 text-sm mb-2"> ALERTA DE ALÉRGENOS!</h3>
{allergenAlerts.map((a: any, i: number) => (
<p key={i} className="text-red-300 text-xs">🔴 <b>{a.ingredient}</b> contém <b>{a.allergy}</b></p>
))}
</div>
)}
<button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white"> Novo Scan</button> <button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white"> Novo Scan</button>
{/* Score */} {/* Score */}
@@ -153,30 +177,18 @@ export default function ScanPage() {
<span className="text-lg">{getScoreLabel(result.score).emoji}</span> <span className="text-lg">{getScoreLabel(result.score).emoji}</span>
<span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(result.score).label}</span> <span className="font-bold text-lg ml-1" style={{ color }}>{getScoreLabel(result.score).label}</span>
</div> </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>
{/* Why this score */} {/* Summary */}
<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 */} {/* Nutrition */}
{Object.keys(nutrition).length > 0 && ( {Object.keys(nutrition).length > 0 && (
<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 mb-3 text-sm">📊 Informações Nutricionais</h3> <h3 className="font-semibold mb-3 text-sm">📊 Informações Nutricionais</h3>
{result.nutrition_verdict && ( {result.nutrition_verdict && <p className="text-gray-400 text-xs mb-3 italic">{result.nutrition_verdict}</p>}
<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.calorias && getNutritionBar('Calorias', nutrition.calorias, guessLevel('calorias', nutrition.calorias))}
{nutrition.acucar && getNutritionBar('Açúcar', nutrition.acucar, guessLevel('acucar', nutrition.acucar))} {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_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 && ( {result.positives?.length > 0 && (
<div className="bg-green-500/5 border border-green-500/20 rounded-xl p-3"> <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> <h3 className="font-semibold text-green-400 text-xs mb-2"> Positivos</h3>
{result.positives.map((p: string, i: number) => ( {result.positives.map((p: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>)}
<p key={i} className="text-gray-300 text-xs mb-1"> {p}</p>
))}
</div> </div>
)} )}
{result.negatives?.length > 0 && ( {result.negatives?.length > 0 && (
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-3"> <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> <h3 className="font-semibold text-red-400 text-xs mb-2"> Negativos</h3>
{result.negatives.map((n: string, i: number) => ( {result.negatives.map((n: string, i: number) => <p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>)}
<p key={i} className="text-gray-300 text-xs mb-1"> {n}</p>
))}
</div> </div>
)} )}
</div> </div>
@@ -214,11 +222,12 @@ export default function ScanPage() {
<h3 className="font-semibold mb-3 text-sm">📋 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 ${ing.is_allergen ? 'border-2 border-red-500/50 animate-pulse' : ''}`}>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span>{getClassIcon(ing.classification)}</span> <span>{ing.is_allergen ? '🚨' : getClassIcon(ing.classification)}</span>
<span className={'font-medium text-sm ' + getClassColor(ing.classification)}> <span className={`font-medium text-sm ${ing.is_allergen ? 'text-red-400 font-bold' : getClassColor(ing.classification)}`}>
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ' (' + ing.popular_name + ')' : ''} {ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ` (${ing.popular_name})` : ''}
{ing.is_allergen && ' ⚠️ ALÉRGENO'}
</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>
@@ -229,6 +238,27 @@ export default function ScanPage() {
</div> </div>
)} )}
{/* Substitutions */}
{substitutions?.length > 0 && result.score < 50 && (
<div className="bg-gradient-to-br from-green-500/10 to-primary/10 border border-green-500/20 rounded-2xl p-4 mb-4">
<h3 className="font-semibold text-sm mb-3">🔄 Alternativas Mais Saudáveis</h3>
<div className="space-y-3">
{substitutions.map((sub: any, i: number) => (
<div key={i} className="bg-dark/40 rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-green-400">{sub.name}</span>
{sub.estimated_score && (
<span className="text-xs bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full">~{sub.estimated_score}</span>
)}
</div>
{sub.brand && <p className="text-gray-500 text-xs">{sub.brand}</p>}
<p className="text-gray-400 text-xs mt-1">{sub.reason}</p>
</div>
))}
</div>
</div>
)}
{/* Recipe */} {/* Recipe */}
{recipe && ( {recipe && (
<div className="bg-gradient-to-br from-primary/10 to-accent/10 border border-primary/20 rounded-2xl p-4 mb-4"> <div className="bg-gradient-to-br from-primary/10 to-accent/10 border border-primary/20 rounded-2xl p-4 mb-4">
@@ -239,65 +269,47 @@ export default function ScanPage() {
{recipe.prep_time && <span className="text-xs bg-dark/50 px-2 py-1 rounded-lg"> {recipe.prep_time}</span>} {recipe.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>} {recipe.calories && <span className="text-xs bg-dark/50 px-2 py-1 rounded-lg">🔥 {recipe.calories}</span>}
</div> </div>
{recipe.ingredients_list && (
<div className="mb-3"> <div className="mb-3">
<p className="text-xs font-semibold text-gray-300 mb-1">Ingredientes:</p> <p className="text-xs font-semibold text-gray-300 mb-1">Ingredientes:</p>
{recipe.ingredients_list?.map((ing: string, i: number) => ( {recipe.ingredients_list.map((ing: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2"> {ing}</p>)}
<p key={i} className="text-gray-400 text-xs ml-2"> {ing}</p>
))}
</div> </div>
)}
{recipe.steps && (
<div className="mb-3"> <div className="mb-3">
<p className="text-xs font-semibold text-gray-300 mb-1">Preparo:</p> <p className="text-xs font-semibold text-gray-300 mb-1">Preparo:</p>
{recipe.steps?.map((step: string, i: number) => ( {recipe.steps.map((step: string, i: number) => <p key={i} className="text-gray-400 text-xs ml-2 mb-1">{i + 1}. {step}</p>)}
<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>
)} )}
{recipe.tip && <div className="bg-dark/30 rounded-lg p-2 mt-2"><p className="text-primary text-xs">💡 {recipe.tip}</p></div>}
</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 */} {/* Actions */}
<div className="flex gap-3"> <div className="flex gap-2 mb-4">
<button onClick={() => { <button onClick={handleShare} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">📤 Compartilhar</button>
if (navigator.share) { <button onClick={handleAddToList} disabled={addedToList}
navigator.share({ title: 'Aletheia: ' + result.product_name, text: 'Score: ' + result.score + '/100 - ' + result.summary, url: window.location.href }); className={`flex-1 py-3 rounded-xl font-bold ${addedToList ? 'bg-green-500/20 text-green-400' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}>
} {addedToList ? '✓ Na lista' : '🛒 Adicionar'}
}} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">
📤 Compartilhar
</button>
<button onClick={() => setResult(null)} className="flex-1 bg-dark-light text-white font-bold py-3 rounded-xl">
📷 Novo Scan
</button> </button>
</div> </div>
<button onClick={() => setResult(null)} className="w-full bg-dark-light text-white font-bold py-3 rounded-xl">📷 Novo Scan</button>
<BottomNav />
</div> </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 pb-24 max-w-lg mx-auto">
<nav className="flex items-center justify-between mb-8"> <nav className="flex items-center justify-between mb-8">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xl">👁</span> <span className="text-xl">👁</span>
<span className="font-bold tracking-wider text-primary">ALETHEIA</span> <span className="font-bold tracking-wider text-primary">ALETHEIA</span>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Link href="/history" className="text-gray-400 text-sm hover:text-white">Histórico</Link> <Link href="/compare" className="text-gray-400 text-sm hover:text-white">Comparar</Link>
<Link href="/premium" className="text-primary text-sm font-semibold">Premium</Link> <Link href="/achievements" className="text-gray-400 text-sm hover:text-white">🏆</Link>
</div> </div>
</nav> </nav>
@@ -307,68 +319,83 @@ export default function ScanPage() {
</div> </div>
{error && <div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-xl mb-4">{error}</div>} {error && <div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-xl mb-4">{error}</div>}
{loading && (
<div className="text-center py-20"> {notFound && !photoLoading && (
<div className="animate-spin text-4xl mb-4">👁</div> <div className="bg-orange-500/10 border border-orange-500/30 rounded-2xl p-6 mb-6 text-center">
<p className="text-gray-400">Analisando produto...</p> <span className="text-4xl mb-3 block">🔍</span>
<p className="text-gray-600 text-xs mt-2">Nossa IA está analisando cada ingrediente</p> <h3 className="font-bold text-orange-400 mb-2">Produto não encontrado</h3>
<p className="text-gray-400 text-sm mb-4">Tire uma foto do <b>rótulo</b> e nossa IA analisa.</p>
<input type="file" accept="image/*" capture="environment" ref={photoInputRef} onChange={handlePhoto} className="hidden" />
<button onClick={() => photoInputRef.current?.click()}
className="w-full bg-gradient-to-r from-orange-500 to-amber-500 text-white py-4 rounded-xl font-bold text-lg">
📷 Fotografar Rótulo
</button>
<button onClick={() => setNotFound(false)} className="text-gray-500 text-xs mt-3 underline">Tentar outro código</button>
</div> </div>
)} )}
{!loading && ( {(photoLoading || loading) && (
<div className="text-center py-20">
<div className="animate-spin text-4xl mb-4">{photoLoading ? '📷' : '👁️'}</div>
<p className="text-gray-400">{photoLoading ? 'Analisando foto...' : 'Analisando produto...'}</p>
</div>
)}
{!loading && !notFound && !photoLoading && (
<> <>
{/* Camera Scanner */}
<div className="mb-6"> <div className="mb-6">
{scanning ? ( {scanning ? (
<div> <div>
<div id="scanner-view" ref={scannerDivRef} className="rounded-2xl overflow-hidden mb-4" /> <div id="scanner-view" ref={scannerDivRef} className="rounded-2xl overflow-hidden mb-4" />
<button onClick={stopScanner} className="w-full bg-red-500/20 text-red-400 py-3 rounded-xl font-semibold"> <button onClick={stopScanner} className="w-full bg-red-500/20 text-red-400 py-3 rounded-xl font-semibold">Parar</button>
Parar Scanner
</button>
</div> </div>
) : ( ) : (
<button onClick={startScanner} className="w-full bg-primary text-dark py-6 rounded-2xl font-bold text-xl hover:bg-primary-dark transition transform hover:scale-[1.02] active:scale-95"> <div className="space-y-3">
<button onClick={startScanner} className="w-full bg-primary text-dark py-6 rounded-2xl font-bold text-xl hover:bg-primary-dark transition">
📷 Escanear Código de Barras 📷 Escanear Código de Barras
</button> </button>
<div>
<input type="file" accept="image/*" capture="environment" ref={photoInputRef} onChange={handlePhoto} className="hidden" />
<button onClick={() => photoInputRef.current?.click()}
className="w-full bg-accent/20 text-accent py-4 rounded-2xl font-bold text-lg hover:bg-accent/30 transition">
📸 Fotografar Rótulo
</button>
</div>
</div>
)} )}
</div> </div>
{/* Manual Input */}
<div className="relative mb-6"> <div className="relative mb-6">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-700" /></div> <div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-700" /></div>
<div className="relative flex justify-center"><span className="bg-dark px-4 text-gray-500 text-sm">ou digite o código</span></div> <div className="relative flex justify-center"><span className="bg-dark px-4 text-gray-500 text-sm">ou digite</span></div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 mb-8">
<input type="text" placeholder="Ex: 7891000100103" value={manualCode} onChange={e => setManualCode(e.target.value)} <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)} /> 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</button>
Buscar
</button>
</div> </div>
{/* Quick Demo */} <div>
<div className="mt-8"> <p className="text-gray-500 text-sm mb-3">🧪 Teste rápido:</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: '7894900011517' }, { name: 'Coca-Cola', code: '7894900011517' },
{ name: 'Nescau', code: '7891000379691' }, { name: 'Nescau', code: '7891000379691' },
{ name: 'Miojo', code: '7891079000212' }, { name: 'Miojo', code: '7891079000212' },
{ name: 'Aveia', code: '7894321219820' }, { name: 'Aveia', code: '7894321219820' },
{ 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">{p.name}</button>
{p.name}
</button>
))} ))}
</div> </div>
</div> </div>
</> </>
)} )}
<BottomNav />
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,36 @@ export const api = {
login: (data: { email: string; password: string }) => login: (data: { email: string; password: string }) =>
request('/api/auth/login', { method: 'POST', body: JSON.stringify(data) }), request('/api/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => request('/api/auth/me'), 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) => scan: (barcode: string) =>
request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }), request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }),
scanPhoto: async (file: File) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_URL}/api/scan/photo`, { method: 'POST', headers, body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Erro ao analisar foto' }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
},
history: () => request("/api/history"), history: () => request("/api/history"),
scanDetail: (id: number) => request(`/api/history/${id}`), 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' }),
}; };

39
migrate.sql Normal file
View File

@@ -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);