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
133 changes: 133 additions & 0 deletions src/components/catalog/ProductCardImage.tsx
Original file line number Diff line number Diff line change
@@ -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:
* <ProductCardImage
* mainUrl={product.image_url}
* setUrl={product.set_image_url}
* alt={product.name}
* />
*/

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;
}
Comment on lines +42 to +51

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({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire the hover image into the rendered card

This new crossfade component is not imported by the catalog card path: src/components/products/ProductCard.tsx imports ./ProductCardImage, which resolves to src/components/products/ProductCardImage.tsx, and repo search only finds this src/components/catalog/ProductCardImage.tsx referenced inside its own file. As a result, products with set_image_url still render through the existing OptimizedImage component and never show the hover crossfade; please move this behavior into the rendered product card image component or update the import path.

Useful? React with 👍 / 👎.

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 (
<div
className={cn(
'product-card-img-wrapper relative overflow-hidden bg-muted/20',
aspect,
rounded,
// Classe group Tailwind — ativa hover em todos os filhos
hasHover && 'group',
className,
)}
>
{/* Imagem principal — visível por padrão, some no hover */}
<img
src={mainSrc}
alt={alt}
loading="lazy"
decoding="async"
className={cn(
'absolute inset-0 h-full w-full object-contain',
'transition-opacity duration-300 ease-in-out',
hasHover && 'group-hover:opacity-0',
)}
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = '/placeholder.svg';
}}
/>
Comment on lines +106 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Bug crítico: loop infinito se placeholder falhar.

O onError na linha 107 seta src para '/placeholder.svg', mas se o próprio placeholder falhar (arquivo faltando, erro de rede, CORS), dispara onError de novo → loop infinito. Browser pode travar ou consumir memória descontroladamente.

Solução: adicionar guard para evitar trocar novamente se já estiver no placeholder.

🛡️ Fix proposto para prevenir loop
         onError={(e) => {
-          (e.currentTarget as HTMLImageElement).src = '/placeholder.svg';
+          const img = e.currentTarget as HTMLImageElement;
+          if (img.src !== '/placeholder.svg' && !img.src.endsWith('/placeholder.svg')) {
+            img.src = '/placeholder.svg';
+          }
         }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = '/placeholder.svg';
}}
/>
onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (img.src !== '/placeholder.svg' && !img.src.endsWith('/placeholder.svg')) {
img.src = '/placeholder.svg';
}
}}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/catalog/ProductCardImage.tsx` around lines 106 - 109, The
onError handler in ProductCardImage.tsx currently always sets (e.currentTarget
as HTMLImageElement).src = '/placeholder.svg' which can cause an infinite loop
if the placeholder fails; modify the onError handler to first check the current
image src or a flag (e.g., img.dataset['errored'] or compare
e.currentTarget.src) and only set src to '/placeholder.svg' if it isn’t already
the placeholder (and mark the image as errored to prevent repeated retries),
updating the onError in the ProductCardImage component accordingly.


{/* Imagem hover (set — todas as cores) — só renderiza se existir */}
{hasHover && setSrc && (
<img
src={setSrc}
alt={`${alt} — todas as cores`}
loading="lazy"
decoding="async"
Comment on lines +113 to +117
className={cn(
'absolute inset-0 h-full w-full object-contain',
'opacity-0 transition-opacity duration-300 ease-in-out',
'group-hover:opacity-100',
)}
onError={(e) => {
// Se imagem set falhar, esconde para evitar broken image
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
Comment on lines +123 to +126
/>
)}
</div>
);
}

export default ProductCardImage;
78 changes: 26 additions & 52 deletions src/hooks/products/useProductsLightweight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -83,72 +90,46 @@ 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[];
nextOffset: number | null;
totalEstimate: number | null;
}

// Module-level singleton: fetched once per session, shared across all catalog pages.
let categoriesMapPromise: Promise<ReadonlyMap<string, string>> | null = null;

async function loadCategoriesMap(): Promise<ReadonlyMap<string, string>> {
if (!categoriesMapPromise) {
categoriesMapPromise = fetchPromobrindCategories()
.then((categories) => new Map(categories.map((c) => [String(c.id), c.name])) as ReadonlyMap<string, string>)
.catch(() => {
categoriesMapPromise = null; // allow retry on next request
return new Map() as ReadonlyMap<string, string>;
});
try {
const categories = await fetchPromobrindCategories();
return new Map(categories.map((c) => [String(c.id), c.name]));
} catch {
return new Map();
}
return categoriesMapPromise;
}
Comment on lines 112 to 119
Comment on lines 112 to 119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Procurar a definição de fetchPromobrindCategories e checar caching interno
ast-grep --pattern 'export async function fetchPromobrindCategories($$$) { $$$ }'
ast-grep --pattern 'export const fetchPromobrindCategories = $_'
# Contexto adicional: procurar caches/memo no arquivo de categorias
fd -i 'products-detail' --exec rg -n -C3 'cache|memo|singleton|staleTime|let .*Promise|Map\(' {}

Repository: adm01-debug/promo-gifts-v4

Length of output: 2729


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Encontrar uso/definição de fetchPromobrindCategories no repo
rg -n "fetchPromobrindCategories" .

# 2) Mostrar o contexto do arquivo alvo (linhas 80-200)
sed -n '80,200p' src/hooks/products/useProductsLightweight.ts

# 3) Localizar o arquivo que exporta fetchPromobrindCategories (se houver) e procurar cache/memo
# (procura padrões típicos no arquivo que tiver o símbolo; fallback: busca por palavra-chave no repo)
FILES="$(rg -l "fetchPromobrindCategories" . || true)"
for f in $FILES; do
  echo "---- $f ----"
  sed -n '1,120p' "$f" | cat
  rg -n "cache|memo|singleton|staleTime|let .*Promise|Map\(" "$f" || true
done

Repository: adm01-debug/promo-gifts-v4

Length of output: 25046


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Mostrar o corpo de fetchPromobrindCategories e checar caching
sed -n '480,620p' src/lib/external-db/products-detail.ts

# Buscar no arquivo qualquer uso de immutableCache relacionado a categorias
rg -n "fetchPromobrindCategories|getFreshFromCacheSafe|putInCacheSafe|getCachedByIds|immutableCache|categories" src/lib/external-db/products-detail.ts

Repository: adm01-debug/promo-gifts-v4

Length of output: 4672


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Ver como fetchCatalogPage é usado no useInfiniteQuery (freq. por página)
sed -n '120,340p' src/hooks/products/useProductsLightweight.ts

# 2) Ver como funciona immutableCache (se há leitura/early return quando categorias estão no cache)
ls src/lib/external-db | sed -n '1,200p'
fd -i 'immutableCache' src/lib/external-db -a
# (fallback caso o nome não esteja exatamente)
FILES="$(rg -l "putInCacheSafe|getFreshFromCacheSafe|getCachedByIds" src/lib/external-db || true)"
for f in $FILES; do
  echo "---- $f ----"
  rg -n "function putInCacheSafe|putInCacheSafe\\(|getFreshFromCacheSafe|getCachedByIds\\(" "$f" || true
done

# Abrir o arquivo que contém as defs (preferir o provável)
if [ -f src/lib/external-db/immutableCache.ts ]; then
  sed -n '1,260p' src/lib/external-db/immutableCache.ts
fi

Repository: adm01-debug/promo-gifts-v4

Length of output: 12321


Memoize categorias no useProductsCatalog (evita N chamadas de rede ao scroll)

loadCategoriesMap chama fetchPromobrindCategories e monta um novo Map; como fetchCatalogPage executa isso dentro do queryFn, cada página carregada no useInfiniteQuery dispara novamente a busca das categorias.

Além disso, fetchPromobrindCategories não faz early-return do immutableCache: ele sempre roda dbInvoke em categories e só depois preenche putInCacheSafe('categories', ...), então o cache atual não evita essas chamadas.

Sugerido: memoizar a promessa/lista de categorias (ex.: ref escopo do hook ou cache de módulo com TTL) e reutilizar o categoriesById nas páginas; ou reaproveitar useCategories/React Query (mesmo staleTime: 30 min) e passar o mapa para mapLightweightToProduct.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/products/useProductsLightweight.ts` around lines 112 - 119,
loadCategoriesMap currently calls fetchPromobrindCategories on every queryFn run
(triggered by fetchCatalogPage in useProductsCatalog/useInfiniteQuery) and does
not short‑circuit when immutableCache exists, causing repeated network/dbInvoke;
memoize the categories promise or result at hook/module scope (or reuse the
existing useCategories React Query hook with a long staleTime) and change
loadCategoriesMap to return the cached Promise/Map (or early‑return when
immutableCache present after checking putInCacheSafe) so mapLightweightToProduct
and fetchCatalogPage reuse the single categoriesById instance instead of
refetching on each page load.


async function fetchCatalogPage(
offset: number,
search?: string,
categories?: string[],
suppliers?: string[],
sortBy?: string,
): Promise<CatalogPage> {

const filters: Record<string, unknown> = { 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;

Expand Down Expand Up @@ -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<T>[] 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) =>
Expand Down Expand Up @@ -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<CatalogPage, Error>({
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,
Expand Down
28 changes: 11 additions & 17 deletions src/lib/external-db/products-lightweight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<LightweightProduct>;
Expand Down
18 changes: 8 additions & 10 deletions src/types/product-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -180,10 +180,8 @@ export interface ProductFilters {
maxPrice?: number;
inStock?: boolean;
limit?: number;
sortBy?: string;
}


export interface ProductLightweight {
id: string;
name: string;
Expand Down
Loading