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
+18410
-7595
No files matched your search
+67
-6
@@ -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(
|
return NextResponse.json(
|
||||||
{ error: "Invalid page_size parameter (max 100)" },
|
{ error: "Invalid page_size parameter" },
|
||||||
{ status: 400 }
|
{ 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({
|
const params = new URLSearchParams({
|
||||||
search_terms: query,
|
search_terms: query,
|
||||||
page_size: pageSize,
|
page_size: apiPageSize.toString(),
|
||||||
page: page,
|
page: page,
|
||||||
json: "true",
|
json: "true",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -223,8 +226,24 @@ export async function GET(request: NextRequest) {
|
|||||||
`Cache JSON retornou ${localResults.products.length} produtos, buscando na API externa...`
|
`Cache JSON retornou ${localResults.products.length} produtos, buscando na API externa...`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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(
|
const response = await fetch(
|
||||||
`https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
|
`https://world.openfoodfacts.org/cgi/search.pl?${currentParams.toString()}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": USER_AGENT,
|
"User-Agent": USER_AGENT,
|
||||||
@@ -235,9 +254,52 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const apiData = await response.json();
|
const apiData = await response.json();
|
||||||
apiProducts = apiData.products || [];
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiProducts = allApiProducts;
|
||||||
|
|
||||||
// Ordenar produtos da API por relevância
|
// Ordenar produtos da API por relevância
|
||||||
if (apiProducts.length > 0) {
|
if (apiProducts.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -254,7 +316,6 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Combinar resultados JSON e da API
|
// Combinar resultados JSON e da API
|
||||||
const allProducts: Product[] = [...localResults.products];
|
const allProducts: Product[] = [...localResults.products];
|
||||||
|
|||||||
@@ -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">
|
<ul className="space-y-3">
|
||||||
<li className="flex items-center gap-2 text-gray-400">
|
<li className="flex items-center gap-2 text-gray-400">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
<span>Dados verificados</span>
|
<span>Dados colaborativos</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2 text-gray-400">
|
<li className="flex items-center gap-2 text-gray-400">
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
@@ -125,11 +125,34 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Bottom */}
|
||||||
<div className="border-t border-gray-800 mt-12 pt-8">
|
<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="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
© 2024 NutriScan. Todos os direitos reservados.
|
© 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>
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-400">
|
<div className="flex items-center gap-6 text-sm text-gray-400">
|
||||||
<a href="#" className="hover:text-white transition-colors">
|
<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}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-semibold">
|
||||||
Informação Nutricional (por 100g)
|
Informação Nutricional
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|||||||
@@ -44,29 +44,31 @@ export function Pagination({
|
|||||||
const startIndex = (currentPage - 1) * itemsPerPage + 1;
|
const startIndex = (currentPage - 1) * itemsPerPage + 1;
|
||||||
const endIndex = Math.min(currentPage * itemsPerPage, totalItems);
|
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 visiblePages = useMemo(() => {
|
||||||
const pages: (number | string)[] = [];
|
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
|
// Se total de páginas é menor que o máximo, mostrar todas
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push(i);
|
pages.push(i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Lógica para mostrar páginas com ellipsis
|
// 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);
|
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
|
// Ajustar se estivermos no final
|
||||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
if (endPage - startPage + 1 < maxVisible) {
|
||||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primeira página
|
// Primeira página (ocultar em mobile)
|
||||||
if (startPage > 1) {
|
if (startPage > 1 && !isMobile) {
|
||||||
pages.push(1);
|
pages.push(1);
|
||||||
if (startPage > 2) {
|
if (startPage > 2) {
|
||||||
pages.push("...");
|
pages.push("...");
|
||||||
@@ -78,8 +80,8 @@ export function Pagination({
|
|||||||
pages.push(i);
|
pages.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Última página
|
// Última página (ocultar em mobile)
|
||||||
if (endPage < totalPages) {
|
if (endPage < totalPages && !isMobile) {
|
||||||
if (endPage < totalPages - 1) {
|
if (endPage < totalPages - 1) {
|
||||||
pages.push("...");
|
pages.push("...");
|
||||||
}
|
}
|
||||||
@@ -106,9 +108,88 @@ export function Pagination({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`w-full ${className}`}>
|
||||||
className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${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>
|
||||||
|
|
||||||
|
{/* 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-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} 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 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
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 */}
|
{/* Informações dos itens */}
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Mostrando {startIndex} a {endIndex} de {totalItems} resultado
|
Mostrando {startIndex} a {endIndex} de {totalItems} resultado
|
||||||
@@ -214,5 +295,6 @@ export function Pagination({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import Image from "next/image";
|
import { OptimizedImage } from "@/components/OptimizedImage";
|
||||||
import { Product } from "@/lib/openFoodFactsApi";
|
import { Product } from "@/lib/openFoodFactsApi";
|
||||||
import { Heart, AlertTriangle, Zap, Plus, Eye } from "lucide-react";
|
import { Heart, AlertTriangle, Zap, Plus, Eye } from "lucide-react";
|
||||||
import { formatNutritionData } from "@/lib/openFoodFactsApi";
|
import { formatNutritionData } from "@/lib/openFoodFactsApi";
|
||||||
@@ -12,6 +13,9 @@ import { formatNutritionData } from "@/lib/openFoodFactsApi";
|
|||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
className?: string;
|
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 = {
|
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 router = useRouter();
|
||||||
|
|
||||||
const getProductImageUrl = (): string => {
|
const getProductImageUrl = (): string => {
|
||||||
@@ -102,21 +112,107 @@ export function ProductCard({ product, className = "" }: ProductCardProps) {
|
|||||||
)
|
)
|
||||||
) || false;
|
) || 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 (
|
return (
|
||||||
<Card
|
<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}`}
|
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 */}
|
{/* 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">
|
<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}
|
src={imageUrl}
|
||||||
alt={product.product_name || "Produto"}
|
alt={product.product_name || "Produto"}
|
||||||
fill
|
fill
|
||||||
|
priority={priority}
|
||||||
|
onPageChange={currentPage}
|
||||||
className="object-contain p-6 group-hover:scale-110 transition-transform duration-500"
|
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"
|
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 */}
|
{/* 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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { detectSearchType, isValidBarcode } from "@/lib/openFoodFactsApi";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
compact?: boolean; // Modo compacto para mobile
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBar({
|
export function SearchBar({
|
||||||
initialQuery = "",
|
initialQuery = "",
|
||||||
className = "",
|
className = "",
|
||||||
placeholder = "Buscar produtos...",
|
placeholder = "Buscar produtos...",
|
||||||
|
compact = false,
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [searchType, setSearchType] = useState<"barcode" | "name" | null>(null);
|
||||||
const router = useRouter();
|
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 = () => {
|
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())}`);
|
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 (
|
return (
|
||||||
<div className={`flex items-center gap-2 ${className}`}>
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
@@ -44,6 +112,15 @@ export function SearchBar({
|
|||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
className="pl-10 pr-4 h-10"
|
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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
|
|||||||
+12
-19
@@ -24,13 +24,13 @@ export function Header() {
|
|||||||
return (
|
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">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-14 sm:h-16">
|
||||||
{/* Logo */}
|
{/* Logo - compacto em mobile */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm: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">
|
<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-6 h-6 text-white" />
|
<ScanLine className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
NutriScan
|
NutriScan
|
||||||
</h1>
|
</h1>
|
||||||
@@ -38,6 +38,12 @@ export function Header() {
|
|||||||
Informações nutricionais
|
Informações nutricionais
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
@@ -54,12 +60,6 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
Recursos
|
Recursos
|
||||||
</a>
|
</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
|
<a
|
||||||
href="#sobre"
|
href="#sobre"
|
||||||
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
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
|
Recursos
|
||||||
</a>
|
</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
|
<a
|
||||||
href="#sobre"
|
href="#sobre"
|
||||||
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors py-2"
|
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 {
|
import { Shield, Zap, Sparkles, Heart, CheckCircle } from "lucide-react";
|
||||||
BarChart3,
|
|
||||||
Shield,
|
|
||||||
List,
|
|
||||||
Globe,
|
|
||||||
Zap,
|
|
||||||
Heart,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: Shield,
|
||||||
title: "Informações Nutricionais Completas",
|
title: "Dados da Comunidade Open Food Facts",
|
||||||
description:
|
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",
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||||
iconBg: "bg-blue-100 dark:bg-blue-900/40",
|
iconBg: "bg-blue-100 dark:bg-blue-900/40",
|
||||||
},
|
benefit: "Base colaborativa mundial",
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
title: "Busca Instantânea",
|
title: "Atualizações em Tempo Real",
|
||||||
description:
|
description:
|
||||||
"Resultados em segundos. Digite o código de barras e obtenha informações imediatamente.",
|
"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-yellow-600 dark:text-yellow-400",
|
color: "text-green-600 dark:text-green-400",
|
||||||
bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
|
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||||
iconBg: "bg-yellow-100 dark:bg-yellow-900/40",
|
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,
|
icon: Heart,
|
||||||
title: "Alimentação Consciente",
|
title: "100% Gratuito, Para Sempre",
|
||||||
description:
|
description:
|
||||||
"Tome decisões mais informadas sobre sua alimentação com dados confiáveis e atualizados.",
|
"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-red-600 dark:text-red-400",
|
color: "text-orange-600 dark:text-orange-400",
|
||||||
bgColor: "bg-red-50 dark:bg-red-900/20",
|
bgColor: "bg-orange-50 dark:bg-orange-900/20",
|
||||||
iconBg: "bg-red-100 dark:bg-red-900/40",
|
iconBg: "bg-orange-100 dark:bg-orange-900/40",
|
||||||
},
|
benefit: "Sempre gratuito, sem truques",
|
||||||
];
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Features() {
|
export function Features() {
|
||||||
return (
|
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">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
<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>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
Descubra todas as informações que você precisa sobre seus alimentos
|
Transparência que transforma alimentação
|
||||||
de forma rápida, precisa e confiável.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features Grid */}
|
{/* 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) => (
|
{features.map((feature, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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
|
<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>
|
</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}
|
{feature.title}
|
||||||
</h3>
|
</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}
|
{feature.description}
|
||||||
</p>
|
</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>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,18 +113,51 @@ export function Hero() {
|
|||||||
|
|
||||||
{/* Main Heading */}
|
{/* 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">
|
<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">
|
<span className="bg-gradient-to-r from-blue-600 to-green-600 bg-clip-text text-transparent">
|
||||||
alimentos
|
seus alimentos
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* 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">
|
<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">
|
||||||
Consulte informações nutricionais completas, ingredientes e
|
Transparência total, zero custo, milhões de dados colaborativos ao seu
|
||||||
classificações de qualidade de milhares de produtos alimentícios.
|
alcance.
|
||||||
</p>
|
</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 */}
|
{/* Search Form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSearch}
|
onSubmit={handleSearch}
|
||||||
@@ -196,33 +229,10 @@ export function Hero() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Dica sobre código de barras */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 sm:gap-8 max-w-4xl mx-auto">
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mb-8">
|
||||||
<div className="text-center">
|
💡 Dica: O código de barras está na parte de trás da embalagem
|
||||||
<div className="text-2xl sm:text-3xl font-bold text-blue-600 dark:text-blue-400 mb-2">
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll Indicator */}
|
{/* Scroll Indicator */}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { ScanLine, Search, BarChart3, CheckCircle } from "lucide-react";
|
import { ScanLine, Search, BarChart3, CheckCircle } from "lucide-react";
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
number: "01",
|
number: "01",
|
||||||
icon: ScanLine,
|
icon: ScanLine,
|
||||||
title: "Digite o código de barras",
|
title: "Digitalize sem complicação",
|
||||||
description:
|
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",
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||||
iconBg: "bg-blue-100 dark:bg-blue-900/40",
|
iconBg: "bg-blue-100 dark:bg-blue-900/40",
|
||||||
@@ -14,9 +16,9 @@ const steps = [
|
|||||||
{
|
{
|
||||||
number: "02",
|
number: "02",
|
||||||
icon: Search,
|
icon: Search,
|
||||||
title: "Buscamos no banco de dados",
|
title: "Acesso a milhões de dados colaborativos",
|
||||||
description:
|
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",
|
color: "text-green-600 dark:text-green-400",
|
||||||
bgColor: "bg-green-50 dark:bg-green-900/20",
|
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||||
iconBg: "bg-green-100 dark:bg-green-900/40",
|
iconBg: "bg-green-100 dark:bg-green-900/40",
|
||||||
@@ -24,9 +26,9 @@ const steps = [
|
|||||||
{
|
{
|
||||||
number: "03",
|
number: "03",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
title: "Veja informações detalhadas",
|
title: "Informações que fazem diferença",
|
||||||
description:
|
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",
|
color: "text-purple-600 dark:text-purple-400",
|
||||||
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
||||||
iconBg: "bg-purple-100 dark:bg-purple-900/40",
|
iconBg: "bg-purple-100 dark:bg-purple-900/40",
|
||||||
@@ -34,9 +36,9 @@ const steps = [
|
|||||||
{
|
{
|
||||||
number: "04",
|
number: "04",
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
title: "Faça escolhas conscientes",
|
title: "Decisões mais inteligentes, saúde melhor",
|
||||||
description:
|
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",
|
color: "text-orange-600 dark:text-orange-400",
|
||||||
bgColor: "bg-orange-50 dark:bg-orange-900/20",
|
bgColor: "bg-orange-50 dark:bg-orange-900/20",
|
||||||
iconBg: "bg-orange-100 dark:bg-orange-900/40",
|
iconBg: "bg-orange-100 dark:bg-orange-900/40",
|
||||||
@@ -80,11 +82,11 @@ export function HowItWorks() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
<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>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto">
|
<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
|
Descubra como milhares de pessoas já tomam decisões alimentares mais
|
||||||
nutricionais de qualquer produto alimentício.
|
conscientes com informações transparentes e confiáveis.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,10 +180,21 @@ export function HowItWorks() {
|
|||||||
|
|
||||||
{/* Bottom CTA */}
|
{/* Bottom CTA */}
|
||||||
<div className="text-center mt-16">
|
<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" />
|
<ScanLine className="w-5 h-5" />
|
||||||
Começar agora
|
Começar agora
|
||||||
</div>
|
</a>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-4">
|
<p className="text-gray-500 dark:text-gray-400 text-sm mt-4">
|
||||||
É rápido, fácil e totalmente gratuito
|
É rápido, fácil e totalmente gratuito
|
||||||
</p>
|
</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 components;
|
||||||
@tailwind utilities;
|
@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 */
|
/* Otimizações de performance para animações */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.animate-in {
|
.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 */
|
/* Otimização para listas grandes */
|
||||||
.product-grid {
|
.product-grid {
|
||||||
contain: layout style paint;
|
contain: layout style paint;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import "./globals.css";
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { ThemeProvider } from "@/app/providers";
|
import { ThemeProvider } from "@/app/providers";
|
||||||
import { FloatingDonateButton } from "@/app/components/FloatingDonateButton";
|
|
||||||
|
|
||||||
// Use Inter como fallback já que é uma fonte similar à Geist
|
// Use Inter como fallback já que é uma fonte similar à Geist
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -65,7 +64,6 @@ export default function RootLayout({
|
|||||||
<html lang="pt-BR" suppressHydrationWarning className={inter.className}>
|
<html lang="pt-BR" suppressHydrationWarning className={inter.className}>
|
||||||
<body className="min-h-screen bg-background antialiased">
|
<body className="min-h-screen bg-background antialiased">
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
<FloatingDonateButton />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Footer } from "./components/Footer";
|
|||||||
import { Hero } from "./components/landing/Hero";
|
import { Hero } from "./components/landing/Hero";
|
||||||
import { Features } from "./components/landing/Features";
|
import { Features } from "./components/landing/Features";
|
||||||
import { HowItWorks } from "./components/landing/HowItWorks";
|
import { HowItWorks } from "./components/landing/HowItWorks";
|
||||||
|
import { DonationSection } from "./components/landing/DonationSection";
|
||||||
|
import { TrustBadges } from "./components/landing/TrustBadges";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
@@ -10,12 +12,14 @@ export default function Home() {
|
|||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
<TrustBadges />
|
||||||
<section id="recursos">
|
<section id="recursos">
|
||||||
<Features />
|
<Features />
|
||||||
</section>
|
</section>
|
||||||
<section id="como-funciona">
|
<section id="como-funciona">
|
||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
</section>
|
</section>
|
||||||
|
<DonationSection />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+61
-127
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
formatNutritionData,
|
formatNutritionData,
|
||||||
} from "@/lib/openFoodFactsApi";
|
} from "@/lib/openFoodFactsApi";
|
||||||
import Link from "next/link";
|
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() {
|
export default function ProdutoPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -127,16 +130,7 @@ export default function ProdutoPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <ProductSkeleton />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !product) {
|
if (error || !product) {
|
||||||
@@ -172,23 +166,25 @@ export default function ProdutoPage() {
|
|||||||
const nutritionData = formatNutritionData(product);
|
const nutritionData = formatNutritionData(product);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white dark:bg-gray-900">
|
<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">
|
||||||
{/* Header */}
|
{/* Hero Section com gradiente e animação */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<div className="relative overflow-hidden">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="flex items-center gap-2"
|
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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2 animate-fade-in">
|
||||||
{product.product_name || "Produto"}
|
{product.product_name || "Produto"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
@@ -198,20 +194,21 @@ export default function ProdutoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Product Image */}
|
{/* Product Image */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="relative w-full max-w-2xl mx-auto bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-lg">
|
<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">
|
<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 ? (
|
{imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={product.product_name || "Produto"}
|
alt={product.product_name || "Produto"}
|
||||||
fill
|
fill
|
||||||
className="object-contain"
|
className="object-contain transition-transform duration-300 hover:scale-105"
|
||||||
priority
|
priority
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||||
placeholder="blur"
|
placeholder="blur"
|
||||||
@@ -229,37 +226,37 @@ export default function ProdutoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
<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="font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Informações Básicas
|
Informações Básicas
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
{product.brands && (
|
{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">
|
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||||
Marca:
|
Marca:
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-gray-900 dark:text-white">
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
{product.brands}
|
{product.brands}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{product.categories && (
|
{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">
|
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||||
Categoria:
|
Categoria:
|
||||||
</span>
|
</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}
|
{product.categories}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{product.countries && (
|
{product.countries && (
|
||||||
<div>
|
<div className="flex justify-between items-center py-2">
|
||||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||||
Países:
|
Países:
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-gray-900 dark:text-white">
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
{product.countries}
|
{product.countries}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,24 +268,20 @@ export default function ProdutoPage() {
|
|||||||
{/* Product Details */}
|
{/* Product Details */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Nutrition Grades */}
|
{/* 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 && (
|
{nutritionGrade && (
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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="font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Nutri-Score
|
Nutri-Score
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<NutriScoreBadge
|
||||||
className={`w-12 h-12 rounded-full ${nutritionGrade.color} flex items-center justify-center`}
|
grade={product.nutrition_grades || ""}
|
||||||
>
|
size="lg"
|
||||||
<span
|
showTooltip={true}
|
||||||
className={`text-lg font-bold ${nutritionGrade.textColor}`}
|
/>
|
||||||
>
|
|
||||||
{nutritionGrade.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<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}
|
{nutritionGrade.description}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
@@ -300,22 +293,18 @@ export default function ProdutoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{novaGroup && (
|
{novaGroup && (
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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="font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
NOVA
|
NOVA
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<NovaBadge
|
||||||
className={`w-12 h-12 rounded-full ${novaGroup.color} flex items-center justify-center`}
|
group={product.nova_group || 0}
|
||||||
>
|
size="lg"
|
||||||
<span
|
showTooltip={true}
|
||||||
className={`text-lg font-bold ${novaGroup.textColor}`}
|
/>
|
||||||
>
|
|
||||||
{novaGroup.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<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}
|
Grupo {novaGroup.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
@@ -329,75 +318,15 @@ export default function ProdutoPage() {
|
|||||||
|
|
||||||
{/* Nutritional Information */}
|
{/* Nutritional Information */}
|
||||||
{nutritionData && (
|
{nutritionData && (
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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="font-semibold text-gray-900 dark:text-white mb-4">
|
<NutritionGrid nutritionData={nutritionData} />
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ingredients */}
|
{/* Ingredients */}
|
||||||
{product.ingredients_text && (
|
{product.ingredients_text && (
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<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="font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Ingredientes
|
Ingredientes
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
@@ -408,15 +337,20 @@ export default function ProdutoPage() {
|
|||||||
|
|
||||||
{/* Allergens */}
|
{/* Allergens */}
|
||||||
{product.allergens_tags && product.allergens_tags.length > 0 && (
|
{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">
|
<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">
|
||||||
<h3 className="font-semibold text-yellow-800 dark:text-yellow-200 mb-2">
|
<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
|
Alérgenos
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{product.allergens_tags.map((allergen, index) => (
|
{product.allergens_tags.map((allergen, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
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:", "")}
|
{allergen.replace("en:", "").replace("pt:", "")}
|
||||||
</span>
|
</span>
|
||||||
@@ -427,8 +361,8 @@ export default function ProdutoPage() {
|
|||||||
|
|
||||||
{/* Additives */}
|
{/* Additives */}
|
||||||
{product.additives_tags && product.additives_tags.length > 0 && (
|
{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">
|
<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="font-semibold text-blue-800 dark:text-blue-200 mb-2">
|
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-4">
|
||||||
Aditivos
|
Aditivos
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -437,13 +371,13 @@ export default function ProdutoPage() {
|
|||||||
.map((additive, index) => (
|
.map((additive, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
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:", "")}
|
{additive.replace("en:", "").replace("pt:", "")}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{product.additives_tags.length > 10 && (
|
{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
|
+{product.additives_tags.length - 10} mais
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
-7
@@ -1,19 +1,20 @@
|
|||||||
'use client'
|
"use client";
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: ProvidersProps) {
|
export function ThemeProvider({ children }: ProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<NextThemesProvider
|
<NextThemesProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="light"
|
||||||
enableSystem
|
enableSystem={true}
|
||||||
|
enableColorScheme
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NextThemesProvider>
|
</NextThemesProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
+211
-16
@@ -7,11 +7,20 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Database,
|
Database,
|
||||||
|
ArrowUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { ProductCard } from "@/app/components/ProductCard";
|
||||||
import { ProductCardSkeleton } from "@/app/components/ProductCardSkeleton";
|
import { ProductCardSkeleton } from "@/app/components/ProductCardSkeleton";
|
||||||
|
import { DonateCard } from "@/app/components/DonateCard";
|
||||||
import { FilterSidebar } from "@/app/components/FilterSidebar";
|
import { FilterSidebar } from "@/app/components/FilterSidebar";
|
||||||
import { SearchBar } from "@/app/components/SearchBar";
|
import { SearchBar } from "@/app/components/SearchBar";
|
||||||
import { Pagination } from "@/app/components/Pagination";
|
import { Pagination } from "@/app/components/Pagination";
|
||||||
@@ -57,6 +66,11 @@ function ResultadosContent() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
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
|
// Hook de cache
|
||||||
const {
|
const {
|
||||||
state: cacheState,
|
state: cacheState,
|
||||||
@@ -133,7 +147,7 @@ function ResultadosContent() {
|
|||||||
q: query,
|
q: query,
|
||||||
country: countryToUse,
|
country: countryToUse,
|
||||||
page: "1",
|
page: "1",
|
||||||
page_size: "50", // Buscar até 50 produtos por página
|
page_size: "1000", // Buscar até 1000 produtos por página
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cacheOnly) {
|
if (cacheOnly) {
|
||||||
@@ -297,9 +311,52 @@ function ResultadosContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Header */}
|
{/* Header Mobile Otimizado */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-800 sticky top-0 z-20">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<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">
|
||||||
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button
|
<Button
|
||||||
@@ -329,8 +386,8 @@ function ResultadosContent() {
|
|||||||
)}
|
)}
|
||||||
{storageStats && (
|
{storageStats && (
|
||||||
<span className="ml-2 text-xs text-gray-400">
|
<span className="ml-2 text-xs text-gray-400">
|
||||||
• Storage: {storageStats.storageSizeMB} MB (
|
• Storage: {storageStats?.storageSizeMB || 0} MB (
|
||||||
{storageStats.totalProducts} produtos)
|
{storageStats?.totalProducts || 0} produtos)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -398,7 +455,6 @@ function ResultadosContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layout principal */}
|
{/* Layout principal */}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -449,9 +505,9 @@ function ResultadosContent() {
|
|||||||
Carregando todos os resultados...
|
Carregando todos os resultados...
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||||
Página {cacheState.progress.currentPage} de{" "}
|
Página {cacheState.progress?.currentPage || 0} de{" "}
|
||||||
{cacheState.progress.totalPages} •
|
{cacheState.progress?.totalPages || 0} •
|
||||||
{cacheState.progress.loadedProducts} produtos
|
{cacheState.progress?.loadedProducts || 0} produtos
|
||||||
carregados
|
carregados
|
||||||
</p>
|
</p>
|
||||||
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mt-2">
|
<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"
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
width: `${
|
width: `${
|
||||||
(cacheState.progress.currentPage /
|
((cacheState.progress?.currentPage || 0) /
|
||||||
cacheState.progress.totalPages) *
|
(cacheState.progress?.totalPages || 1)) *
|
||||||
100
|
100
|
||||||
}%`,
|
}%`,
|
||||||
}}
|
}}
|
||||||
@@ -526,12 +582,24 @@ function ResultadosContent() {
|
|||||||
{!loading && totalItems > 0 && (
|
{!loading && totalItems > 0 && (
|
||||||
<>
|
<>
|
||||||
<div
|
<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"
|
viewMode === "grid"
|
||||||
? "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
? "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||||
: "grid-cols-1"
|
: "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) => (
|
{currentPageProducts.map((product, index) => (
|
||||||
<div
|
<div
|
||||||
key={product.code}
|
key={product.code}
|
||||||
@@ -541,7 +609,20 @@ function ResultadosContent() {
|
|||||||
animationFillMode: "both",
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -597,7 +678,7 @@ function ResultadosContent() {
|
|||||||
onClick={handleRefreshStorageStats}
|
onClick={handleRefreshStorageStats}
|
||||||
className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 underline"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -608,6 +689,120 @@ function ResultadosContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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 currentPage = 1;
|
||||||
let hasMore = true;
|
let hasMore = true;
|
||||||
let totalCount = 0;
|
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}"`);
|
console.log(`Iniciando busca completa para: "${query}"`);
|
||||||
|
|
||||||
|
|||||||
@@ -387,3 +387,45 @@ export interface CacheFile {
|
|||||||
lastSync: number;
|
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