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:
Evandro Fonseca Junior committed 2025-10-09 01:53:42 -03:00
1 parent 166abeb64e
commit e4ed666074
11 files changed
+1301 -86

No files matched your search

+347
View File
@@ -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
View File
@@ -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":
+44
View File
@@ -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;
}