From 9a4e4c762e7db3f6affa1d01e09602625050d5e0 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 9 Oct 2025 00:14:30 -0300 Subject: [PATCH] feat(ui): enhance footer with NutriScan branding and fix database path - Redesigned Footer.tsx with comprehensive layout including brand section, navigation links, social media icons, and contact info to improve user engagement and brand visibility - Updated DATABASE_URL in .env to correct Prisma database path for proper configuration and connectivity - Updated dependencies to support new UI components and ensure compatibility with enhanced features --- .vscode/settings.json | 4 + app/api/location/route.ts | 97 ++++ app/api/products/[barcode]/route.ts | 55 ++ app/api/search/route.ts | 209 +++++++ app/components/FilterBadge.tsx | 181 +++++++ app/components/FilterSidebar.tsx | 508 +++++++++++++++++ app/components/Footer.tsx | 174 +++++- app/components/NutritionChart.tsx | 299 ++++++++++ app/components/ProductCard.tsx | 310 ++++++++--- app/components/ProductCardSkeleton.tsx | 29 + app/components/SortDropdown.tsx | 138 +++++ app/components/header.tsx | 151 +++++- app/components/landing/BarcodeScanner.tsx | 237 ++++++++ app/components/landing/Features.tsx | 175 ++++++ app/components/landing/Hero.tsx | 270 ++++++++++ app/components/landing/HowItWorks.tsx | 184 +++++++ app/components/landing/SearchBar.tsx | 164 ++++++ app/components/pricing/BillingToggle.tsx | 60 +++ app/components/pricing/PricingCard.tsx | 283 ++++++++++ app/components/pricing/PricingFAQ.tsx | 92 ++++ app/components/pricing/PricingHero.tsx | 20 + app/layout.tsx | 81 +-- app/page.tsx | 24 +- app/pricing/page.tsx | 257 +++++++++ app/produto/[barcode]/page.tsx | 452 ++++++++++++++++ app/resultados/page.tsx | 399 ++++++++++++++ components/ui/accordion.tsx | 57 ++ components/ui/avatar.tsx | 50 ++ components/ui/badge.tsx | 36 ++ components/ui/card.tsx | 76 +++ components/ui/checkbox.tsx | 30 ++ components/ui/radio-group.tsx | 44 ++ components/ui/separator.tsx | 31 ++ components/ui/sheet.tsx | 140 +++++ components/ui/skeleton.tsx | 15 + components/ui/slider.tsx | 28 + components/ui/switch.tsx | 29 + docs/openFoodFacts.md | 285 ++++++++++ hooks/useProductFilters.ts | 255 +++++++++ lib/geolocation.ts | 145 +++++ lib/openFoodFactsApi.ts | 434 +++++++++++++++ lib/types.ts | 282 ++++++++++ package.json | 9 + pnpm-lock.yaml | 630 +++++++++++++++++++++- pnpm-workspace.yaml | 4 + prisma/prisma/db/database.sqlite | Bin 0 -> 12288 bytes prisma/prisma/db/database.sqlite-journal | Bin 0 -> 512 bytes prisma/prisma/prisma/db/database.sqlite | Bin 0 -> 28672 bytes prisma/schema.prisma | 6 +- public/manifest.json | 37 +- tailwind.config.ts | 131 +++-- 51 files changed, 7389 insertions(+), 218 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/api/location/route.ts create mode 100644 app/api/products/[barcode]/route.ts create mode 100644 app/api/search/route.ts create mode 100644 app/components/FilterBadge.tsx create mode 100644 app/components/FilterSidebar.tsx create mode 100644 app/components/NutritionChart.tsx create mode 100644 app/components/ProductCardSkeleton.tsx create mode 100644 app/components/SortDropdown.tsx create mode 100644 app/components/landing/BarcodeScanner.tsx create mode 100644 app/components/landing/Features.tsx create mode 100644 app/components/landing/Hero.tsx create mode 100644 app/components/landing/HowItWorks.tsx create mode 100644 app/components/landing/SearchBar.tsx create mode 100644 app/components/pricing/BillingToggle.tsx create mode 100644 app/components/pricing/PricingCard.tsx create mode 100644 app/components/pricing/PricingFAQ.tsx create mode 100644 app/components/pricing/PricingHero.tsx create mode 100644 app/pricing/page.tsx create mode 100644 app/produto/[barcode]/page.tsx create mode 100644 app/resultados/page.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/switch.tsx create mode 100644 docs/openFoodFacts.md create mode 100644 hooks/useProductFilters.ts create mode 100644 lib/geolocation.ts create mode 100644 lib/openFoodFactsApi.ts create mode 100644 lib/types.ts create mode 100644 pnpm-workspace.yaml create mode 100644 prisma/prisma/db/database.sqlite create mode 100644 prisma/prisma/db/database.sqlite-journal create mode 100644 prisma/prisma/prisma/db/database.sqlite diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e5a22b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "WillLuke.nextjs.addTypesOnSave": true, + "WillLuke.nextjs.hasPrompted": true +} diff --git a/app/api/location/route.ts b/app/api/location/route.ts new file mode 100644 index 0000000..f875b25 --- /dev/null +++ b/app/api/location/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + try { + let ip: string | null = null; + + // 1. Tentar obter IP dos headers (produção com proxy/CDN) + const forwarded = request.headers.get("x-forwarded-for"); + const realIp = request.headers.get("x-real-ip"); + + if (forwarded) { + ip = forwarded.split(",")[0].trim(); + } else if (realIp) { + ip = realIp; + } + + // 2. Se não conseguiu IP dos headers (localhost), usar serviço externo + if ( + !ip || + ip === "::1" || + ip === "127.0.0.1" || + ip.startsWith("192.168.") || + ip.startsWith("10.") + ) { + console.log( + "IP local detectado, obtendo IP público via api.ipify.org..." + ); + + try { + const ipifyResponse = await fetch("https://api.ipify.org?format=json", { + headers: { + "User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)", + }, + }); + + if (ipifyResponse.ok) { + const ipifyData = await ipifyResponse.json(); + ip = ipifyData.ip; + console.log(`IP público obtido: ${ip}`); + } + } catch (ipifyError) { + console.warn("Erro ao obter IP via ipify:", ipifyError); + } + } + + // 3. Se ainda não tem IP, usar fallback + if (!ip) { + console.warn("Não foi possível detectar IP, usando fallback"); + return NextResponse.json({ + country: "Brazil", + countryCode: "br", + }); + } + + console.log(`Detectando localização para IP: ${ip}`); + + // 4. Consultar API de geolocalização ip-api.com + const response = await fetch( + `http://ip-api.com/json/${ip}?fields=status,country,countryCode`, + { + headers: { + "User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)", + }, + } + ); + + if (!response.ok) { + throw new Error(`IP API returned ${response.status}`); + } + + const data = await response.json(); + + // 5. Verificar se a API retornou sucesso + if (data.status === "fail") { + console.warn("IP API failed, using fallback"); + return NextResponse.json({ + country: "Brazil", + countryCode: "br", + }); + } + + console.log(`País detectado: ${data.country} (${data.countryCode})`); + + return NextResponse.json({ + country: data.country, + countryCode: data.countryCode.toLowerCase(), + }); + } catch (error) { + console.error("Erro ao detectar localização:", error); + + // Fallback para Brasil em caso de erro + return NextResponse.json({ + country: "Brazil", + countryCode: "br", + }); + } +} diff --git a/app/api/products/[barcode]/route.ts b/app/api/products/[barcode]/route.ts new file mode 100644 index 0000000..49b996d --- /dev/null +++ b/app/api/products/[barcode]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; + +const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; + +export async function GET( + request: NextRequest, + { params }: { params: { barcode: string } } +) { + const { barcode } = params; + + // Validar código de barras + const cleanBarcode = barcode.replace(/\D/g, ""); + if (cleanBarcode.length < 8 || cleanBarcode.length > 13) { + return NextResponse.json( + { error: "Código de barras inválido" }, + { status: 400 } + ); + } + + try { + const response = await fetch( + `https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json`, + { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + return NextResponse.json( + { error: "Produto não encontrado" }, + { status: 404 } + ); + } + + const data = await response.json(); + + if (data.status === 0) { + return NextResponse.json( + { error: "Produto não encontrado" }, + { status: 404 } + ); + } + + return NextResponse.json(data.product); + } catch (error) { + console.error("Erro ao buscar produto:", error); + return NextResponse.json( + { error: "Erro ao buscar produto" }, + { status: 500 } + ); + } +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..073f987 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from "next/server"; + +const USER_AGENT = "NutriScan/1.0 (https://nutriscan.com.br)"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("q"); + const country = searchParams.get("country"); + const page = searchParams.get("page") || "1"; + const pageSize = searchParams.get("page_size") || "20"; + + if (!query) { + return NextResponse.json( + { error: "Query parameter is required" }, + { status: 400 } + ); + } + + // Validar parâmetros + const pageNum = parseInt(page); + const pageSizeNum = parseInt(pageSize); + + if (isNaN(pageNum) || pageNum < 1) { + return NextResponse.json( + { error: "Invalid page parameter" }, + { status: 400 } + ); + } + + if (isNaN(pageSizeNum) || pageSizeNum < 1 || pageSizeNum > 50) { + return NextResponse.json( + { error: "Invalid page_size parameter (max 50)" }, + { status: 400 } + ); + } + + const params = new URLSearchParams({ + search_terms: query, + page_size: pageSize, + page: page, + json: "true", + fields: [ + "code", + "product_name", + "brands", + "categories", + "ingredients_text", + "nutriments", + "nutrition_grades", + "nova_group", + "image_url", + "image_small_url", + "image_front_url", + "image_front_small_url", + "countries", + "countries_tags", + "allergens_tags", + "additives_tags", + ].join(","), + }); + + // Filtros básicos + if (country) { + params.append("countries_tags_en", country); + } + + // Filtros de classificação nutricional + const nutritionGrades = searchParams.get("nutrition_grades"); + if (nutritionGrades) { + params.append("nutrition_grades_tags", nutritionGrades); + } + + const novaGroups = searchParams.get("nova_groups"); + if (novaGroups) { + params.append("nova_groups", novaGroups); + } + + // Filtros de categoria e marca + const categories = searchParams.get("categories"); + if (categories) { + params.append("categories_tags", categories); + } + + const brands = searchParams.get("brands"); + if (brands) { + params.append("brands", brands); + } + + // Filtros de alérgenos + const allergens = searchParams.get("allergens"); + if (allergens) { + params.append("allergens_tags", allergens); + } + + const excludeAllergens = searchParams.get("exclude_allergens"); + if (excludeAllergens) { + params.append("exclude_allergens_tags", excludeAllergens); + } + + // Filtros de aditivos + const additives = searchParams.get("additives"); + if (additives) { + params.append("additives_tags", additives); + } + + const excludeAdditives = searchParams.get("exclude_additives"); + if (excludeAdditives) { + params.append("exclude_additives_tags", excludeAdditives); + } + + const noAdditives = searchParams.get("no_additives"); + if (noAdditives === "true") { + params.append("additives_tags", "without-additives"); + } + + // Filtros de países + const countries = searchParams.get("countries"); + if (countries) { + params.append("countries_tags", countries); + } + + // Filtros nutricionais (valores por 100g) + const nutritionFilters = [ + "energy_min", + "energy_max", + "fat_min", + "fat_max", + "carbohydrates_min", + "carbohydrates_max", + "proteins_min", + "proteins_max", + "sugars_min", + "sugars_max", + "fiber_min", + "fiber_max", + "sodium_min", + "sodium_max", + ]; + + nutritionFilters.forEach((filter) => { + const value = searchParams.get(filter); + if (value) { + params.append(filter, value); + } + }); + + // Ordenação + const sortBy = searchParams.get("sort_by"); + const sortOrder = searchParams.get("sort_order") || "asc"; + + if (sortBy) { + let sortField = ""; + switch (sortBy) { + case "nutrition_grade": + sortField = "nutrition_grades"; + break; + case "name": + sortField = "product_name"; + break; + case "brand": + sortField = "brands"; + break; + case "energy": + sortField = "nutriments.energy_100g"; + break; + case "fat": + sortField = "nutriments.fat_100g"; + break; + case "sugars": + sortField = "nutriments.sugars_100g"; + break; + default: + sortField = "popularity"; + } + + if (sortField) { + params.append("sort_by", sortField); + params.append("sort_order", sortOrder); + } + } + + try { + const response = await fetch( + `https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`, + { + headers: { + "User-Agent": USER_AGENT, + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + return NextResponse.json( + { error: "Erro ao buscar produtos" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error("Erro ao buscar produtos:", error); + return NextResponse.json( + { error: "Erro ao buscar produtos" }, + { status: 500 } + ); + } +} diff --git a/app/components/FilterBadge.tsx b/app/components/FilterBadge.tsx new file mode 100644 index 0000000..9eda23c --- /dev/null +++ b/app/components/FilterBadge.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface FilterBadgeProps { + label: string; + onRemove: () => void; + variant?: "default" | "secondary" | "destructive" | "outline"; + color?: string; +} + +export function FilterBadge({ + label, + onRemove, + variant = "secondary", + color, +}: FilterBadgeProps) { + return ( + + {label} + + + ); +} + +interface FilterBadgesProps { + filters: Record; + onRemoveFilter: (key: string, value?: string) => void; + onClearAll: () => void; +} + +export function FilterBadges({ + filters, + onRemoveFilter, + onClearAll, +}: FilterBadgesProps) { + const activeFilters = Object.entries(filters).filter(([, 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 !== ""; + }); + + if (activeFilters.length === 0) { + return null; + } + + return ( +
+ + Filtros ativos: + + +
+ {activeFilters.map(([key, value]) => { + if (Array.isArray(value)) { + return value.map((item, index) => ( + onRemoveFilter(key, item)} + color={getFilterColor(key, item)} + /> + )); + } + + if (typeof value === "object" && value !== null) { + return Object.entries(value) + .map(([rangeKey, rangeValue]) => { + const range = rangeValue as { min?: number; max?: number }; + if (range?.min !== undefined || range?.max !== undefined) { + return ( + onRemoveFilter(key, rangeKey)} + /> + ); + } + return null; + }) + .filter(Boolean); + } + + return ( + onRemoveFilter(key)} + color={getFilterColor(key, value)} + /> + ); + })} +
+ + +
+ ); +} + +function getFilterLabel(key: string, value: unknown): string { + const labels: Record string> = { + nutrition_grades: (v) => `Nutri-Score ${String(v).toUpperCase()}`, + nova_groups: (v) => `NOVA ${v}`, + categories: (v) => String(v), + brands: (v) => String(v), + allergens: (v) => `Alérgeno: ${v}`, + exclude_allergens: (v) => `Sem: ${v}`, + additives: (v) => `Aditivo: ${v}`, + exclude_additives: (v) => `Sem aditivo: ${v}`, + no_additives: () => "Sem aditivos", + countries: (v) => String(v), + sort_by: (v) => `Ordenar por: ${v}`, + sort_order: (v) => (v === "asc" ? "Crescente" : "Decrescente"), + }; + + return labels[key] ? labels[key](value) : `${key}: ${value}`; +} + +function getFilterColor(key: string, value: unknown): string { + if (key === "nutrition_grades") { + const colors: Record = { + a: "bg-green-100 text-green-800 border-green-200", + b: "bg-green-100 text-green-800 border-green-200", + c: "bg-yellow-100 text-yellow-800 border-yellow-200", + d: "bg-orange-100 text-orange-800 border-orange-200", + e: "bg-red-100 text-red-800 border-red-200", + }; + return colors[String(value)] || ""; + } + + if (key === "nova_groups") { + const colors: Record = { + 1: "bg-green-100 text-green-800 border-green-200", + 2: "bg-yellow-100 text-yellow-800 border-yellow-200", + 3: "bg-orange-100 text-orange-800 border-orange-200", + 4: "bg-red-100 text-red-800 border-red-200", + }; + return colors[String(value)] || ""; + } + + if (key === "allergens" || key === "exclude_allergens") { + return "bg-yellow-100 text-yellow-800 border-yellow-200"; + } + + if ( + key === "additives" || + key === "exclude_additives" || + key === "no_additives" + ) { + return "bg-blue-100 text-blue-800 border-blue-200"; + } + + return ""; +} diff --git a/app/components/FilterSidebar.tsx b/app/components/FilterSidebar.tsx new file mode 100644 index 0000000..ccb3873 --- /dev/null +++ b/app/components/FilterSidebar.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Search, Filter, X, AlertTriangle, Zap } from "lucide-react"; +import * as LucideIcons from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Switch } from "@/components/ui/switch"; +import { Slider } from "@/components/ui/slider"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + ProductFilters, + NUTRITION_GRADES, + NOVA_GROUPS, + POPULAR_CATEGORIES, + POPULAR_CATEGORIES_WITH_ICONS, + COMMON_ALLERGENS, + COMMON_ADDITIVES, +} from "@/lib/types"; + +interface FilterSidebarProps { + filters: ProductFilters; + onFiltersChange: (filters: ProductFilters) => void; + onClearFilters: () => void; + isLoading?: boolean; + totalResults?: number; + className?: string; +} + +export function FilterSidebar({ + filters, + onFiltersChange, + onClearFilters, + isLoading = false, + totalResults = 0, + className = "", +}: FilterSidebarProps) { + const [searchBrands, setSearchBrands] = useState(""); + const [searchCategories, setSearchCategories] = useState(""); + const [searchAllergens, setSearchAllergens] = useState(""); + + const updateFilter = (key: keyof ProductFilters, value: any) => { + onFiltersChange({ ...filters, [key]: value }); + }; + + const updateArrayFilter = ( + key: keyof ProductFilters, + value: string, + checked: boolean + ) => { + const currentArray = (filters[key] as string[]) || []; + const newArray = checked + ? [...currentArray, value] + : currentArray.filter((item) => item !== value); + updateFilter(key, newArray); + }; + + const updateNutritionRange = ( + nutrient: string, + type: "min" | "max", + value: number + ) => { + const currentRanges = filters.nutritionRanges || {}; + const currentRange = + currentRanges[nutrient as keyof typeof currentRanges] || {}; + + updateFilter("nutritionRanges", { + ...currentRanges, + [nutrient]: { + ...currentRange, + [type]: value, + }, + }); + }; + + const hasActiveFilters = 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 !== ""; + }); + + return ( +
+ {/* Header */} +
+
+ +

Filtros

+
+ {hasActiveFilters && ( + + )} +
+ + {/* Resultados */} + {totalResults > 0 && ( +
+ {totalResults.toLocaleString()} produto{totalResults !== 1 ? "s" : ""}{" "} + encontrado{totalResults !== 1 ? "s" : ""} +
+ )} + + + {/* Nutri-Score */} + + +
+ + Classificação Nutricional +
+
+ +
+
Nutri-Score
+
+ {NUTRITION_GRADES.map((grade) => ( +
+ + updateArrayFilter( + "nutritionGrades", + grade.grade, + checked as boolean + ) + } + /> + +
+ ))} +
+ + + +
+ NOVA (Processamento) +
+ + updateFilter( + "novaGroups", + value ? [parseInt(value) as 1 | 2 | 3 | 4] : [] + ) + } + className="space-y-2" + > + {NOVA_GROUPS.map((group) => ( +
+ + +
+ ))} +
+
+
+
+ + {/* Categorias */} + + +
+
+ Categorias +
+ + +
+ setSearchCategories(e.target.value)} + className="h-8" + /> +
+ {POPULAR_CATEGORIES_WITH_ICONS.filter( + (cat) => + cat.label + .toLowerCase() + .includes(searchCategories.toLowerCase()) || + cat.value + .toLowerCase() + .includes(searchCategories.toLowerCase()) + ).map((category) => { + const IconComponent = + LucideIcons[category.icon as keyof typeof LucideIcons]; + return ( +
+ + updateArrayFilter( + "categories", + category.value, + checked as boolean + ) + } + /> + +
+ ); + })} +
+
+
+ + + {/* Marcas */} + + +
+
+ Marcas +
+ + +
+ setSearchBrands(e.target.value)} + className="h-8" + /> +
+ Digite o nome da marca para buscar +
+
+
+ + + {/* Alérgenos */} + + +
+ + Alérgenos +
+
+ +
+ setSearchAllergens(e.target.value)} + className="h-8" + /> +
+ {COMMON_ALLERGENS.filter((allergen) => + allergen.toLowerCase().includes(searchAllergens.toLowerCase()) + ).map((allergen) => ( +
+ + updateArrayFilter( + "allergens", + allergen, + checked as boolean + ) + } + /> + +
+ ))} +
+
+
+
+ + {/* Aditivos */} + + +
+
+ Aditivos +
+ + +
+
+ + + updateFilter("noAdditives", checked) + } + /> +
+ +
+ Mostrar apenas produtos sem aditivos +
+
+
+ + + {/* Valores Nutricionais */} + + +
+
+ Valores Nutricionais +
+ + +
+ {/* Energia */} +
+ +
+ { + updateNutritionRange("energy", "min", min); + updateNutritionRange("energy", "max", max); + }} + max={500} + step={10} + className="w-full" + /> +
+ + {filters.nutritionRanges?.energy?.min || 0} kcal + + + {filters.nutritionRanges?.energy?.max || 500} kcal + +
+
+
+ + {/* Açúcares */} +
+ +
+ { + updateNutritionRange("sugars", "min", min); + updateNutritionRange("sugars", "max", max); + }} + max={50} + step={1} + className="w-full" + /> +
+ {filters.nutritionRanges?.sugars?.min || 0}g + {filters.nutritionRanges?.sugars?.max || 50}g +
+
+
+ + {/* Sódio */} +
+ +
+ { + updateNutritionRange("sodium", "min", min); + updateNutritionRange("sodium", "max", max); + }} + max={3} + step={0.1} + className="w-full" + /> +
+ {filters.nutritionRanges?.sodium?.min || 0}g + {filters.nutritionRanges?.sodium?.max || 3}g +
+
+
+
+
+ + + + {/* Botões de ação */} +
+ + +
+
+ ); +} diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 2b66362..3d639ef 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,34 +1,150 @@ -import Link from "next/link"; +import { + ScanLine, + ExternalLink, + Github, + Heart, + Globe, + Shield, +} from "lucide-react"; export function Footer() { return ( -