📚 Documentação inicial do ALETHEIA
- MANUAL-PRODUTO.md: Manual do usuário final - MANUAL-VENDAS.md: Estratégia comercial e vendas - MANUAL-TECNICO.md: Infraestrutura e deploy - README.md: Visão geral do projeto
This commit is contained in:
199
frontend/src/app/globals.css
Normal file
199
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,199 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
|
||||
:root {
|
||||
--primary: #00D4AA;
|
||||
--primary-glow: #00FFD0;
|
||||
--accent: #7C3AED;
|
||||
--dark: #0A0E17;
|
||||
--dark-card: #111827;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--dark);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Premium Glassmorphism */
|
||||
.glass {
|
||||
background: rgba(17, 24, 39, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.glass-hover {
|
||||
background: rgba(17, 24, 39, 0.4);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glass-hover:hover {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
border-color: rgba(0, 212, 170, 0.2);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 0 40px rgba(0, 212, 170, 0.05);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #00D4AA, #00FFD0, #7C3AED);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradientX 3s ease infinite;
|
||||
}
|
||||
|
||||
.gradient-text-gold {
|
||||
background: linear-gradient(135deg, #FFD700, #FFA502, #FF6B6B);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Glow Button */
|
||||
.btn-glow {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #00D4AA, #00B894);
|
||||
color: #0A0E17;
|
||||
font-weight: 700;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem 2rem;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-glow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: linear-gradient(135deg, #00FFD0, #7C3AED, #00FFD0);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn-glow:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-glow:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(0, 212, 170, 0.4);
|
||||
}
|
||||
|
||||
/* Score Ring */
|
||||
.score-ring {
|
||||
filter: drop-shadow(0 0 15px var(--ring-color));
|
||||
}
|
||||
|
||||
/* Ambient Orbs */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb-primary {
|
||||
background: #00D4AA;
|
||||
}
|
||||
|
||||
.orb-accent {
|
||||
background: #7C3AED;
|
||||
}
|
||||
|
||||
/* Scan Animation */
|
||||
.scan-active {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, #00FFD0, transparent);
|
||||
animation: scanLine 2s linear infinite;
|
||||
box-shadow: 0 0 20px #00FFD0;
|
||||
}
|
||||
|
||||
/* Ingredient Pill */
|
||||
.pill-good {
|
||||
background: rgba(0, 212, 170, 0.1);
|
||||
border: 1px solid rgba(0, 212, 170, 0.3);
|
||||
color: #00D4AA;
|
||||
}
|
||||
|
||||
.pill-warning {
|
||||
background: rgba(255, 165, 2, 0.1);
|
||||
border: 1px solid rgba(255, 165, 2, 0.3);
|
||||
color: #FFA502;
|
||||
}
|
||||
|
||||
.pill-danger {
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
border: 1px solid rgba(255, 71, 87, 0.3);
|
||||
color: #FF4757;
|
||||
}
|
||||
|
||||
/* Shimmer effect */
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.05) 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Premium Card */
|
||||
.card-premium {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1), rgba(0, 212, 170, 0.05));
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
border-radius: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-premium::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(124, 58, 237, 0.5), rgba(0, 212, 170, 0.5), transparent);
|
||||
}
|
||||
|
||||
/* Noise texture overlay */
|
||||
.noise::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
opacity: 0.015;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0A0E17; }
|
||||
::-webkit-scrollbar-thumb { background: #1E293B; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #00D4AA; }
|
||||
|
||||
/* Mobile bottom safe area */
|
||||
@supports(padding: max(0px)) {
|
||||
.safe-bottom { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }
|
||||
}
|
||||
53
frontend/src/app/history/page.tsx
Normal file
53
frontend/src/app/history/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [scans, setScans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { hydrate } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
if (!localStorage.getItem('token')) { router.push('/login'); return; }
|
||||
api.history().then(setScans).catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const getScoreColor = (s: number) => s >= 71 ? 'text-green-400' : s >= 51 ? 'text-yellow-400' : s >= 31 ? 'text-orange-400' : 'text-red-400';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
|
||||
<nav className="flex items-center justify-between mb-8">
|
||||
<Link href="/scan" className="text-gray-400 hover:text-white">← Voltar</Link>
|
||||
<span className="font-bold tracking-wider text-primary">Histórico</span>
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-gray-400">Carregando...</div>
|
||||
) : scans.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-gray-500 text-lg mb-4">Nenhum scan ainda</p>
|
||||
<Link href="/scan" className="bg-primary text-dark px-6 py-3 rounded-xl font-bold">Escanear agora</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{scans.map(s => (
|
||||
<div key={s.id} className="bg-dark-light rounded-xl p-4 flex items-center justify-between">
|
||||
<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>}
|
||||
<p className="text-gray-600 text-xs mt-1">{new Date(s.scanned_at).toLocaleDateString('pt-BR')}</p>
|
||||
</div>
|
||||
<span className={`text-2xl font-black ${getScoreColor(s.score)}`}>{s.score}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/layout.tsx
Normal file
40
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import { InstallPrompt } from '@/components/InstallPrompt'
|
||||
import { ServiceWorkerRegister } from '@/components/ServiceWorkerRegister'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ALETHEIA — A verdade sobre o que você come',
|
||||
description: 'Escaneie qualquer produto e nossa IA revela o que a indústria alimentícia esconde nos rótulos.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'ALETHEIA',
|
||||
},
|
||||
other: {
|
||||
'mobile-web-app-capable': 'yes',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#1A7A4C',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt-BR" className="dark">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
<body className="bg-dark text-white min-h-screen antialiased">
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
85
frontend/src/app/login/page.tsx
Normal file
85
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { setAuth } = useAuthStore();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const res = await fetch(`${API}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Credenciais inválidas');
|
||||
setAuth(data.access_token, data.user);
|
||||
router.push('/scan');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Credenciais inválidas');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark flex items-center justify-center px-4 relative overflow-hidden">
|
||||
{/* Orbs */}
|
||||
<div className="orb orb-primary w-[400px] h-[400px] -top-[150px] -right-[100px]" />
|
||||
<div className="orb orb-accent w-[300px] h-[300px] bottom-[10%] -left-[100px]" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md animate-fade-up">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent flex items-center justify-center mx-auto mb-4">
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-black gradient-text">ALETHEIA</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">A verdade sobre o que você come</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="glass rounded-3xl p-10 space-y-5">
|
||||
<h2 className="text-xl font-bold">Entrar</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-dark border border-dark-border text-white placeholder-gray-600 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 transition"
|
||||
placeholder="seu@email.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Senha</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-dark border border-dark-border text-white placeholder-gray-600 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 transition"
|
||||
placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn-glow w-full !text-center disabled:opacity-50">
|
||||
{loading ? 'Entrando...' : 'Entrar →'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
Não tem conta? <Link href="/register" className="text-primary hover:underline">Criar conta grátis</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
frontend/src/app/page.tsx
Normal file
324
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
const DEMO_PRODUCTS = [
|
||||
{ name: 'Coca-Cola', score: 12, verdict: 'Péssimo', desc: '11 colheres de açúcar por lata', color: '#FF4757', emoji: '🥤' },
|
||||
{ name: 'Miojo Nissin', score: 18, verdict: 'Péssimo', desc: '67% do sódio diário em 1 pacote', color: '#FF4757', emoji: '🍜' },
|
||||
{ name: 'Nescau 2.0', score: 28, verdict: 'Ruim', desc: '3 tipos de açúcar disfarçado', color: '#FF6B6B', emoji: '🍫' },
|
||||
{ name: 'Iogurte Grego', score: 45, verdict: 'Regular', desc: 'Mais açúcar que proteína real', color: '#FFA502', emoji: '🥛' },
|
||||
{ name: 'Aveia Quaker', score: 92, verdict: 'Excelente', desc: '100% aveia integral, sem aditivos', color: '#00D4AA', emoji: '🌾' },
|
||||
];
|
||||
|
||||
function ScoreCircle({ score, size = 120 }: { score: number; size?: number }) {
|
||||
const radius = (size - 12) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
const color = score >= 70 ? '#00D4AA' : score >= 50 ? '#FFA502' : score >= 30 ? '#FF6B6B' : '#FF4757';
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="score-ring -rotate-90" style={{ '--ring-color': color } as any}>
|
||||
<circle cx={size/2} cy={size/2} r={radius} stroke="#1E293B" strokeWidth="8" fill="none" />
|
||||
<circle cx={size/2} cy={size/2} r={radius} stroke={color} strokeWidth="8" fill="none"
|
||||
strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
|
||||
className="transition-all duration-1000 ease-out" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-black" style={{ color }}>{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingOrbs() {
|
||||
return (
|
||||
<>
|
||||
<div className="orb orb-primary w-[500px] h-[500px] -top-[200px] -left-[200px] animate-float" />
|
||||
<div className="orb orb-accent w-[400px] h-[400px] top-[40%] -right-[150px]" style={{ animationDelay: '3s' }} />
|
||||
<div className="orb orb-primary w-[300px] h-[300px] bottom-[10%] left-[20%] animate-float" style={{ animationDelay: '5s' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { user, hydrate } = useAuthStore();
|
||||
const [activeDemo, setActiveDemo] = useState(0);
|
||||
|
||||
useEffect(() => { hydrate(); }, []);
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setActiveDemo(i => (i + 1) % DEMO_PRODUCTS.length), 3000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const demo = DEMO_PRODUCTS[activeDemo];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark noise">
|
||||
<FloatingOrbs />
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="relative z-20 flex items-center justify-between px-6 lg:px-16 py-5 border-b border-white/5 backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="4" /><line x1="12" y1="2" x2="12" y2="6" /><line x1="12" y1="18" x2="12" y2="22" /><line x1="2" y1="12" x2="6" y2="12" /><line x1="18" y1="12" x2="22" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-black tracking-wider gradient-text">ALETHEIA</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{user ? (
|
||||
<Link href="/scan" className="btn-glow text-sm !py-2 !px-5">Escanear →</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="text-gray-400 hover:text-white text-sm transition">Entrar</Link>
|
||||
<Link href="/register" className="btn-glow text-sm !py-2 !px-5">Começar Grátis</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative z-10 max-w-6xl mx-auto px-6 pt-20 pb-32">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Left - Text */}
|
||||
<div className="animate-fade-up">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass text-xs text-primary mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
Powered by IA • 100% gratuito para começar
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-black leading-[1.05] mb-6">
|
||||
A verdade<br />
|
||||
<span className="gradient-text">nua e crua</span><br />
|
||||
sobre o que<br />
|
||||
você come.
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg mb-10 max-w-md leading-relaxed">
|
||||
Escaneie qualquer produto e nossa IA revela o que a indústria alimentícia esconde nos rótulos.
|
||||
Sem jargão. Sem mentira.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Link href={user ? "/scan" : "/register"} className="btn-glow text-center text-lg">
|
||||
📷 Escanear Agora
|
||||
</Link>
|
||||
<a href="#como-funciona" className="px-6 py-4 rounded-2xl glass text-center text-white font-semibold hover:bg-white/5 transition">
|
||||
Como funciona
|
||||
</a>
|
||||
</div>
|
||||
{/* Stats */}
|
||||
<div className="flex gap-10 mt-14">
|
||||
{[
|
||||
{ value: '10K+', label: 'Produtos' },
|
||||
{ value: '<5s', label: 'Análise' },
|
||||
{ value: '100%', label: 'Honesto' },
|
||||
].map((s, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-2xl font-black gradient-text">{s.value}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Interactive Demo Card */}
|
||||
<div className="relative animate-fade-up" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="glass-hover p-8 relative overflow-hidden">
|
||||
{/* Shimmer top border */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-primary/50 to-transparent" />
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{demo.emoji}</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{demo.name}</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: demo.color, background: `${demo.color}15`, border: `1px solid ${demo.color}30` }}>
|
||||
{demo.verdict}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ScoreCircle score={demo.score} size={90} />
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 text-sm mb-6">{demo.desc}</p>
|
||||
|
||||
{/* Fake ingredient bars */}
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ name: 'Açúcar', pct: 85, risk: 'danger' },
|
||||
{ name: 'Sódio', pct: 70, risk: 'warning' },
|
||||
{ name: 'Fibras', pct: 10, risk: 'good' },
|
||||
].map((ing, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-300">{ing.name}</span>
|
||||
<span className={`pill-${ing.risk} px-2 py-0.5 rounded-full text-[10px]`}>
|
||||
{ing.risk === 'danger' ? 'Alto' : ing.risk === 'warning' ? 'Médio' : 'Baixo'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-dark-border rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-1000" style={{
|
||||
width: `${ing.pct}%`,
|
||||
background: ing.risk === 'danger' ? '#FF4757' : ing.risk === 'warning' ? '#FFA502' : '#00D4AA'
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation dots */}
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
{DEMO_PRODUCTS.map((_, i) => (
|
||||
<button key={i} onClick={() => setActiveDemo(i)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${i === activeDemo ? 'w-6 bg-primary' : 'bg-gray-600'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating badges */}
|
||||
<div className="absolute -top-4 -right-4 glass px-4 py-2 rounded-xl text-xs animate-float">
|
||||
🤖 IA Analisando...
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -left-4 glass px-4 py-2 rounded-xl text-xs animate-float" style={{ animationDelay: '2s' }}>
|
||||
⚡ 4.2s de análise
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section id="como-funciona" className="relative z-10 py-24 border-t border-white/5">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-5xl font-black mb-4">
|
||||
Simples como <span className="gradient-text">tirar uma foto</span>
|
||||
</h2>
|
||||
<p className="text-gray-500 max-w-lg mx-auto">Três passos para nunca mais ser enganado no supermercado</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ step: '01', icon: '📷', title: 'Escaneie', desc: 'Aponte a câmera para o código de barras. Funciona com qualquer produto.', gradient: 'from-primary/20 to-transparent' },
|
||||
{ step: '02', icon: '🧠', title: 'IA Analisa', desc: 'Nossa IA lê cada ingrediente e traduz o juridiquês alimentar em português claro.', gradient: 'from-accent/20 to-transparent' },
|
||||
{ step: '03', icon: '✅', title: 'Verdade Revelada', desc: 'Score de 0 a 100, ingredientes com semáforo e alternativas mais saudáveis.', gradient: 'from-primary/20 to-transparent' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="glass-hover p-8 relative group" style={{ animationDelay: `${i * 0.15}s` }}>
|
||||
<div className={`absolute inset-0 bg-gradient-to-b ${s.gradient} rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
<div className="relative">
|
||||
<span className="text-xs font-bold text-gray-600 tracking-widest">{s.step}</span>
|
||||
<div className="text-5xl my-4">{s.icon}</div>
|
||||
<h3 className="text-xl font-bold mb-3">{s.title}</h3>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Shocking Stats */}
|
||||
<section className="relative z-10 py-24 bg-gradient-to-b from-dark via-dark-card/50 to-dark">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<h2 className="text-3xl md:text-5xl font-black text-center mb-16">
|
||||
O que a indústria <span className="text-danger">não quer</span> que você saiba
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ number: '200+', desc: 'Nomes diferentes que a indústria usa para esconder AÇÚCAR nos rótulos', icon: '🍬' },
|
||||
{ number: '67%', desc: 'Dos produtos com claims "saudáveis" são ultraprocessados (NUPENS/USP)', icon: '🏷️' },
|
||||
{ number: '60%', desc: 'Dos brasileiros com sobrepeso — a maior taxa da história', icon: '⚠️' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="text-center p-8 glass-hover">
|
||||
<span className="text-4xl">{s.icon}</span>
|
||||
<div className="text-5xl md:text-6xl font-black gradient-text-gold mt-4 mb-3">{s.number}</div>
|
||||
<p className="text-gray-400 text-sm">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="relative z-10 py-24 border-t border-white/5">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<h2 className="text-3xl md:text-5xl font-black text-center mb-4">
|
||||
Comece <span className="gradient-text">grátis</span>
|
||||
</h2>
|
||||
<p className="text-gray-500 text-center mb-16">Sem cartão de crédito. Sem pegadinha. Upgrade quando quiser.</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Free */}
|
||||
<div className="glass-hover p-10">
|
||||
<h3 className="text-xl font-bold mb-2">Grátis</h3>
|
||||
<div className="flex items-baseline gap-1 mb-6">
|
||||
<span className="text-5xl font-black">R$0</span>
|
||||
<span className="text-gray-500">/sempre</span>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-300 text-sm mb-8">
|
||||
{['3 scans por dia', 'Score de saúde 0-100', 'Análise IA dos ingredientes', 'Semáforo de risco'].map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-3"><span className="text-primary">✓</span> {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/register" className="block w-full py-3 rounded-xl text-center glass hover:bg-white/5 font-semibold transition">
|
||||
Começar Grátis
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Premium */}
|
||||
<div className="card-premium p-10 relative">
|
||||
<span className="absolute top-4 right-4 text-[10px] font-bold tracking-widest text-accent bg-accent/10 px-3 py-1 rounded-full border border-accent/20">
|
||||
POPULAR
|
||||
</span>
|
||||
<h3 className="text-xl font-bold mb-2 gradient-text">Premium</h3>
|
||||
<div className="flex items-baseline gap-1 mb-6">
|
||||
<span className="text-5xl font-black">R$9,90</span>
|
||||
<span className="text-gray-500">/mês</span>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-300 text-sm mb-8">
|
||||
{['Scans ilimitados', 'Análise IA detalhada', 'Alternativas mais saudáveis', 'Histórico completo', 'Perfil alimentar', 'Sem anúncios'].map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-3"><span className="text-accent">✓</span> {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/premium" className="btn-glow block w-full text-center !bg-gradient-to-r !from-accent !to-primary">
|
||||
Assinar Premium
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="relative z-10 py-24">
|
||||
<div className="max-w-3xl mx-auto px-6 text-center">
|
||||
<div className="text-6xl mb-6">👁️</div>
|
||||
<h2 className="text-3xl md:text-5xl font-black mb-6">
|
||||
Pronto para ver a <span className="gradient-text">verdade</span>?
|
||||
</h2>
|
||||
<p className="text-gray-400 text-lg mb-10 max-w-md mx-auto">
|
||||
Junte-se a milhares de brasileiros que decidiram parar de ser enganados.
|
||||
</p>
|
||||
<Link href={user ? "/scan" : "/register"} className="btn-glow inline-block text-lg">
|
||||
📷 Escanear Primeiro Produto
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t border-white/5 py-10 px-6">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-accent flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold gradient-text">ALETHEIA</span>
|
||||
<span className="text-xs text-gray-600">ἀλήθεια — verdade</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-xs">Informações educativas. Não substitui orientação profissional. © 2026</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/app/premium/page.tsx
Normal file
44
frontend/src/app/premium/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function PremiumPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
|
||||
<nav className="flex items-center justify-between mb-8">
|
||||
<Link href="/scan" className="text-gray-400 hover:text-white">← Voltar</Link>
|
||||
<span className="font-bold tracking-wider text-primary">Premium</span>
|
||||
<div />
|
||||
</nav>
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-6xl">👁️</span>
|
||||
<h1 className="text-3xl font-black mt-4 mb-2">ALETHEIA <span className="text-primary">PRO</span></h1>
|
||||
<p className="text-gray-400">Desbloqueie todo o poder da verdade</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-primary/20 to-dark-light rounded-2xl p-8 border border-primary/30 mb-8">
|
||||
<p className="text-center text-4xl font-black mb-1">R$ 9,90<span className="text-sm font-normal text-gray-400">/mês</span></p>
|
||||
<p className="text-center text-gray-400 text-sm mb-6">ou R$ 79,90/ano (economize 33%)</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
'✅ Scans ilimitados',
|
||||
'✅ Análise IA detalhada com perfil personalizado',
|
||||
'✅ Histórico completo de todos os scans',
|
||||
'✅ Top 5 alternativas saudáveis',
|
||||
'✅ Alertas personalizados (alergias, dietas)',
|
||||
'✅ Export CSV/PDF',
|
||||
'✅ Sem anúncios',
|
||||
].map((f, i) => (
|
||||
<p key={i} className="text-gray-300 text-sm">{f}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full bg-primary text-dark font-bold py-4 rounded-2xl text-lg hover:bg-primary-dark transition">
|
||||
Assinar Premium
|
||||
</button>
|
||||
<p className="text-center text-gray-500 text-xs mt-4">Cancele quando quiser. Sem compromisso.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/app/register/page.tsx
Normal file
95
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { setAuth } = useAuthStore();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const res = await fetch(`${API}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Erro ao criar conta');
|
||||
setAuth(data.access_token, data.user);
|
||||
router.push('/scan');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erro ao criar conta');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark flex items-center justify-center px-4 relative overflow-hidden">
|
||||
<div className="orb orb-primary w-[400px] h-[400px] -top-[150px] -left-[100px]" />
|
||||
<div className="orb orb-accent w-[300px] h-[300px] bottom-[10%] -right-[100px]" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md animate-fade-up">
|
||||
<div className="text-center mb-10">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent flex items-center justify-center mx-auto mb-4">
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-black gradient-text">ALETHEIA</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Comece a ver a verdade</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="glass rounded-3xl p-10 space-y-5">
|
||||
<h2 className="text-xl font-bold">Criar Conta Grátis</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Nome</label>
|
||||
<input type="text" value={name} onChange={e => setName(e.target.value)} required
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-dark border border-dark-border text-white placeholder-gray-600 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 transition"
|
||||
placeholder="Seu nome" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-dark border border-dark-border text-white placeholder-gray-600 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 transition"
|
||||
placeholder="seu@email.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Senha</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={6}
|
||||
className="w-full px-4 py-3.5 rounded-xl bg-dark border border-dark-border text-white placeholder-gray-600 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 transition"
|
||||
placeholder="Mínimo 6 caracteres" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-danger text-sm">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn-glow w-full !text-center disabled:opacity-50">
|
||||
{loading ? 'Criando...' : 'Criar Conta Grátis →'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm">
|
||||
Já tem conta? <Link href="/login" className="text-primary hover:underline">Entrar</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-600 text-xs mt-6">
|
||||
🔒 Seus dados são protegidos. Sem spam, sem venda de dados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
frontend/src/app/scan/page.tsx
Normal file
252
frontend/src/app/scan/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ScanPage() {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [manualCode, setManualCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const { user, hydrate } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<any>(null);
|
||||
const scannerDivRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) router.push('/login');
|
||||
}, []);
|
||||
|
||||
const startScanner = async () => {
|
||||
setScanning(true);
|
||||
setError('');
|
||||
try {
|
||||
const { Html5Qrcode } = await import('html5-qrcode');
|
||||
const scanner = new Html5Qrcode('scanner-view');
|
||||
scannerRef.current = scanner;
|
||||
await scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 150 } },
|
||||
(decodedText) => {
|
||||
scanner.stop().catch(() => {});
|
||||
setScanning(false);
|
||||
handleScan(decodedText);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
} catch (err) {
|
||||
setScanning(false);
|
||||
setError('Não foi possível acessar a câmera. Use o código manual.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopScanner = () => {
|
||||
scannerRef.current?.stop().catch(() => {});
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleScan = async (barcode: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.scan(barcode);
|
||||
setResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 71) return '#10B981';
|
||||
if (score >= 51) return '#EAB308';
|
||||
if (score >= 31) return '#F97316';
|
||||
return '#EF4444';
|
||||
};
|
||||
|
||||
const getClassColor = (c: string) => {
|
||||
if (c === 'good') return 'text-green-400';
|
||||
if (c === 'warning') return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
const getClassIcon = (c: string) => {
|
||||
if (c === 'good') return '🟢';
|
||||
if (c === 'warning') return '🟡';
|
||||
return '🔴';
|
||||
};
|
||||
|
||||
// Result view
|
||||
if (result) {
|
||||
const color = getScoreColor(result.score);
|
||||
return (
|
||||
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
|
||||
<button onClick={() => setResult(null)} className="text-gray-400 mb-4 hover:text-white">← Voltar</button>
|
||||
|
||||
{/* Score */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-lg font-semibold mb-1">{result.product_name || 'Produto'}</h2>
|
||||
{result.brand && <p className="text-gray-500 text-sm">{result.brand}</p>}
|
||||
<div className="relative w-40 h-40 mx-auto mt-6">
|
||||
<svg viewBox="0 0 120 120" className="w-full h-full -rotate-90">
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke="#374151" strokeWidth="10" />
|
||||
<circle cx="60" cy="60" r="52" fill="none" stroke={color} strokeWidth="10"
|
||||
strokeDasharray={`${result.score * 3.267} 326.7`} strokeLinecap="round" className="transition-all duration-1000" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-black" style={{ color }}>{result.score}</span>
|
||||
<span className="text-gray-500 text-sm">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-dark-light rounded-2xl p-4 mb-4">
|
||||
<p className="text-gray-300 text-sm leading-relaxed">{result.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Positives & Negatives */}
|
||||
{result.positives?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-green-400 mb-2">✅ Positivos</h3>
|
||||
{result.positives.map((p: string, i: number) => (
|
||||
<p key={i} className="text-gray-300 text-sm ml-4 mb-1">• {p}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.negatives?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-red-400 mb-2">❌ Negativos</h3>
|
||||
{result.negatives.map((n: string, i: number) => (
|
||||
<p key={i} className="text-gray-300 text-sm ml-4 mb-1">• {n}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
{result.ingredients?.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="font-semibold mb-3">📋 Ingredientes</h3>
|
||||
<div className="space-y-2">
|
||||
{result.ingredients.map((ing: any, i: number) => (
|
||||
<div key={i} className="bg-dark-light rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span>{getClassIcon(ing.classification)}</span>
|
||||
<span className={`font-medium text-sm ${getClassColor(ing.classification)}`}>
|
||||
{ing.name}{ing.popular_name && ing.popular_name !== ing.name ? ` (${ing.popular_name})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs ml-6">{ing.explanation}</p>
|
||||
<p className="text-gray-500 text-xs ml-6 italic">{ing.reason}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: `Aletheia: ${result.product_name}`, text: `Score: ${result.score}/100 - ${result.summary}`, url: window.location.href });
|
||||
}
|
||||
}} className="flex-1 bg-primary text-dark font-bold py-3 rounded-xl">
|
||||
📤 Compartilhar
|
||||
</button>
|
||||
<button onClick={() => setResult(null)} className="flex-1 bg-dark-light text-white font-bold py-3 rounded-xl">
|
||||
📷 Novo Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark px-4 py-6 max-w-lg mx-auto">
|
||||
<nav className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">👁️</span>
|
||||
<span className="font-bold tracking-wider text-primary">ALETHEIA</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/history" className="text-gray-400 text-sm hover:text-white">Histórico</Link>
|
||||
<Link href="/premium" className="text-primary text-sm font-semibold">Premium</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Escanear Produto</h1>
|
||||
<p className="text-gray-400 text-sm">Aponte a câmera para o código de barras</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-xl mb-4">{error}</div>}
|
||||
{loading && (
|
||||
<div className="text-center py-20">
|
||||
<div className="animate-spin text-4xl mb-4">👁️</div>
|
||||
<p className="text-gray-400">Analisando produto...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Camera Scanner */}
|
||||
<div className="mb-6">
|
||||
{scanning ? (
|
||||
<div>
|
||||
<div id="scanner-view" ref={scannerDivRef} className="rounded-2xl overflow-hidden mb-4" />
|
||||
<button onClick={stopScanner} className="w-full bg-red-500/20 text-red-400 py-3 rounded-xl font-semibold">
|
||||
Parar Scanner
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={startScanner} className="w-full bg-primary text-dark py-6 rounded-2xl font-bold text-xl hover:bg-primary-dark transition transform hover:scale-[1.02] active:scale-95">
|
||||
📷 Escanear Código de Barras
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Input */}
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-700" /></div>
|
||||
<div className="relative flex justify-center"><span className="bg-dark px-4 text-gray-500 text-sm">ou digite o código</span></div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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" />
|
||||
<button onClick={() => manualCode && handleScan(manualCode)} disabled={!manualCode}
|
||||
className="bg-primary text-dark px-6 py-3 rounded-xl font-bold disabled:opacity-50">
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Demo */}
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-500 text-sm mb-3">🧪 Teste com produtos demo:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ name: 'Coca-Cola', code: '7891000100103' },
|
||||
{ name: 'Nescau', code: '7891000053508' },
|
||||
{ name: 'Miojo', code: '7891000305232' },
|
||||
{ name: 'Aveia', code: '7891000362006' },
|
||||
{ name: 'Oreo', code: '7622300830236' },
|
||||
].map(p => (
|
||||
<button key={p.code} onClick={() => handleScan(p.code)}
|
||||
className="bg-dark-light text-gray-300 px-3 py-1.5 rounded-lg text-xs hover:bg-gray-600 transition">
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/InstallPrompt.tsx
Normal file
42
frontend/src/components/InstallPrompt.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function InstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: any) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e);
|
||||
setShow(true);
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
return () => window.removeEventListener('beforeinstallprompt', handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50 bg-gradient-to-r from-[#1A7A4C] to-[#0d5c38] text-white p-4 rounded-2xl shadow-2xl flex items-center justify-between gap-3 animate-fade-up">
|
||||
<div>
|
||||
<p className="font-bold text-sm">📲 Instalar ALETHEIA</p>
|
||||
<p className="text-xs text-white/70">Acesse direto da tela inicial</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShow(false)} className="text-xs text-white/50 px-3 py-2">Depois</button>
|
||||
<button onClick={handleInstall} className="bg-white text-[#1A7A4C] font-bold text-sm px-4 py-2 rounded-xl">
|
||||
Instalar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/ServiceWorkerRegister.tsx
Normal file
11
frontend/src/components/ServiceWorkerRegister.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
28
frontend/src/lib/api.ts
Normal file
28
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8090';
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Erro de rede' }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register: (data: { email: string; name: string; password: string }) =>
|
||||
request('/api/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
request('/api/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
me: () => request('/api/auth/me'),
|
||||
scan: (barcode: string) =>
|
||||
request('/api/scan', { method: 'POST', body: JSON.stringify({ barcode }) }),
|
||||
history: () => request('/api/history'),
|
||||
};
|
||||
38
frontend/src/stores/authStore.ts
Normal file
38
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
is_premium: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
hydrate: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
setAuth: (token, user) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
set({ token, user });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
hydrate: () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
set({ token, user: JSON.parse(userStr) });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user