Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/ThemeInitializer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<ThemeContext.Provider value={undefined as any}>
<ThemeInitializer />
</ThemeContext.Provider>
Expand All @@ -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
<ThemeContext.Provider value={{ actualTheme: 'dark' } as any}>
<ThemeInitializer />
</ThemeContext.Provider>
Expand Down
1 change: 1 addition & 0 deletions src/components/products/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/products/useCatalogPrefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
60 changes: 39 additions & 21 deletions src/hooks/products/useProductImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,37 +77,55 @@ export async function fetchProductImages(productId: string): Promise<ProductImag
}

/**
* Busca imagens de múltiplos produtos de uma vez (batch)
* Tamanho do chunk de product_ids por query IN(). Alinhado com o enriquecimento
* em src/lib/external-db/products.ts (CHUNK_SIZE=80) para manter a URL do
* PostgREST dentro de limites seguros.
*/
const IMAGE_BATCH_CHUNK_SIZE = 80;

/**
* Busca imagens de múltiplos produtos de uma vez (batch).
*
* Usa filtro `IN(product_id)` (suportado pelo PostgREST via array — ver
* postgrest.ts `query.in(key, value)`), em chunks, em vez de baixar a tabela
* inteira e filtrar em memória. Isso evita transferir milhares de linhas e o
* truncamento silencioso pelo teto de `limit`.
*/
export async function fetchProductImagesBatch(
productIds: string[],
): Promise<Map<string, ProductImage[]>> {
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<ProductImage>({
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<ProductImage>({
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<string, ProductImage[]>();
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) {
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/ui/useMobileSidebarFix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
67 changes: 56 additions & 11 deletions src/pages/filters/useFiltersPageState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
...SORT_OPTIONS.map((o) => o.value),
'name-asc',
'name-desc',
'popularity',
'color-match',
]);

export function useFiltersPageState() {
const [searchParams, setSearchParams] = useSearchParams();
const isInitialMount = useRef(true);
Expand Down Expand Up @@ -71,18 +83,34 @@ 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;
if (get('isNew') === '1') f.isNew = true;
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;
});

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
});
}
Expand All @@ -481,6 +524,7 @@ export function useFiltersPageState() {
hasFuzzySearch,
fuzzySearchResults,
realProducts,
techniquesDataAvailable,
hasMaterialFilter,
materialFilteredProductIds,
isLoadingMaterialFilter,
Expand Down Expand Up @@ -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' : ''}`,
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions src/services/productService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading