From 83d448b282a43005f6c540f5b30479fae035924e Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 22:53:49 -0500 Subject: [PATCH] feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo Fase 3b (ultima) de ADR 0006 Etapa 2: los conteos de categoria/marca/edad del sidebar de /catalog dejan de ser un snapshot client-side fijo. Ahora se refetchean en vivo contra /api/search en cada cambio de filtro (query debounced 300ms), tres llamadas paralelas -- una por dimension, cada una omitiendo su propio filtro para preservar el patron Amazon de "cuantos resultados habria si eligiera este otro valor". - catalog-client.tsx: useMemo client-side originales pasan a ser fallback (mock/shopify sin searchWithFacets, o cuando la categoria activa es un grupo raiz sintetico que Meilisearch no puede filtrar). - ProductFilters gana `limit` opcional; /api/search y el provider medusa lo reenvian -- las llamadas de solo-conteo piden limit=0 para no rehidratar precios de productos que no se van a mostrar. --- workloads/ecommerce/app/api/search/route.ts | 2 + .../components/catalog/catalog-client.tsx | 104 +++++++++++++++++- .../lib/headless/providers/medusa.ts | 2 +- workloads/ecommerce/lib/headless/types.ts | 4 + 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/workloads/ecommerce/app/api/search/route.ts b/workloads/ecommerce/app/api/search/route.ts index c55a0e8..bdf18d3 100644 --- a/workloads/ecommerce/app/api/search/route.ts +++ b/workloads/ecommerce/app/api/search/route.ts @@ -20,6 +20,8 @@ export async function GET(request: Request) { if (minPrice) filters.minPrice = Number(minPrice); const maxPrice = searchParams.get("maxPrice"); if (maxPrice) filters.maxPrice = Number(maxPrice); + const limit = searchParams.get("limit"); + if (limit) filters.limit = Number(limit); try { const result = await searchWithFacets(filters); diff --git a/workloads/ecommerce/components/catalog/catalog-client.tsx b/workloads/ecommerce/components/catalog/catalog-client.tsx index eb4f3ea..7188b85 100644 --- a/workloads/ecommerce/components/catalog/catalog-client.tsx +++ b/workloads/ecommerce/components/catalog/catalog-client.tsx @@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles"; import { ProductCard } from "@/components/product/product-card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import type { Category, Product } from "@/lib/headless/types"; +import type { Category, FacetDistribution, Product } from "@/lib/headless/types"; // Cada cuantas tarjetas de producto se intercala un tile promocional en // la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles @@ -156,8 +156,10 @@ export function CatalogClient({ // Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados // sobre los productos que ya pasan el resto de filtros activos -- cada // dimension se cuenta excluyendose a si misma para reflejar cuantos - // resultados habria si se eligiera ese valor. - const categoryCounts = useMemo(() => { + // resultados habria si se eligiera ese valor. Sirven de fallback cuando + // el provider activo no soporta facetas reales (mock/shopify) o cuando + // la busqueda en vivo todavia no respondio. + const fallbackCategoryCounts = useMemo(() => { const base = products.filter( (product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice), ); @@ -170,7 +172,7 @@ export function CatalogClient({ return counts; }, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]); - const brandCounts = useMemo(() => { + const fallbackBrandCounts = useMemo(() => { const base = products.filter( (product) => matchesQuery(product, query) && @@ -187,7 +189,7 @@ export function CatalogClient({ return counts; }, [age, allowedCategories, brands, category, collection, maxPrice, products, query]); - const ageCounts = useMemo(() => { + const fallbackAgeCounts = useMemo(() => { const base = products.filter( (product) => matchesQuery(product, query) && @@ -204,6 +206,98 @@ export function CatalogClient({ return counts; }, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]); + // "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe + // como valor indexado en Meilisearch (ningun producto lo tiene como + // category real, solo sus hijos -- ver categoryGroups arriba). Mientras + // esa sea la categoria activa, las facetas en vivo de marca/edad no + // pueden filtrar por ella y se degrada al conteo client-side, que si + // sabe expandir el grupo via matchesCategory/allowedCategories. + const categoryIsLeafOrNone = category === "Todos" || !categoryGroups[category]; + + // Conteos reales via Meilisearch (ADR 0006 Etapa 2 Fase 3b): un fetch + // por dimension contra /api/search, cada uno omitiendo su propio filtro + // (mismo criterio de auto-exclusion que el fallback de arriba) para que + // los conteos reflejen el subconjunto ya filtrado por las OTRAS + // dimensiones y se puedan seguir eligiendo otros valores. `limit=0` evita + // rehidratar precios de productos que no vamos a mostrar -- solo importan + // `facetDistribution` y `count`. + const [liveFacets, setLiveFacets] = useState<{ + category: Record | null; + categoryTotal: number | null; + brand: Record | null; + brandTotal: number | null; + ageRange: Record | null; + ageTotal: number | null; + }>({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null }); + + useEffect(() => { + const controller = new AbortController(); + + function buildParams(omit: "category" | "brand" | "ageRange"): URLSearchParams { + const params = new URLSearchParams({ limit: "0" }); + if (query) params.set("q", query); + if (collection) params.set("collection", collection); + if (omit !== "category" && category !== "Todos" && categoryIsLeafOrNone) { + params.set("category", category); + } + if (omit !== "brand" && brand !== "Todas") params.set("brand", brand); + if (omit !== "ageRange" && age !== "Todas") params.set("ageRange", age); + if (maxPrice > 0) params.set("maxPrice", String(maxPrice)); + return params; + } + + async function fetchFacet(omit: "category" | "brand" | "ageRange") { + const response = await fetch(`/api/search?${buildParams(omit).toString()}`, { + signal: controller.signal, + }); + return (await response.json()) as { facetDistribution?: FacetDistribution; count?: number }; + } + + const timer = setTimeout(() => { + Promise.all([fetchFacet("category"), fetchFacet("brand"), fetchFacet("ageRange")]) + .then(([categoryRes, brandRes, ageRes]) => { + // Un facetDistribution vacio ({}) es la senal de degrade explicita + // de /api/search (provider sin searchWithFacets, o Meilisearch + // sin indices todavia) -- distinto de una faceta real con 0 + // resultados, que igual trae la clave presente. + const categoryFacet = categoryRes.facetDistribution && "category" in categoryRes.facetDistribution ? categoryRes.facetDistribution.category : null; + const brandFacet = brandRes.facetDistribution && "brand" in brandRes.facetDistribution ? brandRes.facetDistribution.brand : null; + const ageFacet = ageRes.facetDistribution && "ageRange" in ageRes.facetDistribution ? ageRes.facetDistribution.ageRange : null; + setLiveFacets({ + category: categoryFacet, + categoryTotal: categoryFacet ? (categoryRes.count ?? 0) : null, + brand: categoryIsLeafOrNone ? brandFacet : null, + brandTotal: categoryIsLeafOrNone && brandFacet ? (brandRes.count ?? 0) : null, + ageRange: categoryIsLeafOrNone ? ageFacet : null, + ageTotal: categoryIsLeafOrNone && ageFacet ? (ageRes.count ?? 0) : null, + }); + }) + .catch((error) => { + if ((error as Error).name !== "AbortError") { + setLiveFacets({ category: null, categoryTotal: null, brand: null, brandTotal: null, ageRange: null, ageTotal: null }); + } + }); + }, 300); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]); + + const categoryCounts = useMemo( + () => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), + [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal], + ); + const brandCounts = useMemo( + () => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), + [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal], + ); + const ageCounts = useMemo( + () => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), + [fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal], + ); + const reset = () => { setQuery(""); setCategory("Todos"); diff --git a/workloads/ecommerce/lib/headless/providers/medusa.ts b/workloads/ecommerce/lib/headless/providers/medusa.ts index 211f60b..790779c 100644 --- a/workloads/ecommerce/lib/headless/providers/medusa.ts +++ b/workloads/ecommerce/lib/headless/providers/medusa.ts @@ -473,6 +473,6 @@ export const medusaCatalogProvider: CatalogProvider = { }, async searchWithFacets(filters) { - return fetchWithFacets(filters); + return fetchWithFacets(filters, filters?.limit ?? 24); }, }; diff --git a/workloads/ecommerce/lib/headless/types.ts b/workloads/ecommerce/lib/headless/types.ts index f92783b..d8ed126 100644 --- a/workloads/ecommerce/lib/headless/types.ts +++ b/workloads/ecommerce/lib/headless/types.ts @@ -38,6 +38,10 @@ export interface ProductFilters { ageRange?: string; minPrice?: number; maxPrice?: number; + // Solo lo usa searchWithFacets -- limit=0 pide facetDistribution/count + // sin rehidratar precios de productos que no se van a mostrar (ver + // sidebar de facetas en catalog-client.tsx). + limit?: number; } export interface Category {