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:
1 parent
c173fec668
commit
bfb58ef16e
34 files changed
+18630
-7815
No files matched your search
+90
-29
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 "{query}"
|
||||
</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 "{query}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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}"`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user