📚 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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user