chore: remover código morto e artefatos, adicionar CI e script typecheck
- componentes sem nenhuma importação (card, items, DeleteButton, IdInput, NutriScoreBadge, NutritionBar, NutritionChart, PageTitle, PersuasiveCTA, landing/SearchBar), imports e variável não usados (avisos do lint) - sw.js na raiz: não é referenciado e carrega script de terceiros (fenoofaussut.net) - bun.lock (o deploy e o README usam pnpm), graphify-out/ e os bancos SQLite versionados em prisma/prisma (pnpm db:push recria o banco) - devDependency depcheck sem uso - .gitignore: *.sqlite, *.sqlite-journal, graphify-out/ - script typecheck e workflow do GitHub Actions (lint, typecheck, test, build)
This commit is contained in:
1 parent
bf688257ef
commit
a9ab66e26f
122 files changed
+36
-15241
No files matched your search
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { deletarProduto } from "../actions";
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface DeleteButtonProps {
|
||||
produtoId: number;
|
||||
produtoNome: string;
|
||||
listaId: string;
|
||||
}
|
||||
|
||||
export function DeleteButton({ produtoId, produtoNome, listaId }: DeleteButtonProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await deletarProduto(produtoId, listaId);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o produto "{produtoNome}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Excluindo...' : 'Excluir'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SortDropdown } from "@/app/components/SortDropdown";
|
||||
import { FilterBadges } from "@/app/components/FilterBadge";
|
||||
import {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { verificarLista } from '../actions'
|
||||
|
||||
export function IdInput() {
|
||||
const [id, setId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
if (!id.trim()) {
|
||||
setError('Digite um ID válido')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await verificarLista(id.trim(), true) // Forçar criação se não existir
|
||||
if (result.success) {
|
||||
localStorage.setItem('listaId', id.trim())
|
||||
router.push(`/lista/${id.trim()}`)
|
||||
} else {
|
||||
setError('Erro ao acessar/criar lista')
|
||||
}
|
||||
} catch {
|
||||
setError('Erro ao processar solicitação')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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-display"
|
||||
style={{ color: 'var(--fg-primary)' }}
|
||||
>
|
||||
Acesse sua lista
|
||||
</h2>
|
||||
<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>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
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"
|
||||
style={{ borderColor: 'var(--border-strong)', background: 'var(--bg-surface)' }}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-center" style={{ color: 'var(--nutri-e)' }}>{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-accent w-full justify-center"
|
||||
>
|
||||
{loading ? 'Carregando...' : 'Acessar/Criar Lista'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { criarItens } from "../actions";
|
||||
import { useTransition, useState } from 'react';
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NutriScoreBadgeProps {
|
||||
grade: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
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 [hover, setHover] = useState(false);
|
||||
const key = grade?.toLowerCase();
|
||||
const info = GRADE_INFO[key];
|
||||
if (!info) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
className={cn(
|
||||
"nutriscore-hero font-sans font-bold rounded-full flex items-center justify-center cursor-default",
|
||||
GRADE_CLASS[key],
|
||||
SIZE_CLASS[size],
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{key.toUpperCase()}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── NOVA Badge ───────────────────────────────────────────────────────────── */
|
||||
interface NovaBadgeProps {
|
||||
group: number;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
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 [hover, setHover] = useState(false);
|
||||
const info = NOVA_INFO[group];
|
||||
if (!info) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
className={cn(
|
||||
"nutriscore-hero font-sans font-bold rounded-full flex items-center justify-center cursor-default",
|
||||
SIZE_CLASS[size],
|
||||
className
|
||||
)}
|
||||
style={{ background: info.bg, color: info.fg, animation: "none" }}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
{group}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NutritionBarProps {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
percentage?: number;
|
||||
isHigh?: boolean;
|
||||
isLow?: boolean;
|
||||
recommended?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NutritionBar({
|
||||
label,
|
||||
value,
|
||||
unit = "",
|
||||
percentage = 0,
|
||||
isHigh = false,
|
||||
isLow = false,
|
||||
className,
|
||||
}: NutritionBarProps) {
|
||||
const barWidth = Math.min(Math.max(percentage, 2), 100);
|
||||
|
||||
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)]";
|
||||
|
||||
const valueColor = isHigh
|
||||
? "text-[var(--score-e)]"
|
||||
: isLow
|
||||
? "text-[var(--score-a)]"
|
||||
: "text-[var(--fg-primary)]";
|
||||
|
||||
return (
|
||||
<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>
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NutritionGridProps {
|
||||
nutritionData: {
|
||||
energy?: string;
|
||||
fat?: string;
|
||||
carbohydrates?: string;
|
||||
proteins?: string;
|
||||
sugars?: string;
|
||||
fiber?: string;
|
||||
sodium?: string;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const REFERENCE = {
|
||||
energy: 2000, fat: 65, carbohydrates: 300,
|
||||
proteins: 50, sugars: 50, fiber: 25, sodium: 2.3,
|
||||
};
|
||||
|
||||
function extractNumber(val?: string): number {
|
||||
if (!val) return 0;
|
||||
const m = val.match(/(\d+(?:\.\d+)?)/);
|
||||
return m ? parseFloat(m[1]) : 0;
|
||||
}
|
||||
|
||||
function pct(val: string | undefined, ref: number): number {
|
||||
return (extractNumber(val) / ref) * 100;
|
||||
}
|
||||
|
||||
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-5", className)}>
|
||||
{items.map((item) => {
|
||||
const p = pct(item.value, item.ref);
|
||||
return (
|
||||
<NutritionBar
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
value={item.value || "—"}
|
||||
unit={item.unit}
|
||||
percentage={p}
|
||||
isHigh={p > 80}
|
||||
isLow={p < 20 && p > 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
<span className="label text-[var(--fg-faint)] ml-auto">% do VDR</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface NutritionData {
|
||||
energy?: number;
|
||||
fat?: number;
|
||||
carbohydrates?: number;
|
||||
proteins?: number;
|
||||
sugars?: number;
|
||||
fiber?: number;
|
||||
sodium?: number;
|
||||
}
|
||||
|
||||
interface NutritionChartProps {
|
||||
data: NutritionData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NutritionChart({ data, className = "" }: NutritionChartProps) {
|
||||
const nutritionItems = [
|
||||
{
|
||||
key: "energy",
|
||||
label: "Energia",
|
||||
value: data.energy,
|
||||
unit: "kcal",
|
||||
color: "bg-orange-500",
|
||||
maxValue: 500,
|
||||
},
|
||||
{
|
||||
key: "fat",
|
||||
label: "Gorduras",
|
||||
value: data.fat,
|
||||
unit: "g",
|
||||
color: "bg-red-500",
|
||||
maxValue: 30,
|
||||
},
|
||||
{
|
||||
key: "carbohydrates",
|
||||
label: "Carboidratos",
|
||||
value: data.carbohydrates,
|
||||
unit: "g",
|
||||
color: "bg-blue-500",
|
||||
maxValue: 60,
|
||||
},
|
||||
{
|
||||
key: "proteins",
|
||||
label: "Proteínas",
|
||||
value: data.proteins,
|
||||
unit: "g",
|
||||
color: "bg-green-500",
|
||||
maxValue: 25,
|
||||
},
|
||||
{
|
||||
key: "sugars",
|
||||
label: "Açúcares",
|
||||
value: data.sugars,
|
||||
unit: "g",
|
||||
color: "bg-pink-500",
|
||||
maxValue: 25,
|
||||
},
|
||||
{
|
||||
key: "fiber",
|
||||
label: "Fibras",
|
||||
value: data.fiber,
|
||||
unit: "g",
|
||||
color: "bg-purple-500",
|
||||
maxValue: 10,
|
||||
},
|
||||
{
|
||||
key: "sodium",
|
||||
label: "Sódio",
|
||||
value: data.sodium,
|
||||
unit: "mg",
|
||||
color: "bg-yellow-500",
|
||||
maxValue: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const getBarWidth = (value: number, maxValue: number) => {
|
||||
const percentage = Math.min((value / maxValue) * 100, 100);
|
||||
return `${percentage}%`;
|
||||
};
|
||||
|
||||
const getBarColor = (value: number, maxValue: number, color: string) => {
|
||||
const percentage = (value / maxValue) * 100;
|
||||
if (percentage > 80) return "bg-red-500";
|
||||
if (percentage > 60) return "bg-orange-500";
|
||||
if (percentage > 40) return "bg-yellow-500";
|
||||
return color;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Informação Nutricional
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{nutritionItems.map((item) => {
|
||||
if (item.value === undefined || item.value === null) {
|
||||
return (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
N/A
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const barWidth = getBarWidth(item.value, item.maxValue);
|
||||
const barColor = getBarColor(item.value, item.maxValue, item.color);
|
||||
|
||||
return (
|
||||
<div key={item.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{item.value.toFixed(1)}
|
||||
{item.unit}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
item.value / item.maxValue > 0.8
|
||||
? "border-red-200 text-red-700"
|
||||
: item.value / item.maxValue > 0.6
|
||||
? "border-orange-200 text-orange-700"
|
||||
: "border-green-200 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{((item.value / item.maxValue) * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: barWidth }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0{item.unit}</span>
|
||||
<span>
|
||||
{item.maxValue}
|
||||
{item.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface NutritionSummaryProps {
|
||||
data: NutritionData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NutritionSummary({
|
||||
data,
|
||||
className = "",
|
||||
}: NutritionSummaryProps) {
|
||||
const getNutritionScore = () => {
|
||||
if (!data.energy || !data.fat || !data.sugars || !data.sodium) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cálculo simplificado do Nutri-Score
|
||||
let score = 0;
|
||||
|
||||
// Pontos negativos
|
||||
if (data.energy > 335) score += 1;
|
||||
if (data.energy > 670) score += 2;
|
||||
if (data.energy > 1005) score += 3;
|
||||
if (data.energy > 1340) score += 4;
|
||||
if (data.energy > 1675) score += 5;
|
||||
if (data.energy > 2010) score += 6;
|
||||
if (data.energy > 2345) score += 7;
|
||||
if (data.energy > 2680) score += 8;
|
||||
if (data.energy > 3015) score += 9;
|
||||
if (data.energy > 3350) score += 10;
|
||||
|
||||
if (data.sugars > 4.5) score += 1;
|
||||
if (data.sugars > 9) score += 2;
|
||||
if (data.sugars > 13.5) score += 3;
|
||||
if (data.sugars > 18) score += 4;
|
||||
if (data.sugars > 22.5) score += 5;
|
||||
if (data.sugars > 27) score += 6;
|
||||
if (data.sugars > 31) score += 7;
|
||||
if (data.sugars > 36) score += 8;
|
||||
if (data.sugars > 40) score += 9;
|
||||
if (data.sugars > 45) score += 10;
|
||||
|
||||
if (data.fat > 1) score += 1;
|
||||
if (data.fat > 2) score += 2;
|
||||
if (data.fat > 3) score += 3;
|
||||
if (data.fat > 4) score += 4;
|
||||
if (data.fat > 5) score += 5;
|
||||
if (data.fat > 6) score += 6;
|
||||
if (data.fat > 7) score += 7;
|
||||
if (data.fat > 8) score += 8;
|
||||
if (data.fat > 9) score += 9;
|
||||
if (data.fat > 10) score += 10;
|
||||
|
||||
if (data.sodium > 0.3) score += 1;
|
||||
if (data.sodium > 0.6) score += 2;
|
||||
if (data.sodium > 0.9) score += 3;
|
||||
if (data.sodium > 1.2) score += 4;
|
||||
if (data.sodium > 1.5) score += 5;
|
||||
if (data.sodium > 1.8) score += 6;
|
||||
if (data.sodium > 2.1) score += 7;
|
||||
if (data.sodium > 2.4) score += 8;
|
||||
if (data.sodium > 2.7) score += 9;
|
||||
if (data.sodium > 3) score += 10;
|
||||
|
||||
// Pontos positivos
|
||||
if (data.fiber && data.fiber > 0.9) score -= 1;
|
||||
if (data.fiber && data.fiber > 1.9) score -= 2;
|
||||
if (data.fiber && data.fiber > 2.8) score -= 3;
|
||||
if (data.fiber && data.fiber > 3.7) score -= 4;
|
||||
if (data.fiber && data.fiber > 4.7) score -= 5;
|
||||
|
||||
if (data.proteins && data.proteins > 1.6) score -= 1;
|
||||
if (data.proteins && data.proteins > 3.2) score -= 2;
|
||||
if (data.proteins && data.proteins > 4.8) score -= 3;
|
||||
if (data.proteins && data.proteins > 6.4) score -= 4;
|
||||
if (data.proteins && data.proteins > 8) score -= 5;
|
||||
|
||||
// Converter para letra
|
||||
if (score <= -1) return "A";
|
||||
if (score <= 2) return "B";
|
||||
if (score <= 10) return "C";
|
||||
if (score <= 18) return "D";
|
||||
return "E";
|
||||
};
|
||||
|
||||
const nutritionScore = getNutritionScore();
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Nutri-Score Estimado</span>
|
||||
{nutritionScore && (
|
||||
<Badge
|
||||
className={`${
|
||||
nutritionScore === "A"
|
||||
? "bg-green-500 text-white"
|
||||
: nutritionScore === "B"
|
||||
? "bg-green-400 text-white"
|
||||
: nutritionScore === "C"
|
||||
? "bg-yellow-500 text-white"
|
||||
: nutritionScore === "D"
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-red-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{nutritionScore}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span>Energia:</span>
|
||||
<span className="font-medium">
|
||||
{data.energy?.toFixed(0) || "N/A"} kcal
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Gorduras:</span>
|
||||
<span className="font-medium">{data.fat?.toFixed(1) || "N/A"}g</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Carboidratos:</span>
|
||||
<span className="font-medium">
|
||||
{data.carbohydrates?.toFixed(1) || "N/A"}g
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Proteínas:</span>
|
||||
<span className="font-medium">
|
||||
{data.proteins?.toFixed(1) || "N/A"}g
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
export function PageTitle() {
|
||||
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-center">
|
||||
<span className="relative inline-block">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">
|
||||
Resenha Itapoá
|
||||
</span>
|
||||
<motion.span
|
||||
className="absolute -bottom-2 left-0 w-full h-1 bg-gradient-to-r from-primary to-secondary rounded-full"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
/>
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-4 text-center text-muted-foreground max-w-2xl mx-auto">
|
||||
Gerencie suas listas de compras de forma simples e eficiente
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
interface CardProps {
|
||||
produto: {
|
||||
id: number;
|
||||
nome: string;
|
||||
qtd: number;
|
||||
tipoUN: string;
|
||||
comprado: boolean;
|
||||
listaId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
}
|
||||
|
||||
export function Card({ produto }: CardProps) {
|
||||
return (
|
||||
<div className="card bg-white shadow-lg rounded-lg hover:shadow-xl transition-duration-300">
|
||||
<div className="card-body p-6">
|
||||
<h2 className="card-title text-xl font-bold mb-2">{produto.nome}</h2>
|
||||
<div className="space-y-2">
|
||||
<p className="text-gray-600">
|
||||
Quantidade: <span className="font-semibold">{produto.qtd}</span>
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
Tipo: <span className="font-semibold">{produto.tipoUN}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function Items() {
|
||||
const produtos = await prisma.produtos.findMany({});
|
||||
console.log(produtos);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, ScanLine, Activity } from "lucide-react";
|
||||
import { useSearchLogic } from "@/hooks/use-search-logic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function PersuasiveCTA() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const { searchType, isValid, loading, setLoading } = useSearchLogic(query);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
const target =
|
||||
searchType === "barcode" && isValid
|
||||
? `/produto/${query.trim()}`
|
||||
: `/resultados?q=${encodeURIComponent(query.trim())}`;
|
||||
router.push(target);
|
||||
};
|
||||
|
||||
const icon =
|
||||
searchType === "barcode" ? (
|
||||
<ScanLine className="w-4 h-4" />
|
||||
) : searchType === "name" ? (
|
||||
<Search className="w-4 h-4" />
|
||||
) : (
|
||||
<Activity className="w-4 h-4" />
|
||||
);
|
||||
|
||||
const label =
|
||||
searchType === "barcode"
|
||||
? "Código de barras detectado"
|
||||
: searchType === "name"
|
||||
? "Pesquisa por nome"
|
||||
: "Detecção automática";
|
||||
|
||||
return (
|
||||
<section className="bg-[var(--bg-surface)] section-py border-t border-[var(--border-subtle)]">
|
||||
<div className="container-editorial">
|
||||
|
||||
{/* ── 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 form ──────────────────────────────────────────────── */}
|
||||
<form onSubmit={handleSearch} className="max-w-2xl">
|
||||
<div className="search-editorial">
|
||||
<div className="text-[var(--fg-muted)] shrink-0">{icon}</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 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,164 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, ScanLine, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface SearchBarProps {
|
||||
variant?: "hero" | "compact" | "floating";
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
variant = "hero",
|
||||
placeholder = "Digite o código de barras (ex: 7896004713588)",
|
||||
className = "",
|
||||
}: SearchBarProps) {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const validateBarcode = (code: string) => {
|
||||
const cleanCode = code.replace(/\D/g, "");
|
||||
return cleanCode.length >= 8 && cleanCode.length <= 13;
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!barcode.trim()) {
|
||||
setError("Digite um código de barras válido");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateBarcode(barcode)) {
|
||||
setError("Código de barras deve ter entre 8 e 13 dígitos");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Simula busca (futuramente será integração com API)
|
||||
setTimeout(() => {
|
||||
router.push(`/produto/${barcode.trim()}`);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case "compact":
|
||||
return {
|
||||
container: "max-w-md mx-auto",
|
||||
input: "text-base py-3",
|
||||
button: "px-6 py-3",
|
||||
};
|
||||
case "floating":
|
||||
return {
|
||||
container:
|
||||
"fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50 max-w-sm w-full mx-4",
|
||||
input: "text-sm py-3",
|
||||
button: "px-4 py-3",
|
||||
};
|
||||
default: // hero
|
||||
return {
|
||||
container: "max-w-2xl mx-auto",
|
||||
input: "text-lg py-4",
|
||||
button: "px-8 py-4",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getVariantStyles();
|
||||
|
||||
if (variant === "floating") {
|
||||
return (
|
||||
<div className={`${styles.container} ${className}`}>
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="flex-1 relative">
|
||||
<ScanLine className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
className={`pl-12 pr-4 ${styles.input} border-0 bg-transparent focus:ring-0 placeholder:text-gray-400`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`m-2 ${styles.button} bg-gradient-to-r from-blue-600 to-green-600 hover:from-blue-700 hover:to-green-700 text-white font-semibold rounded-xl transition-all duration-200 transform hover:scale-105 disabled:opacity-50`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl shadow-lg">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${className}`}>
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="relative">
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="flex-1 relative">
|
||||
<ScanLine className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={barcode}
|
||||
onChange={(e) => setBarcode(e.target.value)}
|
||||
className={`pl-12 pr-4 ${styles.input} border-0 bg-transparent focus:ring-0 placeholder:text-gray-400`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`m-2 ${styles.button} bg-gradient-to-r from-blue-600 to-green-600 hover:from-blue-700 hover:to-green-700 text-white font-semibold rounded-xl transition-all duration-200 transform hover:scale-105 disabled:opacity-50`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Buscando...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
Buscar
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user