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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user