feat(results-page): enhance search with caching, pagination, and UI tweaks
- Add dynamic rendering and Node.js runtime config to location API for better performance - Improve ProductCardSkeleton with dark mode styling for consistent theming - Simplify Hero search placeholder to focus on product name input - Integrate caching, pagination, and cache clearing in results page for optimized search experience and user control
This commit is contained in:
1 parent
166abeb64e
commit
e4ed666074
11 files changed
+1301
-86
No files matched your search
+102
@@ -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 `<Suspense>` 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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResultadosContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${className}`}
|
||||
>
|
||||
{/* Informações dos itens */}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Mostrando {startIndex} a {endIndex} de {totalItems} resultado
|
||||
{totalItems !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
{/* Controles de paginação */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Seletor de itens por página */}
|
||||
{onItemsPerPageChange && (
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Por página:
|
||||
</span>
|
||||
<Select
|
||||
value={localItemsPerPage.toString()}
|
||||
onValueChange={handleItemsPerPageChange}
|
||||
>
|
||||
<SelectTrigger className="w-20 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botões de navegação */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Primeira página */}
|
||||
{finalConfig.showFirstLast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={!canGoPrevious}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Página anterior */}
|
||||
{finalConfig.showPrevNext && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={!canGoPrevious}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Números das páginas */}
|
||||
{visiblePages.map((page, index) => (
|
||||
<div key={index}>
|
||||
{page === "..." ? (
|
||||
<span className="px-3 py-1 text-sm text-gray-500">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page as number)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Próxima página */}
|
||||
{finalConfig.showPrevNext && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={!canGoNext}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Última página */}
|
||||
{finalConfig.showFirstLast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={!canGoNext}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function ProductCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<Card className="overflow-hidden bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800">
|
||||
{/* Imagem Skeleton */}
|
||||
<Skeleton className="h-48 w-full" />
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
+263
-80
@@ -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<Product[]>([]);
|
||||
// Estados principais
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [detectedCountry, setDetectedCountry] = useState<string>("");
|
||||
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() {
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Resultados para "{query}"
|
||||
</h1>
|
||||
{!loading && products.length > 0 && (
|
||||
{!loading && totalItems > 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{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 && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
||||
<Database className="w-3 h-3" />
|
||||
Cache
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -215,6 +320,31 @@ export default function ResultadosPage() {
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Botões de controle de cache */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
title="Atualizar busca"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
{cacheState.isCached && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearCache}
|
||||
title="Limpar cache e refazer busca"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros Mobile */}
|
||||
@@ -235,7 +365,7 @@ export default function ResultadosPage() {
|
||||
onFiltersChange={updateFilters}
|
||||
onClearFilters={clearFilters}
|
||||
isLoading={loading}
|
||||
totalResults={products.length}
|
||||
totalResults={totalItems}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -278,7 +408,7 @@ export default function ResultadosPage() {
|
||||
onFiltersChange={updateFilters}
|
||||
onClearFilters={clearFilters}
|
||||
isLoading={loading}
|
||||
totalResults={products.length}
|
||||
totalResults={totalItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,16 +417,51 @@ export default function ResultadosPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Loading Inicial */}
|
||||
{loading && (
|
||||
<div
|
||||
className={`grid gap-6 ${
|
||||
viewMode === "grid"
|
||||
? "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||
: "grid-cols-1"
|
||||
}`}
|
||||
>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<ProductCardSkeleton key={i} />
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{/* Indicador de progresso do cache */}
|
||||
{cacheState.progress && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Carregando todos os resultados...
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Página {cacheState.progress.currentPage} de{" "}
|
||||
{cacheState.progress.totalPages} •
|
||||
{cacheState.progress.loadedProducts} produtos
|
||||
carregados
|
||||
</p>
|
||||
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${
|
||||
(cacheState.progress.currentPage /
|
||||
cacheState.progress.totalPages) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skeleton dos produtos */}
|
||||
<div
|
||||
className={`grid gap-6 ${
|
||||
viewMode === "grid"
|
||||
? "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||
: "grid-cols-1"
|
||||
}`}
|
||||
>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<ProductCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -308,7 +473,7 @@ export default function ResultadosPage() {
|
||||
Erro ao buscar produtos
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-300 mb-4">{error}</p>
|
||||
<Button onClick={() => loadProducts(1)} variant="outline">
|
||||
<Button onClick={() => loadProducts(true)} variant="outline">
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</div>
|
||||
@@ -316,7 +481,7 @@ export default function ResultadosPage() {
|
||||
)}
|
||||
|
||||
{/* Sem Resultados */}
|
||||
{!loading && !error && products.length === 0 && (
|
||||
{!loading && !error && totalItems === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
@@ -337,7 +502,7 @@ export default function ResultadosPage() {
|
||||
)}
|
||||
|
||||
{/* Grid de Produtos */}
|
||||
{!loading && products.length > 0 && (
|
||||
{!loading && totalItems > 0 && (
|
||||
<>
|
||||
<div
|
||||
className={`product-grid grid gap-6 mb-8 ${
|
||||
@@ -346,7 +511,7 @@ export default function ResultadosPage() {
|
||||
: "grid-cols-1"
|
||||
}`}
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
{currentPageProducts.map((product, index) => (
|
||||
<div
|
||||
key={product.code}
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-4"
|
||||
@@ -360,33 +525,37 @@ export default function ResultadosPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Botão Carregar Mais */}
|
||||
{hasMore && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
size="lg"
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Carregando...
|
||||
</>
|
||||
) : (
|
||||
"Carregar Mais"
|
||||
)}
|
||||
</Button>
|
||||
{/* Paginação */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
config={{
|
||||
itemsPerPage: 20,
|
||||
maxVisiblePages: 5,
|
||||
showFirstLast: true,
|
||||
showPrevNext: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contador de Resultados */}
|
||||
{!hasMore && products.length > 0 && (
|
||||
<div className="text-center mt-8">
|
||||
{/* Informações adicionais */}
|
||||
{cacheState.isCached && (
|
||||
<div className="text-center mt-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Todos os {products.length} produto
|
||||
{products.length !== 1 ? "s" : ""} foram carregados
|
||||
Dados carregados do cache •
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
Atualizar busca
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -398,3 +567,17 @@ export default function ResultadosPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResultadosPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResultadosContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof cacheManager.getCacheStats>;
|
||||
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<SearchCacheState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, FilterValue>;
|
||||
|
||||
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<CacheConfig> = {}) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
+103
-3
@@ -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":
|
||||
|
||||
@@ -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<PaginationConfig>;
|
||||
className?: string;
|
||||
}
|
||||
+1
-1
@@ -50,7 +50,7 @@ const nextConfig = {
|
||||
|
||||
// Otimizações de bundle
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
// optimizeCss: true, // Removido para evitar erro de build com critters
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user