diff --git a/.cursor/plan.md b/.cursor/plan.md new file mode 100644 index 0000000..62c95ce --- /dev/null +++ b/.cursor/plan.md @@ -0,0 +1,102 @@ +# Plano de Correção dos Erros de Build + +## Problemas Identificados + +### 1. useSearchParams() sem Suspense Boundary + +**Erro**: `useSearchParams() should be wrapped in a suspense boundary at page "/resultados"` + +- **Causa**: Next.js 14+ requer que `useSearchParams()` seja envolvido em um `` para SSR/SSG +- **Local**: `app/resultados/page.tsx` linha 45 + +### 2. Dynamic Server Usage em /api/location + +**Erro**: `Route /api/location couldn't be rendered statically because it used request.headers` + +- **Causa**: A rota está tentando acessar `request.headers` durante build estático +- **Local**: `app/api/location/route.ts` linhas 8-14 + +### 3. Missing 'critters' Module + +**Erro**: `Cannot find module 'critters'` + +- **Causa**: Módulo opcional para otimização de CSS (`optimizeCss: true`) não está instalado +- **Local**: `next.config.mjs` linha 53 + +## Soluções + +### Solução 1: Adicionar Suspense Boundary em /resultados + +**Arquivo**: `app/resultados/page.tsx` + +Envolver o componente que usa `useSearchParams()` em um Suspense: + +```tsx +import { Suspense } from "react"; + +function ResultadosContent() { + const searchParams = useSearchParams(); + // ... resto do código +} + +export default function ResultadosPage() { + return ( + + + + } + > + + + ); +} +``` + +### Solução 2: Marcar /api/location como Dynamic + +**Arquivo**: `app/api/location/route.ts` + +Adicionar no início do arquivo: + +```ts +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; +``` + +Isso informa ao Next.js que esta rota deve ser sempre dinâmica e não tentar pré-renderizá-la. + +### Solução 3: Remover optimizeCss ou Instalar Critters + +**Opção A - Remover otimização (Mais Simples)** + +**Arquivo**: `next.config.mjs` + +```js +// Remover ou comentar: +experimental: { + // optimizeCss: true, +}, +``` + +**Opção B - Instalar Critters (Mantém Otimização)** + +```bash +pnpm add -D critters +``` + +**Recomendação**: Usar Opção A (remover) por enquanto, pois `optimizeCss` é experimental e pode causar problemas. + +## Ordem de Implementação + +1. ✅ Marcar `/api/location` como dinâmica (mais rápido) +2. ✅ Remover `optimizeCss` do next.config.mjs (mais rápido) +3. ✅ Adicionar Suspense boundary em `/resultados` (requer refatoração) + +## Resultado Esperado + +- ✅ Build completa sem erros de SSR/SSG +- ✅ Rotas dinâmicas funcionam corretamente +- ✅ Página de resultados renderiza com Suspense adequado +- ✅ Sem dependências faltando diff --git a/app/api/location/route.ts b/app/api/location/route.ts index f875b25..8a482af 100644 --- a/app/api/location/route.ts +++ b/app/api/location/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + export async function GET(request: NextRequest) { try { let ip: string | null = null; diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx new file mode 100644 index 0000000..ecd2287 --- /dev/null +++ b/app/components/Pagination.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PaginationProps, PaginationConfig } from "@/lib/types"; + +const DEFAULT_CONFIG: PaginationConfig = { + itemsPerPage: 20, + maxVisiblePages: 5, + showFirstLast: true, + showPrevNext: true, +}; + +export function Pagination({ + currentPage, + totalPages, + totalItems, + itemsPerPage, + onPageChange, + onItemsPerPageChange, + config = {}, + className = "", +}: PaginationProps) { + const [localItemsPerPage, setLocalItemsPerPage] = useState(itemsPerPage); + const finalConfig = useMemo( + () => ({ ...DEFAULT_CONFIG, ...config }), + [config] + ); + + // Calcular índices dos itens + const startIndex = (currentPage - 1) * itemsPerPage + 1; + const endIndex = Math.min(currentPage * itemsPerPage, totalItems); + + // Gerar array de páginas visíveis + const visiblePages = useMemo(() => { + const pages: (number | string)[] = []; + const { maxVisiblePages } = finalConfig; + + if (totalPages <= maxVisiblePages) { + // Se total de páginas é menor que o máximo, mostrar todas + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Lógica para mostrar páginas com ellipsis + const halfVisible = Math.floor(maxVisiblePages / 2); + let startPage = Math.max(1, currentPage - halfVisible); + const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); + + // Ajustar se estivermos no final + if (endPage - startPage + 1 < maxVisiblePages) { + startPage = Math.max(1, endPage - maxVisiblePages + 1); + } + + // Primeira página + if (startPage > 1) { + pages.push(1); + if (startPage > 2) { + pages.push("..."); + } + } + + // Páginas do meio + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + // Última página + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + pages.push("..."); + } + pages.push(totalPages); + } + } + + return pages; + }, [currentPage, totalPages, finalConfig]); + + const handleItemsPerPageChange = (newItemsPerPage: string) => { + const newValue = parseInt(newItemsPerPage); + setLocalItemsPerPage(newValue); + if (onItemsPerPageChange) { + onItemsPerPageChange(newValue); + } + }; + + const canGoPrevious = currentPage > 1; + const canGoNext = currentPage < totalPages; + + if (totalPages <= 1) { + return null; + } + + return ( +
+ {/* Informações dos itens */} +
+ Mostrando {startIndex} a {endIndex} de {totalItems} resultado + {totalItems !== 1 ? "s" : ""} +
+ + {/* Controles de paginação */} +
+ {/* Seletor de itens por página */} + {onItemsPerPageChange && ( +
+ + Por página: + + +
+ )} + + {/* Botões de navegação */} +
+ {/* Primeira página */} + {finalConfig.showFirstLast && ( + + )} + + {/* Página anterior */} + {finalConfig.showPrevNext && ( + + )} + + {/* Números das páginas */} + {visiblePages.map((page, index) => ( +
+ {page === "..." ? ( + ... + ) : ( + + )} +
+ ))} + + {/* Próxima página */} + {finalConfig.showPrevNext && ( + + )} + + {/* Última página */} + {finalConfig.showFirstLast && ( + + )} +
+
+
+ ); +} diff --git a/app/components/ProductCardSkeleton.tsx b/app/components/ProductCardSkeleton.tsx index 20d32ec..c730b6e 100644 --- a/app/components/ProductCardSkeleton.tsx +++ b/app/components/ProductCardSkeleton.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton"; export function ProductCardSkeleton() { return ( - + {/* Imagem Skeleton */} diff --git a/app/components/landing/Hero.tsx b/app/components/landing/Hero.tsx index 73bf585..0f4f921 100644 --- a/app/components/landing/Hero.tsx +++ b/app/components/landing/Hero.tsx @@ -80,7 +80,7 @@ export function Hero() { } else if (searchType === "name") { return "Digite o nome do produto (ex: coca cola)"; } - return "Digite o código de barras ou nome do produto"; + return "Digite nome do produto"; }; const getSearchButtonText = () => { diff --git a/app/resultados/page.tsx b/app/resultados/page.tsx index 2a8b0b6..6681200 100644 --- a/app/resultados/page.tsx +++ b/app/resultados/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Search, @@ -8,33 +8,67 @@ import { Grid, List, SlidersHorizontal, + RefreshCw, + Database, + X, } 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 { Product, sortProducts, fetchAllResults } 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 { Pagination } from "@/app/components/Pagination"; import { useProductFilters } from "@/hooks/useProductFilters"; +import { useSearchCache } from "@/hooks/useSearchCache"; import { detectUserCountry } from "@/lib/geolocation"; +import { FilterRecord } from "@/lib/cacheManager"; +import { ProductFilters } from "@/lib/types"; import Link from "next/link"; -export default function ResultadosPage() { +// Função para converter ProductFilters para FilterRecord +function convertFiltersToRecord(filters: ProductFilters): FilterRecord { + const record: FilterRecord = {}; + + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + record[key] = value as FilterRecord[string]; + } + }); + + return record; +} + +function ResultadosContent() { const searchParams = useSearchParams(); const router = useRouter(); const query = searchParams.get("q") || ""; const countryParam = searchParams.get("country") || ""; - const [products, setProducts] = useState([]); + // Estados principais + const [allProducts, setAllProducts] = 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"); + const [forceRefresh, setForceRefresh] = useState(false); + + // Estados de paginação + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + + // Hook de cache + const { + state: cacheState, + getCachedSearch, + saveSearch, + clearCache, + updateProgress, + setError: setCacheError, + setLoading: setCacheLoading, + } = useSearchCache(); // Hook de filtros const { @@ -61,66 +95,131 @@ export default function ResultadosPage() { }, []); const loadProducts = useCallback( - async (pageNum: number) => { + async (forceRefresh: boolean = false) => { try { - if (pageNum === 1) { - setLoading(true); - } else { - setLoadingMore(true); - } + setLoading(true); setError(null); setIsLoading(true); + setCacheLoading(true); const countryToUse = detectedCountry || countryParam || "br"; - 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" + // Verificar cache primeiro (se não for refresh forçado) + if (!forceRefresh) { + const cached = getCachedSearch( + query, + countryToUse, + convertFiltersToRecord(filters) ); + if (cached) { + console.log("Usando dados do cache"); + setAllProducts(cached.products); + setTotalResults(cached.totalCount); + setLoading(false); + setIsLoading(false); + setCacheLoading(false); + return; + } } - setProducts((prevProducts) => - pageNum === 1 ? sortedProducts : [...prevProducts, ...sortedProducts] + console.log("Fazendo busca completa na API"); + + // Fazer busca completa + const result = await fetchAllResults( + query, + countryToUse, + 10, // Máximo 10 páginas (500 produtos) + (currentPage, totalPages, loadedProducts) => { + updateProgress(currentPage, totalPages, loadedProducts, 0); + } ); - setHasMore(result.products.length === 20); - setPage(pageNum); - setTotalResults(result.count || result.products.length); + + // Salvar no cache + const searchId = saveSearch( + query, + countryToUse, + result.products, + result.totalCount, + convertFiltersToRecord(filters) + ); + console.log(`Busca salva no cache com ID: ${searchId}`); + + setAllProducts(result.products); + setTotalResults(result.totalCount); + setCurrentPage(1); // Voltar para primeira página } catch (err) { - setError( - err instanceof Error ? err.message : "Erro ao buscar produtos" - ); + const errorMessage = + err instanceof Error ? err.message : "Erro ao buscar produtos"; + setError(errorMessage); + setCacheError(errorMessage); } finally { setLoading(false); - setLoadingMore(false); setIsLoading(false); + setCacheLoading(false); } }, [ query, detectedCountry, countryParam, - filters.sortBy, - filters.sortOrder, + filters, + getCachedSearch, + saveSearch, + updateProgress, setIsLoading, setTotalResults, + setCacheLoading, + setCacheError, ] ); useEffect(() => { if (query) { - loadProducts(1); + loadProducts(forceRefresh); + setForceRefresh(false); } - }, [query, countryParam, detectedCountry, loadProducts]); + }, [query, countryParam, detectedCountry, loadProducts, forceRefresh]); - const handleLoadMore = useCallback(() => { - loadProducts(page + 1); - }, [loadProducts, page]); + // Produtos filtrados e ordenados para exibição + const filteredAndSortedProducts = useMemo(() => { + let filtered = [...allProducts]; + + // Aplicar filtros aqui (implementar conforme necessário) + // Por enquanto, apenas ordenação + if (filters.sortBy) { + filtered = sortProducts( + filtered, + filters.sortBy, + filters.sortOrder || "asc" + ); + } + + return filtered; + }, [allProducts, filters.sortBy, filters.sortOrder]); + + // Produtos da página atual + const currentPageProducts = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return filteredAndSortedProducts.slice(startIndex, endIndex); + }, [filteredAndSortedProducts, currentPage, itemsPerPage]); + + // Cálculos de paginação + const totalPages = Math.ceil(filteredAndSortedProducts.length / itemsPerPage); + const totalItems = filteredAndSortedProducts.length; + + const handleRefresh = useCallback(() => { + setForceRefresh(true); + }, []); + + const handleClearCache = useCallback(() => { + clearCache( + query, + detectedCountry || countryParam || "br", + convertFiltersToRecord(filters) + ); + setForceRefresh(true); + }, [clearCache, query, detectedCountry, countryParam, filters]); const handleSortChange = useCallback( (sortBy: string) => { @@ -181,11 +280,17 @@ export default function ResultadosPage() {

