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 */}
+

{
+ (e.currentTarget as HTMLImageElement).src = '/placeholder.svg';
+ }}
+ />
+
+ {/* Imagem hover (set — todas as cores) — só renderiza se existir */}
+ {hasHover && setSrc && (
+

{
+ // 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;