v0.2 - 19 features: comparator, allergies, gamification, shopping list, achievements, stats, profile, share, bottom nav
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
19
backend/app/models/achievement.py
Normal file
19
backend/app/models/achievement.py
Normal 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))
|
||||||
12
backend/app/models/shopping_list.py
Normal file
12
backend/app/models/shopping_list.py
Normal 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))
|
||||||
@@ -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))
|
||||||
|
|||||||
57
backend/app/routers/achievements.py
Normal file
57
backend/app/routers/achievements.py
Normal 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}
|
||||||
@@ -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)
|
||||||
|
|||||||
48
backend/app/routers/compare.py
Normal file
48
backend/app/routers/compare.py
Normal 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}
|
||||||
51
backend/app/routers/profile.py
Normal file
51
backend/app/routers/profile.py
Normal 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",
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/app/routers/share.py
Normal file
58
backend/app/routers/share.py
Normal 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)
|
||||||
52
backend/app/routers/shopping.py
Normal file
52
backend/app/routers/shopping.py
Normal 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}
|
||||||
87
backend/app/routers/stats.py
Normal file
87
backend/app/routers/stats.py
Normal 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}
|
||||||
@@ -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):
|
||||||
|
|||||||
59
backend/app/services/achievements.py
Normal file
59
backend/app/services/achievements.py
Normal 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
85
docs/generate-pdfs.py
Normal 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
66
docs/generate-pdfs.sh
Executable 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
247
docs/pdf-template.html
Normal 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>
|
||||||
78
frontend/src/app/achievements/page.tsx
Normal file
78
frontend/src/app/achievements/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
frontend/src/app/compare/page.tsx
Normal file
155
frontend/src/app/compare/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
frontend/src/app/profile/page.tsx
Normal file
128
frontend/src/app/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
116
frontend/src/app/shopping/page.tsx
Normal file
116
frontend/src/app/shopping/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/app/stats/page.tsx
Normal file
124
frontend/src/app/stats/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/BottomNav.tsx
Normal file
33
frontend/src/components/BottomNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
39
migrate.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user