feat: complete NutriScan redesign
This commit is contained in:
1 parent
c1fe4af997
commit
f71cd167b3
163 files changed
+30802
-14169
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
"Como desenvolvedor independente, cada contribuição me permite dedicar mais horas à refatoração e novas features para o NutriScan."
|
||||
{/* 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">
|
||||
“Cada contribuição me permite dedicar mais horas ao{" "}
|
||||
<em className="font-display-i">NutriScan</em>.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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">< 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user