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
31 changes: 24 additions & 7 deletions src/hooks/products/useCatalogPrefetch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/contexts/AuthContext';
import { invokeBatchBridge } from '@/lib/external-db';
import { mapLightweightToProduct, PRODUCT_SELECT_LIGHTWEIGHT, CATALOG_PAGE_SIZE, CATALOG_BATCH_PAGES } from '@/hooks/products';
import { invokeBatchBridge, fetchPromobrindCategories } from '@/lib/external-db';
import {
mapLightweightToProduct,
PRODUCT_SELECT_LIGHTWEIGHT,
CATALOG_PAGE_SIZE,
CATALOG_BATCH_PAGES,
} from '@/hooks/products';

/**
* Prefetch do catálogo SOMENTE após autenticação (#6).
Expand All @@ -15,7 +20,7 @@ export function useCatalogPrefetch() {

useEffect(() => {
if (isLoading || !isAuthenticated || prefetchedRef.current) return;

// Otimização: Delay de 400ms para prefetch não competir com o render inicial crítico (LCP),
// mas rápido o suficiente para estar pronto antes que o usuário interaja.
const timer = setTimeout(() => {
Expand All @@ -33,13 +38,24 @@ export function useCatalogPrefetch() {
offset: i * CATALOG_PAGE_SIZE,
...(i === 0 ? { countMode: 'exact' } : {}),
}));
const batchResults = await invokeBatchBridge(batchQueries);
const [batchResults, categoriesRaw] = await Promise.all([
invokeBatchBridge(batchQueries),
fetchPromobrindCategories().catch(() => [] as { id: string; name: string }[]),
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 Avoid caching category fallback as fresh prefetch data

The prefetch path swallows category lookup failures with fetchPromobrindCategories().catch(() => []), so a transient categories error still stores a successful catalog response with unresolved names (Sem categoria). Because this same query key is prefetched with staleTime: 30 * 60 * 1000, useProductsCatalog will treat that degraded result as fresh and skip refetching for up to 30 minutes, leaving users with incorrect category labels after a temporary backend hiccup.

Useful? React with 👍 / 👎.

]);
const categoriesById = new Map(categoriesRaw.map((c) => [String(c.id), c.name]));
const products: ReturnType<typeof mapLightweightToProduct>[] = [];
let totalEstimate: number | null = null;
let lastPageSize = 0;
for (const result of batchResults) {
if (result.success && result.data?.records) {
products.push(...(result.data.records as unknown[]).map(mapLightweightToProduct));
products.push(
...(result.data.records as unknown[]).map((p) =>
mapLightweightToProduct(
p as Parameters<typeof mapLightweightToProduct>[0],
categoriesById,
),
),
);
lastPageSize = result.data.records.length;
if (result.data.count !== null && totalEstimate === null) {
totalEstimate = result.data.count as number;
Expand All @@ -48,7 +64,8 @@ export function useCatalogPrefetch() {
}
return {
products,
nextOffset: lastPageSize === CATALOG_PAGE_SIZE ? CATALOG_BATCH_PAGES * CATALOG_PAGE_SIZE : null,
nextOffset:
lastPageSize === CATALOG_PAGE_SIZE ? CATALOG_BATCH_PAGES * CATALOG_PAGE_SIZE : null,
totalEstimate,
};
},
Expand All @@ -59,4 +76,4 @@ export function useCatalogPrefetch() {

return () => clearTimeout(timer);
}, [isAuthenticated, isLoading, queryClient]);
}
}
53 changes: 39 additions & 14 deletions src/hooks/products/useProductsLightweight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import {
fetchPromobrindProductsLightweight,
invokeBatchBridge,
fetchPromobrindCategories,
type LightweightProduct,
} from '@/lib/external-db';

Expand Down Expand Up @@ -38,17 +39,24 @@ function getStockStatus(stock: number): 'in-stock' | 'low-stock' | 'out-of-stock
return 'in-stock';
}

export function mapLightweightToProduct(p: LightweightProduct): Product {
export function mapLightweightToProduct(
p: LightweightProduct,
categoriesById?: ReadonlyMap<string, string>,
): Product {
const imageUrl = p.primary_image_url || p.image_url || '/placeholder.svg';
const price = p.sale_price ?? p.cost_price ?? 0;
const stock = p.stock_quantity || 0;
const resolvedCategoryId = p.category_id || p.main_category_id;
const resolvedCategoryName = resolvedCategoryId
? (categoriesById?.get(resolvedCategoryId) ?? null)
: null;

return {
id: p.id,
name: p.name,
description: '',
category_id: p.category_id || p.main_category_id,
category_name: null,
category_id: resolvedCategoryId,
category_name: resolvedCategoryName,
price: typeof price === 'number' ? price : 0,
image_url: imageUrl,
images: [imageUrl],
Expand All @@ -67,8 +75,8 @@ export function mapLightweightToProduct(p: LightweightProduct): Product {
isKit: p.is_kit ?? false,
gender: p.gender || null,
category: {
id: p.category_id || p.main_category_id || '0',
name: 'Sem categoria',
id: resolvedCategoryId || '0',
name: resolvedCategoryName ?? 'Sem categoria',
},
supplier: {
id: p.supplier_id || p.brand || 'unknown',
Expand Down Expand Up @@ -113,6 +121,15 @@ interface CatalogPage {
* First call fetches 4 pages (2000 products) via batch bridge.
* Subsequent calls fetch 1 page (500 products) each.
*/
async function loadCategoriesMap(): Promise<ReadonlyMap<string, string>> {
try {
const categories = await fetchPromobrindCategories();
return new Map(categories.map((c) => [String(c.id), c.name]));
} catch {
return new Map();
}
}

