feat: complete NutriScan redesign

This commit is contained in:
evandrodevbr committed 2026-07-16 16:31:50 -03:00
1 parent c1fe4af997
commit f71cd167b3
163 files changed
+30802 -14169

No files matched your search

+2 -2
View File
@@ -5,9 +5,9 @@ const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)";
export async function GET(
request: NextRequest,
{ params }: { params: { barcode: string } }
{ params }: { params: Promise<{ barcode: string }> }
) {
const { barcode } = params;
const { barcode } = await params;
const searchParams = request.nextUrl.searchParams;
const cacheOnly = searchParams.get("cache_only") === "true";
+2 -2
View File
@@ -21,7 +21,7 @@ export async function CardList({ listaId }: CardListProps) {
if (produtos.length === 0) {
return (
<div className="text-center p-8 text-gray-500">
<div className="text-center p-8" style={{ color: 'var(--fg-muted)' }}>
Nenhum produto cadastrado ainda.
</div>
);
@@ -47,7 +47,7 @@ export async function CardList({ listaId }: CardListProps) {
{produtosComprados.length > 0 && (
<section>
<h2 className="text-2xl font-bold mb-4 text-green-700">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--accent)' }}>
Produtos Comprados
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+1 -2
View File
@@ -42,7 +42,6 @@ export function DeleteButton({ produtoId, produtoNome, listaId }: DeleteButtonPr
<Button
variant="destructive"
size="icon"
className="text-white hover:bg-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -59,7 +58,7 @@ export function DeleteButton({ produtoId, produtoNome, listaId }: DeleteButtonPr
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? 'Excluindo...' : 'Excluir'}
+81 -126
View File
@@ -1,8 +1,7 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Check, Code } from "lucide-react";
import { Heart, Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface DonateCardProps {
className?: string;
@@ -10,141 +9,97 @@ interface DonateCardProps {
}
export function DonateCard({ className = "", variant = "default" }: DonateCardProps) {
// Layout mobile horizontal
/* ── Mobile horizontal ───────────────────────────────────────────────── */
if (variant === "mobile") {
return (
<Card
className={`donate-card group relative overflow-hidden hover:shadow-lg transition-all duration-200 bg-gradient-to-r from-blue-50 to-green-50 dark:from-blue-900/20 dark:to-green-900/20 border border-blue-200 dark:border-blue-800 ${className}`}
<article
className={cn(
"card-warm rounded-xl overflow-hidden flex",
"border-t-2 border-t-[var(--accent)]",
className
)}
>
<div className="flex gap-3 p-3">
{/* Ícone - tamanho ajustado */}
<div className="w-16 h-16 flex-shrink-0 relative bg-gradient-to-br from-blue-500 to-green-500 rounded-lg overflow-hidden flex items-center justify-center">
<Heart className="w-8 h-8 text-white animate-pulse" />
</div>
{/* Conteúdo - melhor espaçamento */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1">
{/* Título */}
<h3 className="font-bold text-sm text-gray-900 dark:text-white mb-1">
Ajude o Projeto
</h3>
{/* Texto persuasivo */}
<p className="text-xs text-gray-600 dark:text-gray-300 mb-2">
Mantenha este serviço gratuito e acessível para todos
</p>
{/* Benefício rápido */}
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="w-3 h-3" />
<span>Qualquer valor faz diferença</span>
</div>
</div>
{/* Botão CTA - menor */}
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block"
>
<Button
size="sm"
className="w-full bg-gradient-to-r from-blue-600 to-green-600 hover:from-blue-700 hover:to-green-700 text-white text-xs font-medium h-8"
>
<Heart className="w-3 h-3 mr-1" />
Doar
</Button>
</a>
<div className="flex-1 p-4 flex flex-col justify-between gap-3">
<div>
<h3 className="font-sans font-semibold text-[var(--fg-primary)] text-sm mb-1">
Apoie o Projeto
</h3>
<p className="label text-[var(--fg-muted)]">
Mantenha o NutriScan gratuito e independente.
</p>
</div>
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="btn-accent self-start py-2 px-4 text-xs"
>
<Heart className="w-3 h-3" strokeWidth={1.5} />
Contribuir
</a>
</div>
</Card>
</article>
);
}
// Layout desktop padrão
/* ── Desktop card ────────────────────────────────────────────────────── */
return (
<Card
className={`donate-card group relative overflow-hidden hover:shadow-2xl transition-all duration-300 hover:-translate-y-1 bg-gradient-to-br from-blue-50 to-green-50 dark:from-blue-900/20 dark:to-green-900/20 border border-blue-200 dark:border-blue-800 ${className}`}
<article
className={cn(
"card-warm hover-lift rounded-xl overflow-hidden flex flex-col h-full",
"border-t-2 border-t-[var(--accent)]",
className
)}
>
{/* Header com ícone animado */}
<div className="relative h-32 bg-gradient-to-br from-blue-500 to-green-500 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<Heart className="w-16 h-16 text-white animate-pulse" />
<div className="p-8 flex flex-col flex-1 gap-6">
{/* Eyebrow */}
<span className="label text-[var(--accent)]">Apoie o Projeto</span>
{/* Headline */}
<div>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] leading-tight mb-2">
Transparência alimentar,{" "}
<em className="font-display-i">para todos.</em>
</h3>
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed">
O NutriScan opera sem anúncios e sem venda de dados. Sua
contribuição mantém o projeto vivo.
</p>
</div>
{/* Overlay sutil */}
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
{/* Badge de destaque */}
<div className="absolute top-3 right-3">
<div className="bg-white/90 dark:bg-gray-800/90 text-blue-600 dark:text-blue-400 rounded-full px-3 py-1 text-xs font-bold">
💛 Apoie
</div>
{/* Benefits */}
<ul className="space-y-2.5">
{[
"Servidores sempre disponíveis",
"Novas funcionalidades",
"Acesso gratuito para todos",
].map((item) => (
<li key={item} className="flex items-center gap-2.5">
<Check
className="w-3.5 h-3.5 text-[var(--accent)] shrink-0"
strokeWidth={2}
/>
<span className="text-sm text-[var(--fg-secondary)]">{item}</span>
</li>
))}
</ul>
{/* CTA */}
<div className="mt-auto pt-2">
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="btn-accent w-full justify-center py-3.5 text-sm"
>
<Heart className="w-4 h-4" strokeWidth={1.5} />
Contribuir agora
</a>
<p className="label text-center text-[var(--fg-faint)] mt-3">
Qualquer valor ajuda
</p>
</div>
</div>
<CardContent className="p-5 space-y-4">
{/* Título principal */}
<div className="space-y-2">
<h3 className="font-bold text-lg leading-tight text-gray-900 dark:text-white">
Apoie a Transparência Alimentar
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Projeto mantido por um estudante dedicado
</p>
</div>
{/* Benefícios */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-400">Servidores sempre disponíveis</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-400">Novas funcionalidades</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-400">Acesso gratuito para todos</span>
</div>
</div>
{/* Mensagem humanizada */}
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<Code className="w-4 h-4 text-blue-500" />
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
Desenvolvido com amor
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Cada doação ajuda a manter este projeto vivo e acessível
</p>
</div>
{/* Botão CTA principal */}
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Button
className="w-full bg-gradient-to-r from-blue-600 to-green-600 hover:from-blue-700 hover:to-green-700 text-white font-medium h-12 text-base"
>
<Heart className="w-5 h-5 mr-2" />
Doar Qualquer Valor
</Button>
</a>
{/* Texto de incentivo */}
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
Qualquer valor faz diferença 💛
</p>
</CardContent>
</Card>
</article>
);
}
+221 -398
View File
@@ -120,106 +120,37 @@ export function FilterSidebar({
});
return (
<div className={`w-full max-w-sm space-y-6 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Filtros</h3>
<div className={`w-full space-y-5 ${className}`}>
{/* ── Header ────────────────────────────────────────────────────────── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: 12, borderBottom: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Filter style={{ width: 15, height: 15, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: 10, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--fg-muted, #9a8870)", fontWeight: 600 }}>
Filtros avançados
</span>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
<button
onClick={onClearFilters}
className="text-xs text-muted-foreground hover:text-foreground"
style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, color: "var(--accent, oklch(52% 0.11 155))", background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", letterSpacing: "0.03em" }}
>
<X className="h-4 w-4 mr-1" />
Limpar
</Button>
<X style={{ width: 12, height: 12 }} strokeWidth={2} />
Limpar tudo
</button>
)}
</div>
{/* Resultados */}
{/* ── Result count ──────────────────────────────────────────────────── */}
{totalResults > 0 && (
<div className="text-sm text-muted-foreground">
{totalResults.toLocaleString()} produto{totalResults !== 1 ? "s" : ""}{" "}
encontrado{totalResults !== 1 ? "s" : ""}
<div style={{ fontFamily: "ui-monospace, monospace", fontSize: 11, color: "var(--fg-muted, #9a8870)", letterSpacing: "0.04em" }}>
{totalResults.toLocaleString()} produto{totalResults !== 1 ? "s" : ""} encontrado{totalResults !== 1 ? "s" : ""}
</div>
)}
{/* Controles de Visualização e Cache */}
<div className="space-y-4">
{/* Modo de Visualização */}
{onViewModeChange && (
<div className="space-y-2">
<div className="text-sm font-medium">Modo de visualização</div>
<div className="flex items-center border border-gray-100 dark:border-gray-800 rounded-lg">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => onViewModeChange("grid")}
className="rounded-r-none flex-1"
>
<Grid className="w-4 h-4 mr-2" />
Grade
</Button>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => onViewModeChange("list")}
className="rounded-l-none flex-1"
>
<List className="w-4 h-4 mr-2" />
Lista
</Button>
</div>
</div>
)}
{/* Controles de Cache */}
{(onRefresh || onClearCache) && (
<div className="space-y-2">
<div className="text-sm font-medium">Controles</div>
<div className="flex gap-2">
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isLoading}
className="flex-1"
title="Atualizar busca"
>
<RefreshCw
className={`w-4 h-4 mr-2 ${
isLoading ? "animate-spin" : ""
}`}
/>
Atualizar
</Button>
)}
{onClearCache && isCached && (
<Button
variant="outline"
size="sm"
onClick={onClearCache}
className="flex-1"
title="Limpar cache e refazer busca"
>
<X className="w-4 h-4 mr-2" />
Limpar Cache
</Button>
)}
</div>
</div>
)}
</div>
{/* Filtros Ativos */}
{/* ── Active filter badges ───────────────────────────────────────────── */}
{onRemoveFilter && hasActiveFilters && (
<div className="space-y-2">
<div className="text-sm font-medium">Filtros ativos</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<FilterBadges
filters={filters}
onRemoveFilter={onRemoveFilter}
@@ -228,183 +159,155 @@ export function FilterSidebar({
</div>
)}
<Accordion
type="multiple"
defaultValue={["sorting"]}
className="space-y-4"
>
{/* Ordenação */}
<AccordionItem
value="sorting"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<ArrowUpDown className="h-4 w-4 text-indigo-500" />
<span className="font-medium">Ordenação</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
<div className="text-sm font-medium mb-3">Ordenar por</div>
{onSortChange && (
<SortDropdown
value={sortBy}
onValueChange={onSortChange}
className="w-full"
/>
)}
</div>
</AccordionContent>
</AccordionItem>
{/* ── Cache / Refresh controls ──────────────────────────────────────── */}
{(onRefresh || onClearCache) && (
<div style={{ display: "flex", gap: 8 }}>
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "7px 0", border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 8, background: "transparent", fontSize: 12, color: "var(--fg-secondary, #5e4e3c)", fontFamily: "inherit", cursor: isLoading ? "not-allowed" : "pointer", opacity: isLoading ? 0.5 : 1 }}
>
<RefreshCw style={{ width: 12, height: 12 }} className={isLoading ? "animate-spin" : ""} />
Atualizar
</button>
)}
{onClearCache && isCached && (
<button
onClick={onClearCache}
style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "7px 0", border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 8, background: "transparent", fontSize: 12, color: "var(--fg-secondary, #5e4e3c)", fontFamily: "inherit", cursor: "pointer" }}
>
<X style={{ width: 12, height: 12 }} />
Limpar cache
</button>
)}
</div>
)}
{/* Nutri-Score */}
<AccordionItem
value="nutrition"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-green-500" />
<span className="font-medium">Classificação Nutricional</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
<div className="text-sm font-medium mb-3">Nutri-Score</div>
<div className="space-y-2">
{NUTRITION_GRADES.map((grade) => (
<div
key={grade.grade}
className="flex items-center space-x-3"
>
<Checkbox
id={`nutrition-${grade.grade}`}
checked={
filters.nutritionGrades?.includes(grade.grade) || false
}
onCheckedChange={(checked) =>
updateArrayFilter(
"nutritionGrades",
grade.grade,
checked as boolean
)
}
/>
<label
htmlFor={`nutrition-${grade.grade}`}
className="flex items-center gap-2 text-sm cursor-pointer"
>
<Badge className={`${grade.color} text-white text-xs`}>
{grade.label}
</Badge>
<span className="text-muted-foreground">
{grade.description}
</span>
</label>
</div>
))}
</div>
<Separator className="my-4" />
<div className="text-sm font-medium mb-3">
NOVA (Processamento)
</div>
<RadioGroup
value={filters.novaGroups?.[0]?.toString() || ""}
onValueChange={(value) =>
updateFilter(
"novaGroups",
value ? [parseInt(value) as 1 | 2 | 3 | 4] : []
)
}
className="space-y-2"
{/* ── View mode ─────────────────────────────────────────────────────── */}
{onViewModeChange && (
<div>
<div style={{ fontFamily: "ui-monospace, monospace", fontSize: 10, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--fg-muted, #9a8870)", marginBottom: 8 }}>Visualização</div>
<div style={{ display: "flex", border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 8, overflow: "hidden" }}>
{(["grid", "list"] as const).map((mode) => (
<button
key={mode}
onClick={() => onViewModeChange(mode)}
style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "7px 0", background: viewMode === mode ? "var(--accent-muted, oklch(92% 0.04 155))" : "transparent", color: viewMode === mode ? "var(--accent, oklch(52% 0.11 155))" : "var(--fg-muted, #9a8870)", border: "none", fontSize: 12, cursor: "pointer", fontFamily: "inherit", fontWeight: viewMode === mode ? 500 : 400 }}
>
{NOVA_GROUPS.map((group) => (
<div
key={group.group}
className="flex items-center space-x-3"
>
<RadioGroupItem
value={group.group.toString()}
id={`nova-${group.group}`}
/>
<label
htmlFor={`nova-${group.group}`}
className="flex items-center gap-2 text-sm cursor-pointer flex-1"
>
<Badge className={`${group.color} text-white text-xs`}>
{group.label}
</Badge>
<span className="text-muted-foreground text-xs">
{group.description}
</span>
</label>
</div>
))}
</RadioGroup>
{mode === "grid" ? <Grid style={{ width: 13, height: 13 }} /> : <List style={{ width: 13, height: 13 }} />}
{mode === "grid" ? "Grade" : "Lista"}
</button>
))}
</div>
</div>
)}
{/* ── Accordion sections ────────────────────────────────────────────── */}
<Accordion type="multiple" defaultValue={["nutrition"]} className="space-y-1">
{/* Ordenação */}
{onSortChange && (
<AccordionItem value="sorting" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline" style={{ background: "transparent" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ArrowUpDown style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Ordenação</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12 }}>
<SortDropdown value={sortBy} onValueChange={onSortChange} className="w-full" />
</div>
</AccordionContent>
</AccordionItem>
)}
{/* Nutri-Score & NOVA */}
<AccordionItem value="nutrition" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Zap style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Classificação Nutricional</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<div style={{ fontFamily: "ui-monospace, monospace", fontSize: 10, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--fg-muted, #9a8870)", marginBottom: 10 }}>Nutri-Score</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{NUTRITION_GRADES.map((grade) => (
<div key={grade.grade} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Checkbox
id={`nutrition-${grade.grade}`}
checked={filters.nutritionGrades?.includes(grade.grade) || false}
onCheckedChange={(checked) => updateArrayFilter("nutritionGrades", grade.grade, checked as boolean)}
/>
<label htmlFor={`nutrition-${grade.grade}`} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer", flex: 1 }}>
<Badge className={`${grade.color} text-white text-xs`}>{grade.label}</Badge>
<span style={{ color: "var(--fg-secondary, #5e4e3c)", fontSize: 12 }}>{grade.description}</span>
</label>
</div>
))}
</div>
</div>
<div style={{ height: 1, background: "var(--border-subtle, #e8e0d0)" }} />
<div>
<div style={{ fontFamily: "ui-monospace, monospace", fontSize: 10, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--fg-muted, #9a8870)", marginBottom: 10 }}>NOVA — Nível de processamento</div>
<RadioGroup
value={filters.novaGroups?.[0]?.toString() || ""}
onValueChange={(value) => updateFilter("novaGroups", value ? [parseInt(value) as 1 | 2 | 3 | 4] : [])}
className="space-y-2"
>
{NOVA_GROUPS.map((group) => (
<div key={group.group} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<RadioGroupItem value={group.group.toString()} id={`nova-${group.group}`} />
<label htmlFor={`nova-${group.group}`} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, cursor: "pointer", flex: 1 }}>
<Badge className={`${group.color} text-white text-xs`}>{group.label}</Badge>
<span style={{ color: "var(--fg-secondary, #5e4e3c)", fontSize: 11 }}>{group.description}</span>
</label>
</div>
))}
</RadioGroup>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Categorias */}
<AccordionItem
value="categories"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionItem value="categories" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4 text-blue-500" />
<span className="font-medium">Categorias</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<LayoutGrid style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Categorias</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
<Input
placeholder="Buscar categorias..."
placeholder="Buscar categorias…"
value={searchCategories}
onChange={(e) => setSearchCategories(e.target.value)}
className="h-8"
style={{ height: 34, fontSize: 13, borderColor: "var(--border-subtle, #e8e0d0)", background: "var(--bg-base, #f9f6f0)" }}
/>
<div className="space-y-2 max-h-48 overflow-y-auto">
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 192, overflowY: "auto" }}>
{POPULAR_CATEGORIES_WITH_ICONS.filter(
(cat) =>
cat.label
.toLowerCase()
.includes(searchCategories.toLowerCase()) ||
cat.value
.toLowerCase()
.includes(searchCategories.toLowerCase())
cat.label.toLowerCase().includes(searchCategories.toLowerCase()) ||
cat.value.toLowerCase().includes(searchCategories.toLowerCase())
).map((category) => {
const IconComponent = LucideIcons[
category.icon as keyof typeof LucideIcons
] as React.ComponentType<{ className?: string }> | undefined;
const IconComponent = LucideIcons[category.icon as keyof typeof LucideIcons] as React.ComponentType<{ style?: React.CSSProperties }> | undefined;
return (
<div
key={category.value}
className="flex items-center space-x-3"
>
<div key={category.value} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Checkbox
id={`category-${category.value}`}
checked={
filters.categories?.includes(category.value) || false
}
onCheckedChange={(checked) =>
updateArrayFilter(
"categories",
category.value,
checked as boolean
)
}
checked={filters.categories?.includes(category.value) || false}
onCheckedChange={(checked) => updateArrayFilter("categories", category.value, checked as boolean)}
/>
<label
htmlFor={`category-${category.value}`}
className="text-sm cursor-pointer flex items-center gap-2"
>
{IconComponent && (
<IconComponent className="w-4 h-4 text-gray-500" />
)}
<label htmlFor={`category-${category.value}`} style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 13, cursor: "pointer", color: "var(--fg-secondary, #5e4e3c)" }}>
{IconComponent && <IconComponent style={{ width: 13, height: 13, color: "var(--fg-muted, #9a8870)" } as React.CSSProperties} />}
{category.label}
</label>
</div>
@@ -416,70 +319,53 @@ export function FilterSidebar({
</AccordionItem>
{/* Marcas */}
<AccordionItem
value="brands"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionItem value="brands" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-purple-500" />
<span className="font-medium">Marcas</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Tag style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Marcas</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<Input
placeholder="Buscar marcas..."
placeholder="Nome da marca…"
value={searchBrands}
onChange={(e) => setSearchBrands(e.target.value)}
className="h-8"
style={{ height: 34, fontSize: 13, borderColor: "var(--border-subtle, #e8e0d0)", background: "var(--bg-base, #f9f6f0)" }}
/>
<div className="text-xs text-muted-foreground">
Digite o nome da marca para buscar
</div>
<div style={{ fontSize: 11, color: "var(--fg-muted, #9a8870)", letterSpacing: "0.02em" }}>Digite para filtrar por marca</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Alérgenos */}
<AccordionItem
value="allergens"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionItem value="allergens" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
<span className="font-medium">Alérgenos</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<AlertTriangle style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Alérgenos</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
<Input
placeholder="Buscar alérgenos..."
placeholder="Buscar alérgenos…"
value={searchAllergens}
onChange={(e) => setSearchAllergens(e.target.value)}
className="h-8"
style={{ height: 34, fontSize: 13, borderColor: "var(--border-subtle, #e8e0d0)", background: "var(--bg-base, #f9f6f0)" }}
/>
<div className="space-y-2 max-h-48 overflow-y-auto">
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 192, overflowY: "auto" }}>
{COMMON_ALLERGENS.filter((allergen) =>
allergen.toLowerCase().includes(searchAllergens.toLowerCase())
).map((allergen) => (
<div key={allergen} className="flex items-center space-x-3">
<div key={allergen} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Checkbox
id={`allergen-${allergen}`}
checked={filters.allergens?.includes(allergen) || false}
onCheckedChange={(checked) =>
updateArrayFilter(
"allergens",
allergen,
checked as boolean
)
}
onCheckedChange={(checked) => updateArrayFilter("allergens", allergen, checked as boolean)}
/>
<label
htmlFor={`allergen-${allergen}`}
className="text-sm cursor-pointer capitalize"
>
<label htmlFor={`allergen-${allergen}`} style={{ fontSize: 13, cursor: "pointer", textTransform: "capitalize", color: "var(--fg-secondary, #5e4e3c)" }}>
{allergen}
</label>
</div>
@@ -490,32 +376,26 @@ export function FilterSidebar({
</AccordionItem>
{/* Aditivos */}
<AccordionItem
value="additives"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionItem value="additives" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<Beaker className="h-4 w-4 text-orange-500" />
<span className="font-medium">Aditivos</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Beaker style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Aditivos</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<label htmlFor="no-additives" className="text-sm font-medium">
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<label htmlFor="no-additives" style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-secondary, #5e4e3c)", cursor: "pointer" }}>
Sem aditivos
</label>
<Switch
id="no-additives"
checked={filters.noAdditives || false}
onCheckedChange={(checked) =>
updateFilter("noAdditives", checked)
}
onCheckedChange={(checked) => updateFilter("noAdditives", checked)}
/>
</div>
<div className="text-xs text-muted-foreground">
<div style={{ fontSize: 11, color: "var(--fg-muted, #9a8870)" }}>
Mostrar apenas produtos sem aditivos
</div>
</div>
@@ -523,119 +403,62 @@ export function FilterSidebar({
</AccordionItem>
{/* Valores Nutricionais */}
<AccordionItem
value="nutrition-values"
className="border border-gray-100 dark:border-gray-800 rounded-lg"
>
<AccordionItem value="nutrition-values" style={{ border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, overflow: "hidden" }}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-green-500" />
<span className="font-medium">Valores Nutricionais</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<BarChart3 style={{ width: 14, height: 14, color: "var(--accent, oklch(52% 0.11 155))" }} strokeWidth={1.75} />
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--fg-primary, #1e1810)" }}>Valores Nutricionais</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-4">
{/* Energia */}
<div className="space-y-2">
<label className="text-sm font-medium">
Energia (kcal/100g)
</label>
<div className="px-3">
<Slider
value={[
filters.nutritionRanges?.energy?.min || 0,
filters.nutritionRanges?.energy?.max || 500,
]}
onValueChange={([min, max]) => {
updateNutritionRange("energy", "min", min);
updateNutritionRange("energy", "max", max);
}}
max={500}
step={10}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>
{filters.nutritionRanges?.energy?.min || 0} kcal
</span>
<span>
{filters.nutritionRanges?.energy?.max || 500} kcal
</span>
<AccordionContent className="px-4 pb-4" style={{ borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<div style={{ paddingTop: 12, display: "flex", flexDirection: "column", gap: 18 }}>
{[
{ key: "energy", label: "Energia", unit: "kcal", max: 500, step: 10 },
{ key: "sugars", label: "Açúcares", unit: "g/100g", max: 50, step: 1 },
{ key: "sodium", label: "Sódio", unit: "g/100g", max: 3, step: 0.1 },
].map(({ key, label, unit, max, step }) => (
<div key={key} style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "var(--fg-secondary, #5e4e3c)" }}>{label} <span style={{ color: "var(--fg-muted)", fontWeight: 400 }}>({unit})</span></label>
<div style={{ paddingLeft: 4, paddingRight: 4 }}>
<Slider
value={[
(filters.nutritionRanges as Record<string, { min?: number; max?: number }>)?.[key]?.min || 0,
(filters.nutritionRanges as Record<string, { min?: number; max?: number }>)?.[key]?.max || max,
]}
onValueChange={([min, maxVal]) => {
updateNutritionRange(key, "min", min);
updateNutritionRange(key, "max", maxVal);
}}
max={max}
step={step}
className="w-full"
/>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4 }}>
<span style={{ fontSize: 11, color: "var(--fg-muted, #9a8870)" }}>
{(filters.nutritionRanges as Record<string, { min?: number; max?: number }>)?.[key]?.min || 0}{key === "energy" ? " kcal" : "g"}
</span>
<span style={{ fontSize: 11, color: "var(--fg-muted, #9a8870)" }}>
{(filters.nutritionRanges as Record<string, { min?: number; max?: number }>)?.[key]?.max || max}{key === "energy" ? " kcal" : "g"}
</span>
</div>
</div>
</div>
</div>
{/* Açúcares */}
<div className="space-y-2">
<label className="text-sm font-medium">Açúcares (g/100g)</label>
<div className="px-3">
<Slider
value={[
filters.nutritionRanges?.sugars?.min || 0,
filters.nutritionRanges?.sugars?.max || 50,
]}
onValueChange={([min, max]) => {
updateNutritionRange("sugars", "min", min);
updateNutritionRange("sugars", "max", max);
}}
max={50}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{filters.nutritionRanges?.sugars?.min || 0}g</span>
<span>{filters.nutritionRanges?.sugars?.max || 50}g</span>
</div>
</div>
</div>
{/* Sódio */}
<div className="space-y-2">
<label className="text-sm font-medium">Sódio (g/100g)</label>
<div className="px-3">
<Slider
value={[
filters.nutritionRanges?.sodium?.min || 0,
filters.nutritionRanges?.sodium?.max || 3,
]}
onValueChange={([min, max]) => {
updateNutritionRange("sodium", "min", min);
updateNutritionRange("sodium", "max", max);
}}
max={3}
step={0.1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{filters.nutritionRanges?.sodium?.min || 0}g</span>
<span>{filters.nutritionRanges?.sodium?.max || 3}g</span>
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Botões de ação */}
<div className="flex gap-2 pt-4 border-t border-gray-100 dark:border-gray-800">
<Button
{/* ── Footer action ─────────────────────────────────────────────────── */}
<div style={{ paddingTop: 16, borderTop: "1px solid var(--border-subtle, #e8e0d0)" }}>
<button
onClick={onClearFilters}
variant="outline"
className="flex-1"
disabled={!hasActiveFilters}
style={{ width: "100%", padding: "10px 0", border: "1px solid var(--border-subtle, #e8e0d0)", borderRadius: 10, background: "transparent", fontSize: 13, color: hasActiveFilters ? "var(--fg-secondary, #5e4e3c)" : "var(--fg-muted, #9a8870)", fontFamily: "inherit", cursor: hasActiveFilters ? "pointer" : "not-allowed", transition: "all 0.15s" }}
>
Limpar Filtros
</Button>
<Button
onClick={() => {
/* Aplicar filtros é automático */
}}
className="flex-1"
disabled={isLoading}
>
{isLoading ? "Aplicando..." : "Aplicar"}
</Button>
Limpar todos os filtros
</button>
</div>
</div>
);
+110 -83
View File
@@ -3,73 +3,75 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import {
ExternalLink,
Github,
Heart,
Globe,
Shield,
ScanLine,
Database,
Lock,
} from "lucide-react";
import { ExternalLink, Github, Heart, Shield, Globe, ScanLine, Database, Lock } from "lucide-react";
const FOOTER_LINKS = {
navigation: [
{ href: "/#como-funciona", label: "Protocolo" },
{ href: "/#recursos", label: "Recursos" },
{ href: "/sobre", label: "Sobre o Projeto" },
{ href: "/produto/exemplo", label: "Sandbox (Exemplo)" },
{ href: "/#como-funciona", label: "Como funciona" },
{ href: "/#recursos", label: "Recursos" },
{ href: "/sobre", label: "Sobre o Projeto" },
{ href: "/produto/exemplo", label: "Produto (demo)" },
],
resources: [
{ icon: Shield, label: "Dados Colaborativos" },
{ icon: Globe, label: "Rede Global" },
infrastructure: [
{ icon: Shield, label: "Dados Colaborativos" },
{ icon: Globe, label: "Rede Global" },
{ icon: ScanLine, label: "Algoritmo de Busca" },
],
};
export function Footer() {
const currentYear = new Date().getFullYear();
const year = new Date().getFullYear();
return (
<footer className="bg-[#030712] text-gray-400 border-t border-gray-800/50 transition-colors duration-500">
<div className="max-w-7xl mx-auto px-6 py-20">
<footer className="bg-[var(--fg-primary)] text-[var(--fg-faint)]">
<div className="container-editorial pt-20 pb-10">
{/* ── Top grid ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 mb-20">
{/* Brand Identity - 4 Colunas */}
<div className="lg:col-span-4 space-y-8">
<Link href="/" className="flex items-center gap-3 group">
<div className="relative w-10 h-10 bg-blue-600 rounded-xl overflow-hidden shadow-lg shadow-blue-500/10">
<Image src="/icon.png" alt="Logo" fill className="object-contain p-2" priority />
</div>
{/* Brand */}
<div className="lg:col-span-4 space-y-6">
<Link href="/" className="flex items-center gap-3">
<Image
src="/icon.png"
alt="NutriScan"
width={36}
height={36}
className="rounded object-contain"
priority
/>
<div>
<h3 className="text-xl font-black text-white tracking-tighter leading-none">
NUTRI<span className="text-blue-500">SCAN</span>
</h3>
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-[0.2em] mt-1">
Data Sovereignty
<p className="font-sans font-semibold text-base text-[var(--bg-base)] leading-none">
NutriScan
</p>
<p className="label text-[var(--fg-faint)] mt-1">
Dados Nutricionais
</p>
</div>
</Link>
<p className="text-sm leading-relaxed max-w-sm">
Soberania alimentar através da transparência. Extraímos metadados nutricionais complexos para devolver o poder de escolha ao cidadão.
<p className="text-sm leading-relaxed max-w-xs">
Soberania alimentar através da transparência. Extraímos
metadados nutricionais para devolver o poder de escolha ao
cidadão.
</p>
<div className="flex items-center gap-2 text-[11px] font-mono uppercase tracking-widest text-gray-500">
<Heart className="w-3 h-3 text-red-500 animate-pulse" />
<span>Engineered in Garuva, SC</span>
<div className="flex items-center gap-1.5 label">
<Heart className="w-3 h-3 text-red-400" strokeWidth={1.5} />
<span>Feito em Garuva, SC</span>
</div>
</div>
{/* Navigation - 2 Colunas */}
{/* Navigation */}
<div className="lg:col-span-2">
<h4 className="text-white font-bold text-sm uppercase tracking-widest mb-6">Sitemap</h4>
<h4 className="label text-[var(--bg-surface)] mb-6">Páginas</h4>
<ul className="space-y-4">
{FOOTER_LINKS.navigation.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-sm hover:text-blue-400 transition-colors">
<Link
href={link.href}
className="text-sm hover:text-[var(--bg-base)] transition-colors duration-200"
>
{link.label}
</Link>
</li>
@@ -77,77 +79,102 @@ export function Footer() {
</ul>
</div>
{/* Logic/Features - 2 Colunas */}
{/* Infrastructure */}
<div className="lg:col-span-2">
<h4 className="text-white font-bold text-sm uppercase tracking-widest mb-6">Infraestrutura</h4>
<h4 className="label text-[var(--bg-surface)] mb-6">Infraestrutura</h4>
<ul className="space-y-4">
{FOOTER_LINKS.resources.map((item) => (
<li key={item.label} className="flex items-center gap-3 text-sm">
<item.icon className="w-4 h-4 text-gray-600" />
<span>{item.label}</span>
{FOOTER_LINKS.infrastructure.map(({ icon: Icon, label }) => (
<li key={label} className="flex items-center gap-3 text-sm">
<Icon className="w-4 h-4 text-[var(--fg-faint)]" strokeWidth={1.5} />
<span>{label}</span>
</li>
))}
<li className="text-[11px] font-mono text-blue-500/80">Nutri-Score v2.1</li>
<li className="label text-[var(--accent)]">Nutri-Score v2.1</li>
</ul>
</div>
{/* Data Source Card - 4 Colunas */}
{/* Data source */}
<div className="lg:col-span-4">
<div className="bg-gray-900/50 border border-gray-800 p-6 rounded-2xl backdrop-blur-sm">
<div
className="rounded-xl p-6"
style={{ border: "1px solid rgba(255,255,255,0.08)", background: "rgba(255,255,255,0.04)" }}
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-blue-500/10 rounded-lg">
<Database className="w-5 h-5 text-blue-500" />
</div>
<h4 className="text-white font-bold">Open Food Facts</h4>
<Database className="w-5 h-5 text-[var(--accent)]" strokeWidth={1.5} />
<h4 className="font-sans font-semibold text-[var(--bg-base)] text-sm">
Open Food Facts
</h4>
</div>
<p className="text-xs leading-relaxed text-gray-500 mb-6">
A maior base de dados colaborativa de alimentos do mundo. Dados sob licença ODbL (Open Database License).
<p className="text-xs leading-relaxed mb-6">
A maior base de dados colaborativa de alimentos do mundo.
Licença ODbL (Open Database License).
</p>
<a
href="https://world.openfoodfacts.org"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-xs font-black text-blue-500 uppercase tracking-widest hover:text-blue-400 transition-all"
className="inline-flex items-center gap-1.5 label text-[var(--accent)] hover:opacity-80 transition-opacity"
>
Protocolo de Dados <ExternalLink size={12} />
Ver base de dados
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
</div>
</div>
</div>
{/* Disclaimer Técnico - Estilo "Regulatory" */}
<div className="border-t border-gray-800/50 pt-12">
<div className="bg-gray-900/30 border border-gray-800/50 p-6 rounded-2xl flex flex-col md:flex-row items-center gap-6">
<Lock className="w-8 h-8 text-gray-700 shrink-0" />
<p className="text-[10px] md:text-xs text-gray-500 leading-relaxed text-center md:text-left">
<strong className="text-gray-400 uppercase tracking-widest">Aviso Legal (Disclaimer):</strong> Os dados são extraídos de fontes colaborativas. Não garantimos a integridade absoluta das informações nutricionais em comparação com a embalagem física. O uso desta ferramenta é consultivo e não substitui orientação médica ou nutricional profissional.
{/* ── Hairline ─────────────────────────────────────────────────── */}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }} className="pt-10">
{/* Legal disclaimer */}
<div
className="rounded-xl p-5 mb-10 flex gap-4 items-start"
style={{ border: "1px solid rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.02)" }}
>
<Lock className="w-4 h-4 shrink-0 mt-0.5 text-[var(--fg-faint)]" strokeWidth={1.5} />
<p className="text-xs leading-relaxed">
<strong className="text-[var(--fg-faint)] label mr-1 not-italic">Aviso Legal:</strong>
Os dados são extraídos de fontes colaborativas. Não garantimos
a integridade absoluta das informações nutricionais em
comparação com a embalagem física. O uso desta ferramenta é
consultivo e não substitui orientação médica ou nutricional
profissional.
</p>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-6">
<div className="text-[11px] font-mono text-gray-600 text-center md:text-left space-y-1">
<p>© {currentYear} NutriScan Systems. Open Source Initiative.</p>
<p>
Lead Architect:{" "}
<a href="https://evandro.dev.br" target="_blank" className="text-blue-500 hover:underline">
{/* Bottom bar */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="label">
© {year} NutriScan ·{" "}
<a
href="https://evandro.dev.br"
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--bg-base)] transition-colors"
>
evandro.dev.br
</a>
</p>
</div>
<div className="flex items-center gap-8">
<div className="flex items-center gap-4">
<Link href="/privacidade" className="text-[11px] font-black uppercase tracking-widest hover:text-white transition-colors">Privacidade</Link>
<Link href="/termos" className="text-[11px] font-black uppercase tracking-widest hover:text-white transition-colors">Termos</Link>
<div className="flex items-center gap-6">
<Link href="/privacidade" className="label hover:text-[var(--bg-base)] transition-colors">
Privacidade
</Link>
<Link href="/termos" className="label hover:text-[var(--bg-base)] transition-colors">
Termos
</Link>
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded"
style={{ border: "1px solid rgba(255,255,255,0.10)" }}
aria-label="GitHub"
>
<Github className="w-3.5 h-3.5 text-[var(--fg-faint)]" strokeWidth={1.5} />
</a>
</div>
<a href="https://github.com" target="_blank" className="p-2 bg-gray-900 rounded-full hover:bg-gray-800 transition-colors">
<Github className="w-4 h-4 text-white" />
</a>
</div>
</div>
</div>
</footer>
);
}
}
+26 -9
View File
@@ -38,13 +38,29 @@ export function IdInput() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
<div
className="min-h-screen flex items-center justify-center"
style={{ background: 'var(--bg-base)' }}
>
<div
className="max-w-md w-full space-y-8 p-8 rounded-xl"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border-subtle)',
boxShadow: '0 8px 32px -8px rgba(30,24,16,0.10)',
}}
>
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
<h2
className="mt-6 text-center text-3xl font-display"
style={{ color: 'var(--fg-primary)' }}
>
Acesse sua lista
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
<p
className="mt-2 text-center text-sm"
style={{ color: 'var(--fg-muted)' }}
>
Digite um ID para acessar ou criar uma nova lista
</p>
</div>
@@ -56,19 +72,20 @@ export function IdInput() {
placeholder="Digite o ID da lista"
value={id}
onChange={(e) => setId(e.target.value)}
className="appearance-none rounded-lg relative block w-full px-3 py-2 border dark:bg-gray-700 dark:border-gray-600"
className="appearance-none rounded-lg relative block w-full px-3 py-2"
style={{ borderColor: 'var(--border-strong)', background: 'var(--bg-surface)' }}
/>
</div>
{error && (
<p className="text-red-500 text-sm text-center">{error}</p>
<p className="text-sm text-center" style={{ color: 'var(--nutri-e)' }}>{error}</p>
)}
<Button
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
className="btn-accent w-full justify-center"
>
{loading ? 'Carregando...' : 'Acessar/Criar Lista'}
</Button>
</button>
</form>
</div>
</div>
+25 -19
View File
@@ -35,21 +35,20 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
return (
<Card
className={`group relative overflow-hidden hover:shadow-lg transition-all duration-300 hover:-translate-y-1 ${
produto.comprado
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700"
}`}
className="group relative overflow-hidden hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
style={{
background: produto.comprado ? 'var(--accent-muted)' : 'var(--bg-elevated)',
borderColor: produto.comprado ? 'var(--accent-light)' : 'var(--border-subtle)',
}}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3
className={`font-semibold text-lg leading-tight ${
produto.comprado
? "text-green-700 dark:text-green-300 line-through"
: "text-gray-900 dark:text-white"
}`}
className={`font-semibold text-lg leading-tight${produto.comprado ? ' line-through' : ''}`}
style={{
color: produto.comprado ? 'var(--accent)' : 'var(--fg-primary)',
}}
>
{produto.nome}
</h3>
@@ -57,7 +56,12 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
<div className="flex items-center gap-2 mt-1">
<Badge
variant="outline"
className="text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700"
className="text-xs"
style={{
background: 'var(--bg-surface)',
color: 'var(--fg-secondary)',
borderColor: 'var(--border-subtle)',
}}
>
{produto.qtd} {produto.tipoUN}
</Badge>
@@ -65,7 +69,12 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
{produto.comprado && (
<Badge
variant="outline"
className="text-xs bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-200 dark:border-green-700"
className="text-xs"
style={{
background: 'var(--accent-muted)',
color: 'var(--accent-dark)',
borderColor: 'var(--accent-light)',
}}
>
Comprado
</Badge>
@@ -79,11 +88,7 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
onClick={handleToggleComprado}
variant={produto.comprado ? "outline" : "default"}
size="sm"
className={`flex-1 ${
produto.comprado
? "hover:bg-red-50 hover:text-red-700 hover:border-red-300"
: "hover:bg-green-50 hover:text-green-700"
}`}
className="flex-1"
>
{produto.comprado ? (
<>
@@ -101,7 +106,8 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
<Button
variant="outline"
size="sm"
className="px-3 hover:bg-gray-50 dark:hover:bg-gray-800"
className="px-3"
style={{ ['--hover-bg' as string]: 'var(--bg-surface)' }}
>
<Edit className="w-4 h-4" />
</Button>
@@ -109,7 +115,7 @@ export function ListaProductCard({ produto, listaId }: ListaProductCardProps) {
<Button
variant="outline"
size="sm"
className="px-3 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
className="px-3"
>
<Trash2 className="w-4 h-4" />
</Button>
+12 -7
View File
@@ -47,7 +47,8 @@ export function Main({ listaId }: MainProps) {
<Input
required
name="produtoNome"
className="flex-1 min-w-[200px] text-base px-4 py-2 border-[#383838] focus:border-[#1118A7]"
className="flex-1 min-w-[200px] text-base px-4 py-2"
style={{ borderColor: 'var(--border-strong)', ['--tw-ring-color' as string]: 'var(--accent)' }}
placeholder="Nome do produto"
/>
<Input
@@ -56,11 +57,15 @@ export function Main({ listaId }: MainProps) {
min="0"
step="0.01"
name="produtoQuantidade"
className="w-32 text-base px-4 py-2 border-[#383838] focus:border-[#1118A7]"
className="w-32 text-base px-4 py-2"
style={{ borderColor: 'var(--border-strong)' }}
placeholder="Quantidade"
/>
<Select name="produtoTipo" required>
<SelectTrigger className="w-32 text-base border-[#383838] focus:border-[#1118A7]">
<SelectTrigger
className="w-32 text-base"
style={{ borderColor: 'var(--border-strong)' }}
>
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
@@ -74,16 +79,16 @@ export function Main({ listaId }: MainProps) {
</SelectGroup>
</SelectContent>
</Select>
<Button
className="bg-[#1118A7] hover:bg-[#080C94] text-white min-w-[100px]"
<button
className="btn-accent min-w-[100px]"
type="submit"
disabled={isPending}
>
{isPending ? 'Salvando...' : 'Adicionar'}
</Button>
</button>
</div>
{error && (
<p className="text-[#CC2121] text-sm">{error}</p>
<p className="text-sm" style={{ color: 'var(--nutri-e)' }}>{error}</p>
)}
</form>
);
+61 -187
View File
@@ -1,6 +1,5 @@
"use client";
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface NutriScoreBadgeProps {
@@ -10,126 +9,65 @@ interface NutriScoreBadgeProps {
showTooltip?: boolean;
}
const GRADE_INFO: Record<string, { desc: string; explanation: string }> = {
a: { desc: "Muito bom", explanation: "Excelente qualidade nutricional" },
b: { desc: "Bom", explanation: "Boa qualidade nutricional" },
c: { desc: "Regular", explanation: "Qualidade nutricional moderada" },
d: { desc: "Ruim", explanation: "Qualidade nutricional baixa" },
e: { desc: "Muito ruim", explanation: "Qualidade nutricional muito baixa" },
};
const GRADE_CLASS: Record<string, string> = {
a: "grade-a", b: "grade-b", c: "grade-c", d: "grade-d", e: "grade-e",
};
const SIZE_CLASS = {
sm: "w-8 h-8 text-sm",
md: "w-14 h-14 text-xl",
lg: "w-20 h-20 text-2xl",
};
export function NutriScoreBadge({
grade,
className,
size = "md",
showTooltip = true,
}: NutriScoreBadgeProps) {
const [showInfo, setShowInfo] = useState(false);
const getGradeInfo = (grade: string) => {
const grades = {
a: {
label: "A",
color: "bg-gradient-to-br from-green-400 to-green-600",
textColor: "text-white",
description: "Muito bom",
explanation: "Produto com excelente qualidade nutricional",
pulse: false,
},
b: {
label: "B",
color: "bg-gradient-to-br from-green-300 to-green-500",
textColor: "text-white",
description: "Bom",
explanation: "Produto com boa qualidade nutricional",
pulse: false,
},
c: {
label: "C",
color: "bg-gradient-to-br from-yellow-400 to-yellow-600",
textColor: "text-white",
description: "Regular",
explanation: "Produto com qualidade nutricional regular",
pulse: false,
},
d: {
label: "D",
color: "bg-gradient-to-br from-orange-400 to-orange-600",
textColor: "text-white",
description: "Ruim",
explanation: "Produto com qualidade nutricional ruim",
pulse: true,
},
e: {
label: "E",
color: "bg-gradient-to-br from-red-400 to-red-600",
textColor: "text-white",
description: "Muito ruim",
explanation: "Produto com qualidade nutricional muito ruim",
pulse: true,
},
};
return grades[grade.toLowerCase() as keyof typeof grades] || null;
};
const gradeInfo = getGradeInfo(grade);
if (!gradeInfo) return null;
const sizeClasses = {
sm: "w-8 h-8 text-sm",
md: "w-16 h-16 text-xl",
lg: "w-20 h-20 text-2xl",
};
const [hover, setHover] = useState(false);
const key = grade?.toLowerCase();
const info = GRADE_INFO[key];
if (!info) return null;
return (
<div className="relative group">
<div className="relative inline-block">
<div
className={cn(
"relative rounded-full flex items-center justify-center shadow-lg transition-all duration-300 transform hover:scale-105",
gradeInfo.color,
sizeClasses[size],
gradeInfo.pulse && "animate-pulse",
"nutriscore-hero font-sans font-bold rounded-full flex items-center justify-center cursor-default",
GRADE_CLASS[key],
SIZE_CLASS[size],
className
)}
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(false)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<span className={cn("font-bold", gradeInfo.textColor)}>
{gradeInfo.label}
</span>
{/* Efeito de brilho para notas baixas */}
{gradeInfo.pulse && (
<div className="absolute inset-0 rounded-full bg-white opacity-20 animate-ping" />
)}
{key.toUpperCase()}
</div>
{/* Tooltip informativo */}
{showTooltip && showInfo && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl max-w-xs">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">
Nutri-Score {gradeInfo.label}
</span>
{gradeInfo.pulse && (
<AlertTriangle className="w-3 h-3 text-yellow-400" />
)}
</div>
<p className="font-medium">{gradeInfo.description}</p>
<p className="text-gray-300 mt-1">{gradeInfo.explanation}</p>
{/* Seta do tooltip */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
{showTooltip && hover && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 whitespace-nowrap">
<div className="bg-[var(--fg-primary)] text-[var(--bg-base)] text-xs rounded-lg px-3 py-2 shadow-xl">
<p className="font-semibold">Nutri-Score {key.toUpperCase()}</p>
<p className="opacity-70">{info.desc}</p>
<p className="opacity-50 mt-0.5">{info.explanation}</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--fg-primary)]" />
</div>
</div>
)}
{/* Badge de alerta para notas baixas */}
{gradeInfo.pulse && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center">
<AlertTriangle className="w-2.5 h-2.5 text-white" />
</div>
)}
</div>
);
}
/* ── NOVA Badge ───────────────────────────────────────────────────────────── */
interface NovaBadgeProps {
group: number;
className?: string;
@@ -137,112 +75,48 @@ interface NovaBadgeProps {
showTooltip?: boolean;
}
const NOVA_INFO: Record<number, { desc: string; explanation: string; bg: string; fg: string }> = {
1: { desc: "Não processado", explanation: "Alimentos não ou minimamente processados", bg: "#1a7a4a", fg: "#fff" },
2: { desc: "Ingrediente", explanation: "Ingredientes culinários processados", bg: "#e8c110", fg: "#1e1810" },
3: { desc: "Processado", explanation: "Alimentos processados", bg: "#e07820", fg: "#fff" },
4: { desc: "Ultraprocessado",explanation: "Alimentos ultraprocessados", bg: "#c0201a", fg: "#fff" },
};
export function NovaBadge({
group,
className,
size = "md",
showTooltip = true,
}: NovaBadgeProps) {
const [showInfo, setShowInfo] = useState(false);
const getGroupInfo = (group: number) => {
const groups = {
1: {
label: "1",
color: "bg-gradient-to-br from-green-400 to-green-600",
textColor: "text-white",
description: "Não processado",
explanation: "Alimentos não processados ou minimamente processados",
pulse: false,
},
2: {
label: "2",
color: "bg-gradient-to-br from-yellow-400 to-yellow-600",
textColor: "text-white",
description: "Ingredientes",
explanation: "Ingredientes culinários processados",
pulse: false,
},
3: {
label: "3",
color: "bg-gradient-to-br from-orange-400 to-orange-600",
textColor: "text-white",
description: "Processado",
explanation: "Alimentos processados",
pulse: true,
},
4: {
label: "4",
color: "bg-gradient-to-br from-red-400 to-red-600",
textColor: "text-white",
description: "Ultraprocessado",
explanation: "Alimentos ultraprocessados",
pulse: true,
},
};
return groups[group as keyof typeof groups] || null;
};
const groupInfo = getGroupInfo(group);
if (!groupInfo) return null;
const sizeClasses = {
sm: "w-8 h-8 text-sm",
md: "w-16 h-16 text-xl",
lg: "w-20 h-20 text-2xl",
};
const [hover, setHover] = useState(false);
const info = NOVA_INFO[group];
if (!info) return null;
return (
<div className="relative group">
<div className="relative inline-block">
<div
className={cn(
"relative rounded-full flex items-center justify-center shadow-lg transition-all duration-300 transform hover:scale-105",
groupInfo.color,
sizeClasses[size],
groupInfo.pulse && "animate-pulse",
"nutriscore-hero font-sans font-bold rounded-full flex items-center justify-center cursor-default",
SIZE_CLASS[size],
className
)}
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(false)}
style={{ background: info.bg, color: info.fg, animation: "none" }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<span className={cn("font-bold", groupInfo.textColor)}>
{groupInfo.label}
</span>
{/* Efeito de brilho para grupos altos */}
{groupInfo.pulse && (
<div className="absolute inset-0 rounded-full bg-white opacity-20 animate-ping" />
)}
{group}
</div>
{/* Tooltip informativo */}
{showTooltip && showInfo && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl max-w-xs">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">NOVA {groupInfo.label}</span>
{groupInfo.pulse && (
<AlertTriangle className="w-3 h-3 text-yellow-400" />
)}
</div>
<p className="font-medium">{groupInfo.description}</p>
<p className="text-gray-300 mt-1">{groupInfo.explanation}</p>
{/* Seta do tooltip */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
{showTooltip && hover && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 whitespace-nowrap">
<div className="bg-[var(--fg-primary)] text-[var(--bg-base)] text-xs rounded-lg px-3 py-2 shadow-xl">
<p className="font-semibold">NOVA {group}</p>
<p className="opacity-70">{info.desc}</p>
<p className="opacity-50 mt-0.5">{info.explanation}</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-[var(--fg-primary)]" />
</div>
</div>
)}
{/* Badge de alerta para grupos altos */}
{groupInfo.pulse && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center">
<AlertTriangle className="w-2.5 h-2.5 text-white" />
</div>
)}
</div>
);
}
+70 -298
View File
@@ -1,6 +1,4 @@
"use client";
import { useState } from "react";
import { Info, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface NutritionBarProps {
@@ -12,7 +10,6 @@ interface NutritionBarProps {
isLow?: boolean;
recommended?: string;
className?: string;
showTooltip?: boolean;
}
export function NutritionBar({
@@ -22,132 +19,41 @@ export function NutritionBar({
percentage = 0,
isHigh = false,
isLow = false,
recommended,
className,
showTooltip = true,
}: NutritionBarProps) {
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const barWidth = Math.min(Math.max(percentage, 2), 100);
// Calcular cor baseada no valor e se é alto/baixo
const getBarColor = () => {
if (isHigh) {
return "bg-gradient-to-r from-red-400 to-red-600";
}
if (isLow) {
return "bg-gradient-to-r from-green-400 to-green-600";
}
if (percentage > 80) {
return "bg-gradient-to-r from-orange-400 to-orange-600";
}
if (percentage > 60) {
return "bg-gradient-to-r from-yellow-400 to-yellow-600";
}
return "bg-gradient-to-r from-blue-400 to-blue-600";
};
const barColor = isHigh
? "bg-[var(--score-e)]"
: isLow
? "bg-[var(--score-a)]"
: percentage > 75
? "bg-[var(--score-d)]"
: percentage > 50
? "bg-[var(--score-c)]"
: "bg-[var(--accent)]";
// Calcular largura da barra baseada na porcentagem
const barWidth = Math.min(Math.max(percentage, 5), 100);
// Determinar ícone baseado no status
const getIcon = () => {
if (isHigh) return <TrendingUp className="w-3 h-3" />;
if (isLow) return <TrendingDown className="w-3 h-3" />;
return null;
};
// Determinar cor do texto baseada no status
const getTextColor = () => {
if (isHigh) return "text-red-600 dark:text-red-400";
if (isLow) return "text-green-600 dark:text-green-400";
return "text-gray-900 dark:text-white";
};
const valueColor = isHigh
? "text-[var(--score-e)]"
: isLow
? "text-[var(--score-a)]"
: "text-[var(--fg-primary)]";
return (
<div className={cn("space-y-3", className)}>
{/* Linha principal: Informação nutricional à esquerda, porcentagem à direita */}
<div className="flex items-center justify-between">
{/* Informação nutricional à esquerda */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{label}:
</span>
<span className={cn("text-sm font-semibold", getTextColor())}>
{value} {unit}
</span>
{getIcon() && (
<div className={cn("flex items-center", getTextColor())}>
{getIcon()}
</div>
)}
{showTooltip && (
<button
onMouseEnter={() => setIsTooltipVisible(true)}
onMouseLeave={() => setIsTooltipVisible(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<Info className="w-3 h-3" />
</button>
)}
</div>
{/* Separador visual */}
<div className="flex-1 mx-4 h-px bg-gray-200 dark:bg-gray-600"></div>
{/* Porcentagem à direita */}
{percentage > 0 && (
<div className="text-xs text-gray-600 dark:text-gray-300 font-medium bg-white dark:bg-gray-800 px-3 py-1 rounded-full shadow-sm border border-gray-200 dark:border-gray-600 flex-shrink-0">
{Math.round(percentage)}%
</div>
)}
<div className={cn("space-y-2", className)}>
<div className="flex items-baseline justify-between gap-3">
<span className="label text-[var(--fg-muted)] truncate">{label}</span>
<span className={cn("label shrink-0 font-medium", valueColor)}>
{value} {unit}
</span>
</div>
{/* Barra de progresso */}
<div className="relative">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out relative",
getBarColor()
)}
style={{ width: `${barWidth}%` }}
>
{/* Efeito de brilho */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-30 animate-pulse" />
</div>
</div>
{/* Hairline track */}
<div className="w-full h-px bg-[var(--border-subtle)] relative overflow-hidden rounded-full">
<div
className={cn("h-full rounded-full transition-all duration-700", barColor)}
style={{ width: `${barWidth}%` }}
/>
</div>
{/* Tooltip informativo */}
{showTooltip && isTooltipVisible && (
<div className="absolute bottom-full left-0 mb-2 z-50">
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl max-w-xs">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold">{label}</span>
{isHigh && <AlertTriangle className="w-3 h-3 text-red-400" />}
{isLow && <TrendingDown className="w-3 h-3 text-green-400" />}
</div>
<p className="text-gray-300">
Valor: {value} {unit}
</p>
{recommended && (
<p className="text-gray-300 mt-1">Recomendado: {recommended}</p>
)}
{isHigh && (
<p className="text-red-300 mt-1">
⚠️ Alto valor - consumir com moderação
</p>
)}
{isLow && (
<p className="text-green-300 mt-1">✅ Baixo valor - boa opção</p>
)}
{/* Seta do tooltip */}
<div className="absolute top-full left-4">
<div className="w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
)}
</div>
);
}
@@ -165,196 +71,62 @@ interface NutritionGridProps {
className?: string;
}
export function NutritionGrid({
nutritionData,
className,
}: NutritionGridProps) {
// Valores de referência para calcular porcentagens (valores diários recomendados)
const referenceValues = {
energy: 2000, // kcal
fat: 65, // g
carbohydrates: 300, // g
proteins: 50, // g
sugars: 50, // g
fiber: 25, // g
sodium: 2.3, // g
};
const REFERENCE = {
energy: 2000, fat: 65, carbohydrates: 300,
proteins: 50, sugars: 50, fiber: 25, sodium: 2.3,
};
// Extrair valor numérico da string
const extractNumericValue = (value: string | undefined): number => {
if (!value) return 0;
const match = value.match(/(\d+(?:\.\d+)?)/);
return match ? parseFloat(match[1]) : 0;
};
function extractNumber(val?: string): number {
if (!val) return 0;
const m = val.match(/(\d+(?:\.\d+)?)/);
return m ? parseFloat(m[1]) : 0;
}
// Calcular porcentagem baseada no valor de referência
const calculatePercentage = (
value: string | undefined,
reference: number
): number => {
const numericValue = extractNumericValue(value);
return (numericValue / reference) * 100;
};
function pct(val: string | undefined, ref: number): number {
return (extractNumber(val) / ref) * 100;
}
// Determinar se um valor é alto ou baixo
const isHighValue = (
value: string | undefined,
reference: number
): boolean => {
const percentage = calculatePercentage(value, reference);
return percentage > 80;
};
const isLowValue = (
value: string | undefined,
reference: number
): boolean => {
const percentage = calculatePercentage(value, reference);
return percentage < 20;
};
const nutritionItems = [
{
label: "Energia",
value: nutritionData.energy,
unit: "kcal",
percentage: calculatePercentage(
nutritionData.energy,
referenceValues.energy
),
isHigh: isHighValue(nutritionData.energy, referenceValues.energy),
isLow: isLowValue(nutritionData.energy, referenceValues.energy),
recommended: "2000 kcal/dia",
},
{
label: "Gorduras",
value: nutritionData.fat,
unit: "g",
percentage: calculatePercentage(nutritionData.fat, referenceValues.fat),
isHigh: isHighValue(nutritionData.fat, referenceValues.fat),
isLow: isLowValue(nutritionData.fat, referenceValues.fat),
recommended: "65g/dia",
},
{
label: "Carboidratos",
value: nutritionData.carbohydrates,
unit: "g",
percentage: calculatePercentage(
nutritionData.carbohydrates,
referenceValues.carbohydrates
),
isHigh: isHighValue(
nutritionData.carbohydrates,
referenceValues.carbohydrates
),
isLow: isLowValue(
nutritionData.carbohydrates,
referenceValues.carbohydrates
),
recommended: "300g/dia",
},
{
label: "Proteínas",
value: nutritionData.proteins,
unit: "g",
percentage: calculatePercentage(
nutritionData.proteins,
referenceValues.proteins
),
isHigh: isHighValue(nutritionData.proteins, referenceValues.proteins),
isLow: isLowValue(nutritionData.proteins, referenceValues.proteins),
recommended: "50g/dia",
},
{
label: "Açúcares",
value: nutritionData.sugars,
unit: "g",
percentage: calculatePercentage(
nutritionData.sugars,
referenceValues.sugars
),
isHigh: isHighValue(nutritionData.sugars, referenceValues.sugars),
isLow: isLowValue(nutritionData.sugars, referenceValues.sugars),
recommended: "50g/dia",
},
{
label: "Fibras",
value: nutritionData.fiber,
unit: "g",
percentage: calculatePercentage(
nutritionData.fiber,
referenceValues.fiber
),
isHigh: isHighValue(nutritionData.fiber, referenceValues.fiber),
isLow: isLowValue(nutritionData.fiber, referenceValues.fiber),
recommended: "25g/dia",
},
{
label: "Sódio",
value: nutritionData.sodium,
unit: "g",
percentage: calculatePercentage(
nutritionData.sodium,
referenceValues.sodium
),
isHigh: isHighValue(nutritionData.sodium, referenceValues.sodium),
isLow: isLowValue(nutritionData.sodium, referenceValues.sodium),
recommended: "2.3g/dia",
},
export function NutritionGrid({ nutritionData, className }: NutritionGridProps) {
const items = [
{ label: "Energia", value: nutritionData.energy, unit: "kcal", ref: REFERENCE.energy },
{ label: "Gorduras", value: nutritionData.fat, unit: "g", ref: REFERENCE.fat },
{ label: "Carboidratos", value: nutritionData.carbohydrates, unit: "g", ref: REFERENCE.carbohydrates },
{ label: "Proteínas", value: nutritionData.proteins, unit: "g", ref: REFERENCE.proteins },
{ label: "Açúcares", value: nutritionData.sugars, unit: "g", ref: REFERENCE.sugars },
{ label: "Fibras", value: nutritionData.fiber, unit: "g", ref: REFERENCE.fiber },
{ label: "Sódio", value: nutritionData.sodium, unit: "g", ref: REFERENCE.sodium },
];
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Informação Nutricional
</h3>
<div className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-full">
Valores diários de referência
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{nutritionItems.map((item) => (
<div className={cn("space-y-5", className)}>
{items.map((item) => {
const p = pct(item.value, item.ref);
return (
<NutritionBar
key={item.label}
label={item.label}
value={item.value || "N/A"}
value={item.value || "—"}
unit={item.unit}
percentage={item.percentage}
isHigh={item.isHigh}
isLow={item.isLow}
recommended={item.recommended}
showTooltip={true}
percentage={p}
isHigh={p > 80}
isLow={p < 20 && p > 0}
/>
))}
</div>
);
})}
{/* Legenda */}
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
Como interpretar os valores:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-400 rounded-full"></div>
<span className="text-gray-600 dark:text-gray-400">
Baixo (verde)
</span>
{/* Legend */}
<div className="pt-4 border-t border-[var(--border-subtle)] flex flex-wrap gap-x-6 gap-y-2">
{[
{ color: "bg-[var(--score-a)]", label: "Baixo" },
{ color: "bg-[var(--accent)]", label: "Moderado" },
{ color: "bg-[var(--score-e)]", label: "Alto" },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", color)} />
<span className="label text-[var(--fg-muted)]">{label}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-400 rounded-full"></div>
<span className="text-gray-600 dark:text-gray-400">
Moderado (amarelo)
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-400 rounded-full"></div>
<span className="text-gray-600 dark:text-gray-400">
Alto (vermelho)
</span>
</div>
</div>
))}
<span className="label text-[var(--fg-faint)] ml-auto">% do VDR</span>
</div>
</div>
);
+254 -312
View File
@@ -1,350 +1,292 @@
"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { OptimizedImage } from "@/components/OptimizedImage";
import { Product } from "@/lib/openFoodFactsApi";
import { Heart, AlertTriangle, Zap, Plus, Eye } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import { formatNutritionData } from "@/lib/openFoodFactsApi";
import { cn } from "@/lib/utils";
interface ProductCardProps {
product: Product;
className?: string;
priority?: number; // Posição na lista (0 = primeiro)
currentPage?: number; // Página atual para controle de cancelamento
variant?: "default" | "mobile"; // Variante de layout
priority?: number;
currentPage?: number;
variant?: "default" | "mobile";
}
const NUTRI_SCORE_CONFIG = {
a: {
label: "A",
className: "bg-green-500 text-white border-green-500",
description: "Muito bom",
},
b: {
label: "B",
className: "bg-green-400 text-white border-green-400",
description: "Bom",
},
c: {
label: "C",
className: "bg-yellow-500 text-white border-yellow-500",
description: "Regular",
},
d: {
label: "D",
className: "bg-orange-500 text-white border-orange-500",
description: "Ruim",
},
e: {
label: "E",
className: "bg-red-500 text-white border-red-500",
description: "Muito ruim",
},
const NUTRI_GRADE_CLASS: Record<string, string> = {
a: "a", b: "b", c: "c", d: "d", e: "e",
};
const NOVA_CONFIG = {
1: {
label: "1",
className: "bg-green-500 text-white",
description: "Minimamente processado",
},
2: {
label: "2",
className: "bg-yellow-500 text-white",
description: "Ingrediente culinário",
},
3: {
label: "3",
className: "bg-orange-500 text-white",
description: "Processado",
},
4: {
label: "4",
className: "bg-red-500 text-white",
description: "Ultraprocessado",
},
};
function getVerdict(grade?: string, nova?: number): { label: string; colorClass: string } {
if (grade === "a" || grade === "b") return { label: "Recomendado", colorClass: "text-[var(--nutri-a)]" };
if (grade === "c" || nova === 3) return { label: "Com moderação", colorClass: "text-[var(--nutri-c)]" };
return { label: "Evitar", colorClass: "text-[var(--nutri-e)]" };
}
export function ProductCard({
product,
className = "",
priority = 999,
currentPage = 1,
variant = "default",
}: ProductCardProps) {
const router = useRouter();
function getVerdictDotBg(grade?: string, nova?: number): string {
if (grade === "a" || grade === "b") return "bg-[var(--nutri-a)]";
if (grade === "c" || nova === 3) return "bg-[var(--nutri-c)]";
return "bg-[var(--nutri-e)]";
}
const getProductImageUrl = (): string => {
return (
product.image_front_url ||
product.image_front_small_url ||
product.image_url ||
product.image_small_url ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect fill='%23f3f4f6' width='400' height='400'/%3E%3Ctext fill='%239ca3af' font-family='sans-serif' font-size='48' x='50%25' y='50%25' text-anchor='middle' dominant-baseline='middle'%3ESem Imagem%3C/text%3E%3C/svg%3E"
);
};
const getNutritionGrade = () => {
if (!product.nutrition_grades) return null;
const grade = product.nutrition_grades.toLowerCase();
return NUTRI_SCORE_CONFIG[grade as keyof typeof NUTRI_SCORE_CONFIG] || null;
};
const getNovaGroup = () => {
if (!product.nova_group) return null;
return NOVA_CONFIG[product.nova_group as keyof typeof NOVA_CONFIG] || null;
};
const nutritionGrade = getNutritionGrade();
const novaGroup = getNovaGroup();
const imageUrl = getProductImageUrl();
const nutritionData = formatNutritionData(product);
// Verificar se tem alérgenos comuns
const hasCommonAllergens =
product.allergens_tags?.some((allergen) =>
["gluten", "milk", "eggs", "nuts", "peanuts", "soy"].some((common) =>
allergen.toLowerCase().includes(common)
)
) || false;
// Layout mobile horizontal
if (variant === "mobile") {
return (
<Card
className={`product-card group relative overflow-hidden hover:shadow-lg transition-all duration-200 bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 ${className}`}
>
<div className="flex gap-3 p-3">
{/* Imagem - tamanho ajustado */}
<div className="w-24 h-24 flex-shrink-0 relative bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-lg overflow-hidden">
<OptimizedImage
src={imageUrl}
alt={product.product_name || "Produto"}
fill
priority={priority}
onPageChange={currentPage}
className="object-contain p-2"
sizes="96px"
placeholder="empty"
/>
{/* Badges - tamanho ajustado */}
{nutritionGrade && (
<div className="absolute top-1 right-1">
<Badge
className={`${nutritionGrade.className} text-[9px] px-1.5 py-0.5 font-bold`}
>
{nutritionGrade.label}
</Badge>
</div>
)}
{hasCommonAllergens && (
<div className="absolute bottom-1 right-1">
<div className="bg-yellow-500 text-white rounded-full p-0.5">
<AlertTriangle className="w-2 h-2" />
</div>
</div>
)}
</div>
{/* Conteúdo - melhor espaçamento */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1">
{/* Nome do produto */}
<h3 className="font-semibold text-sm text-gray-900 dark:text-white line-clamp-2 mb-1">
{product.product_name || "Produto sem nome"}
</h3>
{/* Marca */}
{product.brands && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{product.brands}
</p>
)}
{/* Info nutricional compacta */}
<div className="flex gap-2 mt-2 text-xs">
{product.nutriments?.energy_100g &&
typeof product.nutriments.energy_100g === "number" && (
<span className="text-gray-600 dark:text-gray-400">
{Math.round(product.nutriments.energy_100g)} kcal
</span>
)}
{product.nutriments?.fat_100g &&
typeof product.nutriments.fat_100g === "number" && (
<span className="text-gray-600 dark:text-gray-400">
{product.nutriments.fat_100g.toFixed(1)}g gord.
</span>
)}
</div>
</div>
{/* Botão Ver - menor */}
<Link
href={`/produto/${product.code}`}
className="mt-2 inline-flex items-center justify-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-md transition-colors"
>
<Eye className="w-3 h-3" />
Ver
</Link>
</div>
</div>
</Card>
);
}
// Layout desktop padrão
function getImageUrl(product: Product): string {
return (
<Card
className={`product-card group relative overflow-hidden hover:shadow-2xl transition-all duration-300 hover:-translate-y-2 bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 ${className}`}
product.image_front_url ||
product.image_front_small_url ||
product.image_url ||
product.image_small_url ||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect fill='%23f3efe7' width='400' height='400'/%3E%3Ctext fill='%23a39c93' font-family='Georgia,serif' font-size='16' x='50%25' y='50%25' text-anchor='middle' dominant-baseline='middle'%3ESem imagem%3C/text%3E%3C/svg%3E"
);
}
/* ── Nutri-Letter hero circle ───────────────────────────────────────────── */
function NutriLetter({ grade, size = 56 }: { grade: string; size?: number }) {
const g = grade?.toLowerCase();
if (!g || !NUTRI_GRADE_CLASS[g]) return null;
return (
<div
className={`nutri-letter ${g}`}
style={{ width: size, height: size, fontSize: Math.round(size * 0.62), flexShrink: 0 }}
aria-label={`Nutri-Score ${g.toUpperCase()}`}
>
{/* Imagem do Produto */}
<div className="relative h-56 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 overflow-hidden">
{g.toUpperCase()}
</div>
);
}
/* ── Verdict dot — pure Tailwind, no inline style ──────────────────────── */
function VerdictDot({ grade, nova }: { grade?: string; nova?: number }) {
const { label, colorClass } = getVerdict(grade, nova);
const dotBg = getVerdictDotBg(grade, nova);
return (
<span className={cn("inline-flex items-center gap-1.5 text-[11px] font-semibold", colorClass)}>
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0", dotBg)} />
{label}
</span>
);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MOBILE VARIANT — horizontal row
All hover/focus states are pure CSS via Tailwind so keyboard + touch work
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
function MobileCard({ product, className, priority, currentPage }: Omit<ProductCardProps, "variant">) {
const imageUrl = getImageUrl(product);
const grade = product.nutrition_grades?.toLowerCase();
const nova = product.nova_group;
const allergenCount =
product.allergens_tags?.filter((a) =>
["gluten", "milk", "eggs", "nuts", "peanuts", "soy"].some((c) =>
a.toLowerCase().includes(c)
)
).length ?? 0;
return (
<Link
href={`/produto/${product.code}`}
className={cn(
"product-card group flex gap-3.5 items-center p-3.5",
"bg-[var(--ink-0)] border border-[var(--ink-200)] rounded-[14px]",
/* Hover — CSS only, works for keyboard focus-visible too */
"transition-all duration-200 ease-out",
"hover:border-[var(--ink-400)] hover:-translate-y-px hover:shadow-[0_6px_20px_-10px_rgba(0,0,0,0.14)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink-0)]",
"active:scale-[0.99] active:shadow-none",
className
)}
aria-label={`${product.product_name || "Produto"} — ver detalhes`}
>
{/* Thumb */}
<div className="relative shrink-0 w-[72px] h-[72px] rounded-[10px] overflow-hidden bg-[var(--ink-100)]">
<OptimizedImage
src={imageUrl}
alt={product.product_name || "Produto"}
fill
priority={priority}
onPageChange={currentPage}
className="object-contain p-6 group-hover:scale-110 transition-transform duration-500"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-contain p-1.5"
sizes="72px"
placeholder="empty"
/>
</div>
{/* Overlay com badges */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Badge Nutri-Score */}
{nutritionGrade && (
<div className="absolute top-3 right-3">
<Badge
className={`${nutritionGrade.className} shadow-lg text-xs font-bold px-2 py-1`}
>
<Zap className="w-3 h-3 mr-1" />
{nutritionGrade.label}
</Badge>
</div>
)}
{/* Badge NOVA */}
{novaGroup && (
<div className="absolute top-3 left-3">
<Badge
className={`${novaGroup.className} shadow-lg text-xs font-bold px-2 py-1`}
>
NOVA {novaGroup.label}
</Badge>
</div>
)}
{/* Ícone de alérgenos */}
{hasCommonAllergens && (
<div className="absolute bottom-3 right-3">
<div className="bg-yellow-500 text-white rounded-full p-1.5 shadow-lg">
<AlertTriangle className="w-3 h-3" />
</div>
</div>
)}
{/* Botão de favorito */}
<div className="absolute bottom-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0 rounded-full bg-white/90 hover:bg-white shadow-lg"
>
<Heart className="w-4 h-4" />
</Button>
{/* Center text */}
<div className="flex-1 min-w-0">
<div className="eyebrow-mono truncate mb-0.5">
{product.brands || "Marca desconhecida"}
</div>
<div className="text-[15px] font-medium text-[var(--ink-900)] truncate mb-2 tracking-[-0.005em]">
{product.product_name || "Produto sem nome"}
</div>
<div className="flex items-center gap-2 flex-wrap">
<VerdictDot grade={grade} nova={nova} />
{nova && (
<>
<span className="text-[var(--ink-300)]">·</span>
<span className="eyebrow-mono text-[var(--ink-500)]">NOVA {nova}</span>
</>
)}
{allergenCount > 0 && (
<>
<span className="text-[var(--ink-300)]">·</span>
<span className="inline-flex items-center gap-1 text-[11px] text-[var(--warn)] font-medium">
<AlertTriangle className="w-2.5 h-2.5" strokeWidth={2} />
{allergenCount} alérgeno{allergenCount > 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
<CardContent className="p-5 space-y-4">
{/* Nome do Produto */}
<div className="space-y-2">
<h3 className="font-semibold text-lg leading-tight line-clamp-2 min-h-[2.5rem] text-gray-900 dark:text-white group-hover:text-primary transition-colors">
{/* Nutri-Score letter */}
{grade && NUTRI_GRADE_CLASS[grade] && <NutriLetter grade={grade} size={52} />}
</Link>
);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DEFAULT VARIANT — portrait card, image hero
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
function DefaultCard({ product, className, priority, currentPage }: Omit<ProductCardProps, "variant">) {
const imageUrl = getImageUrl(product);
const nutritionData = formatNutritionData(product);
const grade = product.nutrition_grades?.toLowerCase();
const nova = product.nova_group;
const allergenCount =
product.allergens_tags?.filter((a) =>
["gluten", "milk", "eggs", "nuts", "peanuts", "soy"].some((c) =>
a.toLowerCase().includes(c)
)
).length ?? 0;
return (
<Link
href={`/produto/${product.code}`}
className={cn(
/* Layout */
"product-card group flex flex-col h-full",
/* Surface */
"bg-[var(--ink-0)] border border-[var(--ink-200)] rounded-2xl overflow-hidden",
/* Transitions — one rule governs border + lift + shadow */
"transition-all duration-200 ease-out",
/* Hover — CSS, no JS */
"hover:border-[var(--ink-400)] hover:-translate-y-0.5 hover:shadow-[0_12px_32px_-14px_rgba(0,0,0,0.15)]",
/* Keyboard focus ring — WCAG 2.4.7 compliant */
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)]",
/* Press feedback */
"active:scale-[0.99] active:shadow-none",
className
)}
aria-label={`${product.product_name || "Produto"} — ver detalhes`}
>
{/* ── Hero image area ─────────────────────────────────────────────── */}
<div className="relative aspect-[5/4] bg-[var(--ink-50)] border-b border-[var(--ink-200)] overflow-hidden">
{/* Image zooms on hover because parent now HAS the `group` class */}
<OptimizedImage
src={imageUrl}
alt={product.product_name || "Produto"}
fill
priority={priority}
onPageChange={currentPage}
className="object-contain p-6 transition-transform duration-500 ease-out group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
{/* Nutri-Score — top-right */}
{grade && NUTRI_GRADE_CLASS[grade] && (
<div className="absolute top-3.5 right-3.5 drop-shadow-md">
<NutriLetter grade={grade} size={52} />
</div>
)}
{/* Allergen chip — top-left */}
{allergenCount > 0 && (
<div className="absolute top-3.5 left-3.5">
<span className="rd-chip warn" style={{ padding: "4px 9px", fontSize: 11 }}>
<AlertTriangle className="w-3 h-3" strokeWidth={2} />
{allergenCount}
</span>
</div>
)}
</div>
{/* ── Card body ──────────────────────────────────────────────────── */}
<div className="flex flex-col gap-3.5 p-[18px] flex-1">
{/* Brand + product name */}
<div>
<div className="eyebrow-mono truncate mb-1">
{product.brands || "Marca desconhecida"}
</div>
<h3
className="font-display text-[22px] leading-[1.15] tracking-[-0.01em] text-[var(--ink-900)] line-clamp-2 m-0"
>
{product.product_name || "Nome não disponível"}
</h3>
</div>
{/* Marca */}
{product.brands && (
<p className="text-sm text-gray-600 dark:text-gray-400 font-medium">
{product.brands}
</p>
{/* ── Nutrition mini-bar: 4 columns ─────────────────────────────── */}
{nutritionData && (
<div className="grid grid-cols-4 border-y border-[var(--ink-200)]">
{[
{ l: "kcal", v: nutritionData.energy },
{ l: "Açúcar", v: nutritionData.sugars },
{ l: "Gord.", v: nutritionData.fat },
{ l: "Sódio", v: nutritionData.sodium },
].map((n, i) =>
n.v ? (
<div
key={n.l}
className={cn(
"flex flex-col items-center justify-center py-3 px-1.5 min-w-0 text-center",
i < 3 && "border-r border-[var(--ink-200)]"
)}
>
<span className="font-display text-base text-[var(--ink-900)] leading-none truncate max-w-full">
{n.v}
</span>
<span className="eyebrow-mono text-[9px] text-[var(--ink-500)] mt-1.5">
{n.l}
</span>
</div>
) : null
)}
</div>
)}
{/* Footer row: verdict + nova badge */}
<div className="flex items-center justify-between mt-auto">
<VerdictDot grade={grade} nova={nova} />
{nova && (
<span className="eyebrow-mono text-[var(--ink-500)]">
NOVA {nova}
</span>
)}
</div>
{/* Informações nutricionais resumidas */}
{nutritionData && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Energia:</span>
<span className="font-medium">{nutritionData.energy}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Gorduras:</span>
<span className="font-medium">{nutritionData.fat}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Açúcares:</span>
<span className="font-medium">{nutritionData.sugars}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Sódio:</span>
<span className="font-medium">{nutritionData.sodium}</span>
</div>
</div>
)}
{/* Categorias */}
{product.categories && (
<div className="flex flex-wrap gap-1.5">
{product.categories
.split(",")
.slice(0, 2)
.map((cat, i) => (
<Badge
key={i}
variant="outline"
className="text-xs font-normal bg-gray-50 dark:bg-gray-800 border-gray-100 dark:border-gray-800"
>
{cat.trim()}
</Badge>
))}
</div>
)}
{/* Alérgenos */}
{hasCommonAllergens && (
<div className="flex items-center gap-1 text-xs text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3 h-3" />
<span>Contém alérgenos</span>
</div>
)}
{/* Botões de ação */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1 group-hover:bg-primary group-hover:text-primary-foreground group-hover:border-primary transition-all duration-200"
onClick={() => router.push(`/produto/${product.code}`)}
>
<Eye className="w-4 h-4 mr-2" />
Ver Detalhes
</Button>
<Button
size="sm"
variant="secondary"
className="px-3 group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-200"
>
<Plus className="w-4 h-4" />
</Button>
{/* CTA — styled as ghost button, inherits the card's hover context */}
<div
className={cn(
"block text-center py-2.5 text-[13px] font-medium text-[var(--ink-700)] no-underline",
"border border-[var(--ink-200)] rounded-[10px]",
"transition-colors duration-150",
/* Group-hover darkens the CTA border/text for free */
"group-hover:border-[var(--ink-400)] group-hover:text-[var(--ink-900)]"
)}
aria-hidden="true" /* Link is on the <Link> wrapper above */
tabIndex={-1}
>
Ver detalhes →
</div>
</CardContent>
</Card>
</div>
</Link>
);
}
/* ── Public export ──────────────────────────────────────────────────────── */
export function ProductCard({ variant = "default", ...props }: ProductCardProps) {
if (variant === "mobile") return <MobileCard {...props} />;
return <DefaultCard {...props} />;
}
+57 -129
View File
@@ -1,15 +1,13 @@
"use client";
import { cn } from "@/lib/utils";
interface SkeletonProps {
className?: string;
}
interface SkeletonProps { className?: string; }
function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-gray-200 dark:bg-gray-700",
"animate-pulse rounded bg-[var(--border-subtle)]",
className
)}
/>
@@ -18,124 +16,62 @@ function Skeleton({ className }: SkeletonProps) {
export function ProductSkeleton({ className }: SkeletonProps) {
return (
<div className={cn("space-y-4", className)}>
{/* Header skeleton */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-16" />
<div className="flex-1">
<Skeleton className="h-8 w-64 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
<div className={cn("min-h-screen bg-[var(--bg-base)]", className)}>
{/* Sticky header skeleton */}
<div className="sticky top-0 z-50 bg-[var(--bg-elevated)] border-b border-[var(--border-subtle)] h-20">
<div className="container-editorial h-full flex items-center gap-6">
<Skeleton className="w-9 h-9 rounded" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-64" />
<Skeleton className="h-3 w-32" />
</div>
</div>
</div>
{/* Content skeleton */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left column - Product image */}
<div className="space-y-4">
<div className="relative w-full max-w-2xl mx-auto bg-white dark:bg-gray-800 rounded-xl overflow-hidden shadow-lg">
<div className="aspect-square bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 p-8">
<Skeleton className="w-full h-full rounded-lg" />
</div>
</div>
{/* Basic info skeleton */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4">
<Skeleton className="h-5 w-32 mb-3" />
<div className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex justify-between">
<div className="container-editorial py-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
{/* Left column */}
<div className="lg:col-span-5 space-y-6">
<Skeleton className="aspect-square w-full rounded-2xl" />
<div className="card-warm rounded-xl p-8 space-y-4">
<Skeleton className="h-3 w-24" />
{[1,2,3].map(i => (
<div key={i} className="flex justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
</div>
))}
</div>
</div>
{/* Right column - Product details */}
<div className="space-y-6">
{/* Nutrition grades skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<Skeleton className="h-5 w-24 mb-3" />
<div className="flex items-center gap-3">
<Skeleton className="w-12 h-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-32" />
</div>
{/* Right column */}
<div className="lg:col-span-7 space-y-6">
<div className="grid grid-cols-2 gap-4">
{[1,2].map(i => (
<div key={i} className="card-warm rounded-xl p-8 space-y-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-40" />
<Skeleton className="w-20 h-20 rounded-full" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<Skeleton className="h-5 w-16 mb-3" />
<div className="flex items-center gap-3">
<Skeleton className="w-12 h-12 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-3 w-28" />
</div>
</div>
</div>
))}
</div>
{/* Nutritional information skeleton */}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<Skeleton className="h-5 w-48 mb-4" />
<div className="card-warm rounded-xl p-8 space-y-4">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-2 gap-4">
{[...Array(7)].map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
))}
</div>
</div>
{/* Ingredients skeleton */}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4">
<Skeleton className="h-5 w-24 mb-3" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
{/* Allergens skeleton */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<Skeleton className="h-5 w-20 mb-3" />
<div className="flex flex-wrap gap-2">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-6 w-16 rounded-full" />
))}
</div>
</div>
{/* Additives skeleton */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
<Skeleton className="h-5 w-16 mb-3" />
<div className="flex flex-wrap gap-2">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-6 w-12 rounded-full" />
))}
<Skeleton className="h-6 w-12 rounded-full" />
</div>
<div className="card-warm rounded-xl p-8 space-y-3">
<Skeleton className="h-5 w-36" />
{[1,2,3,4].map(i => <Skeleton key={i} className="h-4 w-full" />)}
</div>
</div>
</div>
@@ -146,26 +82,20 @@ export function ProductSkeleton({ className }: SkeletonProps) {
export function ProductCardSkeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
"bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden",
className
)}
>
<div className="aspect-square bg-gray-100 dark:bg-gray-700 p-4">
<Skeleton className="w-full h-full rounded-lg" />
</div>
<div className="p-4 space-y-3">
<div className={cn("card-warm rounded-xl overflow-hidden", className)}>
<Skeleton className="aspect-square w-full" />
<div className="p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex items-center gap-2">
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-3 w-1/2" />
<div className="pt-3 border-t border-[var(--border-subtle)] space-y-2">
{[1,2,3,4].map(i => (
<div key={i} className="flex justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
<Skeleton className="h-9 w-full rounded" />
</div>
</div>
);
@@ -174,23 +104,21 @@ export function ProductCardSkeleton({ className }: SkeletonProps) {
export function NutritionBarSkeleton({ className }: SkeletonProps) {
return (
<div className={cn("space-y-2", className)}>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<Skeleton className="h-2 w-3/4 rounded-full" />
<div className="flex justify-between">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
);
}
export function BadgeSkeleton({ className }: SkeletonProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<Skeleton className="w-12 h-12 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-16" />
<div className={cn("flex items-center gap-3", className)}>
<Skeleton className="w-14 h-14 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
</div>
+46 -56
View File
@@ -2,32 +2,29 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, ScanLine } from "lucide-react";
import { detectSearchType, isValidBarcode } from "@/lib/openFoodFactsApi";
import { cn } from "@/lib/utils";
interface SearchBarProps {
initialQuery?: string;
className?: string;
placeholder?: string;
compact?: boolean; // Modo compacto para mobile
compact?: boolean;
}
export function SearchBar({
initialQuery = "",
className = "",
placeholder = "Buscar produtos...",
placeholder = "Buscar produtos ou código de barras…",
compact = false,
}: SearchBarProps) {
const [query, setQuery] = useState(initialQuery);
const [searchType, setSearchType] = useState<"barcode" | "name" | null>(null);
const router = useRouter();
// Detectar tipo de busca em tempo real
useEffect(() => {
const cleaned = query.replace(/\D/g, ""); // Remove tudo que não é número
const cleaned = query.replace(/\D/g, "");
if (cleaned.length >= 8 && cleaned.length <= 13) {
setSearchType("barcode");
} else if (query.trim()) {
@@ -39,97 +36,90 @@ export function SearchBar({
const handleSearch = () => {
if (!query.trim()) return;
const type = detectSearchType(query);
if (type === "barcode") {
// Limpar código de barras (remover espaços/caracteres)
const cleanBarcode = query.replace(/\D/g, "");
if (isValidBarcode(cleanBarcode)) {
// Redirecionar para página do produto
router.push(`/produto/${cleanBarcode}`);
const clean = query.replace(/\D/g, "");
if (isValidBarcode(clean)) {
router.push(`/produto/${clean}`);
} else {
// Mostrar erro de código inválido
alert("Código de barras inválido. Deve ter entre 8 e 13 dígitos.");
router.push(`/resultados?q=${encodeURIComponent(query.trim())}`);
}
} else {
// Busca normal por nome
router.push(`/resultados?q=${encodeURIComponent(query.trim())}`);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
};
if (compact) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--fg-faint)]" strokeWidth={1.5} />
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-8 pr-2 h-9 text-sm"
onKeyDown={handleKey}
className={cn(
"w-full pl-9 pr-3 h-9 text-sm rounded border border-[var(--border-subtle)]",
"bg-[var(--bg-elevated)] text-[var(--fg-primary)] placeholder-[var(--fg-faint)]",
"focus:outline-none focus:border-[var(--accent)] transition-colors duration-200"
)}
/>
{/* Indicador visual do tipo de busca - mais compacto */}
{searchType && (
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-[10px]">
{searchType === "barcode" ? "Código" : "Nome"}
</span>
</div>
)}
</div>
<Button
<button
onClick={handleSearch}
disabled={!query.trim()}
size="sm"
className="h-9 px-3"
className="btn-accent h-9 px-3 py-0 text-xs disabled:opacity-40"
>
<Search className="h-4 w-4" />
</Button>
<Search className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--fg-muted)]">
{searchType === "barcode" ? (
<ScanLine className="w-4 h-4" strokeWidth={1.5} />
) : (
<Search className="w-4 h-4" strokeWidth={1.5} />
)}
</div>
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-10 pr-4 h-10"
onKeyDown={handleKey}
className={cn(
"w-full pl-10 pr-4 h-10 text-sm rounded border border-[var(--border-subtle)]",
"bg-[var(--bg-elevated)] text-[var(--fg-primary)] placeholder-[var(--fg-faint)]",
"focus:outline-none focus:border-[var(--accent)] transition-colors duration-200"
)}
/>
{/* Indicador visual do tipo de busca */}
{searchType && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{searchType === "barcode" ? "Código de barras" : "Nome"}
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<span className="label text-[var(--fg-faint)] bg-[var(--bg-surface)] px-2 py-0.5 rounded border border-[var(--border-subtle)]">
{searchType === "barcode" ? "Código" : "Nome"}
</span>
</div>
)}
</div>
<Button
<button
onClick={handleSearch}
disabled={!query.trim()}
className="h-10 px-4"
className="btn-accent h-10 px-4 text-sm disabled:opacity-40"
>
<Search className="h-4 w-4 mr-2" />
<Search className="w-4 h-4" strokeWidth={1.5} />
Buscar
</Button>
</button>
</div>
);
}
+77 -93
View File
@@ -3,14 +3,12 @@
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { motion, AnimatePresence } from "framer-motion";
import { Sun, Moon, Menu, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import Image from "next/image"
import Image from "next/image";
import { cn } from "@/lib/utils";
// 1. Definição de Dados Centralizada (SSOT - Single Source of Truth)
const NAV_LINKS = [
{ href: "/", label: "Como funciona" },
{ href: "/#como-funciona", label: "Como funciona" },
{ href: "/#recursos", label: "Recursos" },
{ href: "/sobre", label: "Sobre" },
];
@@ -18,130 +16,116 @@ const NAV_LINKS = [
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [scrolled, setScrolled] = useState(false);
const { theme, setTheme } = useTheme();
// Prevenção de Hydration Mismatch
useEffect(() => setMounted(true), []);
useEffect(() => {
setMounted(true);
const onScroll = () => setScrolled(window.scrollY > 12);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark");
return (
<header className="sticky top-0 z-[100] w-full bg-white/70 dark:bg-gray-900/70 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 transition-colors duration-500">
<div className="max-w-7xl mx-auto px-6">
<header
className={cn(
"sticky top-0 z-[100] w-full transition-all duration-300",
scrolled
? "bg-[var(--bg-base)]/90 backdrop-blur-md border-b border-[var(--border-subtle)]"
: "bg-[var(--bg-base)] border-b border-transparent"
)}
>
<div className="container-editorial">
<div className="flex items-center justify-between h-16">
{/* Logo Section - Unificada */}
{/* ── Logo ──────────────────────────────────────────────────── */}
<Link href="/" className="flex items-center gap-3 group">
{/* Container do Logo com Referencial Relativo */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:rotate-6 transition-transform relative overflow-hidden">
<Image
src="/apple-touch-icon.png"
alt="NutriScan Logo"
fill // Ocupa todo o container pai
priority // CRÍTICO: Carrega o logo antes de tudo para melhorar o LCP
sizes="40px" // Dica para o browser sobre o tamanho real em pixels
className="object-contain p-2" // Mantém a proporção e adiciona respiro interno
/>
</div>
<div className="flex flex-col">
<span className="text-lg font-black tracking-tighter text-gray-900 dark:text-white leading-none">
NUTRI<span className="text-blue-500">SCAN</span>
<Image
src="/apple-touch-icon.png"
alt="NutriScan"
width={32}
height={32}
priority
className="rounded object-contain"
/>
<div className="flex flex-col leading-none">
<span className="font-sans font-semibold text-[var(--fg-primary)] text-base tracking-tight">
NutriScan
</span>
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest leading-none mt-1">
Data Engineering
<span className="label text-[var(--fg-faint)] mt-0.5">
Dados Nutricionais
</span>
</div>
</Link>
{/* Desktop Nav - Com micro-interações */}
{/* ── Desktop nav ───────────────────────────────────────────── */}
<nav className="hidden md:flex items-center gap-1">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="px-4 py-2 text-sm font-semibold text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-all"
className="px-4 py-2 label text-[var(--fg-muted)] hover:text-[var(--fg-primary)] rounded transition-colors duration-200"
>
{link.label}
</Link>
))}
</nav>
{/* Ações e Mobile Toggle */}
{/* ── Actions ───────────────────────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Theme toggle */}
{mounted && (
<Button
variant="ghost"
size="icon"
<button
onClick={toggleTheme}
className="rounded-full w-10 h-10 border border-gray-200 dark:border-gray-800"
aria-label={theme === "dark" ? "Modo claro" : "Modo escuro"}
className="w-9 h-9 flex items-center justify-center rounded border border-[var(--border-subtle)] text-[var(--fg-muted)] hover:text-[var(--fg-primary)] hover:border-[var(--border-strong)] transition-all duration-200"
>
<motion.div
initial={false}
animate={{ rotate: theme === "dark" ? 0 : 180, scale: 1 }}
whileTap={{ scale: 0.9 }}
>
{theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</motion.div>
</Button>
{theme === "dark" ? (
<Sun className="w-4 h-4" strokeWidth={1.5} />
) : (
<Moon className="w-4 h-4" strokeWidth={1.5} />
)}
</button>
)}
<Button
variant="ghost"
size="icon"
className="md:hidden rounded-full"
{/* Mobile hamburger */}
<button
className="md:hidden w-9 h-9 flex items-center justify-center rounded border border-[var(--border-subtle)] text-[var(--fg-muted)] hover:text-[var(--fg-primary)] transition-all duration-200"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "Fechar menu" : "Abrir menu"}
>
<AnimatePresence mode="wait">
<motion.div
key={isMenuOpen ? "close" : "open"}
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
transition={{ duration: 0.2 }}
>
{isMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</motion.div>
</AnimatePresence>
</Button>
{isMenuOpen ? (
<X className="w-4 h-4" strokeWidth={1.5} />
) : (
<Menu className="w-4 h-4" strokeWidth={1.5} />
)}
</button>
</div>
</div>
</div>
{/* Mobile Menu - Animado com AnimatePresence */}
<AnimatePresence>
{isMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden"
>
<nav className="flex flex-col p-6 gap-4">
{NAV_LINKS.map((link, i) => (
<motion.div
key={link.href}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
>
<Link
href={link.href}
onClick={() => setIsMenuOpen(false)}
className="text-xl font-bold text-gray-900 dark:text-white hover:text-blue-500 transition-colors"
>
{link.label}
</Link>
</motion.div>
))}
<div className="pt-4 border-t border-gray-100 dark:border-gray-800">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-[0.3em]">
NutriScan v2.0
</p>
</div>
</nav>
</motion.div>
{/* ── Mobile menu ───────────────────────────────────────────────── */}
<div
className={cn(
"md:hidden overflow-hidden transition-all duration-300 border-t border-[var(--border-subtle)]",
isMenuOpen ? "max-h-64 opacity-100" : "max-h-0 opacity-0"
)}
</AnimatePresence>
>
<nav className="container-editorial py-6 flex flex-col gap-1">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setIsMenuOpen(false)}
className="py-3 font-sans font-medium text-[var(--fg-secondary)] hover:text-[var(--fg-primary)] border-b border-[var(--border-subtle)] last:border-0 transition-colors duration-200"
>
{link.label}
</Link>
))}
</nav>
</div>
</header>
);
}
}
+79 -65
View File
@@ -1,97 +1,111 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { Heart, Coffee, ShieldCheck, Server, Globe, Code2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Heart, Server, ShieldCheck, Globe } from "lucide-react";
export function DonationSection() {
return (
<section className="py-24 bg-white dark:bg-[#030712] relative overflow-hidden">
{/* Background Decor sutil */}
<div className="absolute top-0 right-0 w-1/3 h-1/3 bg-blue-500/5 blur-[120px] rounded-full pointer-events-none" />
<section className="bg-base section-py">
<div className="container-editorial">
<div className="max-w-5xl mx-auto px-6 relative z-10">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Lado Esquerdo: A Missão (Copywriting Técnico) */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
>
<h2 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter leading-tight">
Mantenha a <span className="text-blue-600">Engenharia</span> Livre de Interesses.
{/* ── Section header ───────────────────────────────────────────── */}
<div className="mb-16">
<span className="label text-[var(--accent)] block mb-4">Apoie o Projeto</span>
<hr className="hairline mb-10" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<h2 className="font-display text-display-lg text-[var(--fg-primary)]">
Mantenha a informação{" "}
<em className="font-display-i text-[var(--accent)]">livre.</em>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
O NutriScan é um projeto de código aberto nascido em Garuva, SC. Operamos sem anúncios e sem venda de dados. Nosso único combustível é o apoio da comunidade que acredita na democratização da saúde.
<p className="text-[var(--fg-secondary)] text-lg leading-relaxed max-w-md">
O NutriScan opera sem anúncios e sem venda de dados. Nosso único
combustível é o apoio de quem acredita na democratização da saúde.
</p>
</div>
</div>
<div className="space-y-4">
{/* ── Content grid ─────────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-20 items-start">
{/* Left: What your support funds */}
<div className="lg:col-span-6">
<div className="divide-y divide-[var(--border-subtle)]">
{[
{ icon: Server, text: "Manutenção de Servidores e API de Alta Latência" },
{ icon: ShieldCheck, text: "Segurança de Dados e Auditoria Open Source" },
{ icon: Globe, text: "Expansão da Base de Dados Internacional" },
].map((item, i) => (
<div key={i} className="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-gray-300">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<item.icon className="w-4 h-4 text-blue-600" />
{
icon: Server,
title: "Infraestrutura",
desc: "Servidores, CDN e API de alta disponibilidade para garantir acesso global sem interrupções.",
},
{
icon: ShieldCheck,
title: "Segurança & Auditoria",
desc: "Revisões de código open source, análise de dependências e manutenção da integridade dos dados.",
},
{
icon: Globe,
title: "Expansão da Base",
desc: "Integração com novos mercados e suporte a mais países, línguas e categorias de produtos.",
},
].map(({ icon: Icon, title, desc }, i) => (
<div key={i} className="flex gap-6 py-8 group">
<div className="shrink-0 mt-0.5">
<Icon
className="w-5 h-5 text-[var(--fg-muted)] group-hover:text-[var(--accent)] transition-colors duration-300"
strokeWidth={1.5}
/>
</div>
<div>
<h4 className="font-sans font-semibold text-[var(--fg-primary)] mb-1.5">
{title}
</h4>
<p className="text-[var(--fg-secondary)] text-sm leading-relaxed">
{desc}
</p>
</div>
{item.text}
</div>
))}
</div>
</motion.div>
</div>
{/* Lado Direito: O Card de Doação (Psychological Anchoring) */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="relative"
>
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 p-8 rounded-[2.5rem] shadow-2xl backdrop-blur-sm">
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-500/20">
<Coffee className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-bold text-gray-900 dark:text-white">Apoie com um Café</h4>
<p className="text-xs text-gray-500 uppercase tracking-widest font-mono">Status: Sistema Online</p>
</div>
</div>
{/* Right: Donation card */}
<div className="lg:col-span-6">
<div className="card-warm p-10 rounded-xl">
<blockquote className="italic text-gray-600 dark:text-gray-400 text-sm border-l-2 border-blue-500 pl-4 mb-8">
&quot;Como desenvolvedor independente, cada contribuição me permite dedicar mais horas à refatoração e novas features para o NutriScan.&quot;
{/* Eyebrow */}
<span className="label text-[var(--fg-muted)] block mb-8">
Desenvolvido em Garuva, SC · Brasil
</span>
{/* Quote */}
<blockquote className="border-l-2 border-[var(--accent)] pl-5 mb-10">
<p className="font-display text-display-sm text-[var(--fg-primary)] leading-snug mb-3">
&ldquo;Cada contribuição me permite dedicar mais horas ao{" "}
<em className="font-display-i">NutriScan</em>.&rdquo;
</p>
<cite className="label text-[var(--fg-muted)] not-italic">
— Evandro, desenvolvedor
</cite>
</blockquote>
{/* CTA */}
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="block group"
className="btn-accent w-full justify-center py-4 text-base"
>
<Button className="w-full h-16 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-bold text-lg transition-all group-hover:scale-[1.02] active:scale-95 shadow-xl shadow-blue-500/25">
<Heart className="w-5 h-5 mr-3 fill-white group-hover:animate-pulse" />
Contribuir agora
</Button>
<Heart className="w-4 h-4" strokeWidth={1.5} />
Contribuir agora
</a>
<div className="mt-6 flex justify-center gap-4">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-tighter">
<Code2 size={12} /> Engenharia Brasileira
</div>
</div>
<p className="mt-4 label text-center text-[var(--fg-faint)]">
Qualquer valor ajuda a manter o NutriScan gratuito.
</p>
</div>
{/* Elemento Decorativo: Floating Badge */}
<div className="absolute -bottom-6 -right-6 bg-white dark:bg-gray-800 p-4 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-700 animate-bounce">
<p className="text-[10px] font-black text-blue-600 uppercase">Qualquer valor ajuda!</p>
</div>
</motion.div>
</div>
</div>
</div>
</section>
);
}
}
+96 -118
View File
@@ -1,147 +1,125 @@
"use client";
import React from "react";
import { motion } from "framer-motion"; // ESSENCIAL: Evita erros de referência
import { Shield, Zap, Sparkles, Heart, CheckCircle } from "lucide-react";
import { cn } from "@/lib/utils";
// Definição de Tipos para garantir integridade no TS
interface Feature {
icon: React.ElementType;
number: string;
title: string;
description: string;
color: string;
bgColor: string;
iconBg: string;
benefit: string;
detail: string;
}
const features: Feature[] = [
{
icon: Shield,
title: "Dados da Comunidade",
description: "Informações fornecidas pela Open Food Facts, a maior base colaborativa global mantida por voluntários.",
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
iconBg: "bg-blue-100 dark:bg-blue-900/40",
benefit: "Base colaborativa mundial",
number: "01",
title: "Dados Colaborativos",
description:
"Informações fornecidas pelo Open Food Facts, a maior base colaborativa global — a Wikipedia dos alimentos, mantida por voluntários em todo o mundo.",
detail: "2 M+ produtos",
},
{
icon: Zap,
title: "Atualização Contínua",
description: "Novos produtos e fórmulas atualizados a cada minuto pela comunidade. Dados sempre frescos no seu dispositivo.",
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-900/20",
iconBg: "bg-green-100 dark:bg-green-900/40",
benefit: "Atualizado em tempo real",
number: "02",
title: "Sempre Atualizado",
description:
"Novos produtos e reformulações são adicionados a cada minuto pela comunidade. Conectamos você direto à fonte, sem intermediários.",
detail: "Atualização em tempo real",
},
{
icon: Sparkles,
title: "Velocidade de Resposta",
description: "Interface otimizada para decisões rápidas. Escaneie e obtenha o diagnóstico nutricional em poucos segundos.",
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
iconBg: "bg-purple-100 dark:bg-purple-900/40",
benefit: "Resultados em <10s",
number: "03",
title: "Decisão em Segundos",
description:
"Interface projetada para clareza imediata. Nutri-Score, NOVA e alérgenos visíveis à primeira vista — sem ruído visual.",
detail: "Resultados em < 10 s",
},
{
icon: Heart,
title: "Filosofia Open Source",
description: "O NutriScan é e sempre será gratuito. Conhecimento sobre saúde deve ser um direito universal.",
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-50 dark:bg-orange-900/20",
iconBg: "bg-orange-100 dark:bg-orange-900/40",
benefit: "Gratuito para sempre",
number: "04",
title: "Open Source, Sempre",
description:
"O NutriScan é e sempre será gratuito. Conhecimento sobre saúde deve ser um direito universal, sem anúncios ou venda de dados.",
detail: "Licença MIT",
},
];
// Variantes de animação para o container pai (Stagger effect)
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.15 },
},
};
// Variantes para cada card individual
const cardVariants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { type: "spring", stiffness: 100 },
},
};
export function Features() {
return (
<section className="py-24 bg-white dark:bg-[#030712] relative overflow-hidden">
{/* Background Decor: Sutil gradiente para profundidade */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-blue-500/5 via-transparent to-transparent pointer-events-none" />
<section className="bg-base section-py">
<div className="container-editorial">
<div className="max-w-7xl mx-auto px-6 relative z-10">
{/* Header com Motion */}
<motion.div
initial={{ opacity: 0, y: -20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-20"
>
<h2 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter">
Por que confiar no <span className="text-blue-600">NutriScan</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Engenharia de dados a serviço da sua saúde, com transparência total e zero custo.
</p>
</motion.div>
{/* ── Section header ───────────────────────────────────────────── */}
<div className="mb-16">
<span className="label text-[var(--accent)] block mb-4">Recursos</span>
<hr className="hairline mb-10" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<h2 className="font-display text-display-lg text-[var(--fg-primary)]">
Por que confiar no{" "}
<em className="font-display-i text-[var(--accent)]">NutriScan</em>
</h2>
<p className="text-[var(--fg-secondary)] text-lg leading-relaxed max-w-md">
Engenharia de dados a serviço da sua saúde, com transparência
total e zero custo.
</p>
</div>
</div>
{/* Features Grid com Stagger */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
className="grid grid-cols-1 md:grid-cols-2 gap-8"
>
{features.map((feature, index) => (
<motion.div
key={index}
variants={cardVariants}
whileHover={{ y: -5, transition: { duration: 0.2 } }}
className="group relative bg-gray-50 dark:bg-gray-900/50 rounded-3xl p-8 border border-gray-100 dark:border-gray-800 hover:border-blue-500/30 transition-colors"
>
{/* Icon Container */}
<div
className={cn(
feature.iconBg,
"w-14 h-14 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300"
)}
>
<feature.icon className={cn("w-7 h-7", feature.color)} />
</div>
{/* Text Content */}
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
{feature.title}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8 leading-relaxed">
{feature.description}
</p>
{/* Benefit Badge */}
<div className={cn(
"inline-flex items-center px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest",
feature.bgColor,
feature.color
)}>
<CheckCircle className="w-3.5 h-3.5 mr-2" />
<span>{feature.benefit}</span>
</div>
</motion.div>
{/* ── Feature rows ─────────────────────────────────────────────── */}
<div className="divide-y divide-[var(--border-subtle)]">
{features.map((f, i) => (
<FeatureRow key={f.number} feature={f} index={i} />
))}
</motion.div>
</div>
{/* ── Bottom rule ──────────────────────────────────────────────── */}
<hr className="hairline mt-0" />
</div>
</section>
);
}
}
function FeatureRow({
feature,
index,
}: {
feature: Feature;
index: number;
}) {
return (
<div
className={cn(
"group grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-12 py-10 md:py-12",
"transition-colors duration-300 hover:bg-[var(--bg-surface)]",
"cursor-default -mx-4 px-4 md:-mx-6 md:px-6"
)}
style={{ animationDelay: `${index * 0.1}s` }}
>
{/* Step number */}
<div className="md:col-span-1 flex items-start">
<span className="label text-[var(--fg-faint)] group-hover:text-[var(--accent)] transition-colors duration-300">
{feature.number}
</span>
</div>
{/* Title */}
<div className="md:col-span-3 flex items-start">
<h3 className="font-display text-display-sm text-[var(--fg-primary)] leading-tight">
{feature.title}
</h3>
</div>
{/* Description */}
<div className="md:col-span-6 flex items-start">
<p className="text-[var(--fg-secondary)] leading-relaxed text-base">
{feature.description}
</p>
</div>
{/* Detail pill */}
<div className="md:col-span-2 flex items-start md:justify-end">
<span className="inline-flex items-center gap-1.5 text-[var(--accent)] label">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)] shrink-0" />
{feature.detail}
</span>
</div>
</div>
);
}
+135 -214
View File
@@ -2,48 +2,40 @@
import React, { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Search, ScanLine, Globe, Activity, Database, Zap, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, ScanLine, Activity, Globe } from "lucide-react";
import { useGeolocation } from "@/hooks/use-geolocation";
import { useSearchLogic } from "@/hooks/use-search-logic";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
export function Hero() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const { countryCode, isMounted } = useGeolocation();
const { searchType, isValid, loading, setLoading } = useSearchLogic(searchQuery);
const { theme } = useTheme();
const searchConfig = useMemo(() => ({
barcode: {
placeholder: "Insira o código EAN/UPC (ex: 789...)",
button: "Analisar Produto",
icon: <ScanLine className="w-5 h-5" />,
subtext: "Protocolo: Detecção de Código de Barras",
color: "blue",
gradient: "from-blue-600 via-cyan-500 to-blue-400"
},
name: {
placeholder: "Qual alimento deseja investigar?",
button: "Consultar Banco",
icon: <Search className="w-5 h-5" />,
subtext: "Protocolo: Pesquisa em Base Colaborativa",
color: "emerald",
gradient: "from-emerald-600 via-teal-500 to-green-400"
},
auto: {
placeholder: "Digite o nome ou aponte o código...",
button: "Buscar",
icon: <Activity className="w-5 h-5" />,
subtext: "Aguardando input do operador...",
color: "slate",
gradient: "from-slate-600 via-blue-500 to-slate-400"
}
}), []);
const searchConfig = useMemo(
() => ({
barcode: {
placeholder: "Cole o código EAN/UPC do produto…",
button: "Analisar",
icon: <ScanLine className="w-4 h-4" />,
label: "Código de barras detectado",
},
name: {
placeholder: "Nome do alimento ou marca…",
button: "Buscar",
icon: <Search className="w-4 h-4" />,
label: "Pesquisa por nome",
},
auto: {
placeholder: "Nome do produto ou código de barras…",
button: "Buscar",
icon: <Activity className="w-4 h-4" />,
label: "Detecção automática",
},
}),
[]
);
const current = searchConfig[searchType];
@@ -51,211 +43,140 @@ export function Hero() {
e.preventDefault();
if (!searchQuery.trim()) return;
setLoading(true);
const target = searchType === "barcode" && isValid
? `/produto/${searchQuery.trim()}`
: `/resultados?q=${encodeURIComponent(searchQuery.trim())}${countryCode ? `&country=${countryCode}` : ""}`;
const target =
searchType === "barcode" && isValid
? `/produto/${searchQuery.trim()}`
: `/resultados?q=${encodeURIComponent(searchQuery.trim())}${
countryCode ? `&country=${countryCode}` : ""
}`;
router.push(target);
};
return (
<section className="relative min-h-[90vh] flex flex-col items-center justify-center px-6 py-20 overflow-hidden bg-white dark:bg-[#030712] transition-colors duration-500">
{/* 1. INFRAESTRUTURA VISUAL: Background de Engenharia */}
<div className="absolute inset-0 z-0">
{/* Grid de Coordenadas */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#e2e8f0_1px,transparent_1px),linear-gradient(to_bottom,#e2e8f0_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,#1e293b_1px,transparent_1px),linear-gradient(to_bottom,#1e293b_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)] opacity-20 dark:opacity-20 transition-opacity duration-500" />
{/* Glow de Fundo Reativo */}
<motion.div
animate={{
scale: searchQuery ? 1.2 : 1,
opacity: searchQuery ? (theme === "dark" ? 0.15 : 0.08) : (theme === "dark" ? 0.05 : 0.02)
}}
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[500px] rounded-full blur-[120px] transition-colors duration-700",
searchType === "barcode" ? "bg-blue-600 dark:bg-blue-600" : searchType === "name" ? "bg-emerald-600 dark:bg-emerald-600" : "bg-blue-400 dark:bg-blue-900"
<section className="relative bg-base overflow-hidden">
{/* ── Subtle grain texture ──────────────────────────────────────── */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.025]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
backgroundSize: "200px 200px",
}}
/>
<div className="container-editorial relative z-10">
{/* ── Upper label row ─────────────────────────────────────────── */}
<div className="pt-24 pb-6 flex items-center justify-between">
<span className="label text-[var(--fg-muted)]">
Open Food Facts · Base Colaborativa
</span>
{isMounted && countryCode && (
<span className="label text-[var(--fg-muted)] flex items-center gap-1.5">
<Globe className="w-3 h-3" />
{countryCode.toUpperCase()}
</span>
)}
/>
</div>
{/* Elemento Decorativo Lua/Sol */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 1, delay: 0.5 }}
className="absolute top-16 right-8 md:top-24 md:right-16 lg:top-32 lg:right-24 z-0 pointer-events-none"
{/* ── Hairline ────────────────────────────────────────────────── */}
<hr className="hairline" />
{/* ── Hero headline ───────────────────────────────────────────── */}
<div className="py-16 md:py-24 max-w-4xl">
<h1
className="font-display text-display-xl text-[var(--fg-primary)] leading-[0.88] tracking-[-0.03em] mb-8 animate-fade-up"
style={{ animationFillMode: "both" }}
>
A verdade sobre{" "}
<em className="not-italic font-display-i text-[var(--accent)]">
o que você come.
</em>
</h1>
<p
className="text-lg md:text-xl text-[var(--fg-secondary)] max-w-xl leading-relaxed animate-fade-up anim-delay-1"
style={{ animationFillMode: "both" }}
>
Dados nutricionais completos de 2 milhões de produtos —
Nutri-Score, NOVA, ingredientes e alérgenos. Gratuito, sempre.
</p>
</div>
{/* ── Search form ─────────────────────────────────────────────── */}
<div
className="pb-20 animate-fade-up anim-delay-2"
style={{ animationFillMode: "both" }}
>
<AnimatePresence mode="wait">
{theme === "dark" ? (
<motion.div
key="moon"
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
transition={{ duration: 0.5 }}
>
<Moon className="w-24 h-24 md:w-32 md:h-32 lg:w-40 lg:h-40 text-blue-400/20 transition-colors duration-500" />
</motion.div>
) : (
<motion.div
key="sun"
initial={{ opacity: 0, rotate: 90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: -90 }}
transition={{ duration: 0.5 }}
>
<Sun className="w-24 h-24 md:w-32 md:h-32 lg:w-40 lg:h-40 text-yellow-400/30 transition-colors duration-500" />
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
<div className="relative z-10 max-w-5xl mx-auto text-center">
{/* 2. BADGE DE STATUS DO SISTEMA */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="inline-flex items-center gap-3 px-4 py-2 rounded-full bg-gray-100/80 dark:bg-white/5 border border-gray-200 dark:border-white/10 backdrop-blur-md mb-12 transition-colors duration-500"
>
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full border-2 border-white dark:border-[#030712] bg-blue-500 flex items-center justify-center">
<Database size={10} className="text-white" />
</div>
<div className="w-6 h-6 rounded-full border-2 border-white dark:border-[#030712] bg-emerald-500 flex items-center justify-center">
<Zap size={10} className="text-white" />
</div>
</div>
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-gray-600 dark:text-slate-400 transition-colors duration-500">
NutriScan Protocol <span className="text-blue-600 dark:text-blue-500">v2.1</span> • {isMounted ? countryCode?.toUpperCase() : "INTL"}
</span>
</motion.div>
{/* 3. HEADLINE DE ALTA DENSIDADE */}
<h1 className="text-5xl md:text-8xl font-black text-gray-900 dark:text-white mb-8 tracking-tighter leading-[0.85] perspective-1000 transition-colors duration-500">
A verdade sobre <br />
<span className={cn(
"text-transparent bg-clip-text bg-gradient-to-r transition-all duration-700",
current.gradient
)}>
seus alimentos.
</span>
</h1>
<p className="text-gray-600 dark:text-slate-400 text-lg md:text-xl max-w-2xl mx-auto mb-16 leading-relaxed font-medium transition-colors duration-500">
Engenharia de dados aplicada à transparência nutricional.
Descriptografamos rótulos para que você tome decisões soberanas.
</p>
{/* 4. O TERMINAL DE BUSCA (A PEÇA CENTRAL) */}
<form onSubmit={handleSearch} className="relative max-w-3xl mx-auto group">
{/* Efeito de Borda Neon Reativa */}
<div className={cn(
"absolute -inset-[2px] rounded-[2rem] blur-sm opacity-20 group-focus-within:opacity-100 transition-all duration-500 bg-gradient-to-r",
current.gradient
)} />
<div className="relative flex items-center bg-white/90 dark:bg-slate-900/90 backdrop-blur-2xl rounded-[1.8rem] border border-gray-200 dark:border-white/10 p-2 shadow-2xl transition-colors duration-500">
<div className="flex-1 relative flex items-center">
{/* Ícone de Estado Animado */}
<div className="absolute left-6">
<AnimatePresence mode="wait">
<motion.div
key={searchType}
initial={{ rotate: -90, opacity: 0, scale: 0.5 }}
animate={{ rotate: 0, opacity: 1, scale: 1 }}
exit={{ rotate: 90, opacity: 0, scale: 0.5 }}
className={cn(
"transition-colors duration-500",
searchType === "barcode" ? "text-blue-600 dark:text-blue-400" : searchType === "name" ? "text-emerald-600 dark:text-emerald-400" : "text-gray-400 dark:text-slate-500"
)}
>
{current.icon}
</motion.div>
</AnimatePresence>
<form onSubmit={handleSearch} className="max-w-2xl">
<div className="search-editorial">
{/* Mode icon */}
<div className="text-[var(--fg-muted)] shrink-0">
{current.icon}
</div>
<Input
<input
type="text"
placeholder={current.placeholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-16 pr-4 py-9 text-xl border-0 bg-transparent focus-visible:ring-0 w-full text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-slate-600 font-medium tracking-tight transition-colors duration-500"
autoComplete="off"
spellCheck={false}
className="flex-1 bg-transparent border-none outline-none py-[1.125rem] text-[var(--fg-primary)] placeholder-[var(--fg-faint)] text-base font-sans"
/>
<button
type="submit"
disabled={loading}
className="btn-accent shrink-0 py-2.5 px-5 text-sm"
>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Buscando…
</span>
) : (
<span className="flex items-center gap-1.5">
{current.button}
<ArrowRight />
</span>
)}
</button>
</div>
<Button
type="submit"
disabled={loading}
className={cn(
"h-14 px-10 rounded-[1.2rem] font-black uppercase tracking-widest text-xs transition-all duration-500 active:scale-95 shadow-xl",
searchType === "barcode" ? "bg-blue-600 hover:bg-blue-500 shadow-blue-500/20" : "bg-emerald-600 hover:bg-emerald-500 shadow-emerald-500/20"
)}
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<div className="flex items-center gap-2">
<span>{current.button}</span>
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</div>
)}
</Button>
</div>
{/* 5. METADADOS DA OPERAÇÃO */}
<div className="flex items-center justify-between px-6 mt-6">
<div className={cn(
"flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500",
searchType === "barcode" ? "text-blue-500" : searchType === "name" ? "text-emerald-500" : "text-slate-600"
)}>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-current"></span>
</span>
{current.subtext}
</div>
{isMounted && countryCode && (
<div className="flex items-center gap-2 text-gray-600 dark:text-slate-500 bg-gray-100 dark:bg-white/5 px-3 py-1 rounded-full border border-gray-200 dark:border-white/10 transition-colors duration-500">
<Globe size={12} />
<span className="text-[10px] font-bold uppercase">Region: {countryCode.toUpperCase()}</span>
</div>
)}
</div>
</form>
</div>
{/* 6. INDICADOR DE MÉTRICAS (Bottom Shelf) */}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 w-full max-w-4xl px-6 hidden md:block">
<div className="grid grid-cols-3 gap-12 border-t border-gray-200 dark:border-white/5 pt-10 opacity-60 dark:opacity-40 grayscale hover:grayscale-0 transition-all duration-700">
<div className="flex flex-col items-center gap-1">
<span className="text-2xl font-mono font-bold text-gray-900 dark:text-white tracking-tighter transition-colors duration-500">2M+</span>
<span className="text-[9px] uppercase tracking-[0.3em] font-black text-gray-500 dark:text-slate-500 transition-colors duration-500">Dataset Entries</span>
</div>
<div className="flex flex-col items-center gap-1 border-x border-gray-200 dark:border-white/5">
<span className="text-2xl font-mono font-bold text-gray-900 dark:text-white tracking-tighter transition-colors duration-500">100%</span>
<span className="text-[9px] uppercase tracking-[0.3em] font-black text-gray-500 dark:text-slate-500 transition-colors duration-500">Open Source</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-2xl font-mono font-bold text-gray-900 dark:text-white tracking-tighter transition-colors duration-500">&lt; 10s</span>
<span className="text-[9px] uppercase tracking-[0.3em] font-black text-gray-500 dark:text-slate-500 transition-colors duration-500">Analysis Speed</span>
</div>
{/* Detection status */}
<p className="mt-3 label text-[var(--fg-muted)] flex items-center gap-1.5">
<span
className={cn(
"w-1.5 h-1.5 rounded-full inline-block transition-colors duration-300",
searchType !== "auto"
? "bg-[var(--accent)]"
: "bg-[var(--fg-faint)]"
)}
/>
{current.label}
</p>
</form>
</div>
</div>
</section>
);
}
// Sub-componente auxiliar para ícone
function ArrowRight({ size, className }: { size: number, className?: string }) {
/** Minimal inline arrow icon */
function ArrowRight() {
return (
<svg
width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className={className}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14m-7-7 7 7-7 7"/>
<path d="M5 12h14m-7-7 7 7-7 7" />
</svg>
);
}
}
+111 -131
View File
@@ -1,9 +1,7 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { ScanLine, Search, BarChart3, CheckCircle, Globe, Zap } from "lucide-react";
import Image from "next/image";
import { ScanLine, Search, BarChart3, CheckCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface Step {
@@ -11,8 +9,6 @@ interface Step {
icon: React.ElementType;
title: string;
description: string;
color: string;
glow: string;
}
const steps: Step[] = [
@@ -20,161 +16,145 @@ const steps: Step[] = [
number: "01",
icon: ScanLine,
title: "Digitalize",
description: "Reconhecimento instantâneo de códigos em mais de 100 países com tecnologia de baixa latência.",
color: "text-blue-500",
glow: "shadow-blue-500/20",
description:
"Insira o código EAN/UPC ou o nome do produto. Detecção automática em mais de 100 países.",
},
{
number: "02",
icon: Search,
title: "Consulte",
description: "Conexão direta ao Open Food Facts, a base de dados colaborativa global (Wikipedia dos alimentos).",
color: "text-green-500",
glow: "shadow-green-500/20",
description:
"Conexão direta ao Open Food Facts — a maior base colaborativa de alimentos do planeta.",
},
{
number: "03",
icon: BarChart3,
title: "Analise",
description: "Processamento de Nutri-Score, graus de processamento NOVA e alertas de alérgenos críticos.",
color: "text-purple-500",
glow: "shadow-purple-500/20",
description:
"Nutri-Score, classificação NOVA, macronutrientes e alertas de alérgenos em uma tela.",
},
{
number: "04",
icon: CheckCircle,
title: "Decida",
description: "Informação técnica pura e sem vieses comerciais para decisões alinhadas à sua saúde.",
color: "text-orange-500",
glow: "shadow-orange-500/20",
description:
"Informação técnica pura, sem vieses comerciais, para escolhas alinhadas à sua saúde.",
},
];
export function HowItWorks() {
return (
<section className="py-24 bg-white dark:bg-[#030712] transition-colors duration-500">
<div className="max-w-7xl mx-auto px-6">
{/* Header - Foco em Valor Técnico */}
<div className="text-center mb-24">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter"
>
Pipeline de <span className="text-blue-600">Consciência Alimentar</span>
</motion.h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Quatro estágios de processamento de dados para transformar um código de barras em inteligência nutricional.
</p>
<section className="bg-[var(--bg-surface)] section-py">
<div className="container-editorial">
{/* ── Section header ───────────────────────────────────────────── */}
<div className="mb-16">
<span className="label text-[var(--accent)] block mb-4">
Como funciona
</span>
<hr className="hairline mb-10" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<h2 className="font-display text-display-lg text-[var(--fg-primary)]">
Quatro etapas,{" "}
<em className="font-display-i text-[var(--accent)]">
clareza total.
</em>
</h2>
<p className="text-[var(--fg-secondary)] text-lg leading-relaxed max-w-md">
De um simples código de barras à inteligência nutricional
completa — em poucos segundos.
</p>
</div>
</div>
{/* Steps Grid - O "Motor" da Seção */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 relative">
{steps.map((step, index) => (
<motion.div
key={step.number}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.15 }}
className="relative group"
>
{/* Conector Visual (Desktop) */}
{index < steps.length - 1 && (
<div className="hidden lg:block absolute top-12 -right-8 w-16 h-[2px] bg-gradient-to-r from-gray-200 to-transparent dark:from-gray-800 z-0" />
)}
<div className={cn(
"relative z-10 p-8 rounded-3xl bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 transition-all duration-500 hover:border-blue-500/30",
"group-hover:translate-y-[-8px] group-hover:shadow-2xl",
step.glow
)}>
{/* Badge de Número em Estilo Industrial */}
<span className="absolute top-4 right-6 text-sm font-black font-mono text-gray-300 dark:text-gray-700">
STEP_{step.number}
</span>
<div className={cn(
"w-14 h-14 rounded-2xl flex items-center justify-center mb-8 bg-white dark:bg-gray-800 shadow-sm transition-transform duration-500 group-hover:rotate-[10deg]",
step.color
)}>
<step.icon className="w-7 h-7" />
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
{step.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed font-medium">
{step.description}
</p>
</div>
</motion.div>
{/* ── Steps grid ───────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-px bg-[var(--border-subtle)]">
{steps.map((step, i) => (
<StepCard key={step.number} step={step} index={i} />
))}
</div>
{/* Technical Datasheet (O que você vai encontrar) */}
<div className="mt-32 mb-12 flex justify-center">
<div className="relative w-full max-w-2xl h-64 rounded-2xl overflow-hidden shadow-2xl">
<Image
src="/images/nut-facts.webp"
alt="Lista de ingredientes e informações nutricionais de produto alimentar"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>
</div>
</div>
<div className="mt-32 p-10 rounded-[2.5rem] bg-slate-900 text-white relative overflow-hidden shadow-3xl">
<div className="absolute top-0 right-0 p-10 opacity-10">
<BarChart3 size={200} />
{/* ── Data spec callout ─────────────────────────────────────────── */}
<div className="mt-20 grid grid-cols-1 md:grid-cols-2 gap-px bg-[var(--border-subtle)] rounded-xl overflow-hidden">
<div className="bg-[var(--bg-elevated)] p-10">
<span className="label text-[var(--accent)] block mb-6">
Bio-Markers &amp; Macros
</span>
<ul className="space-y-4">
{[
"Calorias (kcal / 100 g)",
"Perfil lipídico e glicêmico",
"Proteínas e fibras",
"Micronutrientes",
].map((item) => (
<li
key={item}
className="flex items-center gap-3 text-[var(--fg-secondary)] text-sm"
>
<span className="w-1 h-1 rounded-full bg-[var(--accent)] shrink-0" />
{item}
</li>
))}
</ul>
</div>
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-3 gap-12">
<div className="lg:col-span-1">
<h3 className="text-3xl font-black mb-4 tracking-tighter">Data Specification</h3>
<p className="text-slate-400 text-sm leading-relaxed mb-6">
Extração de metadados via Open Food Facts API v2. Suporte a protocolos Nutri-Score e classificações NOVA.
</p>
<div className="flex items-center gap-4 text-xs font-mono text-blue-400 uppercase tracking-widest">
<Globe size={14} /> Open Source Database
</div>
</div>
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Lista de Features Refinada */}
<div className="space-y-4">
<h4 className="text-blue-500 font-bold text-xs uppercase tracking-widest">Bio-Markers & Macros</h4>
<ul className="space-y-3">
{["Calorias (Kcal/100g)", "Perfil Lipídico e Glicêmico", "Micronutrientes & Fibras"].map(item => (
<li key={item} className="flex items-center gap-3 text-sm text-slate-300">
<Zap size={12} className="text-blue-500" /> {item}
</li>
))}
</ul>
</div>
<div className="relative w-full h-48 rounded-xl overflow-hidden">
<Image
src="/images/nutscore.webp"
alt="Sistema de classificação Nutri-Score"
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
<div className="space-y-4">
<h4 className="text-green-500 font-bold text-xs uppercase tracking-widest">Compliance & Alerta</h4>
<ul className="space-y-3">
{["Identificação de Alérgenos", "Aditivos e Conservantes", "Classificação de Processamento"].map(item => (
<li key={item} className="flex items-center gap-3 text-sm text-slate-300">
<CheckCircle size={12} className="text-green-500" /> {item}
</li>
))}
</ul>
</div>
</div>
<div className="bg-[var(--bg-elevated)] p-10">
<span className="label text-[var(--fg-muted)] block mb-6">
Compliance &amp; Alertas
</span>
<ul className="space-y-4">
{[
"Identificação de alérgenos",
"Aditivos e conservantes",
"Classificação NOVA de processamento",
"Nutri-Score A–E",
].map((item) => (
<li
key={item}
className="flex items-center gap-3 text-[var(--fg-secondary)] text-sm"
>
<span className="w-1 h-1 rounded-full bg-[var(--border-strong)] shrink-0" />
{item}
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
}
function StepCard({ step, index }: { step: Step; index: number }) {
const Icon = step.icon;
return (
<div
className={cn(
"bg-[var(--bg-elevated)] p-8 md:p-10 group",
"transition-colors duration-300 hover:bg-[var(--bg-base)]"
)}
style={{ animationDelay: `${index * 0.1}s` }}
>
{/* Step number — large serif */}
<div className="step-number mb-6 group-hover:text-[var(--accent)] transition-colors duration-300">
{step.number}
</div>
{/* Monochrome icon */}
<div className="mb-6">
<Icon
className="w-5 h-5 text-[var(--fg-muted)] group-hover:text-[var(--fg-secondary)] transition-colors duration-300"
strokeWidth={1.5}
/>
</div>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-3">
{step.title}
</h3>
<p className="text-[var(--fg-secondary)] text-sm leading-relaxed">
{step.description}
</p>
</div>
);
}
+121 -104
View File
@@ -1,130 +1,147 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, ScanLine, Activity } from "lucide-react";
import { useSearchLogic } from "@/hooks/use-search-logic";
import { cn } from "@/lib/utils";
export function PersuasiveCTA() {
const [query, setQuery] = useState("");
const [searchType, setSearchType] = useState<"barcode" | "name" | null>(null);
const router = useRouter();
const [query, setQuery] = useState("");
const { searchType, isValid, loading, setLoading } = useSearchLogic(query);
// Detectar tipo de busca em tempo real
useEffect(() => {
const cleaned = query.replace(/\D/g, ""); // Remove tudo que não é número
if (cleaned.length >= 8 && cleaned.length <= 13) {
setSearchType("barcode");
} else if (query.trim()) {
setSearchType("name");
} else {
setSearchType(null);
}
}, [query]);
const handleSearch = () => {
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
const type = detectSearchType(query);
if (type === "barcode") {
// Limpar código de barras (remover espaços/caracteres)
const cleanBarcode = query.replace(/\D/g, "");
if (isValidBarcode(cleanBarcode)) {
// Redirecionar para página do produto
router.push(`/produto/${cleanBarcode}`);
} else {
// Mostrar erro de código inválido
alert("Código de barras inválido. Deve ter entre 8 e 13 dígitos.");
}
} else {
// Busca normal por nome
router.push(`/resultados?q=${encodeURIComponent(query.trim())}`);
}
setLoading(true);
const target =
searchType === "barcode" && isValid
? `/produto/${query.trim()}`
: `/resultados?q=${encodeURIComponent(query.trim())}`;
router.push(target);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const icon =
searchType === "barcode" ? (
<ScanLine className="w-4 h-4" />
) : searchType === "name" ? (
<Search className="w-4 h-4" />
) : (
<Activity className="w-4 h-4" />
);
// Funções auxiliares (copiadas do SearchBar)
const detectSearchType = (input: string): "barcode" | "name" => {
const cleaned = input.replace(/\D/g, "");
return cleaned.length >= 8 && cleaned.length <= 13 ? "barcode" : "name";
};
const isValidBarcode = (barcode: string): boolean => {
return barcode.length >= 8 && barcode.length <= 13 && /^\d+$/.test(barcode);
};
const label =
searchType === "barcode"
? "Código de barras detectado"
: searchType === "name"
? "Pesquisa por nome"
: "Detecção automática";
return (
<section className="py-20 bg-gradient-to-br from-blue-600 to-green-600 text-white">
<div className="max-w-4xl mx-auto px-4 text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-6">
Pronto para descobrir a verdade sobre seus alimentos?
</h2>
<p className="text-xl mb-8 opacity-90">
Junte-se a milhares de pessoas que já tomam decisões alimentares mais
conscientes com informações transparentes e confiáveis.
</p>
<section className="bg-[var(--bg-surface)] section-py border-t border-[var(--border-subtle)]">
<div className="container-editorial">
{/* Contador Animado de Usuários */}
<div className="mb-8">
<div className="text-2xl font-semibold">
<span className="animate-pulse">12.847</span> pessoas já buscaram
hoje
{/* ── Section header ───────────────────────────────────────────── */}
<div className="mb-16">
<span className="label text-[var(--accent)] block mb-4">
Comece Agora
</span>
<hr className="hairline mb-10" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<h2 className="font-display text-display-lg text-[var(--fg-primary)]">
Pronto para descobrir a{" "}
<em className="font-display-i text-[var(--accent)]">
verdade sobre seus alimentos?
</em>
</h2>
<p className="text-[var(--fg-secondary)] text-lg leading-relaxed max-w-md">
Sem cadastro, sem anúncios, sem venda de dados. Resultados
nutricionais completos em segundos.
</p>
</div>
</div>
{/* Search Bar Destacado */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl p-6 shadow-2xl">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Digite o código de barras de qualquer produto..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-10 pr-4 h-12 text-gray-900"
/>
{/* ── Search form ──────────────────────────────────────────────── */}
<form onSubmit={handleSearch} className="max-w-2xl">
<div className="search-editorial">
<div className="text-[var(--fg-muted)] shrink-0">{icon}</div>
{/* Indicador visual do tipo de busca */}
{searchType && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded">
{searchType === "barcode" ? "Código de barras" : "Nome"}
</span>
</div>
)}
</div>
<Button
onClick={handleSearch}
disabled={!query.trim()}
className="h-12 px-6 bg-gradient-to-r from-blue-600 to-green-600 hover:from-blue-700 hover:to-green-700"
<input
type="text"
placeholder="Nome do produto ou código de barras…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
className="flex-1 bg-transparent border-none outline-none py-[1.125rem] text-[var(--fg-primary)] placeholder-[var(--fg-faint)] text-base font-sans"
/>
<button
type="submit"
disabled={loading}
className="btn-accent shrink-0 py-2.5 px-5 text-sm"
>
<Search className="h-4 w-4 mr-2" />
Buscar
</Button>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin" />
Buscando…
</span>
) : (
<span className="flex items-center gap-1.5">
Buscar
<ArrowRight />
</span>
)}
</button>
</div>
<p className="text-center mt-3 text-sm text-gray-500">
💡 Dica: O código de barras está na parte de trás da embalagem
</p>
</div>
{/* Trust Indicators */}
<div className="mt-8 flex justify-center gap-6 text-sm opacity-75">
<span>✓ Sem cadastro necessário</span>
<span>✓ Resultados instantâneos</span>
<span>✓ 100% gratuito</span>
{/* Detection status */}
<p className="mt-3 label text-[var(--fg-muted)] flex items-center gap-1.5">
<span
className={cn(
"w-1.5 h-1.5 rounded-full inline-block",
searchType !== "auto"
? "bg-[var(--accent)]"
: "bg-[var(--fg-faint)]"
)}
/>
{label}
</p>
</form>
{/* ── Trust indicators ─────────────────────────────────────────── */}
<div className="mt-12 flex flex-wrap gap-x-10 gap-y-3">
{["Sem cadastro necessário", "Resultados instantâneos", "100% gratuito e open source"].map(
(item) => (
<span
key={item}
className="label text-[var(--fg-muted)] flex items-center gap-2"
>
<span className="w-1 h-1 rounded-full bg-[var(--accent)]" />
{item}
</span>
)
)}
</div>
</div>
</section>
);
}
function ArrowRight() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14m-7-7 7 7-7 7" />
</svg>
);
}
+34 -44
View File
@@ -1,51 +1,41 @@
import { Database, Globe, Zap, Code } from "lucide-react";
const stats = [
{
value: "2 M+",
label: "Produtos no Open Food Facts",
},
{
value: "100+",
label: "Países cobertos",
},
{
value: "99.9%",
label: "Uptime garantido",
},
{
value: "100%",
label: "Open Source",
},
];
export function TrustBadges() {
return (
<section className="py-12 bg-gray-50 dark:bg-gray-800 border-t border-b">
<div className="max-w-7xl mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div className="animate-fadeIn">
<div className="w-16 h-16 mx-auto mb-3 bg-blue-100 dark:bg-blue-900/40 rounded-full flex items-center justify-center">
<Database className="w-8 h-8 text-blue-600" />
<section className="bg-[var(--bg-surface)] border-y border-[var(--border-subtle)]">
<div className="container-editorial">
<div className="grid grid-cols-2 md:grid-cols-4 md:divide-x divide-[var(--border-subtle)]">
{stats.map(({ value, label }, i) => (
<div
key={i}
className="flex flex-col items-center gap-2 px-6 py-8"
>
<span className="font-display text-2xl text-[var(--fg-primary)] leading-none">
{value}
</span>
<span className="label text-center text-[var(--fg-muted)]">
{label}
</span>
</div>
<div className="text-2xl font-bold text-blue-600">
2M+
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Produtos no Open Food Facts
</div>
</div>
<div className="animate-fadeIn" style={{animationDelay: '0.1s'}}>
<div className="w-16 h-16 mx-auto mb-3 bg-green-100 dark:bg-green-900/40 rounded-full flex items-center justify-center">
<Globe className="w-8 h-8 text-green-600" />
</div>
<div className="text-2xl font-bold text-green-600">100+</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Países cobertos
</div>
</div>
<div className="animate-fadeIn" style={{animationDelay: '0.2s'}}>
<div className="w-16 h-16 mx-auto mb-3 bg-purple-100 dark:bg-purple-900/40 rounded-full flex items-center justify-center">
<Zap className="w-8 h-8 text-purple-600" />
</div>
<div className="text-2xl font-bold text-purple-600">99.9%</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Uptime garantido
</div>
</div>
<div className="animate-fadeIn" style={{animationDelay: '0.3s'}}>
<div className="w-16 h-16 mx-auto mb-3 bg-orange-100 dark:bg-orange-900/40 rounded-full flex items-center justify-center animate-pulse-slow">
<Code className="w-8 h-8 text-orange-600" />
</div>
<div className="text-xl font-bold text-orange-600">100%</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Open Source
</div>
</div>
))}
</div>
</div>
</section>
+505 -149
View File
@@ -2,172 +2,528 @@
@tailwind components;
@tailwind utilities;
/* Scrollbar utilities */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
}
/* Otimizações de performance para animações */
@layer utilities {
.animate-in {
animation: fadeInUp 0.6s ease-out forwards;
}
.fade-in-0 {
opacity: 0;
}
.slide-in-from-bottom-4 {
transform: translateY(1rem);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Animações personalizadas para UI/UX */
.animate-fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out forwards;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-bounce-gentle {
animation: bounceGentle 2s infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounceGentle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-0.25rem);
}
}
/* Padrão de grid para background */
.bg-grid-pattern {
background-image: linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
background-size: 20px 20px;
}
/* Otimização para listas grandes */
.product-grid {
contain: layout style paint;
}
.product-card {
will-change: transform;
backface-visibility: hidden;
}
}
/* Fonts are self-hosted through next/font. */
/* ─── Design Tokens ───────────────────────────────────────────────────── */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Warm neutral palette */
--bg-base: #f9f6f0;
--bg-surface: #f4f0e8;
--bg-elevated: #ffffff;
--fg-primary: #1e1810;
--fg-secondary: #5e4e3c;
--fg-muted: #9a8870;
--fg-faint: #b8a88a;
--border-subtle: #e8e0d0;
--border-strong: #d4c8b0;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/* Single accent: editorial green */
--accent: oklch(52% 0.11 155);
--accent-light: oklch(72% 0.10 155);
--accent-dark: oklch(38% 0.10 155);
--accent-muted: oklch(92% 0.04 155);
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Nutri-Score grade colors */
--score-a: #1a7a4a;
--score-b: #5da832;
--score-c: #e8c110;
--score-d: #e07820;
--score-e: #c0201a;
--primary: 215 90% 50%;
/* Azul Royal (#0055FF) */
--primary-foreground: 210 40% 98%;
/* Spacing primitives */
--section-gap: 7rem;
--container: 1120px;
--radius: 0.375rem;
--secondary: 185 70% 45%;
/* Verde-azulado (#19A7A7) */
--secondary-foreground: 222.2 47.4% 11.2%;
/* shadcn/ui required tokens → warm neutral mapped */
--background: 36 30% 96%;
--foreground: 24 20% 10%;
--card: 36 30% 98%;
--card-foreground: 24 20% 10%;
--popover: 36 30% 98%;
--popover-foreground: 24 20% 10%;
--primary: 155 40% 32%; /* accent green */
--primary-foreground: 0 0% 100%;
--secondary: 36 22% 90%;
--secondary-foreground: 24 20% 20%;
--muted: 36 20% 92%;
--muted-foreground: 24 15% 46%;
--destructive: 0 72% 44%;
--destructive-foreground: 0 0% 100%;
--border: 36 20% 86%;
--input: 36 20% 86%;
--ring: 155 40% 32%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 185 70% 95%;
/* Verde-azulado claro */
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 215 90% 50%;
--radius: 0.5rem;
--chart-1: oklch(52% 0.11 155);
--chart-2: oklch(62% 0.14 200);
--chart-3: oklch(70% 0.16 60);
--chart-4: oklch(56% 0.18 30);
--chart-5: oklch(52% 0.15 340);
}
.dark {
--background: 217 33% 17%;
/* Fundo escuro azulado */
--foreground: 210 40% 98%;
--bg-base: #110e09;
--bg-surface: #1a1610;
--bg-elevated: #231e17;
--fg-primary: #f4f0e8;
--fg-secondary: #c8b898;
--fg-muted: #8a7860;
--fg-faint: #5e4e3c;
--border-subtle: #2e2618;
--border-strong: #3e3228;
--card: 217 33% 17%;
--card-foreground: 210 40% 98%;
--accent: oklch(62% 0.12 155);
--accent-light: oklch(75% 0.10 155);
--accent-dark: oklch(44% 0.10 155);
--accent-muted: oklch(20% 0.06 155);
--popover: 217 33% 17%;
--popover-foreground: 210 40% 98%;
--background: 24 20% 8%;
--foreground: 36 30% 94%;
--card: 24 20% 10%;
--card-foreground: 36 30% 94%;
--popover: 24 20% 10%;
--popover-foreground: 36 30% 94%;
--primary: 155 38% 48%;
--primary-foreground: 0 0% 100%;
--secondary: 24 18% 18%;
--secondary-foreground: 36 28% 88%;
--muted: 24 16% 16%;
--muted-foreground: 36 20% 58%;
--destructive: 0 62% 42%;
--destructive-foreground: 0 0% 100%;
--border: 24 18% 18%;
--input: 24 18% 18%;
--ring: 155 38% 48%;
}
--primary: 215 90% 60%;
/* Azul Royal mais claro */
--primary-foreground: 222.2 47.4% 11.2%;
/* ── Base resets & typography ───────────────────────────────────────── */
* {
@apply border-border;
}
--secondary: 185 70% 40%;
/* Verde-azulado ajustado */
--secondary-foreground: 210 40% 98%;
html {
font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "ss01" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
--muted: 217 33% 25%;
--muted-foreground: 215 20.2% 65.1%;
body {
background-color: var(--bg-base);
color: var(--fg-primary);
font-family: var(--font-inter), system-ui, sans-serif;
font-size: 1rem;
line-height: 1.72;
transition: background-color 0.4s ease, color 0.4s ease;
}
--accent: 185 70% 25%;
--accent-foreground: 210 40% 98%;
/* Headings use the active display font. */
h1, h2, h3, h4 {
font-family: var(--font-display), Georgia, serif;
font-weight: 400;
font-style: normal;
}
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 25%;
--input: 217 33% 25%;
--ring: 215 90% 60%;
/* Hairline horizontal rule */
hr {
border: none;
border-top: 1px solid var(--border-subtle);
}
}
/* ─── Utility layer ───────────────────────────────────────────────────── */
@layer utilities {
/* Scrollbar */
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* Editorial typography helpers */
.font-display { font-family: var(--font-display), Georgia, serif; }
.font-display-i { font-family: var(--font-display), Georgia, serif; font-style: italic; }
/* Container max-width */
.container-editorial {
width: 100%;
max-width: var(--container);
margin-inline: auto;
padding-inline: clamp(1.25rem, 5vw, 3rem);
}
/* Hairline divider */
.hairline {
border: none;
border-top: 1px solid var(--border-subtle);
}
.hairline-v {
border: none;
border-left: 1px solid var(--border-subtle);
}
/* Section vertical rhythm */
.section-py { padding-top: var(--section-gap); padding-bottom: var(--section-gap); }
.section-pt { padding-top: var(--section-gap); }
.section-pb { padding-bottom: var(--section-gap); }
/* Warm surface tokens */
.bg-base { background-color: var(--bg-base); }
.bg-surface { background-color: var(--bg-surface); }
.bg-elevated { background-color: var(--bg-elevated); }
.text-primary { color: var(--fg-primary); }
.text-secondary{ color: var(--fg-secondary); }
.text-muted { color: var(--fg-muted); }
.text-faint { color: var(--fg-faint); }
/* Accent utilities */
.text-accent { color: var(--accent); }
.bg-accent { background-color: var(--accent); }
.bg-accent-muted { background-color: var(--accent-muted); }
.border-accent { border-color: var(--accent); }
/* Label caps */
.label {
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
font-family: var(--font-inter), system-ui, sans-serif;
}
.label-lg {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.10em;
text-transform: uppercase;
font-family: var(--font-inter), system-ui, sans-serif;
}
/* Product grid performance */
.product-grid { contain: layout style; }
.product-card { will-change: transform; backface-visibility: hidden; }
/* Stagger animation helpers */
.anim-delay-1 { animation-delay: 0.1s; }
.anim-delay-2 { animation-delay: 0.2s; }
.anim-delay-3 { animation-delay: 0.3s; }
.anim-delay-4 { animation-delay: 0.4s; }
.anim-delay-5 { animation-delay: 0.5s; }
/* Nutri-Score grade pills */
.grade-a { background: var(--score-a); color: #fff; }
.grade-b { background: var(--score-b); color: #fff; }
.grade-c { background: var(--score-c); color: #1e1810; }
.grade-d { background: var(--score-d); color: #fff; }
.grade-e { background: var(--score-e); color: #fff; }
/* Thin top-border accent card */
.card-accent-top {
border-top: 2px solid var(--accent);
}
/* Hover lift */
.hover-lift {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.hover-lift:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px -8px rgba(30, 24, 16, 0.12);
}
/* Monochrome icon filter for editorial look */
.icon-mono {
filter: grayscale(1);
opacity: 0.7;
}
}
/* ─── Component layer ─────────────────────────────────────────────────── */
@layer components {
/* ── Editorial Section Header ────────────────────────────────────────── */
.section-header {
@apply mb-16;
}
.section-header .eyebrow {
@apply label text-accent mb-4 block;
}
.section-header h2 {
@apply font-display text-display-lg text-[var(--fg-primary)] mb-5;
}
.section-header p {
@apply text-[var(--fg-secondary)] text-lg leading-relaxed max-w-[65ch];
}
/* ── Warm Card ───────────────────────────────────────────────────────── */
.card-warm {
background-color: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: 0.75rem;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.card-warm:hover {
border-color: var(--border-strong);
box-shadow: 0 8px 32px -8px rgba(30, 24, 16, 0.10);
}
/* ── Accent Button ───────────────────────────────────────────────────── */
.btn-accent {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--accent);
color: #fff;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.02em;
font-family: var(--font-inter), sans-serif;
transition: background-color 0.2s ease, transform 0.15s ease;
cursor: pointer;
border: none;
}
.btn-accent:hover {
background-color: var(--accent-dark);
transform: translateY(-1px);
}
.btn-accent:active { transform: translateY(0); }
.btn-accent:focus-visible,
.btn-ghost:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* ── Ghost Button ────────────────────────────────────────────────────── */
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6875rem 1.375rem;
background: transparent;
color: var(--fg-primary);
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
font-family: var(--font-inter), sans-serif;
transition: background-color 0.2s ease, border-color 0.2s ease;
cursor: pointer;
}
.btn-ghost:hover {
background-color: var(--bg-surface);
border-color: var(--fg-muted);
}
/* ── Stat Cell (Hero metrics) ────────────────────────────────────────── */
.stat-cell {
@apply flex flex-col items-center gap-1;
}
.stat-cell .stat-value {
font-family: var(--font-display), Georgia, serif;
font-size: 2rem;
line-height: 1;
color: var(--fg-primary);
}
.stat-cell .stat-label {
@apply label text-[var(--fg-muted)];
}
/* ── Step number (How It Works) ──────────────────────────────────────── */
.step-number {
font-family: var(--font-display), Georgia, serif;
font-size: 3.5rem;
line-height: 1;
color: var(--border-strong);
}
/* ── Nutri-Score Hero metric ─────────────────────────────────────────── */
.nutriscore-hero {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
font-family: var(--font-display), Georgia, serif;
font-size: 2.25rem;
animation: score-reveal 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* ── Search input editorial ──────────────────────────────────────────── */
.search-editorial {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1.25rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.search-editorial:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px oklch(52% 0.11 155 / 0.12);
}
.search-editorial input {
flex: 1;
background: transparent;
border: none;
outline: none;
padding: 1.125rem 0;
font-family: var(--font-inter), sans-serif;
font-size: 1rem;
color: var(--fg-primary);
}
.search-editorial input::placeholder {
color: var(--fg-faint);
}
}
/* ─── Keyframe animations ─────────────────────────────────────────────── */
@keyframes fade-up {
from { opacity: 0; transform: translateY(1.5rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY(0.75rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes line-grow {
from { transform: scaleX(0); transform-origin: left; }
to { transform: scaleX(1); transform-origin: left; }
}
@keyframes score-reveal {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(1rem); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Legacy animation class aliases (keep compatibility) ─────────────── */
.animate-in { animation: fadeInUp 0.6s ease-out forwards; }
.fade-in-0 { opacity: 0; }
.slide-in-from-bottom-4 { transform: translateY(1rem); }
.animate-fade-up { animation: fade-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-fade-in { animation: fade-in 0.5s ease forwards; }
.animate-stagger-in { animation: stagger-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-pulse-slow { animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
/* ─── Redesign: ink palette tokens (mirror the HTML spec) ────────────── */
:root {
--ink-900: #141312;
--ink-800: #26231f;
--ink-700: #3d3934;
--ink-600: #595149;
--ink-500: #7a7269;
--ink-400: #a39c93;
--ink-300: #c9c3ba;
--ink-200: #e4dfd7;
--ink-150: #ece7de;
--ink-100: #f3efe7;
--ink-50: #f9f6f0;
--ink-0: #fdfbf5;
--nutri-a: #038141;
--nutri-b: #85bb2f;
--nutri-c: #fecb02;
--nutri-d: #ee8100;
--nutri-e: #e63e11;
--accent-soft: oklch(94% 0.04 155);
--accent-ink: oklch(28% 0.06 155);
--warn: #b45309;
--warn-soft: #fef3c7;
--font-display: var(--font-instrument-serif);
--font-serif: var(--font-display), 'Iowan Old Style', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, Menlo, monospace;
}
.landing-font {
--font-display: var(--font-newsreader);
}
/* ─── Redesign: nutri-letter hero circle ─────────────────────────────── */
.nutri-letter {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-family: var(--font-display), Georgia, serif;
color: #fff;
font-weight: 400;
letter-spacing: -0.03em;
line-height: 1;
box-shadow: inset 0 -3px 0 rgba(0,0,0,0.15), 0 8px 24px -8px rgba(0,0,0,0.2);
}
.nutri-letter.grade-a, .nutri-letter.a { background: var(--nutri-a); }
.nutri-letter.grade-b, .nutri-letter.b { background: var(--nutri-b); }
.nutri-letter.grade-c, .nutri-letter.c { background: var(--nutri-c); color: #3a2a00; }
.nutri-letter.grade-d, .nutri-letter.d { background: var(--nutri-d); }
.nutri-letter.grade-e, .nutri-letter.e { background: var(--nutri-e); }
/* ─── Redesign: nutri-scale badge (full A–E row) ─────────────────────── */
.nutri-badge {
display: inline-flex;
align-items: center;
font-family: var(--font-inter), sans-serif;
font-weight: 800;
border-radius: 999px;
overflow: hidden;
background: var(--ink-100);
padding: 3px;
gap: 2px;
}
.nutri-badge .seg {
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
letter-spacing: -0.02em;
transition: transform 0.25s;
opacity: 0.35;
filter: saturate(0.3);
}
.nutri-badge .seg.active { opacity: 1; filter: none; transform: scale(1.15); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.nutri-badge .seg.a { background: var(--nutri-a); }
.nutri-badge .seg.b { background: var(--nutri-b); }
.nutri-badge .seg.c { background: var(--nutri-c); color: #3a2a00; }
.nutri-badge .seg.d { background: var(--nutri-d); }
.nutri-badge .seg.e { background: var(--nutri-e); }
/* ─── Redesign: eyebrow mono label ──────────────────────────────────── */
.eyebrow-mono {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-500);
font-weight: 500;
}
/* ─── Redesign: product card chip ─────────────────────────────────── */
.rd-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: var(--ink-100, #f3efe7);
color: var(--ink-700, #3d3934);
border: 1px solid var(--ink-200, #e4dfd7);
}
.rd-chip.warn {
background: var(--warn-soft, #fef3c7);
color: var(--warn, #b45309);
border-color: #fcd34d;
}
+25 -18
View File
@@ -1,13 +1,31 @@
import "./globals.css";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import { Instrument_Serif, Inter, Newsreader } from "next/font/google";
import Script from "next/script";
import { ThemeProvider } from "@/app/providers";
import { ErrorBoundary } from "@/app/components/ErrorBoundary";
import { ErrorTracking } from "@/app/components/ErrorTracking";
// Use Inter como fallback já que é uma fonte similar à Geist
const inter = Inter({ subsets: ["latin"] });
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
variable: "--font-instrument-serif",
weight: "400",
style: ["normal", "italic"],
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: ["normal", "italic"],
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL("https://nutriscan.com.br"),
@@ -43,14 +61,7 @@ export const metadata: Metadata = {
description:
"Descubra informações nutricionais completas de milhares de produtos alimentícios.",
siteName: "NutriScan",
images: [
{
url: "/icon.png",
width: 512,
height: 512,
alt: "NutriScan Logo",
},
],
images: [{ url: "/icon.png", width: 512, height: 512, alt: "NutriScan Logo" }],
},
twitter: {
card: "summary_large_image",
@@ -74,14 +85,10 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" suppressHydrationWarning className={inter.className}>
<body className="min-h-screen bg-background antialiased">
<html lang="pt-BR" suppressHydrationWarning className={`${inter.variable} ${instrumentSerif.variable} ${newsreader.variable}`}>
<body className="min-h-screen bg-base antialiased font-sans">
<ErrorBoundary>
<ThemeProvider>
{children}
+13 -9
View File
@@ -8,35 +8,39 @@ import { verificarLista } from '@/app/actions';
import { redirect } from 'next/navigation';
interface ListaPageProps {
params: {
params: Promise<{
id: string
}
}>
}
export default async function ListaPage({ params }: ListaPageProps) {
const { success } = await verificarLista(params.id);
const { id } = await params;
const { success } = await verificarLista(id);
if (!success) {
redirect('/');
}
return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-300">
<ListHeader listaId={params.id} />
<main className="min-h-screen transition-colors duration-300" style={{ background: 'var(--bg-base)' }}>
<ListHeader listaId={id} />
<ThemeToggle />
<div className="container mx-auto px-4 pt-20 pb-8">
<div className="mx-auto px-4 pt-20 pb-8" style={{ maxWidth: 1400 }}>
<div className="mb-8">
<Main listaId={params.id} />
<Main listaId={id} />
</div>
<Suspense
fallback={
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#1118A7] dark:border-[#212FCC]"></div>
<div
className="animate-spin rounded-full h-8 w-8 border-b-2"
style={{ borderColor: 'var(--accent)' }}
/>
</div>
}
>
<CardList listaId={params.id} />
<CardList listaId={id} />
</Suspense>
<Footer />
+1 -1
View File
@@ -8,7 +8,7 @@ import { TrustBadges } from "./components/landing/TrustBadges";
export default function Home() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="landing-font min-h-screen bg-base">
<Header />
<main>
<Hero />
+777 -158
View File
@@ -7,19 +7,173 @@ import {
ArrowLeft,
AlertCircle,
AlertTriangle,
Database,
Globe,
ShieldCheck,
Beaker,
ClipboardList,
Heart,
Share2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import { searchByBarcode, Product, formatNutritionData } from "@/lib/openFoodFactsApi";
import { NutriScoreBadge, NovaBadge } from "@/app/components/NutriScoreBadge";
import { NutritionGrid } from "@/app/components/NutritionBar";
import { ProductSkeleton } from "@/app/components/ProductSkeleton";
/* ── Nutri-Score letter circle ─────────────────────────────────────────── */
const NUTRI_BG: Record<string, string> = {
a: "#038141",
b: "#85bb2f",
c: "#fecb02",
d: "#ee8100",
e: "#e63e11",
};
const NUTRI_INK: Record<string, string> = {
a: "#fff",
b: "#fff",
c: "#3a2a00",
d: "#fff",
e: "#fff",
};
const GRADE_DESC: Record<string, string> = {
a: "Excelente qualidade nutricional",
b: "Boa qualidade nutricional",
c: "Qualidade regular",
d: "Qualidade ruim",
e: "Qualidade muito ruim",
};
const NOVA_DESC: Record<number, string> = {
1: "Alimento in natura ou minimamente processado.",
2: "Ingrediente culinário processado.",
3: "Alimento processado com adição de sal, açúcar ou óleo.",
4: "Ultraprocessado — formulação industrial com aditivos.",
};
const NOVA_BG: Record<number, string> = {
1: "#038141",
2: "#fecb02",
3: "#ee8100",
4: "#e63e11",
};
const NOVA_INK: Record<number, string> = {
1: "#fff",
2: "#3a2a00",
3: "#fff",
4: "#fff",
};
function NutriLetter({ grade, size = 92 }: { grade: string; size?: number }) {
const g = grade?.toLowerCase();
if (!NUTRI_BG[g]) return null;
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: Math.round(size * 0.63),
color: NUTRI_INK[g],
background: NUTRI_BG[g],
fontWeight: 400,
letterSpacing: "-0.03em",
lineHeight: 1,
flexShrink: 0,
boxShadow: "inset 0 -3px 0 rgba(0,0,0,0.15), 0 8px 24px -8px rgba(0,0,0,0.2)",
}}
>
{g.toUpperCase()}
</div>
);
}
/* ── Nutri-Scale row (A–E with active highlight) ────────────────────────── */
function NutriScale({ active, size = "sm" }: { active: string; size?: "sm" | "md" }) {
const px = size === "sm" ? 18 : 26;
const fs = size === "sm" ? 9 : 12;
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
background: "var(--ink-100, #f3efe7)",
padding: 3,
borderRadius: 999,
gap: 2,
}}
>
{["a", "b", "c", "d", "e"].map((l) => (
<div
key={l}
style={{
width: px,
height: px,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: l === "c" ? "#3a2a00" : "#fff",
fontSize: fs,
fontWeight: 700,
background: NUTRI_BG[l],
opacity: active?.toLowerCase() === l ? 1 : 0.3,
filter: active?.toLowerCase() === l ? "none" : "saturate(0.3)",
transform: active?.toLowerCase() === l ? "scale(1.18)" : "none",
boxShadow:
active?.toLowerCase() === l
? "0 2px 8px rgba(0,0,0,0.15)"
: "none",
transition: "all 0.25s",
}}
>
{l.toUpperCase()}
</div>
))}
</div>
);
}
/* ── NOVA pill ───────────────────────────────────────────────────────────── */
function NovaPill({ group }: { group: number }) {
const labels: Record<number, string> = {
1: "Não processado",
2: "Ingrediente culinário",
3: "Processado",
4: "Ultraprocessado",
};
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "4px 10px 4px 4px",
borderRadius: 999,
background: "var(--ink-100, #f3efe7)",
border: "1px solid var(--ink-200, #e4dfd7)",
fontSize: 12,
color: "var(--ink-700, #3d3934)",
flexShrink: 0,
}}
>
<span
style={{
width: 22,
height: 22,
borderRadius: 999,
background: NOVA_BG[group] || "#a39c93",
color: NOVA_INK[group] || "#fff",
display: "grid",
placeItems: "center",
fontFamily: "ui-monospace, monospace",
fontWeight: 700,
fontSize: 11,
}}
>
{group}
</span>
NOVA · {labels[group] || "Desconhecido"}
</div>
);
}
export default function ProdutoPage() {
const params = useParams();
const router = useRouter();
@@ -35,9 +189,9 @@ export default function ProdutoPage() {
setError(null);
const result = await searchByBarcode(barcode);
if (result) setProduct(result);
else setError("Produto não catalogado na base ODbL.");
else setError("Produto não encontrado na base de dados.");
} catch {
setError("Falha crítica na sincronização de dados.");
setError("Falha ao carregar os dados do produto.");
} finally {
setLoading(false);
}
@@ -49,21 +203,94 @@ export default function ProdutoPage() {
if (loading) return <ProductSkeleton />;
// Handler de Erro - Estética Industrial
/* ── Error state ─────────────────────────────────────────────────────── */
if (error || !product) {
return (
<div className="min-h-screen bg-white flex items-center justify-center p-6 dark:bg-[#030712]">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full text-center space-y-6 border border-slate-200 p-10 rounded-[2.5rem] bg-slate-50 dark:bg-red-950/10 dark:border-red-900/20"
<div
style={{
minHeight: "100vh",
background: "var(--ink-0, #fdfbf5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
style={{
maxWidth: 420,
width: "100%",
background: "var(--ink-0, #fdfbf5)",
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 20,
padding: 48,
textAlign: "center",
}}
>
<AlertCircle className="w-16 h-16 text-red-500 mx-auto" />
<h1 className="text-2xl font-black text-slate-900 dark:text-white uppercase tracking-tighter">Erro de Protocolo</h1>
<p className="text-slate-600 text-sm dark:text-slate-400 font-medium">{error}</p>
<div className="flex gap-3">
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={() => router.push("/")}>Nova Busca</Button>
<Button variant="outline" className="w-full border-slate-300 dark:border-slate-800" onClick={() => router.back()}>Retornar</Button>
<AlertCircle
style={{
width: 40,
height: 40,
color: "#e63e11",
margin: "0 auto 20px",
}}
strokeWidth={1.5}
/>
<h1
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: 28,
color: "var(--ink-900, #141312)",
marginBottom: 10,
}}
>
Produto não encontrado
</h1>
<p
style={{
color: "var(--ink-600, #595149)",
fontSize: 14,
marginBottom: 28,
lineHeight: 1.55,
}}
>
{error}
</p>
<div style={{ display: "flex", gap: 10 }}>
<button
onClick={() => router.push("/")}
style={{
flex: 1,
padding: "12px 0",
background: "var(--accent, oklch(52% 0.11 155))",
color: "#fff",
border: "none",
borderRadius: 10,
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Nova Busca
</button>
<button
onClick={() => router.back()}
style={{
flex: 1,
padding: "12px 0",
background: "transparent",
color: "var(--ink-900, #141312)",
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 10,
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Voltar
</button>
</div>
</motion.div>
</div>
@@ -71,161 +298,553 @@ export default function ProdutoPage() {
}
const imageUrl = product.image_front_url || product.image_url || null;
const grade = product.nutrition_grades?.toLowerCase();
const nova = product.nova_group;
const gradeDesc = grade ? GRADE_DESC[grade] : null;
const novaDesc = nova ? NOVA_DESC[nova] : null;
/* ── Nutrition bars — % of daily reference ─────────────────────────── */
const dailyRef: Record<string, number> = {
energy_100g: 2000,
fat_100g: 70,
saturated_fat_100g: 20,
sugars_100g: 90,
salt_100g: 6,
fiber_100g: 30,
proteins_100g: 50,
};
const nutriments = product.nutriments || {};
const nutritionRows = [
{ key: "energy_100g", label: "Energia", unit: "kcal" },
{ key: "fat_100g", label: "Gorduras totais", unit: "g" },
{ key: "saturated_fat_100g",label: "Gorduras saturadas", unit: "g" },
{ key: "sugars_100g", label: "Açúcares", unit: "g" },
{ key: "salt_100g", label: "Sódio", unit: "g" },
{ key: "fiber_100g", label: "Fibras", unit: "g" },
{ key: "proteins_100g", label: "Proteínas", unit: "g" },
];
const allergenTags = product.allergens_tags || [];
return (
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-[#030712] dark:text-slate-200 transition-colors duration-500">
{/* 1. NAVEGAÇÃO E TÍTULO (Sticky Header) */}
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-slate-200 dark:bg-[#030712]/80 dark:border-white/5">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center gap-6">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
className="rounded-full hover:bg-slate-100 dark:hover:bg-white/5"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-black tracking-tighter text-slate-900 dark:text-white truncate uppercase">
{product.product_name || "Unlabeled Product"}
</h1>
<div className="flex items-center gap-2 text-[10px] font-mono font-bold text-slate-500 uppercase tracking-widest">
<Database size={10} className="text-blue-500" /> {product.code}
</div>
</div>
<div
style={{
minHeight: "100vh",
background: "var(--ink-0, #fdfbf5)",
color: "var(--ink-900, #141312)",
}}
>
{/* ── Top bar ──────────────────────────────────────────────────────── */}
<div
style={{
padding: "18px clamp(20px, 5vw, 40px)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: "1px solid var(--ink-200, #e4dfd7)",
position: "sticky",
top: 0,
zIndex: 50,
background: "rgba(253,251,245,0.92)",
backdropFilter: "blur(12px)",
}}
>
<button
onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
fontSize: 13,
color: "var(--ink-700, #3d3934)",
background: "none",
border: "none",
cursor: "pointer",
}}
>
<ArrowLeft style={{ width: 16, height: 16 }} strokeWidth={1.75} />
Voltar
</button>
<div
style={{
fontFamily: "ui-monospace, monospace",
fontSize: 11,
color: "var(--ink-500, #7a7269)",
letterSpacing: "0.12em",
}}
>
EAN · {product.code}
</div>
</header>
<div style={{ display: "flex", gap: 8 }}>
<button
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 8,
background: "transparent",
color: "var(--ink-700, #3d3934)",
cursor: "pointer",
}}
title="Salvar"
>
<Heart style={{ width: 14, height: 14 }} strokeWidth={1.75} />
</button>
<button
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 8,
background: "transparent",
color: "var(--ink-700, #3d3934)",
cursor: "pointer",
}}
title="Compartilhar"
>
<Share2 style={{ width: 14, height: 14 }} strokeWidth={1.75} />
</button>
</div>
</div>
<main className="max-w-7xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
{/* COLUNA ESQUERDA: Ativos Visuais (5 Colunas) */}
<div className="lg:col-span-5 space-y-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative aspect-square bg-white rounded-[3rem] border border-slate-200 overflow-hidden shadow-2xl dark:bg-slate-900 dark:border-white/5 group"
{/* ── Hero: image + headline + Nutri-Score ────────────────────────── */}
<div
style={{
padding: "40px clamp(20px, 5vw, 40px) 32px",
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 48,
maxWidth: 1100,
margin: "0 auto",
}}
>
{/* Product image */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45 }}
style={{
background: "var(--ink-50, #f9f6f0)",
borderRadius: 18,
aspectRatio: "1/1",
display: "grid",
placeItems: "center",
border: "1px solid var(--ink-200, #e4dfd7)",
overflow: "hidden",
position: "relative",
}}
>
{imageUrl ? (
<Image
src={imageUrl}
alt={product.product_name || "Produto"}
fill
className="object-contain p-10"
priority
sizes="(max-width: 768px) 100vw, 500px"
/>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
color: "var(--ink-400, #a39c93)",
}}
>
{imageUrl ? (
<Image
src={imageUrl}
alt={product.product_name || "Product"}
fill
className="object-contain p-12 transition-transform duration-700 group-hover:scale-110"
priority
sizes="(max-width: 768px) 100vw, 500px"
/>
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-300 dark:text-slate-800">
<Beaker size={80} className="mb-4 opacity-50" />
<span className="text-xs font-mono font-black uppercase tracking-[0.4em]">No Visual Metadata</span>
<Beaker style={{ width: 64, height: 64 }} strokeWidth={1} />
<span
style={{
fontSize: 12,
fontFamily: "ui-monospace, monospace",
textTransform: "uppercase",
letterSpacing: "0.1em",
marginTop: 12,
}}
>
Sem imagem
</span>
</div>
)}
</motion.div>
{/* Right column — headline + score cards */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.05 }}
>
{/* Brand eyebrow */}
<div
style={{
fontSize: 12,
color: "var(--ink-500, #7a7269)",
fontFamily: "ui-monospace, monospace",
letterSpacing: "0.12em",
textTransform: "uppercase",
marginBottom: 12,
}}
>
{product.brands || "Marca desconhecida"}
</div>
{/* Product name */}
<h1
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "clamp(28px, 4vw, 44px)",
letterSpacing: "-0.01em",
lineHeight: 1.08,
color: "var(--ink-900, #141312)",
marginBottom: 28,
}}
>
{product.product_name || "Produto sem nome"}
</h1>
{/* ── Nutri-Score hero metric ─────────────────────────────────── */}
{grade && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
style={{
display: "flex",
alignItems: "center",
gap: 20,
padding: 24,
background: "var(--ink-50, #f9f6f0)",
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 16,
marginBottom: 16,
}}
>
<NutriLetter grade={grade} size={92} />
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: "ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "var(--ink-500, #7a7269)",
fontWeight: 500,
marginBottom: 6,
}}
>
Nutri‑Score
</div>
<div
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: 26,
letterSpacing: "-0.01em",
color: "var(--ink-900, #141312)",
lineHeight: 1.1,
marginBottom: 12,
}}
>
{gradeDesc || `Nota ${grade.toUpperCase()}`}
</div>
<NutriScale active={grade} size="sm" />
</div>
</motion.div>
)}
{/* ── NOVA row ─────────────────────────────────────────────────── */}
{nova && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
padding: 20,
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 14,
marginBottom: 16,
display: "flex",
gap: 18,
alignItems: "center",
}}
>
<NovaPill group={nova} />
{novaDesc && (
<div
style={{
fontSize: 14,
color: "var(--ink-600, #595149)",
lineHeight: 1.55,
flex: 1,
}}
>
{novaDesc}
</div>
)}
</motion.div>
)}
{/* Ficha Técnica (Bento-style) */}
<div className="p-8 rounded-[2.5rem] bg-white border border-slate-200 shadow-sm dark:bg-white/[0.02] dark:border-white/5 space-y-6">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-blue-600 dark:text-blue-400">SKU Datasheet</h3>
<div className="space-y-4 font-medium">
{[
{ label: "Fabricante", val: product.brands, icon: ShieldCheck },
{ label: "Região", val: product.countries, icon: Globe },
{ label: "Registro", val: "Auditado", icon: ClipboardList }
].map((item, i) => item.val && (
<div key={i} className="flex justify-between items-center text-sm">
<span className="text-slate-400 flex items-center gap-2 font-bold uppercase text-[11px] tracking-tight">
<item.icon size={14} className="text-slate-300" /> {item.label}
</span>
<span className="text-slate-900 dark:text-white text-right max-w-[60%] truncate">{item.val}</span>
{/* ── Allergens ────────────────────────────────────────────────── */}
<AnimatePresence>
{allergenTags.length > 0 && (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.25 }}
style={{
padding: 18,
borderRadius: 14,
background: "#fef3c7",
border: "1px solid #fcd34d",
display: "flex",
alignItems: "flex-start",
gap: 14,
}}
>
<AlertTriangle
style={{ width: 20, height: 20, color: "#b45309", flexShrink: 0, marginTop: 1 }}
strokeWidth={1.75}
/>
<div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#b45309",
marginBottom: 6,
}}
>
Contém alérgenos
</div>
))}
</div>
</div>
</div>
{/* COLUNA DIREITA: Inteligência de Dados (7 Colunas) */}
<div className="lg:col-span-7 space-y-8">
{/* Veredito Rápido: Scores */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DataCard
title="Nutri-Score"
desc="Indicador de Qualidade Nutricional"
badge={<NutriScoreBadge grade={product.nutrition_grades || ""} size="lg" />}
/>
<DataCard
title="Protocolo NOVA"
desc="Nível de Processamento Industrial"
badge={<NovaBadge group={product.nova_group || 0} size="lg" />}
/>
</div>
{/* Análise Macronutricional */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="p-1 rounded-[3rem] bg-gradient-to-br from-slate-200 to-slate-100 dark:from-white/10 dark:to-transparent"
>
<div className="bg-white rounded-[2.9rem] p-10 dark:bg-[#030712]">
<h3 className="text-lg font-black text-slate-900 dark:text-white mb-8 flex items-center gap-3 uppercase tracking-tighter">
<div className="w-2 h-6 bg-blue-600 rounded-full" />
Composição Nutricional <span className="text-slate-400 font-mono text-xs font-normal">/ 100g</span>
</h3>
<NutritionGrid nutritionData={formatNutritionData(product) || {}} />
</div>
</motion.div>
{/* Bloqueadores e Alérgenos */}
<AnimatePresence>
{product.allergens_tags && product.allergens_tags.length > 0 && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="p-8 rounded-[2.5rem] bg-orange-50 border border-orange-200 dark:bg-orange-500/5 dark:border-orange-500/20"
>
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-orange-600 rounded-xl text-white shadow-lg shadow-orange-600/20">
<AlertTriangle size={20} />
</div>
<h3 className="text-lg font-black text-orange-950 dark:text-orange-500 uppercase tracking-tighter">Detecção de Alérgenos</h3>
</div>
<div className="flex flex-wrap gap-2">
{product.allergens_tags.map(tag => (
<span key={tag} className="px-4 py-2 rounded-xl bg-white border border-orange-200 text-orange-700 text-[10px] font-black uppercase tracking-widest dark:bg-orange-500/10 dark:text-orange-400 dark:border-orange-500/20 shadow-sm">
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{allergenTags.map((tag) => (
<span
key={tag}
style={{
fontSize: 12,
padding: "3px 10px",
background: "rgba(255,255,255,0.7)",
border: "1px solid #fde68a",
borderRadius: 999,
color: "#78350f",
}}
>
{tag.replace(/en:|pt:/g, "")}
</span>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Lista de Ingredientes (OCR Interpretation) */}
<div className="p-10 rounded-[2.5rem] bg-white border border-slate-200 shadow-sm dark:bg-white/[0.02] dark:border-white/5">
<h3 className="text-lg font-black text-slate-900 dark:text-white mb-6 uppercase tracking-tighter">Relatório de Ingredientes</h3>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 font-medium">
{product.ingredients_text || "O fabricante não forneceu a lista de ingredientes para este SKU."}
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
{/* ── Nutrition composition section ───────────────────────────────── */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
style={{
borderTop: "1px solid var(--ink-200, #e4dfd7)",
padding: "32px clamp(20px, 5vw, 40px) 60px",
maxWidth: 1100,
margin: "0 auto",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 24,
gap: 12,
flexWrap: "wrap",
}}
>
<h2
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "clamp(22px, 3vw, 30px)",
letterSpacing: "-0.01em",
color: "var(--ink-900, #141312)",
}}
>
Composição nutricional
</h2>
<div
style={{
fontFamily: "ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "var(--ink-500, #7a7269)",
fontWeight: 500,
}}
>
por 100g · %VD de referência
</div>
</div>
</main>
</div>
);
}
// Sub-componente para cards de dados
function DataCard({ title, desc, badge }: { title: string, desc: string, badge: React.ReactNode }) {
return (
<div className="p-8 rounded-[2.5rem] bg-white border border-slate-200 shadow-sm dark:bg-white/[0.02] dark:border-white/5 hover:border-blue-500/30 transition-all duration-300">
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-400 mb-1">{title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-8 font-bold leading-tight">{desc}</p>
<div className="flex justify-start transform hover:scale-105 transition-transform origin-left">
{badge}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: "0 48px",
}}
>
{nutritionRows.map((row) => {
const raw = nutriments[row.key as keyof typeof nutriments];
const v = raw != null ? Number(raw) : null;
if (v == null) return null;
const ref = dailyRef[row.key] || 100;
const pct = Math.min(100, Math.round((v / ref) * 100));
const isHigh =
pct > 40 &&
["fat_100g", "saturated_fat_100g", "sugars_100g", "salt_100g"].includes(row.key);
const isGood = ["fiber_100g", "proteins_100g"].includes(row.key);
const barColor = isHigh
? "#e63e11"
: isGood
? "var(--accent, oklch(52% 0.11 155))"
: "var(--ink-500, #7a7269)";
return (
<div
key={row.key}
style={{
padding: "14px 0",
borderBottom: "1px solid var(--ink-200, #e4dfd7)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: 8,
}}
>
<span style={{ fontSize: 14, color: "var(--ink-800, #26231f)" }}>
{row.label}
</span>
<span
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: 18,
color: "var(--ink-900, #141312)",
}}
>
{v}
<span
style={{
fontSize: 11,
color: "var(--ink-500, #7a7269)",
marginLeft: 3,
fontFamily: "ui-monospace, monospace",
}}
>
{row.unit}
</span>
</span>
</div>
<div
style={{
height: 4,
background: "var(--ink-100, #f3efe7)",
borderRadius: 999,
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background: barColor,
borderRadius: 999,
transition: "width 0.6s",
}}
/>
</div>
<div
style={{
fontSize: 10,
color: "var(--ink-500, #7a7269)",
marginTop: 4,
fontFamily: "ui-monospace, monospace",
letterSpacing: "0.04em",
}}
>
{pct}% VD
</div>
</div>
);
})}
</div>
{/* Ingredients */}
{product.ingredients_text && (
<div
style={{
marginTop: 40,
padding: 24,
background: "var(--ink-50, #f9f6f0)",
border: "1px solid var(--ink-200, #e4dfd7)",
borderRadius: 14,
}}
>
<h3
style={{
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: 20,
color: "var(--ink-900, #141312)",
marginBottom: 14,
}}
>
Lista de Ingredientes
</h3>
<p
style={{
fontSize: 14,
color: "var(--ink-600, #595149)",
lineHeight: 1.65,
}}
>
{product.ingredients_text}
</p>
</div>
)}
</motion.div>
{/* ── Footer attribution ─────────────────────────────────────────── */}
<div
style={{
padding: "24px clamp(20px, 5vw, 40px)",
background: "var(--ink-50, #f9f6f0)",
borderTop: "1px solid var(--ink-200, #e4dfd7)",
}}
>
<div
style={{
maxWidth: 1100,
margin: "0 auto",
fontSize: 12,
color: "var(--ink-500, #7a7269)",
fontFamily: "ui-monospace, monospace",
letterSpacing: "0.04em",
lineHeight: 1.6,
}}
>
Dados fornecidos pela comunidade Open Food Facts sob licença ODbL.
{product.countries && ` · Região: ${product.countries}`}
</div>
</div>
</div>
);
}
}
+721 -448
View File
File diff suppressed because it is too large. Load diff
+80 -83
View File
@@ -1,113 +1,110 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { ArrowRight, Code2, Heart, Globe, Database, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
export function CTASection() {
return (
<section className="relative py-32 bg-[#030712] overflow-hidden transition-colors duration-500">
{/* 1. LAYER DE INFRAESTRUTURA: Background Glows Inteligentes */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[500px] bg-blue-600/10 blur-[160px] rounded-full" />
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-emerald-500/5 blur-[120px] rounded-full" />
<div className="absolute inset-0 bg-[url('data:image/svg+xml,...')] opacity-[0.03] invert dark:invert-0" />
</div>
<section className="bg-[var(--fg-primary)] section-py relative overflow-hidden">
{/* Subtle warm texture */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E\")",
backgroundSize: "200px 200px",
}}
/>
<div className="relative z-10 max-w-5xl mx-auto px-6 text-center">
{/* 2. LAYER DE CONTEXTO: Tag de Sistema */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/5 border border-blue-500/20 text-blue-400 text-[10px] font-black uppercase tracking-[0.3em] mb-8"
>
<Database size={12} /> Open Access Protocol
</motion.div>
<div className="container-editorial relative z-10">
{/* 3. LAYER DE MENSAGEM: Headlines de Alta Densidade */}
<div className="mb-12">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-5xl md:text-7xl font-black text-white mb-8 tracking-tighter leading-[0.9]"
>
Soberania alimentar via <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-cyan-300 to-emerald-400">
transparência algorítmica.
</span>
</motion.h2>
<p className="text-lg md:text-xl text-slate-400 max-w-3xl mx-auto leading-relaxed font-medium">
Junte-se à infraestrutura global que está devolvendo o poder de escolha ao cidadão. Dados abertos para uma vida sem processados ocultos.
{/* Top hairline — warm on dark */}
<div className="border-t border-[var(--border-subtle)]/20 mb-20" />
{/* Large editorial headline */}
<div className="max-w-3xl mb-16">
<span className="label text-[var(--accent)] mb-6 block">
Comece agora
</span>
<h2 className="font-display text-display-xl text-[var(--bg-base)] mb-8 leading-[0.9]">
Soberania alimentar via{" "}
<em className="font-display-i italic">transparência.</em>
</h2>
<p className="text-lg text-[var(--fg-muted)] leading-relaxed max-w-xl">
Junte-se à infraestrutura global que está devolvendo o poder de escolha
ao cidadão. Dados abertos para uma vida sem processados ocultos.
</p>
</div>
{/* 4. LAYER DE EXECUÇÃO: CTAs de Alta Tração */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-24"
>
<Link href="/" className="w-full sm:w-auto">
<Button
size="lg"
className="w-full sm:w-auto h-16 px-10 bg-blue-600 hover:bg-blue-700 text-white rounded-2xl font-bold text-lg shadow-2xl shadow-blue-500/20 transition-all hover:scale-[1.02] active:scale-95 group"
>
<span>Executar Scan</span>
<ArrowRight className="ml-2 w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Button>
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-4 mb-24">
<Link href="/" className="btn-accent inline-flex items-center gap-2 group">
Executar Scan
<ArrowRight
size={15}
className="transition-transform duration-200 group-hover:translate-x-0.5"
/>
</Link>
<Link
href="https://github.com/evandrodevbr/lista-compras"
target="_blank"
className="w-full sm:w-auto"
rel="noopener noreferrer"
className="btn-ghost border-[var(--border-subtle)]/30 text-[var(--bg-base)] hover:bg-[var(--bg-base)]/10"
>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto h-16 px-10 border-slate-800 text-white hover:bg-slate-900 rounded-2xl font-bold text-lg transition-all"
>
<Code2 className="mr-2 w-5 h-5 text-black" />
<span className="text-gray-800">Audit Code</span>
</Button>
Auditar o código
</Link>
</motion.div>
</div>
{/* 5. LAYER DE VALIDAÇÃO: Bento Grid de Metadados */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Three-column info grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-[var(--border-subtle)]/20 border border-[var(--border-subtle)]/20 rounded-lg overflow-hidden mb-20">
{[
{ icon: Globe, label: "Rede", val: "Global Node", desc: "Integridade em 100+ países" },
{ icon: ShieldCheck, label: "Segurança", val: "Privacy First", desc: "Zero tracking de dados" },
{ icon: Heart, label: "Sustentabilidade", val: "Open Source", desc: "Mantido pela comunidade" },
].map((item, i) => (
<div key={i} className="p-6 rounded-3xl bg-white/[0.02] border border-white/[0.05] backdrop-blur-sm text-left group hover:bg-white/[0.04] transition-colors">
<item.icon className="w-6 h-6 text-blue-500 mb-4" />
<div className="text-[10px] font-black uppercase tracking-widest text-slate-500 mb-1">{item.label}</div>
<div className="text-white font-bold text-lg mb-1 tracking-tight">{item.val}</div>
<p className="text-xs text-slate-500 leading-relaxed">{item.desc}</p>
{
label: "Rede",
val: "Global Node",
desc: "Integridade em 100+ países",
},
{
label: "Segurança",
val: "Privacy First",
desc: "Zero tracking de dados",
},
{
label: "Sustentabilidade",
val: "Open Source",
desc: "Mantido pela comunidade",
},
].map((item) => (
<div
key={item.label}
className="px-8 py-10 group hover:bg-[var(--bg-base)]/5 transition-colors duration-300"
>
<div className="label text-[var(--fg-faint)] mb-2 block">{item.label}</div>
<div className="font-display text-display-sm text-[var(--bg-base)] mb-2">
{item.val}
</div>
<p className="text-xs text-[var(--fg-muted)]">{item.desc}</p>
</div>
))}
</div>
{/* 6. MENSAGEM FINAL: Minimalismo de Autoridade */}
<div className="mt-24 pt-12 border-t border-white/[0.05]">
<p className="text-[10px] font-mono font-bold text-slate-600 uppercase tracking-[0.5em] mb-6">
NutriScan Governance Protocol v2.0
{/* Bottom hairline + governance line */}
<div className="border-t border-[var(--border-subtle)]/20 pt-10 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<p className="label text-[var(--fg-faint)]">
NutriScan · Transparência Alimentar · 2024
</p>
<Link
href="/sobre"
className="inline-flex items-center gap-2 text-sm font-bold text-blue-500 hover:text-blue-400 transition-colors group"
<Link
href="/sobre"
className="inline-flex items-center gap-2 text-sm text-[var(--accent)] hover:text-[var(--accent-light)] transition-colors duration-200 group"
>
Conheça nossa arquitetura de dados
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
Conheça nossa arquitetura de dados
<ArrowRight
size={13}
className="transition-transform duration-200 group-hover:translate-x-0.5"
/>
</Link>
</div>
</div>
</section>
);
}
}
+82 -48
View File
@@ -1,68 +1,102 @@
"use client";
import { ChevronDown } from "lucide-react";
import { ArrowDown } from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
export function HeroSection() {
const scrollToNext = () => {
const nextSection = document.getElementById("problema");
nextSection?.scrollIntoView({ behavior: "smooth" });
document.getElementById("problema")?.scrollIntoView({ behavior: "smooth" });
};
return (
<section className="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-green-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-[url('data:image/svg+xml,%3Csvg%20width%3D%2260%22%20height%3D%2260%22%20viewBox%3D%220%200%2060%2060%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20fill%3D%22%230055ff%22%20fill-opacity%3D%220.05%22%3E%3Ccircle%20cx%3D%2230%22%20cy%3D%2230%22%20r%3D%222%22/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] opacity-40"></div>
<section className="relative bg-base overflow-hidden">
{/* Subtle warm grain texture overlay */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E\")",
backgroundSize: "200px 200px",
}}
/>
<div className="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
{/* Logo */}
<div className="mb-8">
<Image
src="/apple-touch-icon.png"
alt="NutriScan Logo"
width={96}
height={96}
className="mx-auto rounded-2xl shadow-xl"
priority
/>
{/* Top hairline */}
<div className="hairline" />
<div className="container-editorial">
{/* Eyebrow row */}
<div className="flex items-center justify-between border-b border-[var(--border-subtle)] py-6">
<span className="label text-[var(--fg-muted)]">Sobre o NutriScan</span>
<span className="label text-[var(--fg-faint)]">Est. 2024</span>
</div>
{/* Hero Title */}
<h1 className="text-4xl sm:text-5xl lg:text-6xl xl:text-7xl font-bold text-gray-900 dark:text-white mb-6 leading-tight">
Saber o que colocamos no{" "}
<span className="bg-gradient-to-r from-blue-600 to-green-600 bg-clip-text text-transparent">
prato
</span>
<br />é um direito de todos
</h1>
{/* Main hero content */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-16 pt-20 pb-24 lg:pt-28 lg:pb-32">
<div className="max-w-3xl">
{/* Issue label */}
<p className="label text-accent mb-8 tracking-widest">
Transparência Alimentar
</p>
{/* Subtitle */}
<p className="text-xl sm:text-2xl text-gray-600 dark:text-gray-300 mb-12 max-w-3xl mx-auto leading-relaxed">
Em um mundo onde rótulos são confusos e ingredientes impossíveis de
pronunciar, o NutriScan nasceu para devolver o poder de escolha às
suas mãos.
</p>
{/* Editorial headline — Instrument Serif, weight 400, no gradient */}
<h1 className="font-display text-display-xl text-[var(--fg-primary)] mb-8 leading-[0.9]">
Saber o que{" "}
<em className="font-display-i not-italic italic">colocamos</em>
<br />
no prato é um direito{" "}
<em className="font-display-i not-italic italic">de todos.</em>
</h1>
{/* CTA */}
<Button
onClick={scrollToNext}
variant="outline"
size="lg"
className="group hover:bg-gradient-to-r hover:from-blue-600 hover:to-green-600 hover:text-white hover:border-transparent transition-all duration-300"
>
<span className="mr-2">Conheça nossa história</span>
<ChevronDown className="w-5 h-5 group-hover:animate-bounce" />
</Button>
</div>
<p className="text-lg text-[var(--fg-secondary)] leading-relaxed max-w-xl mb-12">
Em um mundo onde rótulos são confusos e ingredientes impossíveis
de pronunciar, o NutriScan nasceu para devolver o poder de escolha
às suas mãos.
</p>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2">
<div className="animate-bounce">
<div className="w-6 h-10 border-2 border-gray-400 dark:border-gray-500 rounded-full flex justify-center">
<div className="w-1 h-3 bg-gray-400 dark:bg-gray-500 rounded-full mt-2 animate-pulse"></div>
<button
onClick={scrollToNext}
className="btn-ghost group inline-flex items-center gap-3"
>
Conheça nossa história
<ArrowDown
size={15}
className="transition-transform duration-300 group-hover:translate-y-1"
/>
</button>
</div>
{/* Logo mark — editorial, no shadow */}
<div className="hidden lg:flex items-start pt-2">
<div className="relative">
<div className="w-20 h-20 rounded-xl overflow-hidden border border-[var(--border-subtle)]">
<Image
src="/apple-touch-icon.png"
alt="NutriScan"
width={80}
height={80}
className="w-full h-full object-cover"
priority
/>
</div>
</div>
</div>
</div>
{/* Stats row — hairline separated */}
<div className="hairline" />
<div className="grid grid-cols-3 divide-x divide-[var(--border-subtle)] py-10">
{[
{ value: "2M+", label: "Alimentos indexados" },
{ value: "100+", label: "Países cobertos" },
{ value: "ODBL", label: "Licença aberta" },
].map((stat) => (
<div key={stat.label} className="stat-cell px-8 first:pl-0 last:pr-0">
<span className="stat-value">{stat.value}</span>
<span className="stat-label">{stat.label}</span>
</div>
))}
</div>
<div className="hairline" />
</div>
</section>
);
+63 -99
View File
@@ -1,134 +1,98 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { Scan, Search, CheckCircle, Zap, Shield, Globe, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
// Tipagem para escalabilidade
interface Step {
id: number;
title: string;
description: string;
icon: React.ElementType;
color: string;
features: string[];
}
const STEPS: Step[] = [
const STEPS = [
{
id: 1,
id: "01",
title: "Captura de Dados",
description: "Input instantâneo via scanner óptico ou entrada manual de protocolo.",
icon: Scan,
color: "from-blue-500 to-cyan-400",
features: ["OCR de alta precisão", "Busca por SKU/EAN", "Detecção automática"],
description:
"Input instantâneo via scanner óptico ou entrada manual do código de barras do produto.",
features: ["OCR de alta precisão", "Busca por código EAN", "Detecção automática"],
},
{
id: 2,
id: "02",
title: "Análise de Base",
description: "Cruzamento com o dataset global Open Food Facts em milissegundos.",
icon: Search,
color: "from-green-500 to-emerald-400",
features: ["Nutri-Score v2.0", "Grau NOVA", "Alertas de Alérgenos"],
description:
"Cruzamento com o dataset global Open Food Facts em milissegundos. Dados validados por curadoria colaborativa.",
features: ["Nutri-Score v2.0", "Grau NOVA", "Alertas de alérgenos"],
},
{
id: 3,
title: "Veredito Técnico",
description: "Saída de dados interpretada para uma tomada de decisão consciente.",
icon: CheckCircle,
color: "from-purple-500 to-pink-500",
features: ["Sugestões saudáveis", "Histórico local", "Comparativo de ROI nutricional"],
id: "03",
title: "Veredito Claro",
description:
"Saída de dados interpretada para uma tomada de decisão consciente, sem jargões técnicos.",
features: ["Sugestões saudáveis", "Histórico local", "Comparativo nutricional"],
},
];
export function HowItWorksSection() {
return (
<section className="relative py-32 bg-white dark:bg-[#030712] overflow-hidden transition-colors duration-500">
{/* Background Decor: Engenharia de Luz */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-blue-500/5 via-transparent to-transparent pointer-events-none" />
<section className="bg-base section-py">
<div className="container-editorial">
<div className="max-w-7xl mx-auto px-6 relative z-10">
<div className="text-center mb-24">
<motion.span
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
className="text-blue-600 font-mono text-sm font-black uppercase tracking-[0.3em]"
>
Pipeline de Operação
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-5xl md:text-7xl font-black text-gray-900 dark:text-white mt-4 mb-8 tracking-tighter"
>
A inteligência por trás do <span className="bg-gradient-to-r from-blue-600 to-green-500 bg-clip-text text-transparent italic">scan</span>.
</motion.h2>
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Como Funciona</span>
<h2 className="font-display">
A inteligência por trás do{" "}
<em className="font-display-i italic">scan.</em>
</h2>
<p>
Três etapas simples entre o código de barras e uma escolha informada.
</p>
</div>
{/* Main Pipeline Flow */}
[Image of a modern user interface flow diagram]
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-32 relative">
{STEPS.map((step, index) => (
<motion.div
{/* Step rows — editorial numbered layout */}
<div className="divide-y divide-[var(--border-subtle)] border-y border-[var(--border-subtle)] mb-20">
{STEPS.map((step) => (
<div
key={step.id}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.2 }}
viewport={{ once: true }}
className="group relative p-8 rounded-[2.5rem] bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 hover:border-blue-500/30 transition-all duration-500"
className="group grid grid-cols-1 lg:grid-cols-[5rem_1fr_1fr] gap-8 py-12 hover-lift cursor-default"
>
{/* Step Badge */}
<div className="absolute top-8 right-8 text-4xl font-black opacity-5 dark:opacity-10 group-hover:opacity-20 transition-opacity italic">
0{step.id}
{/* Step number */}
<div className="step-number">{step.id}</div>
{/* Title + description */}
<div>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-3">
{step.title}
</h3>
<p className="text-[var(--fg-secondary)] leading-relaxed">
{step.description}
</p>
</div>
<div className={cn(
"w-16 h-16 rounded-2xl bg-gradient-to-br flex items-center justify-center mb-10 shadow-xl transition-transform group-hover:scale-110 group-hover:rotate-3",
step.color
)}>
<step.icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
{step.title}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8 leading-relaxed font-medium">
{step.description}
</p>
<ul className="space-y-3 border-t border-gray-200 dark:border-gray-800 pt-6">
{/* Features list */}
<ul className="space-y-3 lg:pt-1">
{step.features.map((f) => (
<li key={f} className="flex items-center text-xs font-bold text-gray-500 dark:text-gray-500 uppercase tracking-widest">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 mr-3 animate-pulse" />
<li
key={f}
className="flex items-center gap-3 text-sm text-[var(--fg-muted)]"
>
<span className="w-1.5 h-px bg-[var(--border-strong)] flex-shrink-0" />
{f}
</li>
))}
</ul>
</motion.div>
</div>
))}
</div>
{/* Global Stats Footer: O 80/20 do Valor */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
>
{/* Bottom stat strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-lg overflow-hidden">
{[
{ icon: Globe, label: "Global", text: "2M+ Alimentos" },
{ icon: Shield, label: "Seguro", text: "100% Gratuito" },
{ icon: Clock, label: "Veloz", text: "< 10 Segundos" },
{ icon: Zap, label: "Offline", text: "Cache Inteligente" },
].map((item, i) => (
<div key={i} className="flex flex-col items-center p-6 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-800 shadow-sm">
<item.icon className="w-6 h-6 text-blue-500 mb-3" />
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-1">{item.label}</span>
<span className="text-sm font-bold text-gray-900 dark:text-white">{item.text}</span>
{ val: "2M+", label: "Alimentos globais" },
{ val: "100%", label: "Gratuito" },
{ val: "< 10s", label: "Por consulta" },
{ val: "Offline", label: "Cache inteligente" },
].map((item) => (
<div key={item.label} className="bg-elevated px-8 py-8 stat-cell items-start">
<span className="stat-value">{item.val}</span>
<span className="stat-label">{item.label}</span>
</div>
))}
</motion.div>
</div>
</div>
</section>
);
}
}
+76 -133
View File
@@ -1,158 +1,101 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { Globe, Database, Activity, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
// Variantes para animação de entrada (Stagger)
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
visible: { y: 0, opacity: 1 }
};
export function ImpactSection() {
return (
<section className="py-32 bg-white dark:bg-[#030712] relative overflow-hidden transition-colors duration-500">
{/* Background Decor - Grid de Engenharia */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
<section className="bg-base section-py">
<div className="container-editorial">
<div className="max-w-7xl mx-auto px-6 relative z-10">
{/* Header - Autoridade Técnica */}
<div className="text-center mb-24">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-4xl md:text-6xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter"
>
Escalabilidade e <span className="bg-gradient-to-r from-blue-600 to-emerald-500 bg-clip-text text-transparent">Impacto Global</span>
</motion.h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Métricas de performance da maior rede colaborativa de transparência alimentar do planeta.
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Impacto</span>
<h2 className="font-display">
Escala global. Escolha individual.
</h2>
<p>
Métricas da maior rede colaborativa de transparência alimentar do planeta.
</p>
</div>
{/* Stats Grid - Estilo Bento Box Sênior */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20"
>
{/* Stats grid — editorial warm cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-lg overflow-hidden mb-20">
{[
{ label: "Data Nodes", val: "2.0M+", icon: Database, color: "text-blue-500", bg: "bg-blue-500/10" },
{ label: "Regions", val: "100+", icon: Globe, color: "text-emerald-500", bg: "bg-emerald-500/10" },
{ label: "Uptime", val: "24/7", icon: Activity, color: "text-purple-500", bg: "bg-purple-500/10" },
{ label: "Local Cache", val: "218k", icon: Zap, color: "text-orange-500", bg: "bg-orange-500/10" },
{ val: "2.0M+", label: "Alimentos indexados", sub: "Dataset global" },
{ val: "100+", label: "Países cobertos", sub: "Rede distribuída" },
{ val: "24/7", label: "Disponibilidade", sub: "Alta redundância" },
{ val: "218k", label: "Cache local", sub: "Acesso offline" },
].map((stat) => (
<motion.div
key={stat.label}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 p-8 rounded-[2rem] shadow-sm hover:shadow-xl transition-all"
>
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center mb-6", stat.bg)}>
<stat.icon className={cn("w-6 h-6", stat.color)} />
</div>
<div className={cn("text-3xl font-mono font-bold tracking-tighter mb-1", stat.color)}>
<div key={stat.label} className="bg-elevated px-8 py-10 hover-lift group cursor-default">
<div className="font-display text-display-md text-[var(--fg-primary)] mb-1 group-hover:text-accent transition-colors duration-300">
{stat.val}
</div>
<div className="text-sm font-black uppercase tracking-widest text-gray-400">
{stat.label}
</div>
</motion.div>
<div className="label text-[var(--fg-primary)] mb-1 block">{stat.label}</div>
<div className="text-xs text-[var(--fg-faint)]">{stat.sub}</div>
</div>
))}
</motion.div>
</div>
{/* World Map - Visualização de Rede de Dados */}
[Image of a world map with data points and connections]
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
className="relative bg-slate-950 rounded-[3rem] p-12 overflow-hidden shadow-3xl mb-20"
>
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')]" />
<div className="relative z-10 flex flex-col items-center">
<h3 className="text-2xl font-bold text-white mb-2 tracking-tight">Rede Global de Sincronização</h3>
<p className="text-slate-500 text-sm mb-12">Monitoramento de tráfego de metadados em tempo real</p>
<div className="w-full aspect-[21/9] relative bg-blue-500/5 rounded-2xl border border-blue-500/10 flex items-center justify-center">
{/* Pulsing Data Points (Engineering nodes) */}
{/* Two-column impact breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Individual impact */}
<div className="card-warm p-10">
<span className="label text-accent mb-6 block">Protocolo Individual</span>
<div className="space-y-6">
{[
{ t: "20%", l: "25%", c: "bg-blue-500" },
{ t: "40%", l: "60%", c: "bg-emerald-500" },
{ t: "70%", l: "30%", c: "bg-purple-500" },
{ t: "25%", l: "80%", c: "bg-orange-500" },
{ t: "55%", l: "15%", c: "bg-red-500" },
].map((dot, i) => (
<div key={i} className="absolute" style={{ top: dot.t, left: dot.l }}>
<div className={cn("w-3 h-3 rounded-full animate-ping opacity-75 absolute", dot.c)} />
<div className={cn("w-3 h-3 rounded-full relative", dot.c)} />
{
label: "Eficiência Cognitiva",
text: "Redução de 80% no tempo de análise de rótulos via consulta direta à base estruturada.",
},
{
label: "Soberania de Dados",
text: "Usuários com controle total sobre o histórico de consumo local, sem rastreamento.",
},
{
label: "Camada de Segurança",
text: "Detecção imediata de alérgenos via cruzamento com base colaborativa validada.",
},
].map((item) => (
<div key={item.label} className="grid grid-cols-[1px_1fr] gap-5 items-start">
<div className="w-px self-stretch bg-[var(--accent)] mt-1" />
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed">
<span className="font-semibold text-[var(--fg-primary)]">{item.label}:</span>{" "}
{item.text}
</p>
</div>
))}
<Globe className="w-24 h-24 text-blue-500/20 animate-pulse" />
<div className="absolute bottom-4 right-6 flex items-center gap-2 text-[10px] font-mono text-blue-400 uppercase tracking-[0.3em]">
<span className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" /> Live Node Updates
</div>
</div>
</div>
</motion.div>
{/* Impact Logs - High Density Info */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 font-medium">
<ImpactCard
title="Individual Protocol"
items={[
{ label: "Cognitive Efficiency", text: "Redução de 80% no tempo de análise de rótulos via algoritmos de OCR." },
{ label: "Data Sovereignty", text: "Usuários com controle total sobre o histórico de consumo local." },
{ label: "Safety Layer", text: "Detecção imediata de alérgenos via cruzamento de base de dados." }
]}
type="blue"
/>
<ImpactCard
title="Collective Governance"
items={[
{ label: "Market Transparency", text: "Pressão algorítmica por rótulos honestos e dados auditáveis." },
{ label: "Crowdsourced Intelligence", text: "Milhares de contribuições diárias validando a integridade da base." },
{ label: "Universal Access", text: "Democratização da informação nutricional de alta fidelidade." }
]}
type="emerald"
/>
{/* Collective impact */}
<div className="card-warm p-10">
<span className="label text-accent mb-6 block">Governança Coletiva</span>
<div className="space-y-6">
{[
{
label: "Transparência de Mercado",
text: "Pressão por rótulos honestos e dados auditáveis — o escrutínio coletivo como regulador.",
},
{
label: "Inteligência Colaborativa",
text: "Milhares de contribuições diárias validando a integridade da base de dados.",
},
{
label: "Acesso Universal",
text: "Democratização da informação nutricional de alta fidelidade, sem paywalls.",
},
].map((item) => (
<div key={item.label} className="grid grid-cols-[1px_1fr] gap-5 items-start">
<div className="w-px self-stretch bg-[var(--border-strong)] mt-1" />
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed">
<span className="font-semibold text-[var(--fg-primary)]">{item.label}:</span>{" "}
{item.text}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
}
function ImpactCard({ title, items, type }: { title: string, items: {label: string, text: string}[], type: 'blue' | 'emerald' }) {
return (
<div className={cn(
"p-10 rounded-[2.5rem] border transition-all duration-500 hover:shadow-2xl",
type === 'blue' ? "bg-blue-50/50 dark:bg-blue-900/10 border-blue-100 dark:border-blue-900/20" : "bg-emerald-50/50 dark:bg-emerald-900/10 border-emerald-100 dark:border-emerald-900/20"
)}>
<h3 className="text-xl font-black mb-8 tracking-tighter uppercase">{title}</h3>
<div className="space-y-6">
{items.map((item, i) => (
<div key={i} className="flex gap-4">
<div className={cn("w-1.5 h-1.5 rounded-full mt-2 shrink-0", type === 'blue' ? "bg-blue-500" : "bg-emerald-500")} />
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
<span className="font-bold text-gray-900 dark:text-white">{item.label}:</span> {item.text}
</p>
</div>
))}
</div>
</div>
);
}
+68 -97
View File
@@ -1,139 +1,110 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { Eye, Shield, Users, Globe, Database, Activity, Map } from "lucide-react";
import { cn } from "@/lib/utils";
import Image from "next/image";
const MISSION_VALUES = [
{
title: "Transparência",
description: "Governança de dados aberta. O que você consome é rastreado sem filtros ou interesses comerciais ocultos.",
icon: Eye,
color: "text-blue-500",
bg: "bg-blue-500/10",
description:
"Governança de dados aberta. O que você consome é rastreado sem filtros ou interesses comerciais ocultos.",
tag: "Open Data",
},
{
title: "Empoderamento",
description: "Autonomia decisória baseada em evidências técnicas. Devolvemos o controle da saúde ao cidadão.",
icon: Shield,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
description:
"Autonomia decisória baseada em evidências. Devolvemos o controle da saúde ao cidadão.",
tag: "Citizen First",
},
{
title: "Comunidade",
description: "Ecossistema colaborativo global. A inteligência coletiva protegendo a saúde de milhões.",
icon: Users,
color: "text-purple-500",
bg: "bg-purple-500/10",
description:
"Ecossistema colaborativo global. A inteligência coletiva protegendo a saúde de milhões.",
tag: "Collaborative",
},
];
export function MissionSection() {
return (
<section className="py-32 bg-white dark:bg-[#030712] relative overflow-hidden transition-colors duration-500">
{/* Background Decor: Mesh Gradient sutil */}
<div className="absolute top-0 left-1/4 w-1/2 h-1/2 bg-blue-500/5 blur-[120px] rounded-full pointer-events-none" />
<section className="bg-surface section-py">
<div className="container-editorial">
<div className="max-w-7xl mx-auto px-6 relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-24"
>
<h2 className="text-4xl md:text-6xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter">
Governança de <span className="bg-gradient-to-r from-blue-600 to-emerald-500 bg-clip-text text-transparent">Dados Alimentares</span>
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Missão</span>
<h2 className="font-display">
Engenharia aplicada à transparência nutricional.
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Engenharia de software aplicada à transparência nutricional. Nossa missão é converter caos informacional em escolhas seguras.
<p>
Nossa missão é converter caos informacional em escolhas seguras — para cada pessoa, em cada supermercado.
</p>
</motion.div>
</div>
{/* Values: Bento-style Cards */}
{/* Values — 3-column warm cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-20">
{MISSION_VALUES.map((value, i) => (
<motion.div
key={value.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
viewport={{ once: true }}
className="group p-8 rounded-[2rem] bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 hover:border-blue-500/30 transition-all duration-500"
>
<div className={cn("w-14 h-14 rounded-2xl flex items-center justify-center mb-8 transition-transform group-hover:scale-110", value.bg)}>
<value.icon className={cn("w-7 h-7", value.color)} />
</div>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
{MISSION_VALUES.map((value) => (
<div key={value.title} className="card-warm hover-lift p-8">
<span className="label text-accent mb-4 block">{value.tag}</span>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-4">
{value.title}
</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed text-sm font-medium">
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed">
{value.description}
</p>
</motion.div>
</div>
))}
</div>
{/* Data Source Dashboard Highlight */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="relative rounded-[3rem] bg-slate-950 p-10 md:p-16 overflow-hidden shadow-3xl"
>
<div className="absolute top-0 right-0 w-full h-full bg-[radial-gradient(circle_at_top_right,_var(--tw-gradient-stops))] from-blue-500/20 via-transparent to-transparent" />
<div className="relative z-10 flex flex-col lg:flex-row gap-12 items-center">
<div className="flex-1 space-y-8">
<div className="inline-flex items-center px-4 py-2 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-black uppercase tracking-widest">
<Database className="w-3 h-3 mr-2" /> Infraestrutura Global
</div>
<h3 className="text-3xl md:text-4xl font-black text-white tracking-tighter">
Integrado ao Open Food Facts API
{/* Open Food Facts callout — accent-bordered panel */}
<div className="border border-[var(--border-subtle)] rounded-lg overflow-hidden bg-elevated">
<div className="border-l-2 border-[var(--accent)] grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-12 p-10 lg:p-14 items-center">
<div>
<span className="label text-accent mb-5 block">Infraestrutura de Dados</span>
<h3 className="font-display text-display-md text-[var(--fg-primary)] mb-5">
Integrado ao Open Food Facts
</h3>
<p className="text-slate-400 text-lg leading-relaxed">
Nossa engine de busca opera sobre o maior dataset colaborativo do planeta, garantindo que cada scan seja validado por uma rede global de curadoria.
<p className="text-[var(--fg-secondary)] leading-relaxed max-w-lg mb-8">
Nossa engine opera sobre o maior dataset colaborativo de alimentos do planeta,
garantindo que cada consulta seja validada por uma rede global de curadoria aberta.
</p>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
{[
{ label: "Produtos", val: "2M+", icon: Activity },
{ label: "Países", val: "100+", icon: Map },
{ label: "Uptime", val: "24/7", icon: Activity },
{ label: "Licença", val: "ODBL", icon: Shield },
].map((stat) => (
<div key={stat.label} className="space-y-1">
<div className="text-2xl font-mono font-bold text-white tracking-tighter">{stat.val}</div>
<div className="text-[10px] uppercase font-black text-slate-500 tracking-widest flex items-center gap-1">
<stat.icon className="w-3 h-3" /> {stat.label}
</div>
{ val: "2M+", label: "Produtos" },
{ val: "100+", label: "Países" },
{ val: "24/7", label: "Uptime" },
{ val: "ODBL", label: "Licença" },
].map((s) => (
<div key={s.label} className="stat-cell items-start">
<span className="stat-value">{s.val}</span>
<span className="stat-label">{s.label}</span>
</div>
))}
</div>
</div>
<div className="relative w-64 h-64 flex items-center justify-center">
{/* Efeito Visual de Radar/Sincronização */}
<div className="absolute inset-0 border border-blue-500/20 rounded-full animate-ping" />
<div className="absolute inset-4 border border-blue-500/10 rounded-full animate-pulse" />
<div className="w-32 h-32 bg-blue-600 rounded-3xl flex items-center justify-center shadow-2xl shadow-blue-500/50 rotate-12 group">
<Globe className="text-white w-16 h-16" />
</div>
{/* Open Food Facts logo emblem */}
<div className="hidden lg:flex items-center justify-center w-40 h-40 rounded-full border border-[var(--border-subtle)] bg-white flex-shrink-0 p-4 shadow-sm">
<Image
src="/images/CMJN-ICON_WHITE_BG_OFF.svg"
alt="Open Food Facts"
width={96}
height={96}
className="h-24 w-auto object-contain"
/>
</div>
</div>
</motion.div>
{/* Footer Quote: Minimalista */}
<div className="mt-24 text-center">
<p className="text-gray-400 dark:text-gray-600 font-mono text-[10px] uppercase tracking-[0.5em] mb-4">
NutriScan Open Data Philosophy
</p>
<p className="text-xl md:text-2xl font-serif italic text-gray-800 dark:text-gray-200">
&ldquo;Dados abertos são a fundação de uma sociedade saudável.&rdquo;
</p>
</div>
{/* Editorial pull-quote */}
<div className="mt-20 text-center">
<div className="hairline mb-10" />
<p className="label text-[var(--fg-faint)] mb-4">NutriScan Open Data Philosophy</p>
<blockquote className="font-display-i text-display-md text-[var(--fg-primary)] italic max-w-2xl mx-auto">
&ldquo;Dados abertos são a fundação de uma sociedade saudável.&rdquo;
</blockquote>
<div className="hairline mt-10" />
</div>
</div>
</section>
);
}
}
+63 -88
View File
@@ -1,104 +1,79 @@
"use client";
import { ShoppingCart, AlertTriangle, Clock, Users } from "lucide-react";
export function ProblemSection() {
const problems = [
{
num: "01",
title: "Rótulos Confusos",
body: "Tabelas nutricionais complexas e listas de ingredientes intermináveis projetadas para desorientar.",
},
{
num: "02",
title: "Alérgenos Escondidos",
body: "Substâncias alergênicas disfarçadas em nomenclaturas técnicas inacessíveis ao consumidor comum.",
},
{
num: "03",
title: "Tempo Perdido",
body: "Minutos preciosos gastos tentando decifrar o que realmente compõe cada embalagem.",
},
{
num: "04",
title: "Desinformação",
body: "Marketing enganoso e informações inconsistentes que obscurecem a verdade nutricional.",
},
];
return (
<section id="problema" className="py-20 bg-gray-50 dark:bg-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Você já se sentiu <span className="text-red-500">perdido</span>{" "}
entre ingredientes impossíveis de pronunciar?
<section id="problema" className="bg-surface section-py">
<div className="container-editorial">
{/* Section header */}
<div className="section-header">
<span className="eyebrow">O Problema</span>
<h2 className="font-display">
Você já se sentiu perdido entre ingredientes impossíveis de pronunciar?
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Não está sozinh{"(a)"}. Milhares de pessoas enfrentam essa mesma
dificuldade todos os dias.
<p>
Não está sozinho. Milhares de pessoas enfrentam essa mesma dificuldade todos os dias.
</p>
</div>
{/* Problem Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-lg flex items-center justify-center mb-4">
<ShoppingCart className="w-6 h-6 text-red-600 dark:text-red-400" />
{/* Problem list — editorial numbered rows */}
<div className="divide-y divide-[var(--border-subtle)] border-y border-[var(--border-subtle)]">
{problems.map((p) => (
<div
key={p.num}
className="group grid grid-cols-[3rem_1fr_1fr] gap-8 py-8 hover-lift cursor-default"
>
<span className="step-number leading-none">{p.num}</span>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] self-center">
{p.title}
</h3>
<p className="text-[var(--fg-secondary)] leading-relaxed self-center text-sm">
{p.body}
</p>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Rótulos Confusos
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Tabelas nutricionais complexas e listas de ingredientes
intermináveis
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-orange-100 dark:bg-orange-900/30 rounded-lg flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Alérgenos Escondidos
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Substâncias alergênicas em nomes técnicos que ninguém entende
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 rounded-lg flex items-center justify-center mb-4">
<Clock className="w-6 h-6 text-yellow-600 dark:text-yellow-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Tempo Perdido
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Minutos preciosos tentando decifrar o que realmente está na
embalagem
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mb-4">
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Desinformação
</h3>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Informações inconsistentes e marketing enganoso nos produtos
</p>
</div>
))}
</div>
{/* Statistics */}
<div className="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg">
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
O problema é real e afeta milhões
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center">
<div className="text-4xl font-bold text-red-500 mb-2">73%</div>
<p className="text-gray-600 dark:text-gray-400">
das pessoas têm dificuldade para entender rótulos nutricionais
</p>
{/* Statistics — hairline grid */}
<div className="mt-20 grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-lg overflow-hidden bg-elevated">
{[
{ value: "73%", label: "Dificuldade com rótulos", desc: "das pessoas não conseguem interpretar tabelas nutricionais" },
{ value: "85%", label: "Alérgenos invisíveis", desc: "não identificam alérgenos escondidos em nomes técnicos" },
{ value: "3 min", label: "Tempo médio perdido", desc: "por produto tentando compreender uma embalagem" },
].map((s) => (
<div key={s.label} className="px-10 py-10">
<div className="font-display text-display-lg text-[var(--fg-primary)] mb-2">
{s.value}
</div>
<div className="label text-accent mb-3 block">{s.label}</div>
<p className="text-sm text-[var(--fg-muted)] leading-relaxed">{s.desc}</p>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-orange-500 mb-2">85%</div>
<p className="text-gray-600 dark:text-gray-400">
não conseguem identificar alérgenos em nomes técnicos
</p>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-blue-500 mb-2">3min</div>
<p className="text-gray-600 dark:text-gray-400">
tempo médio gasto tentando entender um produto
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
+43 -123
View File
@@ -1,164 +1,84 @@
"use client";
import React, { useRef } from "react";
import { motion, useScroll, useSpring } from "framer-motion";
import { Calendar, Lightbulb, Heart, Target } from "lucide-react";
import { cn } from "@/lib/utils";
// 1. Definição da Interface de Dados
interface StoryStep {
year: string;
title: string;
description: string;
icon: React.ElementType;
color: string;
side: "left" | "right";
}
const STORY_DATA: StoryStep[] = [
{
year: "2024",
title: "A Pergunta Simples",
description: "O que realmente tem nesse produto? Decifrar tabelas nutricionais era um desafio individual que percebemos ser global.",
icon: Calendar,
color: "blue",
side: "left",
description:
"O que realmente tem nesse produto? Decifrar tabelas nutricionais era um desafio individual que percebemos ser global.",
},
{
year: "Descoberta",
title: "Não Estávamos Sozinhos",
description: "Milhares de pessoas buscam transparência alimentar. A necessidade de dados abertos tornou-se uma urgência técnica.",
icon: Lightbulb,
color: "green",
side: "right",
description:
"Milhares de pessoas buscam transparência alimentar. A necessidade de dados abertos tornou-se uma urgência coletiva.",
},
{
year: "Solução",
title: "NutriScan Nasce",
description: "Nascido da Engenharia para o mundo. Uma ponte entre o código de barras e a verdade nutricional.",
icon: Heart,
color: "purple",
side: "left",
description:
"Uma ponte entre o código de barras e a verdade nutricional. Engenharia a serviço de uma escolha consciente.",
},
{
year: "Hoje",
title: "Nossa Missão",
description: "Democratizar a informação. Conectar cidadãos à maior base de dados colaborativa do planeta.",
icon: Target,
color: "orange",
side: "right",
description:
"Democratizar a informação. Conectar cidadãos à maior base de dados colaborativa de alimentos do planeta.",
},
];
export function StorySection() {
const containerRef = useRef<HTMLDivElement>(null);
// 2. Lógica de Scroll Progressivo (Engenharia de Movimento)
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end end"],
});
const scaleY = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
return (
<section ref={containerRef} className="py-32 bg-white dark:bg-[#030712] relative overflow-hidden">
<div className="max-w-7xl mx-auto px-6">
{/* Header com Animação de Entrada */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-center mb-32"
>
<h2 className="text-4xl md:text-6xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter">
Nossa <span className="bg-gradient-to-r from-blue-600 to-green-500 bg-clip-text text-transparent">história</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
<section className="bg-base section-py">
<div className="container-editorial">
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Nossa História</span>
<h2 className="font-display">
De uma dúvida individual a uma plataforma de transparência global.
</p>
</motion.div>
</h2>
</div>
{/* Timeline — clean editorial list with hairlines */}
<div className="relative">
{/* 3. A Linha do Tempo Reativa */}
<div className="absolute left-1/2 transform -translate-x-1/2 w-[2px] h-full bg-gray-100 dark:bg-gray-800 hidden md:block">
<motion.div
style={{ scaleY }}
className="absolute top-0 w-full h-full bg-gradient-to-b from-blue-500 via-green-500 to-orange-500 origin-top"
/>
</div>
{/* Vertical accent line */}
<div className="absolute left-[5.75rem] top-0 bottom-0 w-px bg-[var(--border-subtle)] hidden md:block" />
<div className="space-y-24 md:space-y-32">
<div className="space-y-0">
{STORY_DATA.map((step, index) => (
<TimelineItem key={index} step={step} index={index} />
<div
key={index}
className="group relative grid grid-cols-1 md:grid-cols-[6rem_1fr] gap-0 border-b border-[var(--border-subtle)] last:border-b-0 py-10 hover-lift cursor-default"
>
{/* Year label */}
<div className="md:pr-8 mb-4 md:mb-0 flex md:flex-col items-center md:items-start md:pt-1">
<span className="label text-accent">{step.year}</span>
{/* Timeline dot */}
<div className="hidden md:block mt-4 ml-px w-2 h-2 rounded-full bg-[var(--border-strong)] group-hover:bg-[var(--accent)] transition-colors duration-300" />
</div>
{/* Content */}
<div className="md:pl-12">
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-3">
{step.title}
</h3>
<p className="text-[var(--fg-secondary)] leading-relaxed max-w-xl">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
}
// 4. Sub-componente com Encapsulamento de Animação
function TimelineItem({ step }: { step: StoryStep; index: number }) {
const isLeft = step.side === "left";
const colorMap = {
blue: "bg-blue-500 text-blue-500 border-blue-200 dark:border-blue-900/30",
green: "bg-green-500 text-green-500 border-green-200 dark:border-green-900/30",
purple: "bg-purple-500 text-purple-500 border-purple-200 dark:border-purple-900/30",
orange: "bg-orange-500 text-orange-500 border-orange-200 dark:border-orange-900/30",
};
return (
<div className={cn(
"relative flex flex-col md:flex-row items-center justify-between w-full",
isLeft ? "md:flex-row" : "md:flex-row-reverse"
)}>
{/* Conteúdo */}
<motion.div
initial={{ opacity: 0, x: isLeft ? -50 : 50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-100px" }}
transition={{ duration: 0.6, delay: 0.1 }}
className="w-full md:w-[45%] group"
>
<div className={cn(
"p-8 rounded-[2rem] bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 transition-all duration-500",
"hover:shadow-2xl hover:border-transparent group-hover:bg-white dark:group-hover:bg-gray-900"
)}>
<div className="flex items-center gap-4 mb-6">
<div className={cn("w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-lg", colorMap[step.color as keyof typeof colorMap].split(' ')[0])}>
<step.icon size={24} />
</div>
<div>
<span className={cn("text-xs font-black uppercase tracking-widest", colorMap[step.color as keyof typeof colorMap].split(' ')[1])}>
{step.year}
</span>
<h3 className="text-xl font-bold text-gray-900 dark:text-white leading-tight">
{step.title}
</h3>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{step.description}
</p>
</div>
</motion.div>
{/* Ponto Central com Efeito de Pulso */}
<div className="absolute left-1/2 transform -translate-x-1/2 w-4 h-4 z-20 hidden md:block">
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
className={cn("w-full h-full rounded-full ring-4 ring-white dark:ring-[#030712] shadow-sm", colorMap[step.color as keyof typeof colorMap].split(' ')[0])}
/>
</div>
<div className="hidden md:block w-[45%]" />
</div>
);
}
+70 -115
View File
@@ -1,158 +1,113 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { Heart, Zap, BookOpen, Baby, Dumbbell, ArrowRight, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
const PERSONAS = [
{
id: "parents",
tag: "Segurança Familiar",
title: "Pais e Mães",
tagline: "Segurança familiar",
description: "Decifre rótulos complexos e garanta que o lanche escolar seja livre de ingredientes ultraprocessados ocultos.",
icon: Baby,
color: "text-pink-500",
bg: "bg-pink-500/10",
border: "hover:border-pink-500/30",
features: ["Identificação de Alérgenos", "Nutri-Score Infantil", "Educação Nutricional"]
description:
"Decifre rótulos complexos e garanta que o lanche escolar seja livre de ingredientes ultraprocessados ocultos.",
features: ["Identificação de alérgenos", "Nutri-Score infantil", "Educação nutricional"],
},
{
id: "athletes",
tag: "Performance",
title: "Atletas",
tagline: "Performance Máxima",
description: "Otimize sua composição corporal monitorando densidade calórica e a qualidade dos macronutrientes em tempo real.",
icon: Dumbbell,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
border: "hover:border-emerald-500/30",
features: ["Monitoramento de Macros", "Foco em Bio-disponibilidade", "Zero Açúcares Ocultos"]
description:
"Otimize sua composição corporal monitorando densidade calórica e a qualidade dos macronutrientes.",
features: ["Monitoramento de macros", "Biodisponibilidade", "Zero açúcares ocultos"],
},
{
id: "allergic",
tag: "Risco Zero",
title: "Alérgicos",
tagline: "Risco Zero",
description: "Sistemas de alerta para contaminação cruzada e componentes críticos. Segurança total na palma da sua mão.",
icon: ShieldCheck,
color: "text-orange-500",
bg: "bg-orange-500/10",
border: "hover:border-orange-500/30",
features: ["Glúten & Lactose Tracker", "Traços de Alérgenos", "Scan de Segurança"]
description:
"Alertas para contaminação cruzada e componentes críticos. Segurança total na palma da mão.",
features: ["Glúten & lactose tracker", "Traços de alérgenos", "Scan de segurança"],
},
{
id: "conscious",
tag: "Decisão Crítica",
title: "Consumidores",
tagline: "Decisão Crítica",
description: "Para quem questiona a indústria. Acesse a lista real de ingredientes e a origem dos dados de forma aberta.",
icon: BookOpen,
color: "text-blue-500",
bg: "bg-blue-500/10",
border: "hover:border-blue-500/30",
features: ["Análise de Aditivos", "Comparação de SKUs", "Transparência ODBL"]
}
description:
"Para quem questiona a indústria. Acesse a lista real de ingredientes e a origem dos dados de forma aberta.",
features: ["Análise de aditivos", "Comparação de produtos", "Transparência ODBL"],
},
];
export function TargetAudienceSection() {
return (
<section className="py-32 bg-white dark:bg-[#030712] relative overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto px-6 relative z-10">
{/* Header com Motion Stagger */}
<div className="text-center mb-24">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-4xl md:text-6xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter"
>
Projetado para <span className="bg-gradient-to-r from-blue-600 to-emerald-500 bg-clip-text text-transparent">Cidadãos Conscientes</span>
</motion.h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Se a transparência é um requisito para sua mesa, o NutriScan é sua ferramenta de governança alimentar.
<section className="bg-surface section-py">
<div className="container-editorial">
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Para Quem</span>
<h2 className="font-display">
Projetado para cidadãos que exigem transparência.
</h2>
<p>
Se a clareza é um requisito para sua mesa, o NutriScan é a sua ferramenta de governança alimentar.
</p>
</div>
{/* Persona Bento Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-24">
{PERSONAS.map((persona, i) => (
<motion.div
{/* Persona grid — clean, warm cards with numbered list features */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-px bg-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-lg overflow-hidden mb-20">
{PERSONAS.map((persona) => (
<div
key={persona.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
viewport={{ once: true }}
className={cn(
"group relative p-8 rounded-[2.5rem] bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-800 transition-all duration-500",
persona.border,
"hover:shadow-2xl hover:shadow-gray-200 dark:hover:shadow-none"
)}
className="bg-elevated p-8 hover-lift group cursor-default flex flex-col"
>
<div className={cn("w-14 h-14 rounded-2xl flex items-center justify-center mb-8 transition-transform group-hover:scale-110", persona.bg)}>
<persona.icon className={cn("w-7 h-7", persona.color)} />
</div>
<span className={cn("text-[10px] font-black uppercase tracking-[0.2em] mb-2 block", persona.color)}>
{persona.tagline}
</span>
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
<span className="label text-accent mb-4 block">{persona.tag}</span>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-4">
{persona.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mb-8">
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed mb-8 flex-1">
{persona.description}
</p>
<ul className="space-y-3">
{persona.features.map(f => (
<li key={f} className="flex items-center gap-2 text-xs font-bold text-gray-500 dark:text-gray-500">
<div className={cn("w-1 h-1 rounded-full", persona.bg.replace('/10', ''))} />
<ul className="space-y-2 border-t border-[var(--border-subtle)] pt-6">
{persona.features.map((f) => (
<li
key={f}
className="flex items-center gap-3 text-xs text-[var(--fg-muted)]"
>
<span className="w-1 h-1 rounded-full bg-[var(--accent)] flex-shrink-0" />
{f}
</li>
))}
</ul>
</motion.div>
</div>
))}
</div>
{/* High-Impact Callout: Glassmorphism Card */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
className="relative rounded-[3rem] bg-gradient-to-br from-blue-600 to-emerald-600 p-1 md:p-px shadow-3xl"
>
<div className="bg-white/5 dark:bg-black/20 backdrop-blur-3xl rounded-[2.9rem] p-10 md:p-16 text-center text-white">
<h3 className="text-3xl md:text-5xl font-black mb-6 tracking-tighter">
A informação alimentar é um <span className="text-yellow-400">Direito Universal</span>
</h3>
<p className="text-blue-50 text-lg md:text-xl mb-12 max-w-4xl mx-auto leading-relaxed opacity-90">
Se você busca soberania sobre suas escolhas, o NutriScan é o seu protocolo de acesso à verdade. Sem interesses corporativos, apenas dados puros e auditáveis.
</p>
<div className="flex flex-wrap justify-center gap-6">
{[
{ icon: Zap, label: "Baixa Latência", text: "Dados em < 2s" },
{ icon: Heart, label: "Auditável", text: "Open Source" },
{ icon: ShieldCheck, label: "Privacidade", text: "No Tracking" },
].map((item, i) => (
<div key={i} className="flex flex-col items-center gap-2 bg-white/10 px-8 py-6 rounded-2xl border border-white/10 backdrop-blur-md">
<item.icon className="w-6 h-6 text-yellow-400" />
<span className="text-xs font-black uppercase tracking-widest">{item.label}</span>
<span className="text-[10px] text-blue-100 opacity-70 uppercase">{item.text}</span>
</div>
))}
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="mt-16 inline-flex items-center gap-3 px-10 py-5 bg-white text-blue-600 rounded-2xl font-black uppercase tracking-widest text-sm shadow-xl shadow-black/10 transition-all hover:bg-yellow-400 hover:text-black"
>
Iniciar Scan Agora <ArrowRight size={18} />
</motion.button>
{/* Callout banner — warm, accent-bordered, no color blobs */}
<div className="card-accent-top card-warm p-10 lg:p-16 text-center">
<span className="label text-accent mb-5 block">Princípio Fundamental</span>
<h3 className="font-display text-display-md text-[var(--fg-primary)] mb-6 max-w-2xl mx-auto">
A informação alimentar é um direito universal.
</h3>
<p className="text-[var(--fg-secondary)] leading-relaxed max-w-2xl mx-auto mb-10">
Sem interesses corporativos, apenas dados puros e auditáveis. O NutriScan é
o protocolo de acesso à verdade sobre o que você consome.
</p>
<div className="flex flex-wrap justify-center gap-6">
{[
{ label: "Baixa Latência", sub: "Dados em < 2s" },
{ label: "Auditável", sub: "Open Source" },
{ label: "Privacidade", sub: "Zero tracking" },
].map((item) => (
<div
key={item.label}
className="flex flex-col items-center gap-1 px-8 py-5 border border-[var(--border-subtle)] rounded bg-[var(--bg-surface)]"
>
<span className="label text-[var(--fg-primary)]">{item.label}</span>
<span className="text-xs text-[var(--fg-muted)]">{item.sub}</span>
</div>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}
}
+82 -126
View File
@@ -1,173 +1,129 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import {
ShieldCheck, Eye, Gift, Lock,
CheckCircle2, Github, Database,
Scale, Terminal, Fingerprint
} from "lucide-react";
import { cn } from "@/lib/utils";
// 1. Data Contracts - Centralizando a lógica para fácil manutenção
const PROTOCOLS = [
{
tag: "Open Data",
title: "Dados Abertos",
desc: "Acesso irrestrito a metadados alimentares sob licença ODbL.",
icon: Eye,
color: "text-blue-500",
bg: "bg-blue-500/10",
tag: "Open Data"
desc: "Acesso irrestrito a metadados alimentares sob licença ODbL. Nenhum dado fica preso atrás de paywall.",
},
{
tag: "Non-Profit",
title: "Gratuidade Perpétua",
desc: "Arquitetura sustentada por comunidade, livre de paywalls ou ads.",
icon: Gift,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
tag: "Non-Profit"
desc: "Arquitetura sustentada pela comunidade. Livre de assinaturas, anúncios ou interesses comerciais.",
},
{
tag: "Privacy First",
title: "Privacy by Design",
desc: "Zero-tracking. Coleta mínima de dados conforme LGPD/GDPR.",
icon: Lock,
color: "text-purple-500",
bg: "bg-purple-500/10",
tag: "Security"
desc: "Zero rastreamento. Coleta mínima de dados conforme LGPD/GDPR. Sua navegação é sua.",
},
{
tag: "Accuracy",
title: "Alta Fidelidade",
desc: "Algoritmos de validação cruzada para garantir integridade.",
icon: ShieldCheck,
color: "text-orange-500",
bg: "bg-orange-500/10",
tag: "Accuracy"
}
desc: "Algoritmos de validação cruzada para garantir a integridade de cada registro consultado.",
},
];
export function ValuesSection() {
return (
<section className="py-32 bg-white dark:bg-[#030712] relative overflow-hidden transition-colors duration-500">
{/* Background Decor - Grid de Engenharia sutil */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:32px_32px]" />
<section className="bg-surface section-py">
<div className="container-editorial">
<div className="max-w-7xl mx-auto px-6 relative z-10">
{/* Header - Tom de Autoridade */}
<div className="text-center mb-24">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/5 border border-blue-500/20 text-blue-600 dark:text-blue-400 text-[10px] font-black uppercase tracking-[0.3em] mb-6"
>
<Scale size={12} /> Compliance & Ethics
</motion.div>
<h2 className="text-4xl md:text-6xl font-black text-gray-900 dark:text-white mb-6 tracking-tighter">
Protocolos de <span className="bg-gradient-to-r from-blue-600 to-emerald-500 bg-clip-text text-transparent">Integridade</span>
{/* Section header */}
<div className="section-header">
<span className="eyebrow">Valores</span>
<h2 className="font-display">
Protocolos de integridade.
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto font-medium">
Nossa governança é pautada pela transparência radical. Não maquiamos dados; entregamos a verdade técnica.
<p>
Nossa governança é pautada pela transparência radical. Não maquiamos dados — entregamos a verdade técnica.
</p>
</div>
{/* Values Grid - Bento Box Style */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
{PROTOCOLS.map((item, i) => (
<motion.div
{/* Values grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-px bg-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-lg overflow-hidden mb-20">
{PROTOCOLS.map((item) => (
<div
key={item.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
viewport={{ once: true }}
className="group p-8 rounded-[2.5rem] bg-gray-50 dark:bg-gray-900/40 border border-gray-100 dark:border-gray-800 hover:border-blue-500/30 transition-all duration-500"
className="bg-elevated p-8 hover-lift group cursor-default flex flex-col"
>
<div className={cn("w-14 h-14 rounded-2xl flex items-center justify-center mb-8 transition-transform group-hover:scale-110", item.bg)}>
<item.icon className={cn("w-7 h-7", item.color)} />
</div>
<span className={cn("text-[10px] font-mono font-bold uppercase tracking-widest mb-2 block", item.color)}>
{item.tag}
</span>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 tracking-tight">
<span className="label text-accent mb-4 block">{item.tag}</span>
<h3 className="font-display text-display-sm text-[var(--fg-primary)] mb-4">
{item.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed font-medium mb-6">
<p className="text-sm text-[var(--fg-secondary)] leading-relaxed flex-1">
{item.desc}
</p>
<div className="flex items-center gap-2 text-[10px] font-black text-gray-400 uppercase tracking-tighter group-hover:text-blue-500 transition-colors">
<CheckCircle2 size={12} className="text-emerald-500" /> System Verified
<div className="mt-8 pt-6 border-t border-[var(--border-subtle)] flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--accent)]" />
<span className="text-xs text-[var(--fg-faint)] uppercase tracking-widest font-medium">
Verified
</span>
</div>
</motion.div>
</div>
))}
</div>
{/* OSS Deep Dive - O Diferencial Sênior */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
whileInView={{ opacity: 1, scale: 1 }}
className="relative rounded-[3rem] bg-slate-950 border border-slate-800 p-10 md:p-16 overflow-hidden shadow-3xl"
>
<div className="absolute top-0 right-0 w-full h-full bg-[radial-gradient(circle_at_top_right,#1e293b,transparent)] opacity-50" />
<div className="relative z-10 flex flex-col lg:flex-row gap-12 items-center">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/10 rounded-2xl backdrop-blur-md">
<Github className="text-white w-8 h-8" />
</div>
<div>
<h3 className="text-3xl font-black text-white tracking-tighter">Open Source Manifesto</h3>
<p className="text-blue-400 font-mono text-xs uppercase tracking-widest mt-1">Status: Fully Auditability</p>
</div>
</div>
<p className="text-slate-400 text-lg leading-relaxed">
Acreditamos que algoritmos que impactam a saúde pública devem ser de domínio público. Nosso código é aberto para inspeção, garantindo que não existam backdoors ou manipulação de dados comerciais.
{/* Open Source manifest — dark panel with warm contrast */}
<div className="bg-[var(--fg-primary)] rounded-lg overflow-hidden">
<div className="border-l-2 border-[var(--accent)] grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-12 p-10 lg:p-14 items-center">
<div>
<span className="label text-[var(--accent)] mb-5 block">Open Source Manifesto</span>
<h3 className="font-display text-display-md text-[var(--bg-base)] mb-5">
Algoritmos que impactam a saúde pública pertencem ao domínio público.
</h3>
<p className="text-[var(--fg-muted)] leading-relaxed max-w-lg mb-8 text-sm">
Nosso código é aberto para inspeção, garantindo que não existam backdoors
ou manipulação de dados comerciais. A auditabilidade é o nosso nível mais alto de confiança.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ label: "Core Logic", val: "MIT Licensed", icon: Terminal },
{ label: "Dataset", val: "ODbL Support", icon: Database },
{ label: "Auth", val: "Privacy-First", icon: Fingerprint },
{ label: "Core Logic", val: "MIT Licensed" },
{ label: "Dataset", val: "ODbL Support" },
{ label: "Auth", val: "Privacy-First" },
].map((spec) => (
<div key={spec.label} className="p-4 bg-white/5 rounded-2xl border border-white/10">
<spec.icon className="w-4 h-4 text-blue-500 mb-2" />
<div className="text-xs font-black text-white uppercase tracking-widest mb-1">{spec.label}</div>
<div className="text-[10px] font-mono text-slate-500 uppercase">{spec.val}</div>
<div
key={spec.label}
className="p-4 border border-[var(--border-subtle)]/20 rounded"
>
<div className="label text-[var(--fg-faint)] mb-1 block">{spec.label}</div>
<div className="text-sm font-medium text-[var(--bg-base)]">{spec.val}</div>
</div>
))}
</div>
</div>
{/* GitHub Stats Mockup / Placeholder visual */}
<div className="w-full lg:w-72 h-48 bg-slate-900/50 rounded-3xl border border-slate-800 p-6 font-mono text-[10px] space-y-3 relative">
<div className="flex justify-between text-slate-500 border-b border-slate-800 pb-2">
<span>nutriscan-core / main</span>
<span className="text-emerald-500">online</span>
</div>
<div className="space-y-1">
<p className="text-blue-400">$ npm run audit</p>
<p className="text-slate-400"> Scanning dependencies...</p>
<p className="text-emerald-500"> 0 vulnerabilities found</p>
<p className="text-slate-400"> Data Integrity: 100% Verified</p>
</div>
<div className="absolute bottom-4 right-6 animate-pulse">
<div className="w-3 h-3 bg-emerald-500 rounded-full shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
</div>
{/* Terminal mockup */}
<div className="hidden lg:block w-72 shrink-0 border border-[var(--border-subtle)]/20 rounded-lg overflow-hidden">
<div className="flex items-center gap-1.5 px-4 py-3 border-b border-[var(--border-subtle)]/20 bg-[var(--bg-surface)]/5">
<span className="w-2.5 h-2.5 rounded-full bg-[var(--border-strong)]/40" />
<span className="w-2.5 h-2.5 rounded-full bg-[var(--border-strong)]/40" />
<span className="w-2.5 h-2.5 rounded-full bg-[var(--accent)]/60" />
<span className="ml-2 text-[10px] text-[var(--fg-muted)] font-mono">nutriscan-core</span>
</div>
<div className="p-5 font-mono text-[11px] space-y-2">
<p className="text-[var(--accent)]">$ npm run audit</p>
<p className="text-[var(--fg-muted)]"> Scanning dependencies...</p>
<p className="text-[var(--accent-light)]"> 0 vulnerabilities found</p>
<p className="text-[var(--fg-muted)]"> Data Integrity: 100%</p>
<div className="flex items-center gap-2 pt-2">
<span className="w-2 h-2 rounded-full bg-[var(--accent)] animate-pulse" />
<span className="text-[var(--accent)]">All checks passed</span>
</div>
</div>
</div>
</div>
</motion.div>
{/* Final Trust Verification */}
<div className="mt-24 grid grid-cols-2 md:grid-cols-4 gap-12 opacity-50 grayscale hover:grayscale-0 transition-all duration-700">
{/* Aqui você colocaria logotipos de bases de dados ou menções técnicas (ex: Open Food Facts, GitHub, Next.js, Radix UI) */}
<div className="text-center font-mono text-[10px] font-black uppercase tracking-[0.4em] text-gray-500">Open Food Facts</div>
<div className="text-center font-mono text-[10px] font-black uppercase tracking-[0.4em] text-gray-500">MIT License</div>
<div className="text-center font-mono text-[10px] font-black uppercase tracking-[0.4em] text-gray-500">Privacy First</div>
<div className="text-center font-mono text-[10px] font-black uppercase tracking-[0.4em] text-gray-500">ODbL Data</div>
</div>
{/* Trust footer — editorial badge row */}
<div className="mt-16 pt-8 hairline flex flex-wrap items-center justify-center gap-12 opacity-50 hover:opacity-100 transition-opacity duration-500">
{["Open Food Facts", "MIT License", "Privacy First", "ODbL Data"].map((name) => (
<span key={name} className="label text-[var(--fg-muted)]">
{name}
</span>
))}
</div>
</div>
</section>
);
}
}
+2 -2
View File
@@ -37,10 +37,10 @@ export const metadata: Metadata = {
export default function SobrePage() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<div className="bg-base min-h-screen">
<HeroSection />
<ProblemSection />
<StorySection />
<ProblemSection />
<MissionSection />
<ImpactSection />
<TargetAudienceSection />