-
- RESENHA ITAPOA
-
+"use client";
+import { useState, useEffect } from "react";
+import { useTheme } from "next-themes";
+import { Sun, Moon, Menu, X, ScanLine, Info } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export function Header() {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [mounted, setMounted] = useState(false);
+ const { theme, setTheme } = useTheme();
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const toggleTheme = () => {
+ setTheme(theme === "dark" ? "light" : "dark");
+ };
+
+ const toggleMenu = () => {
+ setIsMenuOpen(!isMenuOpen);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/components/landing/BarcodeScanner.tsx b/app/components/landing/BarcodeScanner.tsx
new file mode 100644
index 0000000..acd5c64
--- /dev/null
+++ b/app/components/landing/BarcodeScanner.tsx
@@ -0,0 +1,237 @@
+"use client";
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import { X, Camera, AlertCircle, CheckCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface BarcodeScannerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onScan: (barcode: string) => void;
+}
+
+export function BarcodeScanner({
+ isOpen,
+ onClose,
+ onScan,
+}: BarcodeScannerProps) {
+ const [isScanning, setIsScanning] = useState(false);
+ const [error, setError] = useState
(null);
+ const [success, setSuccess] = useState(false);
+ const scannerRef = useRef(null);
+ const qrCodeScanner = useRef(null);
+
+ // Verificar se é mobile
+ const isMobile =
+ typeof window !== "undefined" &&
+ /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
+
+ // Verificar se é HTTPS ou localhost
+ const isSecure =
+ typeof window !== "undefined" &&
+ (window.location.protocol === "https:" ||
+ window.location.hostname === "localhost");
+
+ const startScanner = useCallback(async () => {
+ try {
+ setError(null);
+ setIsScanning(true);
+
+ // Dynamic import para carregar apenas quando necessário
+ const { Html5QrcodeScanner } = await import("html5-qrcode");
+
+ if (!scannerRef.current) return;
+
+ qrCodeScanner.current = new Html5QrcodeScanner(
+ scannerRef.current.id,
+ {
+ fps: 10,
+ qrbox: { width: 250, height: 250 },
+ aspectRatio: 1.0,
+ },
+ false
+ );
+
+ qrCodeScanner.current.render(
+ (decodedText: string) => {
+ // Validar se é um código de barras válido
+ if (isValidBarcode(decodedText)) {
+ setSuccess(true);
+ setTimeout(() => {
+ onScan(decodedText);
+ onClose();
+ }, 1000);
+ } else {
+ setError("Código de barras inválido. Tente novamente.");
+ }
+ },
+ (error: string) => {
+ // Ignorar erros de "No QR code found" para não spam
+ if (!error.includes("No QR code found")) {
+ console.warn("Erro do scanner:", error);
+ }
+ }
+ );
+ } catch (err) {
+ console.error("Erro ao iniciar scanner:", err);
+ setError("Erro ao acessar a câmera. Verifique as permissões.");
+ setIsScanning(false);
+ }
+ }, [onScan, onClose]);
+
+ useEffect(() => {
+ if (isOpen && isMobile && isSecure) {
+ startScanner();
+ } else if (isOpen && !isMobile) {
+ setError("Scanner de câmera disponível apenas em dispositivos móveis");
+ } else if (isOpen && !isSecure) {
+ setError("Scanner de câmera requer conexão segura (HTTPS)");
+ }
+
+ return () => {
+ stopScanner();
+ };
+ }, [isOpen, isMobile, isSecure, startScanner]);
+
+ const stopScanner = () => {
+ if (qrCodeScanner.current) {
+ qrCodeScanner.current.clear().catch((err: unknown) => {
+ console.warn("Erro ao parar scanner:", err);
+ });
+ qrCodeScanner.current = null;
+ }
+ setIsScanning(false);
+ };
+
+ const isValidBarcode = (code: string): boolean => {
+ const cleanCode = code.replace(/\D/g, "");
+ return cleanCode.length >= 8 && cleanCode.length <= 13;
+ };
+
+ const handleClose = () => {
+ stopScanner();
+ setError(null);
+ setSuccess(false);
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Scanner de Código de Barras
+
+
+
+
+
+ {/* Content */}
+
+ {!isMobile && (
+
+
+
+ Scanner de câmera disponível apenas em dispositivos móveis
+
+
+ Use o campo de busca para digitar o código de barras manualmente
+
+
+ )}
+
+ {!isSecure && (
+
+
+
+ Scanner de câmera requer conexão segura (HTTPS)
+
+
+ Use o campo de busca para digitar o código de barras manualmente
+
+
+ )}
+
+ {isMobile && isSecure && (
+
+ {error && (
+
+ )}
+
+ {success && (
+
+
+
+ Código de barras detectado com sucesso!
+
+
+ )}
+
+
+
+ Aponte a câmera para o código de barras do produto
+
+
+ {/* Scanner Container */}
+
+ {!isScanning && !error && (
+
+
+
+ Iniciando câmera...
+
+
+ )}
+
+
+
+ Mantenha o código de barras dentro da área destacada
+
+
+
+ )}
+
+ {/* Actions */}
+
+
+ {isMobile && isSecure && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/components/landing/Features.tsx b/app/components/landing/Features.tsx
new file mode 100644
index 0000000..8ff5113
--- /dev/null
+++ b/app/components/landing/Features.tsx
@@ -0,0 +1,175 @@
+import {
+ BarChart3,
+ Shield,
+ List,
+ Globe,
+ Zap,
+ Heart,
+ AlertTriangle,
+ CheckCircle,
+} from "lucide-react";
+
+const features = [
+ {
+ icon: BarChart3,
+ title: "Informações Nutricionais Completas",
+ description:
+ "Calorias, proteínas, carboidratos, gorduras e muito mais. Dados precisos para cada produto.",
+ color: "text-blue-600 dark:text-blue-400",
+ bgColor: "bg-blue-50 dark:bg-blue-900/20",
+ iconBg: "bg-blue-100 dark:bg-blue-900/40",
+ },
+ {
+ icon: Shield,
+ title: "Nutri-Score e NOVA",
+ description:
+ "Classificações oficiais de qualidade nutricional para ajudar você a fazer escolhas mais saudáveis.",
+ color: "text-green-600 dark:text-green-400",
+ bgColor: "bg-green-50 dark:bg-green-900/20",
+ iconBg: "bg-green-100 dark:bg-green-900/40",
+ },
+ {
+ icon: List,
+ title: "Ingredientes Detalhados",
+ description:
+ "Lista completa de ingredientes, incluindo alérgenos e aditivos alimentares.",
+ color: "text-orange-600 dark:text-orange-400",
+ bgColor: "bg-orange-50 dark:bg-orange-900/20",
+ iconBg: "bg-orange-100 dark:bg-orange-900/40",
+ },
+ {
+ icon: Globe,
+ title: "Banco de Dados Global",
+ description:
+ "Acesso a milhões de produtos do Open Food Facts, a maior base de dados alimentar do mundo.",
+ color: "text-purple-600 dark:text-purple-400",
+ bgColor: "bg-purple-50 dark:bg-purple-900/20",
+ iconBg: "bg-purple-100 dark:bg-purple-900/40",
+ },
+ {
+ icon: Zap,
+ title: "Busca Instantânea",
+ description:
+ "Resultados em segundos. Digite o código de barras e obtenha informações imediatamente.",
+ color: "text-yellow-600 dark:text-yellow-400",
+ bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
+ iconBg: "bg-yellow-100 dark:bg-yellow-900/40",
+ },
+ {
+ icon: Heart,
+ title: "Alimentação Consciente",
+ description:
+ "Tome decisões mais informadas sobre sua alimentação com dados confiáveis e atualizados.",
+ color: "text-red-600 dark:text-red-400",
+ bgColor: "bg-red-50 dark:bg-red-900/20",
+ iconBg: "bg-red-100 dark:bg-red-900/40",
+ },
+];
+
+const benefits = [
+ {
+ icon: CheckCircle,
+ text: "Dados verificados por especialistas",
+ color: "text-green-600 dark:text-green-400",
+ },
+ {
+ icon: CheckCircle,
+ text: "Atualizações em tempo real",
+ color: "text-green-600 dark:text-green-400",
+ },
+ {
+ icon: CheckCircle,
+ text: "Interface simples e intuitiva",
+ color: "text-green-600 dark:text-green-400",
+ },
+ {
+ icon: CheckCircle,
+ text: "Totalmente gratuito",
+ color: "text-green-600 dark:text-green-400",
+ },
+];
+
+export function Features() {
+ return (
+
+
+ {/* Header */}
+
+
+ Por que escolher o NutriScan?
+
+
+ Descubra todas as informações que você precisa sobre seus alimentos
+ de forma rápida, precisa e confiável.
+
+
+
+ {/* Features Grid */}
+
+ {features.map((feature, index) => (
+
+
+
+
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+ ))}
+
+
+ {/* Benefits Section */}
+
+
+
+ Vantagens do NutriScan
+
+
+ Tudo que você precisa para uma alimentação mais consciente
+
+
+
+
+ {benefits.map((benefit, index) => (
+
+
+
+ {benefit.text}
+
+
+ ))}
+
+
+
+ {/* CTA Section */}
+
+
+
+ Pronto para descobrir seus alimentos?
+
+
+ Comece agora mesmo digitando o código de barras de qualquer
+ produto e descubra todas as informações nutricionais disponíveis.
+
+
+
+
+ Dica: O código de barras está na embalagem do produto
+
+
+
+
+
+
+ );
+}
diff --git a/app/components/landing/Hero.tsx b/app/components/landing/Hero.tsx
new file mode 100644
index 0000000..8182fd8
--- /dev/null
+++ b/app/components/landing/Hero.tsx
@@ -0,0 +1,270 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Search, ScanLine, Sparkles, Camera, Globe } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { BarcodeScanner } from "./BarcodeScanner";
+import { detectUserCountry } from "@/lib/geolocation";
+import { detectSearchType, isValidBarcode } from "@/lib/openFoodFactsApi";
+
+export function Hero() {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [isScannerOpen, setIsScannerOpen] = useState(false);
+ const [userCountry, setUserCountry] = useState(null);
+ const [searchType, setSearchType] = useState<"auto" | "barcode" | "name">(
+ "auto"
+ );
+ const [mounted, setMounted] = useState(false);
+ const router = useRouter();
+
+ // Detectar país do usuário ao carregar
+ useEffect(() => {
+ setMounted(true);
+
+ const detectCountry = async () => {
+ try {
+ // Forçar nova detecção para garantir país correto
+ const country = await detectUserCountry(true);
+ setUserCountry(country.code);
+ console.log("País detectado:", country.code, country.name);
+ } catch (error) {
+ console.warn("Erro ao detectar país:", error);
+ setUserCountry("br"); // Fallback para Brasil
+ }
+ };
+
+ detectCountry();
+ }, []);
+
+ // Detectar tipo de busca baseado na entrada
+ useEffect(() => {
+ if (searchQuery.trim()) {
+ const detectedType = detectSearchType(searchQuery);
+ setSearchType(detectedType);
+ } else {
+ setSearchType("auto");
+ }
+ }, [searchQuery]);
+
+ const handleSearch = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+
+ if (!searchQuery.trim()) {
+ setError("Digite um código de barras ou nome do produto");
+ return;
+ }
+
+ const query = searchQuery.trim();
+ const detectedType = detectSearchType(query);
+
+ if (detectedType === "barcode") {
+ if (!isValidBarcode(query)) {
+ setError("Código de barras deve ter entre 8 e 13 dígitos");
+ return;
+ }
+
+ setLoading(true);
+ // Redirecionar para página do produto
+ setTimeout(() => {
+ router.push(`/produto/${query}`);
+ }, 1000);
+ } else {
+ setLoading(true);
+ // Redirecionar para página de resultados
+ const countryParam = userCountry ? `&country=${userCountry}` : "";
+ setTimeout(() => {
+ router.push(
+ `/resultados?q=${encodeURIComponent(query)}${countryParam}`
+ );
+ }, 1000);
+ }
+ };
+
+ const handleScannerResult = (barcode: string) => {
+ setSearchQuery(barcode);
+ setIsScannerOpen(false);
+ // Disparar busca automaticamente
+ setTimeout(() => {
+ router.push(`/produto/${barcode}`);
+ }, 500);
+ };
+
+ const getPlaceholder = () => {
+ if (searchType === "barcode") {
+ return "Digite o código de barras (ex: 7896004713588)";
+ } else if (searchType === "name") {
+ return "Digite o nome do produto (ex: coca cola)";
+ }
+ return "Digite o código de barras ou nome do produto";
+ };
+
+ const getSearchButtonText = () => {
+ if (loading) return "Buscando...";
+ if (searchType === "barcode") return "Buscar Produto";
+ if (searchType === "name") return "Buscar Produtos";
+ return "Buscar";
+ };
+
+ return (
+
+ {/* Background Pattern */}
+
+
+
+ {/* Badge */}
+
+
+ Desenvolvido por{" "}
+
+ evandrodevbr
+
+ {" • "}Dados do Open Food Facts
+
+
+ {/* Main Heading */}
+
+ Descubra tudo sobre seus{" "}
+
+ alimentos
+
+
+
+ {/* Subtitle */}
+
+ Consulte informações nutricionais completas, ingredientes e
+ classificações de qualidade de milhares de produtos alimentícios com
+ apenas um código de barras.
+
+
+ {/* Search Form */}
+
+
+ {/* Barcode Scanner Modal */}
+
setIsScannerOpen(false)}
+ onScan={handleScannerResult}
+ />
+
+ {/* Quick Stats */}
+
+
+
+ 2M+
+
+
+ Produtos no banco
+
+
+
+
+ 100+
+
+
+ Países cobertos
+
+
+
+
+ 24/7
+
+
+ Disponível sempre
+
+
+
+
+
+ {/* Scroll Indicator */}
+
+
+ );
+}
diff --git a/app/components/landing/HowItWorks.tsx b/app/components/landing/HowItWorks.tsx
new file mode 100644
index 0000000..23b9533
--- /dev/null
+++ b/app/components/landing/HowItWorks.tsx
@@ -0,0 +1,184 @@
+import {
+ ScanLine,
+ Search,
+ BarChart3,
+ CheckCircle,
+ ArrowRight,
+} from "lucide-react";
+
+const steps = [
+ {
+ number: "01",
+ icon: ScanLine,
+ title: "Digite o código de barras",
+ description:
+ "Encontre o código de barras na embalagem do produto e digite-o na nossa busca.",
+ color: "text-blue-600 dark:text-blue-400",
+ bgColor: "bg-blue-50 dark:bg-blue-900/20",
+ iconBg: "bg-blue-100 dark:bg-blue-900/40",
+ },
+ {
+ number: "02",
+ icon: Search,
+ title: "Buscamos no banco de dados",
+ description:
+ "Nossa ferramenta consulta milhões de produtos do Open Food Facts em segundos.",
+ 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",
+ },
+ {
+ number: "03",
+ icon: BarChart3,
+ title: "Veja informações detalhadas",
+ description:
+ "Receba dados nutricionais completos, ingredientes e classificações de qualidade.",
+ 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",
+ },
+ {
+ number: "04",
+ icon: CheckCircle,
+ title: "Faça escolhas conscientes",
+ description:
+ "Use as informações para tomar decisões mais saudáveis sobre sua alimentação.",
+ 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",
+ },
+];
+
+const features = [
+ {
+ title: "Dados Nutricionais",
+ items: [
+ "Calorias por 100g",
+ "Proteínas, carboidratos e gorduras",
+ "Vitaminas e minerais",
+ "Fibra alimentar",
+ ],
+ },
+ {
+ title: "Classificações",
+ items: [
+ "Nutri-Score (A a E)",
+ "NOVA (processamento)",
+ "Alérgenos identificados",
+ "Aditivos alimentares",
+ ],
+ },
+ {
+ title: "Ingredientes",
+ items: [
+ "Lista completa de ingredientes",
+ "Ordem de quantidade",
+ "Alérgenos destacados",
+ "Origem dos ingredientes",
+ ],
+ },
+];
+
+export function HowItWorks() {
+ return (
+
+
+ {/* Header */}
+
+
+ Como funciona?
+
+
+ Em apenas 4 passos simples, você terá acesso a todas as informações
+ nutricionais de qualquer produto alimentício.
+
+
+
+ {/* Steps */}
+
+ {steps.map((step, index) => (
+
+ {/* Arrow (desktop only) */}
+ {index < steps.length - 1 && (
+
+ )}
+
+
+ {/* Number */}
+
+ {step.number}
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+
+ {step.title}
+
+
+ {step.description}
+
+
+
+ ))}
+
+
+ {/* Features Grid */}
+
+
+
+ O que você vai encontrar?
+
+
+ Informações detalhadas e confiáveis sobre cada produto
+
+
+
+
+ {features.map((feature, index) => (
+
+
+ {feature.title}
+
+
+ {feature.items.map((item, itemIndex) => (
+ -
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Bottom CTA */}
+
+
+
+ Começar agora
+
+
+ É rápido, fácil e totalmente gratuito
+
+
+
+
+ );
+}
diff --git a/app/components/landing/SearchBar.tsx b/app/components/landing/SearchBar.tsx
new file mode 100644
index 0000000..417ca2b
--- /dev/null
+++ b/app/components/landing/SearchBar.tsx
@@ -0,0 +1,164 @@
+"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Search, ScanLine, AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+interface SearchBarProps {
+ variant?: "hero" | "compact" | "floating";
+ placeholder?: string;
+ className?: string;
+}
+
+export function SearchBar({
+ variant = "hero",
+ placeholder = "Digite o código de barras (ex: 7896004713588)",
+ className = "",
+}: SearchBarProps) {
+ const [barcode, setBarcode] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const validateBarcode = (code: string) => {
+ const cleanCode = code.replace(/\D/g, "");
+ return cleanCode.length >= 8 && cleanCode.length <= 13;
+ };
+
+ const handleSearch = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+
+ if (!barcode.trim()) {
+ setError("Digite um código de barras válido");
+ return;
+ }
+
+ if (!validateBarcode(barcode)) {
+ setError("Código de barras deve ter entre 8 e 13 dígitos");
+ return;
+ }
+
+ setLoading(true);
+
+ // Simula busca (futuramente será integração com API)
+ setTimeout(() => {
+ router.push(`/produto/${barcode.trim()}`);
+ }, 1000);
+ };
+
+ const getVariantStyles = () => {
+ switch (variant) {
+ case "compact":
+ return {
+ container: "max-w-md mx-auto",
+ input: "text-base py-3",
+ button: "px-6 py-3",
+ };
+ case "floating":
+ return {
+ container:
+ "fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50 max-w-sm w-full mx-4",
+ input: "text-sm py-3",
+ button: "px-4 py-3",
+ };
+ default: // hero
+ return {
+ container: "max-w-2xl mx-auto",
+ input: "text-lg py-4",
+ button: "px-8 py-4",
+ };
+ }
+ };
+
+ const styles = getVariantStyles();
+
+ if (variant === "floating") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/app/components/pricing/BillingToggle.tsx b/app/components/pricing/BillingToggle.tsx
new file mode 100644
index 0000000..757c633
--- /dev/null
+++ b/app/components/pricing/BillingToggle.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+
+interface BillingToggleProps {
+ isAnnual: boolean;
+ onToggle: (isAnnual: boolean) => void;
+ className?: string;
+}
+
+export function BillingToggle({
+ isAnnual,
+ onToggle,
+ className = "",
+}: BillingToggleProps) {
+ return (
+
+
+ {/* Toggle Background */}
+
+
+ {/* Monthly Option */}
+
+
+ {/* Annual Option */}
+
+
+
+ {/* Save Badge */}
+ {isAnnual && (
+
+ Economize 20%
+
+ )}
+
+ );
+}
diff --git a/app/components/pricing/PricingCard.tsx b/app/components/pricing/PricingCard.tsx
new file mode 100644
index 0000000..ed2f210
--- /dev/null
+++ b/app/components/pricing/PricingCard.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import {
+ CheckCircle2,
+ XCircle,
+ Zap,
+ BarChart3,
+ GitCompare,
+ Download,
+ Code,
+ MessageSquare,
+ Shield,
+} 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;
+ className?: string;
+}
+
+const planData = {
+ 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" },
+ ],
+ },
+};
+
+const featureIcons: Record = {
+ consultas: Zap,
+ filtros: BarChart3,
+ comparação: GitCompare,
+ exportação: Download,
+ api: Code,
+ suporte: MessageSquare,
+ beta: Shield,
+};
+
+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";
+
+ const getFeatureIcon = (featureText: string) => {
+ if (featureText.includes("consultas") || featureText.includes("API"))
+ return Zap;
+ if (featureText.includes("filtros")) return BarChart3;
+ if (featureText.includes("comparação")) return GitCompare;
+ if (featureText.includes("exportação")) return Download;
+ if (featureText.includes("suporte")) return MessageSquare;
+ if (featureText.includes("Beta")) return Shield;
+ return CheckCircle2;
+ };
+
+ return (
+
+ {/* Badge */}
+ {data.badge && (
+
+
+ {data.badge}
+
+
+ )}
+
+
+
+
+ {data.name}
+
+
+ {data.description}
+
+
+
+
+
+ {/* Preço */}
+
+
+
+ ${price}
+
+ {!isFree && (
+
+ /mês
+
+ )}
+
+ {isAnnual && !isFree && (
+
+ Economize ${(data.monthlyPrice - data.annualPrice) * 12}/ano
+
+ )}
+
+
+ {/* Botões */}
+
+
+
+
+
+ {/* Features */}
+
+ {data.features.map((feature, index) => {
+ const IconComponent = getFeatureIcon(feature.text);
+ return (
+
+
+ {feature.included ? (
+
+ ) : (
+
+ )}
+
+
+
+ {feature.text}
+
+ {feature.note && (
+
+ {feature.note}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/components/pricing/PricingFAQ.tsx b/app/components/pricing/PricingFAQ.tsx
new file mode 100644
index 0000000..3985d99
--- /dev/null
+++ b/app/components/pricing/PricingFAQ.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { ChevronDown } from "lucide-react";
+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 (
+
+
+
+ Perguntas Frequentes
+
+
+ Tire suas dúvidas sobre nossos planos e funcionalidades
+
+
+
+
+ {faqData.map((item, index) => (
+
+
+
+ {item.question}
+
+
+
+
+ {item.answer}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/app/components/pricing/PricingHero.tsx b/app/components/pricing/PricingHero.tsx
new file mode 100644
index 0000000..93b968b
--- /dev/null
+++ b/app/components/pricing/PricingHero.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+interface PricingHeroProps {
+ className?: string;
+}
+
+export function PricingHero({ className = "" }: PricingHeroProps) {
+ return (
+
+
+ Escolha o plano perfeito para você
+
+
+ Acesse informações nutricionais completas de milhares de produtos. Desde
+ uso pessoal até profissional, temos a solução ideal para suas
+ necessidades.
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 02cdc25..6785154 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,65 +1,70 @@
-import "./globals.css"
-import { Metadata } from 'next'
-import { Inter } from 'next/font/google'
-import { ThemeProvider } from '@/app/providers'
+import "./globals.css";
+import { Metadata } from "next";
+import { Inter } from "next/font/google";
+import { ThemeProvider } from "@/app/providers";
// Use Inter como fallback já que é uma fonte similar à Geist
-const inter = Inter({ subsets: ['latin'] })
+const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
- metadataBase: new URL('https://resenhaitapoa.com.br'),
+ metadataBase: new URL("https://nutriscan.com.br"),
title: {
- default: 'Resenha Itapoá - Gerenciador de Listas de Compras',
- template: '%s | Resenha Itapoá'
+ default: "NutriScan - Informações Nutricionais de Alimentos",
+ template: "%s | NutriScan",
},
- description: 'Gerencie suas listas de compras de forma simples e eficiente. Compartilhe listas, acompanhe itens em tempo real.',
- keywords: ['lista de compras', 'gerenciador de compras', 'Itapoá', 'compras colaborativas', 'organização de compras'],
- authors: [{ name: 'Evandro', url: 'https://evandro.dev.br' }],
- creator: 'Evandro',
+ description:
+ "Descubra informações nutricionais completas de milhares de produtos alimentícios. Consulte Nutri-Score, ingredientes e valores nutricionais.",
+ keywords: [
+ "informações nutricionais",
+ "nutri-score",
+ "ingredientes",
+ "alimentação saudável",
+ "open food facts",
+ "código de barras",
+ "nutrição",
+ ],
+ authors: [{ name: "Evandro", url: "https://evandro.dev.br" }],
+ creator: "Evandro",
openGraph: {
- type: 'website',
- locale: 'pt_BR',
- url: 'https://resenhaitapoa.com.br',
- title: 'Resenha Itapoá - Gerenciador de Listas de Compras',
- description: 'Gerencie suas listas de compras de forma simples e eficiente.',
- siteName: 'Resenha Itapoá',
+ type: "website",
+ locale: "pt_BR",
+ url: "https://nutriscan.com.br",
+ title: "NutriScan - Informações Nutricionais de Alimentos",
+ description:
+ "Descubra informações nutricionais completas de milhares de produtos alimentícios.",
+ siteName: "NutriScan",
},
twitter: {
- card: 'summary_large_image',
- title: 'Resenha Itapoá - Gerenciador de Listas de Compras',
- description: 'Gerencie suas listas de compras de forma simples e eficiente.',
- creator: '@evandrodev',
+ card: "summary_large_image",
+ title: "NutriScan - Informações Nutricionais de Alimentos",
+ description:
+ "Descubra informações nutricionais completas de milhares de produtos alimentícios.",
+ creator: "@evandrodev",
},
- manifest: '/manifest.json',
+ manifest: "/manifest.json",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
- 'max-video-preview': -1,
- 'max-image-preview': 'large',
- 'max-snippet': -1,
+ "max-video-preview": -1,
+ "max-image-preview": "large",
+ "max-snippet": -1,
},
},
-}
+};
export default function RootLayout({
children,
}: {
- children: React.ReactNode
+ children: React.ReactNode;
}) {
return (
-
+
-
- {children}
-
+ {children}
- )
-}
\ No newline at end of file
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 869b9a8..bdde784 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,23 @@
-import { IdInput } from './components/IdInput';
+import { Header } from "./components/header";
+import { Footer } from "./components/Footer";
+import { Hero } from "./components/landing/Hero";
+import { Features } from "./components/landing/Features";
+import { HowItWorks } from "./components/landing/HowItWorks";
export default function Home() {
- return ;
-}
\ No newline at end of file
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx
new file mode 100644
index 0000000..0015764
--- /dev/null
+++ b/app/pricing/page.tsx
@@ -0,0 +1,257 @@
+"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";
+import { BillingToggle } from "@/app/components/pricing/BillingToggle";
+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, setIsAnnual] = useState(false);
+
+ return (
+
+
+
+
+ {/* Hero Section */}
+
+
+ {/* Billing Toggle */}
+
+
+ {/* Pricing Cards */}
+
+
+ {/* Features Comparison */}
+
+
+
+
+ Compare os Planos
+
+
+ Veja todas as funcionalidades disponíveis em cada plano
+
+
+
+
+
+
+
+
+ |
+ Funcionalidades
+ |
+
+ Básico
+ |
+
+ Premium
+ |
+
+ Free Beta
+ |
+
+
+
+ {[
+ {
+ 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) => (
+
+ |
+ {row.feature}
+ |
+
+ {typeof row.basic === "boolean" ? (
+
+ ) : (
+
+ {row.basic}
+
+ )}
+ |
+
+ {typeof row.premium === "boolean" ? (
+
+ ) : (
+
+ {row.premium}
+
+ )}
+ |
+
+ {typeof row.free === "boolean" ? (
+
+ ) : (
+
+ {row.free}
+
+ )}
+ |
+
+ ))}
+
+
+
+
+
+
+
+ {/* FAQ Section */}
+
+
+ {/* CTA Section */}
+
+
+
+ Pronto para começar?
+
+
+ Junte-se a milhares de usuários que já confiam no NutriScan para
+ suas consultas nutricionais.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/produto/[barcode]/page.tsx b/app/produto/[barcode]/page.tsx
new file mode 100644
index 0000000..30b0c59
--- /dev/null
+++ b/app/produto/[barcode]/page.tsx
@@ -0,0 +1,452 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { ArrowLeft, Loader2, AlertCircle, Globe, Star } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ searchByBarcode,
+ Product,
+ formatNutritionData,
+} from "@/lib/openFoodFactsApi";
+import Link from "next/link";
+
+export default function ProdutoPage() {
+ const params = useParams();
+ const router = useRouter();
+ const barcode = params.barcode as string;
+
+ const [product, setProduct] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (barcode) {
+ searchProduct();
+ }
+ }, [barcode]);
+
+ const searchProduct = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const result = await searchByBarcode(barcode);
+ if (result) {
+ setProduct(result);
+ } else {
+ setError("Produto não encontrado");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Erro ao buscar produto");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const getProductImageUrl = (product: Product): string | null => {
+ return (
+ product.image_front_url ||
+ product.image_front_small_url ||
+ product.image_url ||
+ product.image_small_url ||
+ null
+ );
+ };
+
+ const getNutritionGrade = (grade?: string) => {
+ if (!grade) return null;
+
+ const grades = {
+ a: {
+ label: "A",
+ color: "bg-green-500",
+ textColor: "text-green-700",
+ description: "Muito bom",
+ },
+ b: {
+ label: "B",
+ color: "bg-green-400",
+ textColor: "text-green-700",
+ description: "Bom",
+ },
+ c: {
+ label: "C",
+ color: "bg-yellow-500",
+ textColor: "text-yellow-700",
+ description: "Regular",
+ },
+ d: {
+ label: "D",
+ color: "bg-orange-500",
+ textColor: "text-orange-700",
+ description: "Ruim",
+ },
+ e: {
+ label: "E",
+ color: "bg-red-500",
+ textColor: "text-red-700",
+ description: "Muito ruim",
+ },
+ };
+
+ return grades[grade.toLowerCase() as keyof typeof grades] || null;
+ };
+
+ const getNovaGroup = (group?: number) => {
+ if (!group) return null;
+
+ const groups = {
+ 1: {
+ label: "1",
+ color: "bg-green-500",
+ textColor: "text-green-700",
+ description: "Alimentos não processados ou minimamente processados",
+ },
+ 2: {
+ label: "2",
+ color: "bg-yellow-500",
+ textColor: "text-yellow-700",
+ description: "Ingredientes culinários processados",
+ },
+ 3: {
+ label: "3",
+ color: "bg-orange-500",
+ textColor: "text-orange-700",
+ description: "Alimentos processados",
+ },
+ 4: {
+ label: "4",
+ color: "bg-red-500",
+ textColor: "text-red-700",
+ description: "Alimentos ultraprocessados",
+ },
+ };
+
+ return groups[group as keyof typeof groups] || null;
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+ Carregando produto...
+
+
+
+ );
+ }
+
+ if (error || !product) {
+ return (
+
+
+
+
+
+ Produto não encontrado
+
+
+ {error ||
+ "O produto com este código de barras não foi encontrado na base de dados."}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const imageUrl = getProductImageUrl(product);
+ const nutritionGrade = getNutritionGrade(product.nutrition_grades);
+ const novaGroup = getNovaGroup(product.nova_group);
+ const nutritionData = formatNutritionData(product);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {product.product_name || "Produto"}
+
+
+ Código: {product.code}
+
+
+
+
+
+
+ {/* Content */}
+
+
+ {/* Product Image */}
+
+
+
+ {imageUrl ? (
+

+ ) : (
+
+
+
+
Sem imagem disponível
+
+
+ )}
+
+
+
+ {/* Basic Info */}
+
+
+ Informações Básicas
+
+
+ {product.brands && (
+
+
+ Marca:
+
+
+ {product.brands}
+
+
+ )}
+ {product.categories && (
+
+
+ Categoria:
+
+
+ {product.categories}
+
+
+ )}
+ {product.countries && (
+
+
+ Países:
+
+
+ {product.countries}
+
+
+ )}
+
+
+
+
+ {/* Product Details */}
+
+ {/* Nutrition Grades */}
+
+ {nutritionGrade && (
+
+
+ Nutri-Score
+
+
+
+
+ {nutritionGrade.label}
+
+
+
+
+ {nutritionGrade.description}
+
+
+ Classificação nutricional
+
+
+
+
+ )}
+
+ {novaGroup && (
+
+
+ NOVA
+
+
+
+
+ {novaGroup.label}
+
+
+
+
+ Grupo {novaGroup.label}
+
+
+ Nível de processamento
+
+
+
+
+ )}
+
+
+ {/* Nutritional Information */}
+ {nutritionData && (
+
+
+ Informação Nutricional (por 100g)
+
+
+
+
+ Energia:
+
+
+ {nutritionData.energy}
+
+
+
+
+ Gorduras:
+
+
+ {nutritionData.fat}
+
+
+
+
+ Carboidratos:
+
+
+ {nutritionData.carbohydrates}
+
+
+
+
+ Proteínas:
+
+
+ {nutritionData.proteins}
+
+
+
+
+ Açúcares:
+
+
+ {nutritionData.sugars}
+
+
+
+
+ Fibras:
+
+
+ {nutritionData.fiber}
+
+
+
+
+ Sódio:
+
+
+ {nutritionData.sodium}
+
+
+
+
+ )}
+
+ {/* Ingredients */}
+ {product.ingredients_text && (
+
+
+ Ingredientes
+
+
+ {product.ingredients_text}
+
+
+ )}
+
+ {/* Allergens */}
+ {product.allergens_tags && product.allergens_tags.length > 0 && (
+
+
+ Alérgenos
+
+
+ {product.allergens_tags.map((allergen, index) => (
+
+ {allergen.replace("en:", "").replace("pt:", "")}
+
+ ))}
+
+
+ )}
+
+ {/* Additives */}
+ {product.additives_tags && product.additives_tags.length > 0 && (
+
+
+ Aditivos
+
+
+ {product.additives_tags
+ .slice(0, 10)
+ .map((additive, index) => (
+
+ {additive.replace("en:", "").replace("pt:", "")}
+
+ ))}
+ {product.additives_tags.length > 10 && (
+
+ +{product.additives_tags.length - 10} mais
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/resultados/page.tsx b/app/resultados/page.tsx
new file mode 100644
index 0000000..59dee1f
--- /dev/null
+++ b/app/resultados/page.tsx
@@ -0,0 +1,399 @@
+"use client";
+import { useState, useEffect } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import {
+ Search,
+ ArrowLeft,
+ Loader2,
+ Filter,
+ Grid,
+ List,
+ SlidersHorizontal,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
+import { searchByName, Product, sortProducts } from "@/lib/openFoodFactsApi";
+import { ProductCard } from "@/app/components/ProductCard";
+import { ProductCardSkeleton } from "@/app/components/ProductCardSkeleton";
+import { FilterSidebar } from "@/app/components/FilterSidebar";
+import { FilterBadges } from "@/app/components/FilterBadge";
+import { SortDropdown } from "@/app/components/SortDropdown";
+import { useProductFilters } from "@/hooks/useProductFilters";
+import { detectUserCountry } from "@/lib/geolocation";
+import Link from "next/link";
+
+export default function ResultadosPage() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const query = searchParams.get("q") || "";
+ const countryParam = searchParams.get("country") || "";
+
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+ const [detectedCountry, setDetectedCountry] = useState("");
+ const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
+
+ // Hook de filtros
+ const {
+ filters,
+ updateFilters,
+ clearFilters,
+ hasActiveFilters,
+ setTotalResults,
+ setIsLoading,
+ } = useProductFilters();
+
+ // Detectar país do usuário
+ useEffect(() => {
+ const detectCountry = async () => {
+ try {
+ const country = await detectUserCountry(true);
+ setDetectedCountry(country.code);
+ } catch (error) {
+ console.warn("Erro ao detectar país:", error);
+ setDetectedCountry("br");
+ }
+ };
+
+ detectCountry();
+ }, []);
+
+ useEffect(() => {
+ if (query) {
+ loadProducts(1);
+ }
+ }, [query, countryParam, detectedCountry, filters]);
+
+ const loadProducts = async (pageNum: number) => {
+ try {
+ if (pageNum === 1) {
+ setLoading(true);
+ } else {
+ setLoadingMore(true);
+ }
+ setError(null);
+ setIsLoading(true);
+
+ const countryToUse = detectedCountry || countryParam || "br";
+
+ // Construir parâmetros de busca com filtros
+ const searchParams = new URLSearchParams({
+ q: query,
+ country: countryToUse,
+ page: pageNum.toString(),
+ page_size: "20",
+ });
+
+ // Adicionar filtros à URL
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") {
+ if (Array.isArray(value)) {
+ if (value.length > 0) {
+ searchParams.set(key, value.join(","));
+ }
+ } else if (typeof value === "object" && value !== null) {
+ Object.entries(value).forEach(([rangeKey, rangeValue]) => {
+ if (rangeValue?.min !== undefined) {
+ searchParams.set(`${rangeKey}_min`, rangeValue.min.toString());
+ }
+ if (rangeValue?.max !== undefined) {
+ searchParams.set(`${rangeKey}_max`, rangeValue.max.toString());
+ }
+ });
+ } else {
+ searchParams.set(key, value.toString());
+ }
+ }
+ });
+
+ const result = await searchByName(query, countryToUse, pageNum, 20);
+
+ // Aplicar ordenação se especificada
+ let sortedProducts = result.products;
+ if (filters.sortBy) {
+ sortedProducts = sortProducts(
+ result.products,
+ filters.sortBy,
+ filters.sortOrder || "asc"
+ );
+ }
+
+ setProducts(
+ pageNum === 1 ? sortedProducts : [...products, ...sortedProducts]
+ );
+ setHasMore(result.products.length === 20);
+ setPage(pageNum);
+ setTotalResults(result.count || result.products.length);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Erro ao buscar produtos");
+ } finally {
+ setLoading(false);
+ setLoadingMore(false);
+ setIsLoading(false);
+ }
+ };
+
+ const handleLoadMore = () => {
+ loadProducts(page + 1);
+ };
+
+ const handleSortChange = (sortBy: string) => {
+ updateFilters({ sortBy: sortBy as any });
+ };
+
+ if (!query) {
+ return (
+
+
+
+
+ Nenhuma busca realizada
+
+
+ Digite um termo de busca para encontrar produtos
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ Resultados para "{query}"
+
+ {!loading && products.length > 0 && (
+
+ {products.length} produto{products.length !== 1 ? "s" : ""}{" "}
+ encontrado{products.length !== 1 ? "s" : ""}
+ {detectedCountry && ` em ${detectedCountry}`}
+
+ )}
+
+
+ {/* Controles de visualização - Desktop */}
+
+
+
+
+
+
+
+
+
+ {/* Filtros Mobile */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Filtros ativos */}
+ {hasActiveFilters && (
+
+
+ {
+ if (value) {
+ const currentArray =
+ (filters[key as keyof typeof filters] as string[]) || [];
+ updateFilters({
+ [key]: currentArray.filter((item) => item !== value),
+ });
+ } else {
+ updateFilters({ [key]: undefined });
+ }
+ }}
+ onClearAll={clearFilters}
+ />
+
+
+ )}
+
+ {/* Layout principal */}
+
+
+ {/* Sidebar de filtros - Desktop */}
+
+
+ {/* Conteúdo principal */}
+
+ {/* Loading Inicial */}
+ {loading && (
+
+ {[...Array(8)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* Erro */}
+ {error && !loading && (
+
+
+
+ Erro ao buscar produtos
+
+
{error}
+
+
+
+ )}
+
+ {/* Sem Resultados */}
+ {!loading && !error && products.length === 0 && (
+
+
+
+ Nenhum produto encontrado
+
+
+ Tente ajustar os filtros ou buscar com outros termos
+
+
+
+ {hasActiveFilters && (
+
+ )}
+
+
+ )}
+
+ {/* Grid de Produtos */}
+ {!loading && products.length > 0 && (
+ <>
+
+ {products.map((product, index) => (
+
+ ))}
+
+
+ {/* Botão Carregar Mais */}
+ {hasMore && (
+
+
+
+ )}
+
+ {/* Contador de Resultados */}
+ {!hasMore && products.length > 0 && (
+
+
+ Todos os {products.length} produto
+ {products.length !== 1 ? "s" : ""} foram carregados
+
+
+ )}
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..2f55a32
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000..51e507b
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
new file mode 100644
index 0000000..e87d62b
--- /dev/null
+++ b/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..cabfbfc
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
new file mode 100644
index 0000000..c6fdd07
--- /dev/null
+++ b/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx
new file mode 100644
index 0000000..0bdf685
--- /dev/null
+++ b/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
new file mode 100644
index 0000000..12d81c4
--- /dev/null
+++ b/components/ui/separator.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx
new file mode 100644
index 0000000..272cb72
--- /dev/null
+++ b/components/ui/sheet.tsx
@@ -0,0 +1,140 @@
+"use client"
+
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..d7e45f7
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx
new file mode 100644
index 0000000..ab19d57
--- /dev/null
+++ b/components/ui/slider.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SliderPrimitive from "@radix-ui/react-slider"
+
+import { cn } from "@/lib/utils"
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+
+))
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
new file mode 100644
index 0000000..5f4117f
--- /dev/null
+++ b/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/docs/openFoodFacts.md b/docs/openFoodFacts.md
new file mode 100644
index 0000000..9cabaf1
--- /dev/null
+++ b/docs/openFoodFacts.md
@@ -0,0 +1,285 @@
+# Documentação de Uso da API Open Food Facts em JavaScript
+
+A Open Food Facts fornece uma API completa para consultar e adicionar dados de produtos alimentícios, com suporte oficial através de um SDK em JavaScript/TypeScript.[1][2]
+
+## Instalação
+
+### Via NPM
+
+```bash
+npm install @openfoodfacts/openfoodfacts-nodejs
+```
+
+### Via Git (versão mais recente)
+
+```bash
+npm install git+https://github.com/openfoodfacts/openfoodfacts-js.git
+```
+
+## Configuração Básica
+
+### Inicializando o Cliente
+
+```javascript
+import { OpenFoodFacts } from "@openfoodfacts/openfoodfacts-nodejs";
+
+// Para navegador
+const client = new OpenFoodFacts(window.fetch);
+
+// Para Node.js
+const client = new OpenFoodFacts(globalThis.fetch);
+
+// Com implementação customizada do fetch
+import fetch from "node-fetch";
+const client = new OpenFoodFacts(fetch);
+```
+
+## Endpoints Principais
+
+### Buscar Produto por Código de Barras
+
+A API permite consultar produtos específicos através do código de barras EAN :[3][1]
+
+```javascript
+// Usando o SDK
+const { data, error } = await client.getProduct("5000112546415");
+if (!data) {
+ console.error("Erro ao buscar produto:", error);
+ return;
+}
+console.log("Dados do produto:", data);
+```
+
+```javascript
+// Usando Fetch diretamente
+const response = await fetch(
+ "https://world.openfoodfacts.org/api/v2/product/737628064502.json",
+ {
+ headers: {
+ "User-Agent": "MeuApp/1.0 (contato@exemplo.com)",
+ },
+ }
+);
+const produto = await response.json();
+```
+
+### Buscar Produtos por Categoria
+
+É possível filtrar produtos por categorias específicas como bebidas, laticínios, etc :[4][3]
+
+```javascript
+const response = await fetch(
+ "https://world.openfoodfacts.org/api/v0/category/dairy.json"
+);
+const categorias = await response.json();
+```
+
+### Pesquisa com Filtros
+
+A API v2 permite pesquisas avançadas com múltiplos filtros :[5][4]
+
+```javascript
+// Buscar sucos de laranja com Nutri-Score C
+const url =
+ "https://world.openfoodfacts.org/api/v2/search?" +
+ "categories_tags_en=Orange Juice&" +
+ "nutrition_grades_tags=c&" +
+ "fields=code,product_name,nutrition_grades,categories_tags_en";
+
+const response = await fetch(url);
+const resultados = await response.json();
+```
+
+### Pesquisa por Palavras-chave
+
+```javascript
+// Buscar produtos que contenham "chicken"
+const response = await fetch(
+ "https://world.openfoodfacts.org/api/v0/products.json?search=chicken"
+);
+const produtos = await response.json();
+```
+
+## Estrutura de Resposta
+
+### Objeto de Produto
+
+Um produto retornado pela API contém as seguintes informações principais :[6][1]
+
+```javascript
+{
+ "code": "3017620422003",
+ "product_name": "Nutella",
+ "brands": "Ferrero",
+ "categories": "Spreads, Sweet spreads, Chocolate spreads",
+ "ingredients_text": "Açúcar, Óleo de palma, Avelãs...",
+ "nutriments": {
+ "energy_100g": 2252,
+ "fat_100g": 30.9,
+ "carbohydrates_100g": 57.5,
+ "proteins_100g": 6.3
+ },
+ "nutrition_grades": "d",
+ "nova_group": 4
+}
+```
+
+## Parâmetros de Pesquisa
+
+A API suporta diversos parâmetros para filtrar resultados :[6][5]
+
+| Parâmetro | Tipo | Descrição |
+| --------------------- | ------ | --------------------------------------- |
+| search_terms | string | Termo de busca geral |
+| brands | string | Filtrar por marca |
+| categories | string | Filtrar por categoria |
+| code | string | Filtrar por código de barras |
+| countries | string | Filtrar por país |
+| page | number | Número da página (padrão: 1) |
+| pageSize | number | Resultados por página (padrão: 20) |
+| fields | string | Campos específicos a retornar |
+| nutrition_grades_tags | string | Filtrar por Nutri-Score (a, b, c, d, e) |
+
+## Autenticação
+
+### Operações de Leitura
+
+Não requerem autenticação, apenas um User-Agent customizado :[1]
+
+```javascript
+fetch(url, {
+ headers: {
+ "User-Agent": "MeuApp/1.0 (contato@exemplo.com)",
+ },
+});
+```
+
+### Operações de Escrita
+
+Requerem autenticação via credenciais ou sessão cookie :[1]
+
+```javascript
+// Login para obter cookie de sessão
+const formData = new FormData();
+formData.append("user_id", "seu_usuario");
+formData.append("password", "sua_senha");
+
+await fetch("https://world.openfoodfacts.org/cgi/session.pl", {
+ method: "POST",
+ body: formData,
+});
+```
+
+## Limites de Taxa
+
+A API impõe os seguintes limites para proteção da infraestrutura :[1]
+
+- **100 requisições/minuto** para consultas de produtos (GET)
+- **10 requisições/minuto** para pesquisas avançadas
+- **2 requisições/minuto** para consultas de facetas (categorias, labels)
+
+## Ambientes
+
+### Produção
+
+```
+https://world.openfoodfacts.org
+```
+
+### Staging (testes)
+
+```
+https://world.openfoodfacts.net
+```
+
+Para o ambiente de staging, use autenticação básica HTTP com usuário `off` e senha `off` :[1]
+
+```javascript
+fetch("https://world.openfoodfacts.net/api/v2/product/3274080005003.json", {
+ method: "GET",
+ headers: {
+ Authorization: "Basic " + btoa("off:off"),
+ },
+})
+ .then((response) => response.json())
+ .then((json) => console.log(json));
+```
+
+## Exemplos Práticos
+
+### Buscar Produtos Brasileiros
+
+```javascript
+const buscarProdutosBrasileiros = async (termo) => {
+ const url =
+ `https://world.openfoodfacts.org/api/v2/search?` +
+ `countries_tags_en=brazil&` +
+ `search_terms=${termo}&` +
+ `fields=code,product_name,brands,nutriments,nutrition_grades`;
+
+ const response = await fetch(url, {
+ headers: {
+ "User-Agent": "MeuApp/1.0 (contato@exemplo.com)",
+ },
+ });
+
+ return await response.json();
+};
+
+// Exemplo: buscar arroz brasileiro
+const arroz = await buscarProdutosBrasileiros("arroz");
+```
+
+### Verificar Nutri-Score
+
+```javascript
+const verificarNutriScore = async (codigoBarras) => {
+ const { data } = await client.getProduct(codigoBarras);
+
+ if (data && data.product) {
+ return {
+ nome: data.product.product_name,
+ nutriScore: data.product.nutrition_grades,
+ novaGroup: data.product.nova_group,
+ };
+ }
+ return null;
+};
+```
+
+### Buscar por Ingredientes
+
+```javascript
+const buscarPorIngrediente = async (ingrediente) => {
+ const response = await fetch(
+ `https://world.openfoodfacts.org/api/v2/search?` +
+ `ingredients_text=${ingrediente}&` +
+ `page_size=50`
+ );
+
+ return await response.json();
+};
+```
+
+A documentação completa da API está disponível no formato OpenAPI e pode ser consultada para referência detalhada de todos os endpoints disponíveis.[2][1]
+
+[1](https://openfoodfacts.github.io/openfoodfacts-server/api/)
+[2](https://github.com/openfoodfacts/openfoodfacts-nodejs)
+[3](https://publicapi.dev/open-food-facts-api)
+[4](https://openfoodfacts.github.io/openfoodfacts-server/api/tutorial-off-api/)
+[5](https://wiki.openfoodfacts.org/Open_Food_Facts_Search_API_Version_2)
+[6](https://flows.nodered.org/node/node-red-contrib-open-food-facts)
+[7](https://wiki.openfoodfacts.org/API/Javascript)
+[8](https://wiki.openfoodfacts.org/API/Read/Product)
+[9](https://npmjs.com/package/@openfoodfacts/openfoodfacts-webcomponents)
+[10](https://github.com/openfoodfacts/openfoodfacts-dart)
+[11](https://www.npmjs.com/package/@jagjeevan/openfoodfacts-mcp)
+[12](https://wiki.openfoodfacts.org/Documentation)
+[13](https://www.npmjs.com/package/@openfoodfacts/openfoodfacts-nodejs?activeTab=versions)
+[14](https://github.com/openfoodfacts/api-documentation)
+[15](https://fdc.nal.usda.gov/api-guide)
+[16](https://www.jsdelivr.com/package/npm/openfoodfacts-nodejs)
+[17](https://pub.dev/documentation/openfoodfacts/latest/)
+[18](https://dlthub.com/workspace/source/open-food-facts)
+[19](https://www.reddit.com/r/nodered/comments/1k7jozb/built_nodered_nodes_for_open_food_facts_api/)
+[20](https://www.gigasheet.com/no-code-api/open-food-facts-api)
diff --git a/hooks/useProductFilters.ts b/hooks/useProductFilters.ts
new file mode 100644
index 0000000..6b0de23
--- /dev/null
+++ b/hooks/useProductFilters.ts
@@ -0,0 +1,255 @@
+"use client";
+
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { ProductFilters, FilterState } from "@/lib/types";
+
+interface UseProductFiltersOptions {
+ debounceMs?: number;
+ persistToLocalStorage?: boolean;
+ localStorageKey?: string;
+}
+
+export function useProductFilters(options: UseProductFiltersOptions = {}) {
+ const {
+ debounceMs = 300,
+ persistToLocalStorage = true,
+ localStorageKey = "product-filters",
+ } = options;
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [filters, setFilters] = useState({});
+ const [isLoading, setIsLoading] = useState(false);
+ const [totalResults, setTotalResults] = useState(0);
+
+ // Carregar filtros da URL e localStorage
+ useEffect(() => {
+ const urlFilters = parseFiltersFromURL(searchParams);
+ const savedFilters = persistToLocalStorage
+ ? loadFiltersFromStorage(localStorageKey)
+ : {};
+
+ const initialFilters = { ...savedFilters, ...urlFilters };
+ setFilters(initialFilters);
+ }, [searchParams, persistToLocalStorage, localStorageKey]);
+
+ // Salvar filtros no localStorage
+ useEffect(() => {
+ if (persistToLocalStorage && Object.keys(filters).length > 0) {
+ saveFiltersToStorage(localStorageKey, filters);
+ }
+ }, [filters, persistToLocalStorage, localStorageKey]);
+
+ // Sincronizar filtros com URL
+ useEffect(() => {
+ const url = new URL(window.location.href);
+ const currentParams = new URLSearchParams(url.search);
+
+ // Remover parâmetros de filtro existentes
+ const filterParams = [
+ "nutrition_grades",
+ "nova_groups",
+ "categories",
+ "brands",
+ "allergens",
+ "exclude_allergens",
+ "additives",
+ "exclude_additives",
+ "no_additives",
+ "countries",
+ "sort_by",
+ "sort_order",
+ "energy_min",
+ "energy_max",
+ "fat_min",
+ "fat_max",
+ "sugars_min",
+ "sugars_max",
+ "sodium_min",
+ "sodium_max",
+ ];
+
+ filterParams.forEach((param) => {
+ currentParams.delete(param);
+ });
+
+ // Adicionar novos parâmetros de filtro
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== "") {
+ if (Array.isArray(value)) {
+ if (value.length > 0) {
+ currentParams.set(key, value.join(","));
+ }
+ } else if (typeof value === "object" && value !== null) {
+ // Para nutritionRanges
+ Object.entries(value).forEach(([rangeKey, rangeValue]) => {
+ if (rangeValue?.min !== undefined) {
+ currentParams.set(`${rangeKey}_min`, rangeValue.min.toString());
+ }
+ if (rangeValue?.max !== undefined) {
+ currentParams.set(`${rangeKey}_max`, rangeValue.max.toString());
+ }
+ });
+ } else {
+ currentParams.set(key, value.toString());
+ }
+ }
+ });
+
+ // Atualizar URL sem recarregar a página
+ const newUrl = `${url.pathname}?${currentParams.toString()}`;
+ if (newUrl !== window.location.pathname + window.location.search) {
+ router.replace(newUrl, { scroll: false });
+ }
+ }, [filters, router]);
+
+ // Debounced update dos filtros
+ const debouncedUpdateFilters = useCallback(
+ debounce((newFilters: ProductFilters) => {
+ setFilters(newFilters);
+ }, debounceMs),
+ [debounceMs]
+ );
+
+ const updateFilters = useCallback(
+ (newFilters: Partial) => {
+ const updatedFilters = { ...filters, ...newFilters };
+ debouncedUpdateFilters(updatedFilters);
+ },
+ [filters, debouncedUpdateFilters]
+ );
+
+ const clearFilters = useCallback(() => {
+ setFilters({});
+ if (persistToLocalStorage) {
+ localStorage.removeItem(localStorageKey);
+ }
+ }, [persistToLocalStorage, localStorageKey]);
+
+ const hasActiveFilters = useMemo(() => {
+ return Object.values(filters).some((value) => {
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+ if (typeof value === "object" && value !== null) {
+ return Object.values(value).some((v) => v !== undefined && v !== null);
+ }
+ return value !== undefined && value !== null && value !== "";
+ });
+ }, [filters]);
+
+ const filterState: FilterState = {
+ filters,
+ isLoading,
+ hasActiveFilters,
+ totalResults,
+ };
+
+ return {
+ ...filterState,
+ updateFilters,
+ clearFilters,
+ setTotalResults,
+ setIsLoading,
+ };
+}
+
+// Funções auxiliares
+function parseFiltersFromURL(searchParams: URLSearchParams): ProductFilters {
+ const filters: ProductFilters = {};
+
+ // Arrays simples
+ const arrayParams = [
+ "nutrition_grades",
+ "nova_groups",
+ "categories",
+ "brands",
+ "allergens",
+ "exclude_allergens",
+ "additives",
+ "exclude_additives",
+ "countries",
+ ];
+ arrayParams.forEach((param) => {
+ const value = searchParams.get(param);
+ if (value) {
+ filters[param as keyof ProductFilters] = value.split(",") as any;
+ }
+ });
+
+ // Booleanos
+ const booleanParams = ["no_additives"];
+ booleanParams.forEach((param) => {
+ const value = searchParams.get(param);
+ if (value === "true") {
+ filters[param as keyof ProductFilters] = true as any;
+ }
+ });
+
+ // Strings simples
+ const stringParams = ["sort_by", "sort_order"];
+ stringParams.forEach((param) => {
+ const value = searchParams.get(param);
+ if (value) {
+ filters[param as keyof ProductFilters] = value as any;
+ }
+ });
+
+ // Nutrition ranges
+ const nutritionRanges: any = {};
+ const rangeParams = [
+ "energy",
+ "fat",
+ "carbohydrates",
+ "proteins",
+ "sugars",
+ "fiber",
+ "sodium",
+ ];
+ rangeParams.forEach((param) => {
+ const min = searchParams.get(`${param}_min`);
+ const max = searchParams.get(`${param}_max`);
+ if (min || max) {
+ nutritionRanges[param] = {
+ ...(min && { min: parseFloat(min) }),
+ ...(max && { max: parseFloat(max) }),
+ };
+ }
+ });
+
+ if (Object.keys(nutritionRanges).length > 0) {
+ filters.nutritionRanges = nutritionRanges;
+ }
+
+ return filters;
+}
+
+function loadFiltersFromStorage(key: string): ProductFilters {
+ try {
+ const stored = localStorage.getItem(key);
+ return stored ? JSON.parse(stored) : {};
+ } catch {
+ return {};
+ }
+}
+
+function saveFiltersToStorage(key: string, filters: ProductFilters): void {
+ try {
+ localStorage.setItem(key, JSON.stringify(filters));
+ } catch {
+ // Falha silenciosa se localStorage não estiver disponível
+ }
+}
+
+function debounce any>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeout: NodeJS.Timeout;
+ return (...args: Parameters) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+}
diff --git a/lib/geolocation.ts b/lib/geolocation.ts
new file mode 100644
index 0000000..5034040
--- /dev/null
+++ b/lib/geolocation.ts
@@ -0,0 +1,145 @@
+/**
+ * Utilitário para detecção de país do usuário
+ * Usa detecção de IP server-side via API Route
+ */
+
+interface CountryInfo {
+ code: string;
+ name: string;
+}
+
+// Cache para evitar múltiplas requisições
+const CACHE_KEY = "nutriscan_user_country";
+const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 horas
+
+/**
+ * Verifica se o cache ainda é válido
+ */
+function isCacheValid(): boolean {
+ if (typeof window === "undefined") return false;
+
+ try {
+ const cached = localStorage.getItem(CACHE_KEY);
+ if (!cached) return false;
+
+ const { timestamp } = JSON.parse(cached);
+ return Date.now() - timestamp < CACHE_DURATION;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Obtém país do cache
+ */
+function getCachedCountry(): CountryInfo | null {
+ if (typeof window === "undefined") return null;
+
+ try {
+ const cached = localStorage.getItem(CACHE_KEY);
+ if (!cached) return null;
+
+ const { data } = JSON.parse(cached);
+ return data;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Salva país no cache
+ */
+function saveToCache(country: CountryInfo): void {
+ if (typeof window === "undefined") return;
+
+ try {
+ localStorage.setItem(
+ CACHE_KEY,
+ JSON.stringify({
+ data: country,
+ timestamp: Date.now(),
+ })
+ );
+ } catch (error) {
+ console.warn("Erro ao salvar cache:", error);
+ }
+}
+
+/**
+ * Detecta o país do usuário usando detecção de IP server-side
+ * 1. Verifica cache local primeiro
+ * 2. Chama API Route /api/location que usa ip-api.com
+ * 3. Fallback para Brasil em caso de erro
+ */
+export async function detectUserCountry(
+ forceRefresh: boolean = false
+): Promise {
+ // 1. Verificar cache primeiro (a menos que forceRefresh seja true)
+ if (!forceRefresh && isCacheValid()) {
+ const cached = getCachedCountry();
+ if (cached) {
+ return cached;
+ }
+ }
+
+ try {
+ // 2. Chamar API Route que detecta país por IP
+ const response = await fetch("/api/location");
+
+ if (!response.ok) {
+ throw new Error(`API returned ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const countryInfo: CountryInfo = {
+ code: data.countryCode,
+ name: data.country,
+ };
+
+ saveToCache(countryInfo);
+ return countryInfo;
+ } catch (error) {
+ console.warn("Country detection failed:", error);
+
+ // 3. Fallback: Brasil
+ const fallback: CountryInfo = { code: "br", name: "Brazil" };
+ saveToCache(fallback);
+ return fallback;
+ }
+}
+
+/**
+ * Limpa o cache de país
+ */
+export function clearCountryCache(): void {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(CACHE_KEY);
+}
+
+/**
+ * Obtém informações do país atual (síncrono)
+ * Retorna null se não houver cache
+ */
+export function getCurrentCountry(): CountryInfo | null {
+ if (isCacheValid()) {
+ return getCachedCountry();
+ }
+ return null;
+}
+
+/**
+ * Verifica se o usuário está no Brasil
+ */
+export function isBrazil(): boolean {
+ const country = getCurrentCountry();
+ return country?.code === "br" || country?.code === "brazil";
+}
+
+/**
+ * Obtém o código do país para a API Open Food Facts
+ */
+export function getCountryCodeForAPI(): string {
+ const country = getCurrentCountry();
+ return country?.code || "br";
+}
diff --git a/lib/openFoodFactsApi.ts b/lib/openFoodFactsApi.ts
new file mode 100644
index 0000000..17067a4
--- /dev/null
+++ b/lib/openFoodFactsApi.ts
@@ -0,0 +1,434 @@
+/**
+ * Wrapper para a API Open Food Facts
+ * Centraliza todas as chamadas para a API e trata erros
+ */
+
+export interface Product {
+ code: string;
+ product_name: string;
+ brands?: string;
+ categories?: string;
+ ingredients_text?: string;
+ nutriments?: {
+ energy_100g?: number;
+ fat_100g?: number;
+ carbohydrates_100g?: number;
+ proteins_100g?: number;
+ sugars_100g?: number;
+ fiber_100g?: number;
+ sodium_100g?: number;
+ };
+ nutrition_grades?: string;
+ nova_group?: number;
+ image_url?: string;
+ image_small_url?: string;
+ image_front_url?: string;
+ image_front_small_url?: string;
+ countries?: string;
+ countries_tags?: string[];
+ allergens_tags?: string[];
+ additives_tags?: string[];
+}
+
+export interface SearchResult {
+ products: Product[];
+ count: number;
+ page: number;
+ page_size: number;
+ skip: number;
+}
+
+export interface ApiError {
+ message: string;
+ code?: string;
+ status?: number;
+}
+
+// Configurações da API - agora usando API Routes do Next.js
+const API_BASE_URL = ""; // Usar rotas relativas para API Routes
+
+/**
+ * Faz uma requisição para a API com tratamento de erros
+ */
+async function makeApiRequest(endpoint: string): Promise {
+ try {
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(
+ errorData.error || `HTTP ${response.status}: ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error("Erro na API:", error);
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : "Erro desconhecido ao consultar a API"
+ );
+ }
+}
+
+/**
+ * Busca um produto específico por código de barras
+ */
+export async function searchByBarcode(
+ barcode: string
+): Promise {
+ try {
+ // Limpar código de barras (remover espaços e caracteres não numéricos)
+ const cleanBarcode = barcode.replace(/\D/g, "");
+
+ if (cleanBarcode.length < 8 || cleanBarcode.length > 13) {
+ throw new Error("Código de barras deve ter entre 8 e 13 dígitos");
+ }
+
+ const data = await makeApiRequest(`/api/products/${cleanBarcode}`);
+
+ return data || null;
+ } catch (error) {
+ console.error("Erro ao buscar produto por código:", error);
+ return null;
+ }
+}
+
+/**
+ * Busca produtos por nome
+ */
+export async function searchByName(
+ query: string,
+ country?: string,
+ page: number = 1,
+ pageSize: number = 20
+): Promise {
+ try {
+ if (!query.trim()) {
+ throw new Error("Termo de busca não pode estar vazio");
+ }
+
+ // Construir parâmetros da URL para API Route
+ const params = new URLSearchParams({
+ q: query.trim(),
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ });
+
+ // Adicionar filtro de país se especificado
+ if (country) {
+ params.append("country", country);
+ }
+
+ const data = await makeApiRequest(
+ `/api/search?${params.toString()}`
+ );
+
+ return data;
+ } catch (error) {
+ console.error("Erro ao buscar produtos por nome:", error);
+ throw error;
+ }
+}
+
+/**
+ * Busca produtos por categoria
+ */
+export async function searchByCategory(
+ category: string,
+ country?: string,
+ page: number = 1,
+ pageSize: number = 20
+): Promise {
+ try {
+ const params = new URLSearchParams({
+ categories_tags_en: category,
+ page_size: pageSize.toString(),
+ page: page.toString(),
+ json: "true",
+ fields: [
+ "code",
+ "product_name",
+ "brands",
+ "categories",
+ "image_url",
+ "image_small_url",
+ "nutrition_grades",
+ "nova_group",
+ ].join(","),
+ });
+
+ if (country) {
+ params.append("countries_tags_en", country);
+ }
+
+ // Chamada direta à API externa (temporária)
+ const response = await fetch(
+ `https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
+ {
+ headers: {
+ "User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)",
+ Accept: "application/json",
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ return data;
+ } catch (error) {
+ console.error("Erro ao buscar produtos por categoria:", error);
+ throw error;
+ }
+}
+
+/**
+ * Obtém categorias populares
+ */
+export async function getPopularCategories(
+ country?: string
+): Promise {
+ try {
+ const params = new URLSearchParams({
+ json: "true",
+ fields: "categories_tags",
+ });
+
+ if (country) {
+ params.append("countries_tags_en", country);
+ }
+
+ // Chamada direta à API externa (temporária)
+ const response = await fetch(
+ `https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
+ {
+ headers: {
+ "User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)",
+ Accept: "application/json",
+ },
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ return (
+ data.facets?.categories_tags
+ ?.slice(0, 20)
+ .map((cat: { name: string; count: number }) => cat.name) || []
+ );
+ } catch (error) {
+ console.error("Erro ao obter categorias:", error);
+ return [];
+ }
+}
+
+/**
+ * Valida se um código de barras tem formato válido
+ */
+export function isValidBarcode(barcode: string): boolean {
+ const cleanBarcode = barcode.replace(/\D/g, "");
+ return cleanBarcode.length >= 8 && cleanBarcode.length <= 13;
+}
+
+/**
+ * Detecta o tipo de busca baseado na entrada do usuário
+ */
+export function detectSearchType(query: string): "barcode" | "name" {
+ const cleanQuery = query.trim();
+
+ // Se contém apenas números e tem 8-13 dígitos, é código de barras
+ if (/^\d{8,13}$/.test(cleanQuery)) {
+ return "barcode";
+ }
+
+ // Caso contrário, é busca por nome
+ return "name";
+}
+
+/**
+ * Formata dados nutricionais para exibição
+ */
+export function formatNutritionData(product: Product) {
+ const nutriments = product.nutriments;
+ if (!nutriments) return null;
+
+ // Função auxiliar para converter e formatar valores
+ const formatValue = (
+ value: number | undefined,
+ decimals: number = 1
+ ): string => {
+ if (value === undefined || value === null) return "N/A";
+ const numValue = typeof value === "string" ? parseFloat(value) : value;
+ if (isNaN(numValue)) return "N/A";
+ return decimals === 0
+ ? `${Math.round(numValue)}`
+ : `${numValue.toFixed(decimals)}`;
+ };
+
+ return {
+ energy: nutriments.energy_100g
+ ? `${formatValue(nutriments.energy_100g, 0)} kcal`
+ : "N/A",
+ fat: nutriments.fat_100g ? `${formatValue(nutriments.fat_100g)}g` : "N/A",
+ carbohydrates: nutriments.carbohydrates_100g
+ ? `${formatValue(nutriments.carbohydrates_100g)}g`
+ : "N/A",
+ proteins: nutriments.proteins_100g
+ ? `${formatValue(nutriments.proteins_100g)}g`
+ : "N/A",
+ sugars: nutriments.sugars_100g
+ ? `${formatValue(nutriments.sugars_100g)}g`
+ : "N/A",
+ fiber: nutriments.fiber_100g
+ ? `${formatValue(nutriments.fiber_100g)}g`
+ : "N/A",
+ sodium: nutriments.sodium_100g
+ ? `${formatValue(nutriments.sodium_100g)}g`
+ : "N/A",
+ };
+}
+
+/**
+ * Obtém a URL da imagem do produto (prioriza front, depois small)
+ */
+export function getProductImageUrl(product: Product): string | null {
+ return (
+ product.image_front_url ||
+ product.image_front_small_url ||
+ product.image_url ||
+ product.image_small_url ||
+ null
+ );
+}
+
+/**
+ * Calcula pontuação de completude de um produto
+ */
+function calculateCompletenessScore(product: Product): number {
+ let score = 0;
+ if (product.product_name) score += 10;
+ if (product.brands) score += 10;
+ if (product.image_front_url || product.image_url) score += 15;
+ if (product.nutriments?.energy_100g) score += 20;
+ if (product.nutrition_grades) score += 15;
+ if (product.nova_group) score += 10;
+ if (product.ingredients_text) score += 10;
+ if (product.categories) score += 10;
+ return score;
+}
+
+/**
+ * Ordena produtos com base em critérios especiais
+ */
+export function sortProducts(
+ products: Product[],
+ sortBy: string,
+ sortOrder: "asc" | "desc" = "asc"
+): Product[] {
+ const sorted = [...products].sort((a, b) => {
+ let comparison = 0;
+
+ switch (sortBy) {
+ case "with_images":
+ const aHasImage = !!(a.image_front_url || a.image_url);
+ const bHasImage = !!(b.image_front_url || b.image_url);
+ comparison = (bHasImage ? 1 : 0) - (aHasImage ? 1 : 0);
+ break;
+
+ case "with_nutrition":
+ const aHasNutrition = !!a.nutriments?.energy_100g;
+ const bHasNutrition = !!b.nutriments?.energy_100g;
+ comparison = (bHasNutrition ? 1 : 0) - (aHasNutrition ? 1 : 0);
+ break;
+
+ case "completeness":
+ const aScore = calculateCompletenessScore(a);
+ const bScore = calculateCompletenessScore(b);
+ comparison = bScore - aScore;
+ break;
+
+ case "nova_group":
+ comparison = (a.nova_group || 5) - (b.nova_group || 5);
+ break;
+
+ case "eco_score":
+ // Para eco_score, usar nutri-score como proxy (A = melhor eco)
+ const aGrade = a.nutrition_grades?.toLowerCase();
+ const bGrade = b.nutrition_grades?.toLowerCase();
+ const gradeOrder = { a: 1, b: 2, c: 3, d: 4, e: 5 };
+ const aGradeValue = gradeOrder[aGrade as keyof typeof gradeOrder] || 6;
+ const bGradeValue = gradeOrder[bGrade as keyof typeof gradeOrder] || 6;
+ comparison = aGradeValue - bGradeValue;
+ break;
+
+ case "relevance":
+ // Para relevância, usar completude + nutri-score
+ const aRelevance =
+ calculateCompletenessScore(a) + (a.nutrition_grades ? 20 : 0);
+ const bRelevance =
+ calculateCompletenessScore(b) + (b.nutrition_grades ? 20 : 0);
+ comparison = bRelevance - aRelevance;
+ break;
+
+ case "nutrition_grade":
+ const aNutriGrade = a.nutrition_grades?.toLowerCase();
+ const bNutriGrade = b.nutrition_grades?.toLowerCase();
+ const gradeOrderForNutri = { a: 1, b: 2, c: 3, d: 4, e: 5 };
+ const aNutriValue =
+ gradeOrderForNutri[aNutriGrade as keyof typeof gradeOrderForNutri] ||
+ 6;
+ const bNutriValue =
+ gradeOrderForNutri[bNutriGrade as keyof typeof gradeOrderForNutri] ||
+ 6;
+ comparison = aNutriValue - bNutriValue;
+ break;
+
+ case "name":
+ comparison = (a.product_name || "").localeCompare(b.product_name || "");
+ break;
+
+ case "brand":
+ comparison = (a.brands || "").localeCompare(b.brands || "");
+ break;
+
+ case "energy":
+ const aEnergy = a.nutriments?.energy_100g || 0;
+ const bEnergy = b.nutriments?.energy_100g || 0;
+ comparison = aEnergy - bEnergy;
+ break;
+
+ case "fat":
+ const aFat = a.nutriments?.fat_100g || 0;
+ const bFat = b.nutriments?.fat_100g || 0;
+ comparison = aFat - bFat;
+ break;
+
+ case "sugars":
+ const aSugars = a.nutriments?.sugars_100g || 0;
+ const bSugars = b.nutriments?.sugars_100g || 0;
+ comparison = aSugars - bSugars;
+ break;
+
+ default:
+ comparison = 0;
+ }
+
+ return sortOrder === "asc" ? comparison : -comparison;
+ });
+
+ return sorted;
+}
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 0000000..eeb9fce
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,282 @@
+/**
+ * Tipos para sistema de filtros de produtos
+ * Baseado na API do Open Food Facts
+ */
+
+export interface ProductFilters {
+ // Filtros de classificação nutricional
+ nutritionGrades?: ("a" | "b" | "c" | "d" | "e")[];
+ novaGroups?: (1 | 2 | 3 | 4)[];
+
+ // Filtros de categoria e marca
+ categories?: string[];
+ brands?: string[];
+
+ // Filtros de ingredientes e aditivos
+ allergens?: string[];
+ excludeAllergens?: string[];
+ additives?: string[];
+ excludeAdditives?: string[];
+ noAdditives?: boolean;
+
+ // Filtros nutricionais (valores por 100g)
+ nutritionRanges?: {
+ energy?: { min?: number; max?: number };
+ fat?: { min?: number; max?: number };
+ carbohydrates?: { min?: number; max?: number };
+ proteins?: { min?: number; max?: number };
+ sugars?: { min?: number; max?: number };
+ fiber?: { min?: number; max?: number };
+ sodium?: { min?: number; max?: number };
+ };
+
+ // Filtros de país
+ countries?: string[];
+
+ // Ordenação
+ sortBy?:
+ | "relevance"
+ | "nutrition_grade"
+ | "name"
+ | "brand"
+ | "energy"
+ | "fat"
+ | "sugars";
+ sortOrder?: "asc" | "desc";
+}
+
+export interface FilterOption {
+ value: string;
+ label: string;
+ count?: number;
+ color?: string;
+ description?: string;
+}
+
+export interface NutritionGrade {
+ grade: "a" | "b" | "c" | "d" | "e";
+ label: string;
+ color: string;
+ description: string;
+}
+
+export interface NovaGroup {
+ group: 1 | 2 | 3 | 4;
+ label: string;
+ color: string;
+ description: string;
+}
+
+export interface SortOption {
+ value: string;
+ label: string;
+ description?: string;
+ icon?: string;
+}
+
+export interface FilterState {
+ filters: ProductFilters;
+ isLoading: boolean;
+ hasActiveFilters: boolean;
+ totalResults: number;
+}
+
+export interface FilterSidebarProps {
+ filters: ProductFilters;
+ onFiltersChange: (filters: ProductFilters) => void;
+ onClearFilters: () => void;
+ isLoading?: boolean;
+ totalResults?: number;
+}
+
+// Constantes para configuração dos filtros
+export const NUTRITION_GRADES: NutritionGrade[] = [
+ {
+ grade: "a",
+ label: "A",
+ color: "bg-green-500",
+ description: "Muito bom",
+ },
+ {
+ grade: "b",
+ label: "B",
+ color: "bg-green-400",
+ description: "Bom",
+ },
+ {
+ grade: "c",
+ label: "C",
+ color: "bg-yellow-500",
+ description: "Regular",
+ },
+ {
+ grade: "d",
+ label: "D",
+ color: "bg-orange-500",
+ description: "Ruim",
+ },
+ {
+ grade: "e",
+ label: "E",
+ color: "bg-red-500",
+ description: "Muito ruim",
+ },
+];
+
+export const NOVA_GROUPS: NovaGroup[] = [
+ {
+ group: 1,
+ label: "1",
+ color: "bg-green-500",
+ description: "Alimentos não processados ou minimamente processados",
+ },
+ {
+ group: 2,
+ label: "2",
+ color: "bg-yellow-500",
+ description: "Ingredientes culinários processados",
+ },
+ {
+ group: 3,
+ label: "3",
+ color: "bg-orange-500",
+ description: "Alimentos processados",
+ },
+ {
+ group: 4,
+ label: "4",
+ color: "bg-red-500",
+ description: "Alimentos ultraprocessados",
+ },
+];
+
+export const SORT_OPTIONS: SortOption[] = [
+ {
+ value: "relevance",
+ label: "Relevância",
+ description: "Resultados mais relevantes primeiro",
+ icon: "TrendingUp",
+ },
+ {
+ value: "nutrition_grade",
+ label: "Nutri-Score",
+ description: "Melhor classificação nutricional primeiro",
+ icon: "Zap",
+ },
+ {
+ value: "name",
+ label: "Nome (A-Z)",
+ description: "Ordenar por nome do produto",
+ icon: "AlphabeticalOrder",
+ },
+ {
+ value: "brand",
+ label: "Marca (A-Z)",
+ description: "Ordenar por marca",
+ icon: "Building2",
+ },
+ {
+ value: "energy",
+ label: "Calorias",
+ description: "Menor valor energético primeiro",
+ icon: "Flame",
+ },
+ {
+ value: "fat",
+ label: "Gorduras",
+ description: "Menor teor de gordura primeiro",
+ icon: "Droplet",
+ },
+ {
+ value: "sugars",
+ label: "Açúcares",
+ description: "Menor teor de açúcar primeiro",
+ icon: "Candy",
+ },
+ {
+ value: "completeness",
+ label: "Completude",
+ description: "Produtos com mais informações primeiro",
+ icon: "FileCheck",
+ },
+ {
+ value: "with_images",
+ label: "Com Fotos",
+ description: "Produtos com imagens primeiro",
+ icon: "Image",
+ },
+ {
+ value: "with_nutrition",
+ label: "Com Info Nutricional",
+ description: "Produtos com dados nutricionais completos",
+ icon: "Activity",
+ },
+ {
+ value: "eco_score",
+ label: "Eco-Score",
+ description: "Melhor impacto ambiental primeiro",
+ icon: "Leaf",
+ },
+ {
+ value: "nova_group",
+ label: "Nível de Processamento",
+ description: "Menos processados primeiro",
+ icon: "Layers",
+ },
+];
+
+// Categorias populares para filtros
+export const POPULAR_CATEGORIES = [
+ "Beverages",
+ "Dairy",
+ "Snacks",
+ "Bakery",
+ "Meat",
+ "Fruits",
+ "Vegetables",
+ "Cereals",
+ "Sweets",
+ "Condiments",
+ "Frozen foods",
+ "Canned foods",
+];
+
+// Categorias com ícones para interface
+export const POPULAR_CATEGORIES_WITH_ICONS = [
+ { value: "Beverages", label: "Bebidas", icon: "Coffee" },
+ { value: "Dairy", label: "Laticínios", icon: "Milk" },
+ { value: "Snacks", label: "Snacks", icon: "Cookie" },
+ { value: "Bakery", label: "Padaria", icon: "Croissant" },
+ { value: "Meat", label: "Carnes", icon: "Beef" },
+ { value: "Fruits", label: "Frutas", icon: "Apple" },
+ { value: "Vegetables", label: "Vegetais", icon: "Carrot" },
+ { value: "Cereals", label: "Cereais", icon: "Wheat" },
+ { value: "Sweets", label: "Doces", icon: "Candy" },
+ { value: "Condiments", label: "Condimentos", icon: "Soup" },
+ { value: "Frozen foods", label: "Congelados", icon: "Snowflake" },
+ { value: "Canned foods", label: "Enlatados", icon: "Package" },
+];
+
+// Alérgenos comuns
+export const COMMON_ALLERGENS = [
+ "gluten",
+ "milk",
+ "eggs",
+ "nuts",
+ "peanuts",
+ "soy",
+ "fish",
+ "shellfish",
+ "sesame",
+];
+
+// Aditivos comuns
+export const COMMON_ADDITIVES = [
+ "preservatives",
+ "artificial colors",
+ "artificial flavors",
+ "sweeteners",
+ "emulsifiers",
+ "stabilizers",
+ "thickeners",
+];
diff --git a/package.json b/package.json
index 6a77f19..dae31bc 100644
--- a/package.json
+++ b/package.json
@@ -14,19 +14,28 @@
},
"dependencies": {
"@prisma/client": "^5.22.0",
+ "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.2",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
+ "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.1.2",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
+ "@radix-ui/react-switch": "^1.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.12.0",
+ "html5-qrcode": "^2.3.8",
"lucide-react": "^0.460.0",
"next": "14.2.18",
"next-themes": "^0.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-icons": "^5.5.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df9ca66..36137de 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,22 +10,43 @@ importers:
dependencies:
'@prisma/client':
specifier: ^5.22.0
- version: 5.22.0
+ version: 5.22.0(prisma@5.22.0)
+ '@radix-ui/react-accordion':
+ specifier: ^1.2.12
+ version: 1.2.12(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-avatar':
+ specifier: ^1.1.10
+ version: 1.1.10(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-checkbox':
+ specifier: ^1.3.3
+ version: 1.3.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.3.8
+ version: 1.3.8(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-separator':
+ specifier: ^1.1.7
+ version: 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slider':
+ specifier: ^1.3.6
+ version: 1.3.6(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -35,6 +56,9 @@ importers:
framer-motion:
specifier: ^11.12.0
version: 11.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ html5-qrcode:
+ specifier: ^2.3.8
+ version: 2.3.8
lucide-react:
specifier: ^0.460.0
version: 0.460.0(react@18.3.1)
@@ -50,6 +74,9 @@ importers:
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.5.0(react@18.3.1)
tailwind-merge:
specifier: ^2.5.5
version: 2.5.5
@@ -78,6 +105,9 @@ importers:
postcss:
specifier: ^8.4.49
version: 8.4.49
+ prisma:
+ specifier: ^5.22.0
+ version: 5.22.0
tailwindcss:
specifier: ^3.4.15
version: 3.4.15
@@ -281,12 +311,46 @@ packages:
prisma:
optional: true
+ '@prisma/debug@5.22.0':
+ resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
+ resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
+
+ '@prisma/engines@5.22.0':
+ resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
+
+ '@prisma/fetch-engine@5.22.0':
+ resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
+
+ '@prisma/get-platform@5.22.0':
+ resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
+
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
'@radix-ui/primitive@1.1.0':
resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-accordion@1.2.12':
+ resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-alert-dialog@1.1.2':
resolution: {integrity: sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==}
peerDependencies:
@@ -313,6 +377,45 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-avatar@1.1.10':
+ resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-checkbox@1.3.3':
+ resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collapsible@1.1.12':
+ resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collection@1.1.0':
resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==}
peerDependencies:
@@ -326,6 +429,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-compose-refs@1.1.0':
resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==}
peerDependencies:
@@ -335,6 +451,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-context@1.1.0':
resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==}
peerDependencies:
@@ -353,6 +478,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dialog@1.1.2':
resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==}
peerDependencies:
@@ -375,6 +509,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dismissable-layer@1.1.1':
resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==}
peerDependencies:
@@ -432,6 +575,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-menu@2.1.2':
resolution: {integrity: sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==}
peerDependencies:
@@ -484,6 +636,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-primitive@2.0.0':
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
@@ -497,6 +662,32 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-radio-group@1.3.8':
+ resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-roving-focus@1.1.0':
resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==}
peerDependencies:
@@ -510,6 +701,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.1.2':
resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==}
peerDependencies:
@@ -523,6 +727,32 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-separator@1.1.7':
+ resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slider@1.3.6':
+ resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-slot@1.1.0':
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies:
@@ -532,6 +762,28 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-switch@1.2.6':
+ resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-use-callback-ref@1.1.0':
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
peerDependencies:
@@ -541,6 +793,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-controllable-state@1.1.0':
resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==}
peerDependencies:
@@ -550,6 +811,24 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-escape-keydown@1.1.0':
resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==}
peerDependencies:
@@ -559,6 +838,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-is-hydrated@0.1.0':
+ resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-layout-effect@1.1.0':
resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==}
peerDependencies:
@@ -568,6 +856,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
@@ -577,6 +874,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-previous@1.1.1':
+ resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies:
@@ -595,6 +901,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-size@1.1.1':
+ resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-visually-hidden@1.1.0':
resolution: {integrity: sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==}
peerDependencies:
@@ -1362,6 +1677,9 @@ packages:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
engines: {node: '>=0.10.0'}
+ html5-qrcode@2.3.8:
+ resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
+
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1855,6 +2173,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
+ prisma@5.22.0:
+ resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
+ engines: {node: '>=16.13'}
+ hasBin: true
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1870,6 +2193,11 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-icons@5.5.0:
+ resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
+ peerDependencies:
+ react: '*'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -2198,6 +2526,11 @@ packages:
'@types/react':
optional: true
+ use-sync-external-store@1.6.0:
+ resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2442,12 +2775,56 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@prisma/client@5.22.0': {}
+ '@prisma/client@5.22.0(prisma@5.22.0)':
+ optionalDependencies:
+ prisma: 5.22.0
+
+ '@prisma/debug@5.22.0': {}
+
+ '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
+
+ '@prisma/engines@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/fetch-engine': 5.22.0
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/fetch-engine@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
+ '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
+ '@prisma/get-platform': 5.22.0
+
+ '@prisma/get-platform@5.22.0':
+ dependencies:
+ '@prisma/debug': 5.22.0
'@radix-ui/number@1.1.0': {}
+ '@radix-ui/number@1.1.1': {}
+
'@radix-ui/primitive@1.1.0': {}
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-accordion@1.2.12(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -2471,6 +2848,51 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-avatar@1.1.10(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
+ '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -2483,12 +2905,30 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-context@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -2501,6 +2941,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-context@1.1.2(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -2529,6 +2975,12 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -2581,6 +3033,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-id@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -2645,6 +3104,16 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -2654,6 +3123,33 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
@@ -2671,6 +3167,23 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-select@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.0
@@ -2700,6 +3213,34 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
+ '@radix-ui/react-separator@1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
+ '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-slot@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -2707,12 +3248,40 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+ '@types/react-dom': 18.3.1
+
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-use-controllable-state@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -2720,6 +3289,21 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.12)(react@18.3.1)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1)
@@ -2727,18 +3311,37 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ use-sync-external-store: 1.6.0(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-use-previous@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-use-rect@1.1.0(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/rect': 1.1.0
@@ -2753,6 +3356,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ '@radix-ui/react-use-size@1.1.1(@types/react@18.3.12)(react@18.3.1)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+ react: 18.3.1
+ optionalDependencies:
+ '@types/react': 18.3.12
+
'@radix-ui/react-visually-hidden@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -3738,6 +4348,8 @@ snapshots:
dependencies:
parse-passwd: 1.0.0
+ html5-qrcode@2.3.8: {}
+
ignore@5.3.2: {}
import-fresh@3.3.0:
@@ -4200,6 +4812,12 @@ snapshots:
prelude-ls@1.2.1: {}
+ prisma@5.22.0:
+ dependencies:
+ '@prisma/engines': 5.22.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -4216,6 +4834,10 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-icons@5.5.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-is@16.13.1: {}
react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):
@@ -4595,6 +5217,10 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
+ use-sync-external-store@1.6.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
util-deprecate@1.0.2: {}
which-boxed-primitive@1.0.2:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..d9932f9
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+onlyBuiltDependencies:
+ - '@prisma/client'
+ - '@prisma/engines'
+ - prisma
diff --git a/prisma/prisma/db/database.sqlite b/prisma/prisma/db/database.sqlite
new file mode 100644
index 0000000..5a48ed0
Binary files /dev/null and b/prisma/prisma/db/database.sqlite differ
diff --git a/prisma/prisma/db/database.sqlite-journal b/prisma/prisma/db/database.sqlite-journal
new file mode 100644
index 0000000..a64a5a9
Binary files /dev/null and b/prisma/prisma/db/database.sqlite-journal differ
diff --git a/prisma/prisma/prisma/db/database.sqlite b/prisma/prisma/prisma/db/database.sqlite
new file mode 100644
index 0000000..6c32072
Binary files /dev/null and b/prisma/prisma/prisma/db/database.sqlite differ
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cc776c4..42cb658 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -9,9 +9,9 @@ datasource db {
model Lista {
id String @id
- produtos Produtos[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
+ produtos Produtos[]
}
model Produtos {
@@ -20,8 +20,8 @@ model Produtos {
qtd Float
tipoUN String
comprado Boolean @default(false)
- listaId String @default("default") // Valor padrão temporário
- lista Lista @relation(fields: [listaId], references: [id])
+ listaId String @default("default")
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
+ lista Lista @relation(fields: [listaId], references: [id])
}
diff --git a/public/manifest.json b/public/manifest.json
index 2a8616d..98dc39b 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,21 +1,18 @@
{
- "name": "Resenha Itapoá",
- "short_name": "Resenha",
- "description": "Gerenciador de listas de compras",
- "start_url": "/",
- "display": "standalone",
- "background_color": "#ffffff",
- "theme_color": "#0055FF",
- "icons": [
- {
- "src": "/icon-192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "/icon-512.png",
- "sizes": "512x512",
- "type": "image/png"
- }
- ]
- }
\ No newline at end of file
+ "name": "NutriScan - Informações Nutricionais",
+ "short_name": "NutriScan",
+ "description": "Descubra informações nutricionais completas de milhares de produtos alimentícios",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#0055FF",
+ "categories": ["food", "nutrition", "health", "lifestyle"],
+ "lang": "pt-BR",
+ "icons": [
+ {
+ "src": "/favicon.ico",
+ "sizes": "any",
+ "type": "image/x-icon"
+ }
+ ]
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index eabbf2e..77c432a 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -8,59 +8,84 @@ const config: Config = {
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
- extend: {
- fontFamily: {
- display: ['Poppins', 'sans-serif'],
- },
- colors: {
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))'
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))'
- },
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))'
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))'
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))'
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))'
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))'
- },
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- chart: {
- '1': 'hsl(var(--chart-1))',
- '2': 'hsl(var(--chart-2))',
- '3': 'hsl(var(--chart-3))',
- '4': 'hsl(var(--chart-4))',
- '5': 'hsl(var(--chart-5))'
- }
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)'
- }
- }
- },
+ extend: {
+ fontFamily: {
+ display: [
+ 'Poppins',
+ 'sans-serif'
+ ]
+ },
+ colors: {
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))'
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))'
+ },
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))'
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))'
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))'
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))'
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))'
+ },
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))'
+ }
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)'
+ },
+ keyframes: {
+ 'accordion-down': {
+ from: {
+ height: '0'
+ },
+ to: {
+ height: 'var(--radix-accordion-content-height)'
+ }
+ },
+ 'accordion-up': {
+ from: {
+ height: 'var(--radix-accordion-content-height)'
+ },
+ to: {
+ height: '0'
+ }
+ }
+ },
+ animation: {
+ 'accordion-down': 'accordion-down 0.2s ease-out',
+ 'accordion-up': 'accordion-up 0.2s ease-out'
+ }
+ }
+ },
plugins: [require("tailwindcss-animate")],
};
export default config;