diff --git a/src/components/ThemeInitializer.test.tsx b/src/components/ThemeInitializer.test.tsx index 48bc22f2b..9a0faa12d 100644 --- a/src/components/ThemeInitializer.test.tsx +++ b/src/components/ThemeInitializer.test.tsx @@ -22,6 +22,7 @@ describe('ThemeInitializer', () => { it('waits for ThemeContext to be available', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -32,9 +33,11 @@ describe('ThemeInitializer', () => { it('applies theme configuration when context is available', async () => { const mockConfig = { presetId: 'corporate', radius: 14, mode: 'dark' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(themePresets.loadThemeConfig).mockReturnValue(mockConfig as any); render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx index cc0893c65..b050e30ec 100644 --- a/src/components/products/ProductCard.tsx +++ b/src/components/products/ProductCard.tsx @@ -190,6 +190,7 @@ export const ProductCard = memo( } } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- granular deps (product.id, product.colors) intentionally preferred over `product` to avoid spurious re-runs }, [product.id, product.colors, selectedColorFromStore, activeColorFilter, allMatchingVariants, activeVariantIdx]); const actionBusyRef = useRef(false); diff --git a/src/hooks/products/useCatalogPrefetch.test.ts b/src/hooks/products/useCatalogPrefetch.test.ts index 4a4ba3cc3..2eff569c5 100644 --- a/src/hooks/products/useCatalogPrefetch.test.ts +++ b/src/hooks/products/useCatalogPrefetch.test.ts @@ -26,16 +26,19 @@ describe('useCatalogPrefetch', () => { beforeEach(() => { vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(useQueryClient).mockReturnValue(mockQueryClient as any); }); it('does not prefetch if not authenticated', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(useAuth).mockReturnValue({ isAuthenticated: false, isLoading: false } as any); renderHook(() => useCatalogPrefetch()); expect(mockQueryClient.prefetchInfiniteQuery).not.toHaveBeenCalled(); }); it('prefetches catalog after delay when authenticated', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(useAuth).mockReturnValue({ isAuthenticated: true, isLoading: false } as any); renderHook(() => useCatalogPrefetch()); diff --git a/src/hooks/products/useProductImages.ts b/src/hooks/products/useProductImages.ts index e1f44733a..f406d8b31 100644 --- a/src/hooks/products/useProductImages.ts +++ b/src/hooks/products/useProductImages.ts @@ -77,37 +77,55 @@ export async function fetchProductImages(productId: string): Promise> { if (productIds.length === 0) return new Map(); + const uniqueIds = [...new Set(productIds)]; + const chunks: string[][] = []; + for (let i = 0; i < uniqueIds.length; i += IMAGE_BATCH_CHUNK_SIZE) { + chunks.push(uniqueIds.slice(i, i + IMAGE_BATCH_CHUNK_SIZE)); + } + try { - // Buscar todas as imagens ativas - // Nota: O bridge não suporta IN() diretamente, então buscamos todas e filtramos - const result = await dbInvoke({ - table: 'product_images', - operation: 'select', - select: - 'id, product_id, variant_id, color_id, supplier_code, url_cdn, url_original, image_type, is_primary, is_og_image, display_order, is_active, alt_text, title_text', - filters: { is_active: true }, - orderBy: { column: 'display_order', ascending: true }, - limit: 5000, - }); + const results = await Promise.all( + chunks.map((chunk) => + dbInvoke({ + table: 'product_images', + operation: 'select', + select: + 'id, product_id, variant_id, color_id, supplier_code, url_cdn, url_original, image_type, is_primary, is_og_image, display_order, is_active, alt_text, title_text', + filters: { is_active: true, product_id: chunk }, + orderBy: { column: 'display_order', ascending: true }, + limit: 1000, + }), + ), + ); // Agrupar por product_id const imagesByProduct = new Map(); - const productIdSet = new Set(productIds); - - result.records.forEach((image) => { - if (!productIdSet.has(image.product_id)) return; - - const productImages = imagesByProduct.get(image.product_id) ?? []; - imagesByProduct.set(image.product_id, productImages); - productImages.push(image); - }); + for (const result of results) { + result.records.forEach((image) => { + const productImages = imagesByProduct.get(image.product_id) ?? []; + imagesByProduct.set(image.product_id, productImages); + productImages.push(image); + }); + } return imagesByProduct; } catch (err) { diff --git a/src/hooks/ui/useMobileSidebarFix.ts b/src/hooks/ui/useMobileSidebarFix.ts index e2876ab94..769aa33cc 100644 --- a/src/hooks/ui/useMobileSidebarFix.ts +++ b/src/hooks/ui/useMobileSidebarFix.ts @@ -17,5 +17,6 @@ export function useMobileSidebarFix(onToggle: () => void, isOpen: boolean) { if (isOpen && window.innerWidth < 1024) { onToggle(); } - }, [pathname]); // Só depende do pathname + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); // intentionally omit isOpen/onToggle: effect must only fire on route change } diff --git a/src/pages/filters/useFiltersPageState.ts b/src/pages/filters/useFiltersPageState.ts index 71a30920e..1eaeae432 100644 --- a/src/pages/filters/useFiltersPageState.ts +++ b/src/pages/filters/useFiltersPageState.ts @@ -12,9 +12,21 @@ import { useSupplierSalesRanking } from '@/hooks/products/useSupplierSalesRankin import { useDebounce } from '@/hooks/common/useDebounce'; import { usePromoSalesRanking } from '@/hooks/intelligence/usePromoSalesRanking'; import { sortProducts } from '@/utils/product-sorting'; +import { SORT_OPTIONS } from '@/constants/filters'; import { toast } from 'sonner'; import type { ProductVariation } from '@/types/product-catalog'; +// Valores de sortBy aceitos: os expostos na UI (SORT_OPTIONS) + os internos +// suportados pelo pipeline sortProducts (color-match/popularity são definidos +// upstream; name-asc/name-desc são aliases tratados no sorter). +const VALID_SORT_VALUES = new Set([ + ...SORT_OPTIONS.map((o) => o.value), + 'name-asc', + 'name-desc', + 'popularity', + 'color-match', +]); + export function useFiltersPageState() { const [searchParams, setSearchParams] = useSearchParams(); const isInitialMount = useRef(true); @@ -71,9 +83,22 @@ export function useFiltersPageState() { const pMin = get('priceMin'); const pMax = get('priceMax'); // FIX-04: usar parseFloat para preservar centavos (ex: "15.99" → 15.99, não 15) - if (pMin || pMax) f.priceRange = [pMin ? parseFloat(pMin) : 0, pMax ? parseFloat(pMax) : 9999]; + // FIX-28: validar NaN e fazer clamp (min<=max). Valores inválidos na URL + // (?priceMin=abc, min>max) caíam como NaN e zeravam a lista sem feedback. + if (pMin || pMax) { + const PRICE_MAX = 9999; + const parsedMin = pMin ? parseFloat(pMin) : 0; + const parsedMax = pMax ? parseFloat(pMax) : PRICE_MAX; + let min = Number.isFinite(parsedMin) && parsedMin >= 0 ? parsedMin : 0; + let max = Number.isFinite(parsedMax) && parsedMax >= 0 ? parsedMax : PRICE_MAX; + if (min > max) [min, max] = [max, min]; + f.priceRange = [min, max]; + } const ms = get('minStock'); - if (ms) f.minStock = parseInt(ms); // minStock é sempre inteiro — parseInt ok + if (ms) { + const parsedMs = parseInt(ms, 10); + if (Number.isFinite(parsedMs) && parsedMs >= 0) f.minStock = parsedMs; + } if (get('inStock') === '1') f.inStock = true; if (get('isKit') === '1') f.isKit = true; if (get('featured') === '1') f.featured = true; @@ -81,8 +106,11 @@ export function useFiltersPageState() { if (get('hasPersonalization') === '1') f.hasPersonalization = true; if (get('onSale') === '1') f.onSale = true; if (get('hasCommercialPackaging') === '1') f.hasCommercialPackaging = true; + // FIX-28/B5: só aceitar sortBy da URL se for um valor conhecido — evita + // que o Select fique sem opção correspondente (placeholder vazio) e que o + // pipeline de sort receba um valor que cai no no-op silencioso. const sortByParam = get('sortBy'); - if (sortByParam) f.sortBy = sortByParam; + if (sortByParam && VALID_SORT_VALUES.has(sortByParam)) f.sortBy = sortByParam; return f; }); @@ -117,6 +145,20 @@ export function useFiltersPageState() { () => (catalogData?.pages ? catalogData.pages.flatMap((page) => page.products) : []), [catalogData], ); + + // FIX-20: o filtro de Técnicas só funciona se os produtos carregados trouxerem + // `metadata.techniques`. Quando nenhum produto tem esse dado (caso do catálogo + // lightweight atual), selecionar uma técnica não filtra nada — então não + // devemos contá-la como filtro ativo nem exibir o chip (evita falso positivo). + // Até existir um hook server-side (useProductsByTechnique), este sinal mantém + // a UI honesta. + const techniquesDataAvailable = useMemo( + () => + realProducts.some( + (p) => ((p.metadata?.techniques as string[] | undefined)?.length || 0) > 0, + ), + [realProducts], + ); const totalEstimate = catalogData?.pages?.[0]?.totalEstimate ?? null; const isFullyLoaded = !hasNextPage && !isFetchingNextPage; const loadedCount = realProducts.length; @@ -276,13 +318,13 @@ export function useFiltersPageState() { if (filters.hasPersonalization) count++; if (filters.onSale) count++; if (filters.hasCommercialPackaging) count++; - if ((filters.techniques?.length || 0) > 0) count++; + if (techniquesDataAvailable && (filters.techniques?.length || 0) > 0) count++; if ((filters.tags?.length || 0) > 0) count++; if ((filters.gender?.length || 0) > 0) count++; if ((filters.sizes?.length || 0) > 0) count++; if (filters.search) count++; return count; - }, [filters]); + }, [filters, techniquesDataAvailable]); const handleReset = () => { const hadFilters = activeFiltersCount > 0; @@ -457,16 +499,17 @@ export function useFiltersPageState() { // O campo techniques não existe diretamente no Product lightweight — filtro // client-side faz match pelo ID/nome da técnica no metadata do produto. // Para filtro server-side completo, implementar useProductsByTechnique hook. - if (filters.techniques?.length) { + // FIX-20: só aplica o filtro quando há dados de técnica nos produtos + // (techniquesDataAvailable). Sem dados, o filtro era um no-op que ainda + // contava como ativo/chip — agora a seleção é inerte de forma consistente + // (não conta, não chipa, não filtra) até existir suporte server-side. + if (techniquesDataAvailable && filters.techniques?.length) { const techSet = new Set(filters.techniques.map((t) => t.toLowerCase())); result = result.filter((product) => { - // Tenta match via metadata.techniques (se disponível no produto enriquecido) const metaTechs: string[] = (product.metadata?.techniques as string[]) || []; if (metaTechs.length > 0) { return metaTechs.some((t: string) => techSet.has(t.toLowerCase())); } - // Fallback: sem dados de técnica no produto — não filtra (inclui o produto) - // para não esconder produtos válidos enquanto o hook server-side não existe. return true; }); } @@ -481,6 +524,7 @@ export function useFiltersPageState() { hasFuzzySearch, fuzzySearchResults, realProducts, + techniquesDataAvailable, hasMaterialFilter, materialFilteredProductIds, isLoadingMaterialFilter, @@ -634,7 +678,7 @@ export function useFiltersPageState() { }); // Tipos ausentes no original — FIX-05: const techArr = filters.techniques || []; - if (techArr.length > 0) + if (techArr.length > 0 && techniquesDataAvailable) summary.push({ label: 'Técnicas', value: `${techArr.length} selecionada${techArr.length > 1 ? 's' : ''}`, @@ -664,12 +708,13 @@ export function useFiltersPageState() { if (filters.isNew) summary.push({ label: 'Lançamento', value: 'Sim', key: 'isNew' }); if (filters.hasPersonalization) summary.push({ label: 'Personalizável', value: 'Sim', key: 'hasPersonalization' }); + if (filters.onSale) summary.push({ label: 'Em Oferta', value: 'Sim', key: 'onSale' }); if (filters.hasCommercialPackaging) summary.push({ label: 'Embalagem', value: 'Comercial', key: 'hasCommercialPackaging' }); if (filters.search) summary.push({ label: 'Busca', value: `"${filters.search}"`, key: 'search' }); return summary; - }, [filters]); + }, [filters, techniquesDataAvailable]); const clearSingleFilter = (key: keyof FilterState) => { if (key === 'colors') diff --git a/src/services/productService.ts b/src/services/productService.ts index f1fd69e7a..a85ece179 100644 --- a/src/services/productService.ts +++ b/src/services/productService.ts @@ -34,7 +34,11 @@ export const productService = { case 'best-seller-promo': orderBy = { column: 'is_featured', ascending: false }; break; + case 'name-desc': + orderBy = { column: 'name', ascending: false }; + break; case 'name': + case 'name-asc': default: orderBy = { column: 'name', ascending: true }; break; diff --git a/src/utils/product-sorting.ts b/src/utils/product-sorting.ts index 3ccb04058..502b1e3f4 100644 --- a/src/utils/product-sorting.ts +++ b/src/utils/product-sorting.ts @@ -1,5 +1,47 @@ import { type Product, type SupplierSalesEntry } from '@/hooks/products'; +/** + * Collator pt-BR único e reutilizável para ordenação alfabética de nomes. + * + * - `numeric: true` → "Caneta 2" antes de "Caneta 10" (ordenação natural). + * - `sensitivity: 'base'` → ignora caixa e acento na comparação principal + * ("Água"/"agua" tratados de forma consistente), evitando ordem fora de + * lugar para acentuação típica do português. + * + * Sem isso, `String.localeCompare` sem locale usa o locale default do + * ambiente (Node/SSR/test/browser) → ordem não-determinística. + */ +const PT_BR_COLLATOR = new Intl.Collator('pt-BR', { + numeric: true, + sensitivity: 'base', +}); + +/** Compara dois nomes usando o collator pt-BR (null/undefined viram ''). */ +export function compareNamePtBR(a?: string | null, b?: string | null): number { + return PT_BR_COLLATOR.compare(a || '', b || ''); +} + +/** + * Comparador estável: ordena por nome (pt-BR) e desempata por `id`. + * Garante ordem determinística entre páginas no infinite scroll. + */ +function byNameThenId(a: Product, b: Product): number { + const byName = compareNamePtBR(a.name, b.name); + if (byName !== 0) return byName; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** Desempate final por id, preservando o comparador primário fornecido. */ +function withIdTiebreak( + primary: (a: Product, b: Product) => number, +): (a: Product, b: Product) => number { + return (a, b) => { + const result = primary(a, b); + if (result !== 0) return result; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; +} + /** * Centralized product sorting logic. * Used by both the Catalog (Index) and Super Filter (FiltersPage). @@ -16,18 +58,25 @@ export function sortProducts( if (options?.skipSort) return products; switch (sortBy) { + // BUG-SORT FIX: 'name-asc'/'name-desc' caíam no default (no-op) apesar de + // serem valores válidos de ProductFilters.sortBy. Tratados aqui explicitamente. + // ('name' e 'name-asc' compartilham o mesmo corpo via fall-through de case vazio.) case 'name': - products.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + case 'name-asc': + products.sort(byNameThenId); + break; + case 'name-desc': + products.sort((a, b) => byNameThenId(b, a)); break; case 'price-asc': - products.sort((a, b) => a.price - b.price); + products.sort(withIdTiebreak((a, b) => a.price - b.price)); break; case 'price-desc': - products.sort((a, b) => b.price - a.price); + products.sort(withIdTiebreak((a, b) => b.price - a.price)); break; case 'stock': - products.sort((a, b) => (b.stock || 0) - (a.stock || 0)); + products.sort(withIdTiebreak((a, b) => (b.stock || 0) - (a.stock || 0))); break; case 'newest': products.sort((a, b) => { @@ -36,8 +85,7 @@ export function sortProducts( if (bTime !== aTime) return bTime - aTime; // Se datas iguais, prioriza os que têm flag newArrival if (b.newArrival !== a.newArrival) return b.newArrival ? 1 : -1; - return (a.name || '').localeCompare(b.name || ''); - + return byNameThenId(a, b); }); break; case 'best-seller-supplier': { @@ -54,8 +102,7 @@ export function sortProducts( const aVel = aEntry?.velocity7d ?? 0; const bVel = bEntry?.velocity7d ?? 0; if (bVel !== aVel) return bVel - aVel; - return (a.name || '').localeCompare(b.name || ''); - + return byNameThenId(a, b); }); } else { // Fallback: flags do produto (quando MV nao populada) @@ -67,8 +114,7 @@ export function sortProducts( const aStock = a.stock || 0; const bStock = b.stock || 0; if (bStock !== aStock) return bStock - aStock; - return (a.name || '').localeCompare(b.name || ''); - + return byNameThenId(a, b); }); } break; @@ -89,7 +135,7 @@ export function sortProducts( const aCount = map?.get(a.id) || 0; const bCount = map?.get(b.id) || 0; if (bCount !== aCount) return bCount - aCount; - return (a.name || '').localeCompare(b.name || ''); + return byNameThenId(a, b); }); break; default: diff --git a/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql b/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql new file mode 100644 index 000000000..a1197cb22 --- /dev/null +++ b/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql @@ -0,0 +1,25 @@ +-- Índices para as colunas de ORDENAÇÃO do catálogo de produtos. +-- +-- Contexto (auditoria 2026-06-04): o catálogo (view v_products_public sobre +-- public.products) ordena por sale_price (Preço ↑/↓), created_at (Mais Recentes) +-- e stock_quantity (Maior Estoque). Já existem índices para busca +-- (name/sku/description trigram, search_vector) e para category/supplier/brand, +-- mas NÃO para essas colunas de sort — o que força full sort e está alinhado +-- com os statement timeouts observados na paginação defensiva do front +-- (src/lib/external-db/products.ts). +-- +-- Índices PARCIAIS (WHERE active = true) para casar com o filtro padrão do +-- catálogo e manter o índice pequeno, no mesmo estilo dos índices já existentes +-- (idx_products_active_name_sort). Em ~6k linhas a criação é instantânea. + +CREATE INDEX IF NOT EXISTS idx_products_active_sale_price + ON public.products (sale_price) + WHERE active = true; + +CREATE INDEX IF NOT EXISTS idx_products_active_created_at + ON public.products (created_at DESC) + WHERE active = true; + +CREATE INDEX IF NOT EXISTS idx_products_active_stock_quantity + ON public.products (stock_quantity DESC) + WHERE active = true; diff --git a/tests/utils/product-sorting.test.ts b/tests/utils/product-sorting.test.ts index bfd8f604b..0aec38b9a 100644 --- a/tests/utils/product-sorting.test.ts +++ b/tests/utils/product-sorting.test.ts @@ -3,7 +3,7 @@ * Validates all 7 sort modes, edge cases, and parity between Catalog & Super Filter. */ import { describe, it, expect } from "vitest"; -import { sortProducts } from "@/utils/product-sorting"; +import { sortProducts, compareNamePtBR } from "@/utils/product-sorting"; // Minimal product factory function makeProduct(overrides: Record = {}) { @@ -263,6 +263,79 @@ describe("sortProducts", () => { }); }); + // ===== PT-BR COLLATOR + NAME ALIASES + DETERMINISM ===== + describe("pt-BR collation and name aliases", () => { + it("orders numbers naturally (Caneta 2 before Caneta 10)", () => { + const products = [ + makeProduct({ id: "a", name: "Caneta 10" }), + makeProduct({ id: "b", name: "Caneta 2" }), + makeProduct({ id: "c", name: "Caneta 1" }), + ]; + sortProducts(products, "name"); + expect(products.map((p) => p.name)).toEqual(["Caneta 1", "Caneta 2", "Caneta 10"]); + }); + + it("orders accented pt-BR names base-insensitively", () => { + const products = [ + makeProduct({ id: "a", name: "Água" }), + makeProduct({ id: "b", name: "Abacaxi" }), + makeProduct({ id: "c", name: "Açaí" }), + ]; + sortProducts(products, "name"); + // base sensitivity: Abacaxi < Açaí < Água + expect(products.map((p) => p.name)).toEqual(["Abacaxi", "Açaí", "Água"]); + }); + + it("treats 'name-asc' as ascending name sort", () => { + const products = [makeProduct({ name: "Zebra" }), makeProduct({ name: "Alpha" })]; + sortProducts(products, "name-asc"); + expect(products.map((p) => p.name)).toEqual(["Alpha", "Zebra"]); + }); + + it("treats 'name-desc' as descending name sort", () => { + const products = [makeProduct({ name: "Alpha" }), makeProduct({ name: "Zebra" })]; + sortProducts(products, "name-desc"); + expect(products.map((p) => p.name)).toEqual(["Zebra", "Alpha"]); + }); + + it("compareNamePtBR is null/undefined safe", () => { + expect(compareNamePtBR(null, "a")).toBeLessThan(0); + expect(compareNamePtBR("a", undefined)).toBeGreaterThan(0); + expect(compareNamePtBR(null, null)).toBe(0); + }); + }); + + describe("deterministic id tiebreak", () => { + it("breaks equal names by id (stable, page-safe order)", () => { + const products = [ + makeProduct({ id: "p3", name: "Same" }), + makeProduct({ id: "p1", name: "Same" }), + makeProduct({ id: "p2", name: "Same" }), + ]; + sortProducts(products, "name"); + expect(products.map((p) => p.id)).toEqual(["p1", "p2", "p3"]); + }); + + it("breaks equal prices by id deterministically", () => { + const products = [ + makeProduct({ id: "p3", price: 5 }), + makeProduct({ id: "p1", price: 5 }), + makeProduct({ id: "p2", price: 5 }), + ]; + sortProducts(products, "price-asc"); + expect(products.map((p) => p.id)).toEqual(["p1", "p2", "p3"]); + }); + + it("breaks equal stock by id deterministically", () => { + const products = [ + makeProduct({ id: "b", stock: 7 }), + makeProduct({ id: "a", stock: 7 }), + ]; + sortProducts(products, "stock"); + expect(products.map((p) => p.id)).toEqual(["a", "b"]); + }); + }); + // ===== PARITY CHECK ===== describe("parity between Catalog and Super Filter", () => { it("produces identical results for all sort modes", () => {