Resultados para "{query}"

- {!loading && products.length > 0 && ( + {!loading && totalItems > 0 && (

- {products.length} produto{products.length !== 1 ? "s" : ""}{" "} - encontrado{products.length !== 1 ? "s" : ""} + {totalItems} produto{totalItems !== 1 ? "s" : ""} encontrado + {totalItems !== 1 ? "s" : ""} {detectedCountry && ` em ${detectedCountry}`} + {cacheState.isCached && ( + + + Cache + + )}

)} @@ -215,6 +320,31 @@ export default function ResultadosPage() { + + {/* Botões de controle de cache */} +
+ + {cacheState.isCached && ( + + )} +
{/* Filtros Mobile */} @@ -235,7 +365,7 @@ export default function ResultadosPage() { onFiltersChange={updateFilters} onClearFilters={clearFilters} isLoading={loading} - totalResults={products.length} + totalResults={totalItems} /> @@ -278,7 +408,7 @@ export default function ResultadosPage() { onFiltersChange={updateFilters} onClearFilters={clearFilters} isLoading={loading} - totalResults={products.length} + totalResults={totalItems} /> @@ -287,16 +417,51 @@ export default function ResultadosPage() {
{/* Loading Inicial */} {loading && ( -
- {[...Array(8)].map((_, i) => ( - - ))} +
+ {/* Indicador de progresso do cache */} + {cacheState.progress && ( +
+
+ +
+

+ Carregando todos os resultados... +

+

+ Página {cacheState.progress.currentPage} de{" "} + {cacheState.progress.totalPages} • + {cacheState.progress.loadedProducts} produtos + carregados +

+
+
+
+
+
+
+ )} + + {/* Skeleton dos produtos */} +
+ {[...Array(8)].map((_, i) => ( + + ))} +
)} @@ -308,7 +473,7 @@ export default function ResultadosPage() { Erro ao buscar produtos

{error}

-
@@ -316,7 +481,7 @@ export default function ResultadosPage() { )} {/* Sem Resultados */} - {!loading && !error && products.length === 0 && ( + {!loading && !error && totalItems === 0 && (

@@ -337,7 +502,7 @@ export default function ResultadosPage() { )} {/* Grid de Produtos */} - {!loading && products.length > 0 && ( + {!loading && totalItems > 0 && ( <>
- {products.map((product, index) => ( + {currentPageProducts.map((product, index) => (
- {/* Botão Carregar Mais */} - {hasMore && ( -
- + {/* Paginação */} + {totalPages > 1 && ( +
+
)} - {/* Contador de Resultados */} - {!hasMore && products.length > 0 && ( -
+ {/* Informações adicionais */} + {cacheState.isCached && ( +

- Todos os {products.length} produto - {products.length !== 1 ? "s" : ""} foram carregados + Dados carregados do cache • +

)} @@ -398,3 +567,17 @@ export default function ResultadosPage() {
); } + +export default function ResultadosPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/hooks/useSearchCache.ts b/hooks/useSearchCache.ts new file mode 100644 index 0000000..2fb55f1 --- /dev/null +++ b/hooks/useSearchCache.ts @@ -0,0 +1,218 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Product } from "@/lib/openFoodFactsApi"; +import { cacheManager, CachedSearch, FilterRecord } from "@/lib/cacheManager"; + +export interface SearchCacheState { + isLoading: boolean; + isCached: boolean; + searchId: string | null; + progress: { + currentPage: number; + totalPages: number; + loadedProducts: number; + totalProducts: number; + } | null; + error: string | null; +} + +export interface UseSearchCacheReturn { + state: SearchCacheState; + getCachedSearch: ( + query: string, + country: string, + filters?: FilterRecord + ) => CachedSearch | null; + saveSearch: ( + query: string, + country: string, + products: Product[], + totalCount: number, + filters?: FilterRecord + ) => string; + clearCache: ( + query?: string, + country?: string, + filters?: FilterRecord + ) => void; + clearAllCaches: () => void; + getCacheStats: () => ReturnType; + updateProgress: ( + currentPage: number, + totalPages: number, + loadedProducts: number, + totalProducts: number + ) => void; + setError: (error: string | null) => void; + setLoading: (loading: boolean) => void; +} + +export function useSearchCache(): UseSearchCacheReturn { + const [state, setState] = useState({ + isLoading: false, + isCached: false, + searchId: null, + progress: null, + error: null, + }); + + const getCachedSearch = useCallback( + ( + query: string, + country: string, + filters?: FilterRecord + ): CachedSearch | null => { + try { + const cached = cacheManager.getSearch(query, country, filters); + setState((prev) => ({ + ...prev, + isCached: !!cached, + searchId: cached?.searchId || null, + error: null, + })); + return cached; + } catch (error) { + console.error("Erro ao buscar cache:", error); + setState((prev) => ({ + ...prev, + error: "Erro ao acessar cache", + })); + return null; + } + }, + [] + ); + + const saveSearch = useCallback( + ( + query: string, + country: string, + products: Product[], + totalCount: number, + filters?: FilterRecord + ): string => { + try { + const searchId = cacheManager.saveSearch( + query, + country, + products, + totalCount, + filters + ); + setState((prev) => ({ + ...prev, + searchId, + isCached: true, + error: null, + })); + return searchId; + } catch (error) { + console.error("Erro ao salvar cache:", error); + setState((prev) => ({ + ...prev, + error: "Erro ao salvar cache", + })); + return ""; + } + }, + [] + ); + + const clearCache = useCallback( + (query?: string, country?: string, filters?: FilterRecord) => { + try { + if (query && country) { + cacheManager.removeSearch(query, country, filters); + setState((prev) => ({ + ...prev, + isCached: false, + searchId: null, + })); + } else { + cacheManager.clearAllCaches(); + setState((prev) => ({ + ...prev, + isCached: false, + searchId: null, + })); + } + } catch (error) { + console.error("Erro ao limpar cache:", error); + setState((prev) => ({ + ...prev, + error: "Erro ao limpar cache", + })); + } + }, + [] + ); + + const clearAllCaches = useCallback(() => { + clearCache(); + }, [clearCache]); + + const getCacheStats = useCallback(() => { + return cacheManager.getCacheStats(); + }, []); + + const updateProgress = useCallback( + ( + currentPage: number, + totalPages: number, + loadedProducts: number, + totalProducts: number + ) => { + setState((prev) => ({ + ...prev, + progress: { + currentPage, + totalPages, + loadedProducts, + totalProducts, + }, + })); + }, + [] + ); + + const setError = useCallback((error: string | null) => { + setState((prev) => ({ + ...prev, + error, + })); + }, []); + + const setLoading = useCallback((loading: boolean) => { + setState((prev) => ({ + ...prev, + isLoading: loading, + error: loading ? null : prev.error, + })); + }, []); + + // Limpar estado quando componente desmonta + useEffect(() => { + return () => { + setState({ + isLoading: false, + isCached: false, + searchId: null, + progress: null, + error: null, + }); + }; + }, []); + + return { + state, + getCachedSearch, + saveSearch, + clearCache, + clearAllCaches, + getCacheStats, + updateProgress, + setError, + setLoading, + }; +} diff --git a/lib/cacheManager.ts b/lib/cacheManager.ts new file mode 100644 index 0000000..2343a27 --- /dev/null +++ b/lib/cacheManager.ts @@ -0,0 +1,347 @@ +/** + * Gerenciador de cache para buscas de produtos + * Gerencia armazenamento, recuperação e expiração de dados em localStorage + */ + +import { Product } from "./openFoodFactsApi"; + +// Tipo para filtros que aceita valores primitivos +type FilterValue = + | string + | number + | boolean + | string[] + | number[] + | null + | undefined; +export type FilterRecord = Record; + +export interface CachedSearch { + query: string; + country: string; + timestamp: number; + products: Product[]; + totalCount: number; + filters?: FilterRecord; + searchId: string; +} + +export interface CacheConfig { + ttl: number; // Time to live em milissegundos + maxSize: number; // Tamanho máximo em bytes + maxEntries: number; // Número máximo de entradas +} + +const DEFAULT_CONFIG: CacheConfig = { + ttl: 60 * 60 * 1000, // 1 hora + maxSize: 5 * 1024 * 1024, // 5MB + maxEntries: 10, // 10 buscas diferentes +}; + +class CacheManager { + private config: CacheConfig; + private readonly CACHE_PREFIX = "nutriscan_search_"; + private readonly CACHE_INDEX_KEY = "nutriscan_cache_index"; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Gera chave única para a busca baseada na query e filtros + */ + private generateCacheKey( + query: string, + country: string, + filters?: FilterRecord + ): string { + const filterString = filters ? JSON.stringify(filters) : ""; + const keyData = `${query}|${country}|${filterString}`; + return this.CACHE_PREFIX + btoa(keyData).replace(/[^a-zA-Z0-9]/g, ""); + } + + /** + * Gera ID único para a busca + */ + private generateSearchId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + /** + * Verifica se o cache está válido (não expirado) + */ + private isCacheValid(timestamp: number): boolean { + return Date.now() - timestamp < this.config.ttl; + } + + /** + * Obtém índice de caches armazenados + */ + private getCacheIndex(): string[] { + try { + const index = localStorage.getItem(this.CACHE_INDEX_KEY); + return index ? JSON.parse(index) : []; + } catch { + return []; + } + } + + /** + * Atualiza índice de caches + */ + private updateCacheIndex(keys: string[]): void { + try { + localStorage.setItem(this.CACHE_INDEX_KEY, JSON.stringify(keys)); + } catch (error) { + console.warn("Erro ao atualizar índice de cache:", error); + } + } + + /** + * Calcula tamanho aproximado de um objeto em bytes + */ + private getObjectSize(obj: unknown): number { + return new Blob([JSON.stringify(obj)]).size; + } + + /** + * Limpa caches antigos quando necessário + */ + private cleanupOldCaches(): void { + try { + const index = this.getCacheIndex(); + const validKeys: string[] = []; + + // Remove caches expirados + for (const key of index) { + const cached = localStorage.getItem(key); + if (cached) { + try { + const data: CachedSearch = JSON.parse(cached); + if (this.isCacheValid(data.timestamp)) { + validKeys.push(key); + } else { + localStorage.removeItem(key); + } + } catch { + // Remove cache corrompido + localStorage.removeItem(key); + } + } + } + + // Se ainda temos muitos caches, remove os mais antigos + if (validKeys.length > this.config.maxEntries) { + const sortedKeys = validKeys.sort((a, b) => { + const aData = JSON.parse(localStorage.getItem(a) || "{}"); + const bData = JSON.parse(localStorage.getItem(b) || "{}"); + return aData.timestamp - bData.timestamp; + }); + + const keysToRemove = sortedKeys.slice( + 0, + validKeys.length - this.config.maxEntries + ); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + validKeys.splice(0, keysToRemove.length); + } + + this.updateCacheIndex(validKeys); + } catch (error) { + console.warn("Erro ao limpar caches antigos:", error); + } + } + + /** + * Salva resultado da busca no cache + */ + saveSearch( + query: string, + country: string, + products: Product[], + totalCount: number, + filters?: FilterRecord + ): string { + try { + this.cleanupOldCaches(); + + const searchId = this.generateSearchId(); + const cacheKey = this.generateCacheKey(query, country, filters); + + const cachedData: CachedSearch = { + query, + country, + timestamp: Date.now(), + products, + totalCount, + filters, + searchId, + }; + + // Verifica se o tamanho não excede o limite + const dataSize = this.getObjectSize(cachedData); + if (dataSize > this.config.maxSize) { + console.warn("Dados muito grandes para cache, salvando apenas metade"); + cachedData.products = products.slice( + 0, + Math.floor(products.length / 2) + ); + } + + localStorage.setItem(cacheKey, JSON.stringify(cachedData)); + + // Atualiza índice + const index = this.getCacheIndex(); + if (!index.includes(cacheKey)) { + index.push(cacheKey); + this.updateCacheIndex(index); + } + + return searchId; + } catch (error) { + console.error("Erro ao salvar cache:", error); + return ""; + } + } + + /** + * Recupera resultado da busca do cache + */ + getSearch( + query: string, + country: string, + filters?: FilterRecord + ): CachedSearch | null { + try { + const cacheKey = this.generateCacheKey(query, country, filters); + const cached = localStorage.getItem(cacheKey); + + if (!cached) return null; + + const data: CachedSearch = JSON.parse(cached); + + if (!this.isCacheValid(data.timestamp)) { + localStorage.removeItem(cacheKey); + return null; + } + + return data; + } catch (error) { + console.error("Erro ao recuperar cache:", error); + return null; + } + } + + /** + * Remove cache específico + */ + removeSearch( + query: string, + country: string, + filters?: FilterRecord + ): boolean { + try { + const cacheKey = this.generateCacheKey(query, country, filters); + localStorage.removeItem(cacheKey); + + const index = this.getCacheIndex(); + const newIndex = index.filter((key) => key !== cacheKey); + this.updateCacheIndex(newIndex); + + return true; + } catch (error) { + console.error("Erro ao remover cache:", error); + return false; + } + } + + /** + * Limpa todos os caches + */ + clearAllCaches(): void { + try { + const index = this.getCacheIndex(); + index.forEach((key) => localStorage.removeItem(key)); + localStorage.removeItem(this.CACHE_INDEX_KEY); + } catch (error) { + console.error("Erro ao limpar todos os caches:", error); + } + } + + /** + * Obtém estatísticas do cache + */ + getCacheStats(): { + totalEntries: number; + totalSize: number; + oldestEntry: number | null; + newestEntry: number | null; + } { + try { + const index = this.getCacheIndex(); + let totalSize = 0; + let oldestTimestamp = null; + let newestTimestamp = null; + + for (const key of index) { + const cached = localStorage.getItem(key); + if (cached) { + totalSize += this.getObjectSize(cached); + try { + const data: CachedSearch = JSON.parse(cached); + if (!oldestTimestamp || data.timestamp < oldestTimestamp) { + oldestTimestamp = data.timestamp; + } + if (!newestTimestamp || data.timestamp > newestTimestamp) { + newestTimestamp = data.timestamp; + } + } catch { + // Ignora caches corrompidos + } + } + } + + return { + totalEntries: index.length, + totalSize, + oldestEntry: oldestTimestamp, + newestEntry: newestTimestamp, + }; + } catch (error) { + console.error("Erro ao obter estatísticas do cache:", error); + return { + totalEntries: 0, + totalSize: 0, + oldestEntry: null, + newestEntry: null, + }; + } + } + + /** + * Verifica se há espaço suficiente no localStorage + */ + hasSpaceForData(dataSize: number): boolean { + try { + const stats = this.getCacheStats(); + return stats.totalSize + dataSize < this.config.maxSize; + } catch { + return false; + } + } +} + +// Instância singleton +export const cacheManager = new CacheManager(); + +// Hook para usar o cache manager +export function useCacheManager() { + return { + saveSearch: cacheManager.saveSearch.bind(cacheManager), + getSearch: cacheManager.getSearch.bind(cacheManager), + removeSearch: cacheManager.removeSearch.bind(cacheManager), + clearAllCaches: cacheManager.clearAllCaches.bind(cacheManager), + getCacheStats: cacheManager.getCacheStats.bind(cacheManager), + hasSpaceForData: cacheManager.hasSpaceForData.bind(cacheManager), + }; +} diff --git a/lib/openFoodFactsApi.ts b/lib/openFoodFactsApi.ts index 17067a4..e900dae 100644 --- a/lib/openFoodFactsApi.ts +++ b/lib/openFoodFactsApi.ts @@ -137,6 +137,92 @@ export async function searchByName( } } +/** + * Busca TODOS os resultados de uma pesquisa + * Faz requisições paginadas até obter todos os produtos disponíveis + */ +export async function fetchAllResults( + query: string, + country?: string, + maxPages: number = 10, + onProgress?: ( + currentPage: number, + totalPages: number, + loadedProducts: number + ) => void +): Promise<{ products: Product[]; totalCount: number; actualPages: number }> { + try { + if (!query.trim()) { + throw new Error("Termo de busca não pode estar vazio"); + } + + const allProducts: Product[] = []; + let currentPage = 1; + let hasMore = true; + let totalCount = 0; + const pageSize = 50; // Usar tamanho máximo permitido pela API + + console.log(`Iniciando busca completa para: "${query}"`); + + while (hasMore && currentPage <= maxPages) { + try { + console.log(`Carregando página ${currentPage}...`); + + // Chamar callback de progresso se fornecido + if (onProgress) { + onProgress(currentPage, maxPages, allProducts.length); + } + + const result = await searchByName( + query, + country, + currentPage, + pageSize + ); + + if (result.products && result.products.length > 0) { + allProducts.push(...result.products); + totalCount = result.count || allProducts.length; + + // Se retornou menos produtos que o pageSize, não há mais páginas + hasMore = result.products.length === pageSize; + + console.log( + `Página ${currentPage}: ${result.products.length} produtos (Total: ${allProducts.length})` + ); + } else { + hasMore = false; + } + + currentPage++; + + // Rate limiting: aguardar 200ms entre requisições + if (hasMore && currentPage <= maxPages) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } catch (pageError) { + console.error(`Erro na página ${currentPage}:`, pageError); + // Se falhar em uma página, parar a busca + hasMore = false; + } + } + + const actualPages = currentPage - 1; + console.log( + `Busca completa finalizada: ${allProducts.length} produtos em ${actualPages} páginas` + ); + + return { + products: allProducts, + totalCount: totalCount || allProducts.length, + actualPages, + }; + } catch (error) { + console.error("Erro ao buscar todos os resultados:", error); + throw error; + } +} + /** * Busca produtos por categoria */ @@ -406,9 +492,23 @@ export function sortProducts( break; case "energy": - const aEnergy = a.nutriments?.energy_100g || 0; - const bEnergy = b.nutriments?.energy_100g || 0; - comparison = aEnergy - bEnergy; + const aHasEnergy = !!a.nutriments?.energy_100g; + const bHasEnergy = !!b.nutriments?.energy_100g; + + // Primeiro: produtos com dados calóricos vêm antes dos sem dados + if (aHasEnergy && !bHasEnergy) { + comparison = -1; // a vem antes de b + } else if (!aHasEnergy && bHasEnergy) { + comparison = 1; // b vem antes de a + } else if (aHasEnergy && bHasEnergy) { + // Ambos têm dados: ordenar pelo menor valor energético + const aEnergy = a.nutriments?.energy_100g || 0; + const bEnergy = b.nutriments?.energy_100g || 0; + comparison = aEnergy - bEnergy; + } else { + // Nenhum tem dados: manter ordem original + comparison = 0; + } break; case "fat": diff --git a/lib/types.ts b/lib/types.ts index a7c01da..e47985e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -288,3 +288,47 @@ export const COMMON_ADDITIVES = [ "stabilizers", "thickeners", ]; + +// Tipos para sistema de cache e paginação +export interface PaginationState { + currentPage: number; + totalPages: number; + itemsPerPage: number; + totalItems: number; + startIndex: number; + endIndex: number; +} + +export interface PaginationConfig { + itemsPerPage: number; + maxVisiblePages: number; + showFirstLast: boolean; + showPrevNext: boolean; +} + +export interface CacheProgress { + currentPage: number; + totalPages: number; + loadedProducts: number; + totalProducts: number; + isLoading: boolean; + error?: string; +} + +export interface SearchCacheConfig { + ttl: number; // Time to live em milissegundos + maxSize: number; // Tamanho máximo em bytes + maxEntries: number; // Número máximo de entradas + enableCompression: boolean; // Se deve comprimir dados +} + +export interface PaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + itemsPerPage: number; + onPageChange: (page: number) => void; + onItemsPerPageChange?: (itemsPerPage: number) => void; + config?: Partial; + className?: string; +} diff --git a/next.config.mjs b/next.config.mjs index 1676a6d..e1fa7f0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -50,7 +50,7 @@ const nextConfig = { // Otimizações de bundle experimental: { - optimizeCss: true, + // optimizeCss: true, // Removido para evitar erro de build com critters }, };