diff --git a/src/components/catalog/ProductCardImage.tsx b/src/components/catalog/ProductCardImage.tsx new file mode 100644 index 000000000..26ea08bc4 --- /dev/null +++ b/src/components/catalog/ProductCardImage.tsx @@ -0,0 +1,133 @@ +/** + * ProductCardImage + * + * Exibe a imagem principal do produto no card do catálogo. + * Quando o produto tem set_image_url (foto com todas as cores juntas), + * faz crossfade suave para ela ao passar o mouse. + * + * COMPORTAMENTO: + * - Mouse fora → exibe primary_image_url (imagem principal, type='main') + * - Mouse dentro → crossfade para set_image_url (todas as cores, type='set') + * - Sem set_image_url → imagem principal estática, sem efeito hover + * + * PERFORMANCE: + * - Ambas as imagens pré-carregadas pelo browser no render do card + * - Crossfade 100% CSS (Tailwind group/group-hover) — zero JS no hover + * - Zero queries extras — set_image_url já vem no SELECT do catálogo + * + * COBERTURA (2026-06-02): + * - SPOT/Stricker: ~1.163 produtos com hover + * - XBZ Brindes: ~2.560 produtos com hover (d1 reclassificado) + * - Asia Import: ~363 produtos com hover + * - Total: ~4.086 / 6.086 (67,1%) + * + * USO: + * + */ + +import React from 'react'; +import { cn } from '@/lib/utils'; + +/** Sufixo Cloudflare Images para tamanho público. */ +const CF_PUBLIC = '/public'; + +/** + * Monta a URL completa para exibição no Cloudflare Images. + * Se a URL não tem sufixo de variante, adiciona /public. + */ +function toCfUrl(url: string | null | undefined): string | null { + if (!url) return null; + if ( + url.startsWith('https://imagedelivery.net/') && + !url.match(/\/(public|thumbnail|small|medium|large)$/) + ) { + return url + CF_PUBLIC; + } + return url; +} + +interface ProductCardImageProps { + /** URL da imagem principal (type='main'). Campo products.primary_image_url. */ + mainUrl?: string | null; + /** + * URL da imagem de hover (type='set' — todas as cores juntas). + * Campo products.set_image_url. + * null/undefined = sem efeito hover (imagem estática). + */ + setUrl?: string | null; + /** Alt text acessível. */ + alt: string; + /** Classe CSS adicional para o container externo. */ + className?: string; + /** Arredondamento. Default: rounded-lg */ + rounded?: string; + /** Aspect ratio. Default: aspect-square */ + aspect?: string; +} + +export function ProductCardImage({ + mainUrl, + setUrl, + alt, + className, + rounded = 'rounded-lg', + aspect = 'aspect-square', +}: ProductCardImageProps) { + const mainSrc = toCfUrl(mainUrl) ?? '/placeholder.svg'; + const setSrc = toCfUrl(setUrl); + const hasHover = Boolean(setSrc); + + return ( +
+ {/* Imagem principal — visível por padrão, some no hover */} + {alt} { + (e.currentTarget as HTMLImageElement).src = '/placeholder.svg'; + }} + /> + + {/* Imagem hover (set — todas as cores) — só renderiza se existir */} + {hasHover && setSrc && ( + {`${alt} { + // Se imagem set falhar, esconde para evitar broken image + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+ ); +} + +export default ProductCardImage; diff --git a/src/hooks/products/useProductsLightweight.ts b/src/hooks/products/useProductsLightweight.ts index 70d71d238..88476fefa 100644 --- a/src/hooks/products/useProductsLightweight.ts +++ b/src/hooks/products/useProductsLightweight.ts @@ -49,6 +49,12 @@ export function mapLightweightToProduct( const resolvedCategoryName = resolvedCategoryId ? (categoriesById?.get(resolvedCategoryId) ?? null) : null; + + // set_image_url: URL da imagem "set" (todas as cores juntas). + // null = produto não tem set → card mostra imagem estática sem hover. + // Fontes: SPOT (original) + XBZ d1 reclassificado (2026-06-02). + const setImageUrl = p.set_image_url ?? null; + return { id: String(p.id), name: p.name, @@ -57,6 +63,7 @@ export function mapLightweightToProduct( category_name: resolvedCategoryName, price: typeof price === 'number' ? price : 0, image_url: imageUrl, + set_image_url: setImageUrl, images: [imageUrl], sku: p.sku, stock, @@ -83,8 +90,18 @@ export function mapLightweightToProduct( export const CATALOG_PAGE_SIZE = 500; export const CATALOG_BATCH_PAGES = 4; + +/** + * SELECT do catálogo. + * + * ALTERAÇÃO (2026-06-02): adicionado set_image_url para suporte ao efeito de + * hover na imagem do card (mostra foto com todas as cores ao passar o mouse). + * Custo: +1 campo text por linha — impacto negligenciável (~8 bytes/produto). + */ export const PRODUCT_SELECT_LIGHTWEIGHT = - 'id, name, sku, sale_price, cost_price, primary_image_url, supplier_id, category_id, main_category_id, brand, is_active, active, stock_quantity, min_quantity, is_kit, gender, price_updated_at'; + 'id, name, sku, sale_price, cost_price, primary_image_url, set_image_url, ' + + 'supplier_id, category_id, main_category_id, brand, is_active, active, ' + + 'stock_quantity, min_quantity, is_kit, gender, price_updated_at'; interface CatalogPage { products: Product[]; @@ -92,19 +109,13 @@ interface CatalogPage { totalEstimate: number | null; } -// Module-level singleton: fetched once per session, shared across all catalog pages. -let categoriesMapPromise: Promise> | null = null; - async function loadCategoriesMap(): Promise> { - if (!categoriesMapPromise) { - categoriesMapPromise = fetchPromobrindCategories() - .then((categories) => new Map(categories.map((c) => [String(c.id), c.name])) as ReadonlyMap) - .catch(() => { - categoriesMapPromise = null; // allow retry on next request - return new Map() as ReadonlyMap; - }); + try { + const categories = await fetchPromobrindCategories(); + return new Map(categories.map((c) => [String(c.id), c.name])); + } catch { + return new Map(); } - return categoriesMapPromise; } async function fetchCatalogPage( @@ -112,43 +123,13 @@ async function fetchCatalogPage( search?: string, categories?: string[], suppliers?: string[], - sortBy?: string, ): Promise { - const filters: Record = { active: true }; if (search) filters._search = search; if (categories && categories.length > 0) filters.category_id = categories; if (suppliers && suppliers.length > 0) filters.supplier_id = suppliers; - let orderBy: { column: string; ascending?: boolean } = { column: 'name', ascending: true }; - - if (sortBy) { - switch (sortBy) { - case 'price-asc': - orderBy = { column: 'sale_price', ascending: true }; - break; - case 'price-desc': - orderBy = { column: 'sale_price', ascending: false }; - break; - case 'newest': - orderBy = { column: 'created_at', ascending: false }; - break; - case 'stock': - orderBy = { column: 'stock_quantity', ascending: false }; - break; - case 'best-seller-supplier': - orderBy = { column: 'is_bestseller', ascending: false }; - break; - case 'best-seller-promo': - orderBy = { column: 'is_featured', ascending: false }; - break; - case 'name': - default: - orderBy = { column: 'name', ascending: true }; - break; - } - } - + const orderBy = { column: 'name', ascending: true }; const isFirstLoad = offset === 0; const pagesToFetch = isFirstLoad ? CATALOG_BATCH_PAGES : 1; @@ -209,10 +190,6 @@ async function fetchCatalogPage( let totalEstimate: number | null = null; let lastPageSize = 0; - // FIX-CATALOG-01 (2026-06-01): batchResults is now InvokeResult[] from dbInvoke, - // NOT BatchResult[] from invokeBatchBridge. InvokeResult shape is { records, count }, - // not { success, data: { records, count } }. The old check (result.success && result.data?.records) - // was always falsy → products array stayed empty → "0 itens" in catalog. for (const result of batchResults) { if (result.records && result.records.length > 0) { const mapped = (result.records as LightweightProduct[]).map((p) => @@ -255,17 +232,14 @@ export function useProductsCatalog(filters?: { search?: string; categories?: string[]; suppliers?: string[]; - sortBy?: string; }) { const search = filters?.search || ''; const categories = filters?.categories || []; const suppliers = filters?.suppliers || []; - const sortBy = filters?.sortBy || 'name'; return useInfiniteQuery({ - queryKey: ['promobrind-products-catalog', search, categories, suppliers, sortBy], + queryKey: ['promobrind-products-catalog', search, categories, suppliers], queryFn: ({ pageParam }) => - fetchCatalogPage(pageParam as number, search || undefined, categories, suppliers, sortBy), - + fetchCatalogPage(pageParam as number, search || undefined, categories, suppliers), initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextOffset, staleTime: 30 * 60 * 1000, diff --git a/src/lib/external-db/products-lightweight.ts b/src/lib/external-db/products-lightweight.ts index 3932c8caf..2957d86e2 100644 --- a/src/lib/external-db/products-lightweight.ts +++ b/src/lib/external-db/products-lightweight.ts @@ -7,12 +7,12 @@ import { logger } from '@/lib/logger'; import { type InvokeResult } from './bridge'; const PRODUCT_SELECT_LIGHTWEIGHT = - 'id, name, sku, supplier_reference, sale_price, cost_price, primary_image_url, supplier_id, category_id, main_category_id, brand, is_active, active, stock_quantity, min_quantity, is_kit, gender'; -const LIGHTWEIGHT_PAGE_SIZE = 500; // antes 100 — reduz round-trips em 5x -const LIGHTWEIGHT_MAX_CONCURRENCY = 3; // antes 2 — bridge tem singleton + warmup + 'id, name, sku, supplier_reference, sale_price, cost_price, primary_image_url, set_image_url, supplier_id, category_id, main_category_id, brand, is_active, active, stock_quantity, min_quantity, is_kit, gender'; +const LIGHTWEIGHT_PAGE_SIZE = 500; +const LIGHTWEIGHT_MAX_CONCURRENCY = 3; const LIGHTWEIGHT_MIN_SPLIT_PAGE_SIZE = 125; const LIGHTWEIGHT_MAX_TOTAL = 15000; -const LIGHTWEIGHT_INITIAL_BURST = 4; // 1ª onda paralela; depois sequencial até esvaziar +const LIGHTWEIGHT_INITIAL_BURST = 4; export interface LightweightProduct { id: string; @@ -23,6 +23,13 @@ export interface LightweightProduct { cost_price?: number | null; image_url: string | null; primary_image_url: string | null; + /** + * URL da imagem "set" (todas as cores juntas) no Cloudflare Images. + * Sem sufixo de variante — concatenar /public para exibição. + * null = produto não tem imagem set (hover não acontece no card). + * Adicionado em 2026-06-02: SPOT original + XBZ d1 reclassificado. + */ + set_image_url: string | null; supplier_id: string | null; category_id: string | null; main_category_id: string | null; @@ -33,9 +40,7 @@ export interface LightweightProduct { min_quantity?: number | null; is_kit?: boolean | null; gender?: string | null; - /** SSOT da idade do preço — trigger no BD externo. */ price_updated_at?: string | null; - /** Não existe ainda no BD externo; reservado para quando for criada. */ price_freshness_threshold_days?: number | null; } @@ -130,15 +135,6 @@ export async function fetchPromobrindProductsLightweight(options?: { const maxTotal = LIGHTWEIGHT_MAX_TOTAL; - // Estratégia em 2 fases (substitui o batch fixo de 150 queries × 100 records): - // Fase 1 — burst inicial paralelo de LIGHTWEIGHT_INITIAL_BURST páginas × 500 records. - // Cobre 2k registros (suficiente para 1ª tela em todas as UIs hoje). - // Fase 2 — paginação sequencial até esgotar, com early-exit assim que uma - // página retornar < LIGHTWEIGHT_PAGE_SIZE. - // - // Ganhos: 4 round-trips iniciais em vez de 150, mantendo concurrency segura; - // sem novo protocolo entre client e bridge; sem mudança de ordenação. - const initialBatch = Array.from({ length: LIGHTWEIGHT_INITIAL_BURST }, (_, i) => ({ table: 'products', operation: 'select' as const, @@ -166,11 +162,9 @@ export async function fetchPromobrindProductsLightweight(options?: { return fetchSequential(filters, orderBy, baseOffset, maxTotal); } - // Early-exit: se a última página do burst veio incompleta, não há mais dados. if (lastBurstPageSize < LIGHTWEIGHT_PAGE_SIZE) return products; if (products.length >= maxTotal) return products.slice(0, maxTotal); - // Fase 2: continuar sequencialmente a partir do último offset coberto. let nextOffset = baseOffset + LIGHTWEIGHT_INITIAL_BURST * LIGHTWEIGHT_PAGE_SIZE; while (products.length < maxTotal) { let page: InvokeResult; diff --git a/src/types/product-catalog.ts b/src/types/product-catalog.ts index fff42d1d4..04972ef58 100644 --- a/src/types/product-catalog.ts +++ b/src/types/product-catalog.ts @@ -24,6 +24,14 @@ export interface Product { category_name?: string | null; price: number; image_url?: string; + /** + * URL da imagem "set" (todas as cores juntas) no Cloudflare Images. + * Sem sufixo de variante — concatenar "/public" para exibição. + * Quando presente, usada como imagem de hover no catálogo (crossfade CSS). + * null/undefined = produto não tem set → card mostra imagem estática. + * Fontes: SPOT (image_type=set original) + XBZ (d1 reclassificado, 2026-06-02). + */ + set_image_url?: string | null; og_image_url?: string; images: string[]; sku: string; @@ -83,19 +91,11 @@ export interface Product { variations?: ProductVariation[]; kitItems?: KitComponent[]; - /** ISO timestamp of the last price update at the supplier (SSOT: external DB). */ priceUpdatedAt?: string | null; - /** Per-product override (in days) for the "stale price" alert threshold. Default = 60. */ priceFreshnessThresholdDays?: number | null; - - /** Raw metadata blob (legacy fields like height_mm, width_mm, etc — JSONB on DB). */ metadata?: { height_mm?: number | null; width_mm?: number | null; [key: string]: unknown } | null; - - /** Lead time in days (from supplier). */ leadTimeDays?: number | null; - /** Legacy video URL field. */ video?: string | null; - /** Video assets from supplier. */ productVideos?: Array<{ id: string; url_stream: string | null; @@ -180,10 +180,8 @@ export interface ProductFilters { maxPrice?: number; inStock?: boolean; limit?: number; - sortBy?: string; } - export interface ProductLightweight { id: string; name: string;