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