async function fetchCatalogPage(offset: number, search?: string): Promise<CatalogPage> {
const filters: Record<string, unknown> = { active: true };
if (search) filters._search = search;
Expand All @@ -132,32 +149,40 @@ async function fetchCatalogPage(offset: number, search?: string): Promise<Catalo
...(i === 0 && isFirstLoad ? { countMode: 'exact' } : {}),
}));

const categoriesPromise = loadCategoriesMap();
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 Reuse category map across paginated catalog fetches

fetchCatalogPage now starts loadCategoriesMap() on every page request, and useInfiniteQuery calls this function once per fetchNextPage. Because loadCategoriesMap always calls fetchPromobrindCategories (no memoization/query-cache lookup here), scrolling through N pages triggers N extra category queries for the same static data. In production this adds avoidable network/DB load and can slow next-page loads under latency or rate limits; cache the map once per query session (or use the existing categories query cache) instead of refetching per page.

Useful? React with 👍 / 👎.


let batchResults;
try {
batchResults = await invokeBatchBridge(batchQueries);
} catch {
const fallbackProducts = await fetchPromobrindProductsLightweight({
search,
limit: CATALOG_PAGE_SIZE,
offset,
orderBy,
filters: { active: true },
});
const [fallbackProducts, categoriesById] = await Promise.all([
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 Do not block fallback product load on category timeout

When invokeBatchBridge fails, the fallback now waits on Promise.all([fetchPromobrindProductsLightweight(...), categoriesPromise]). That means the resilience path is gated by the categories request too: if categories are slow or timing out, users wait for that timeout before seeing any products, even though product fallback data is already available. This can turn a partial outage (batch bridge only) into a much slower catalog load; the fallback should return products immediately and hydrate category names opportunistically.

Useful? React with 👍 / 👎.

fetchPromobrindProductsLightweight({
search,
limit: CATALOG_PAGE_SIZE,
offset,
orderBy,
filters: { active: true },
}),
categoriesPromise,
]);

return {
products: fallbackProducts.map(mapLightweightToProduct),
products: fallbackProducts.map((p) => mapLightweightToProduct(p, categoriesById)),
nextOffset: fallbackProducts.length === CATALOG_PAGE_SIZE ? offset + CATALOG_PAGE_SIZE : null,
totalEstimate: null,
};
}

const categoriesById = await categoriesPromise;
const products: Product[] = [];
let totalEstimate: number | null = null;
let lastPageSize = 0;

for (const result of batchResults) {
if (result.success && result.data?.records) {
const mapped = (result.data.records as LightweightProduct[]).map(mapLightweightToProduct);
const mapped = (result.data.records as LightweightProduct[]).map((p) =>
mapLightweightToProduct(p, categoriesById),
);
products.push(...mapped);
lastPageSize = result.data.records.length;
if (result.data.count !== null && totalEstimate === null) {
Expand Down
Loading