Files
apps-registry/workloads/ecommerce/components/catalog/catalog-client.tsx
T
devops 83d448b282 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.
2026-08-26 22:53:49 -05:00

475 lines
20 KiB
TypeScript

"use client";
import Link from "next/link";
import { ChevronRight, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
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, 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
// promocionales intercalados, no solo tarjetas homogeneas.
const PROMO_TILE_INTERVAL = 8;
type GridItem = { type: "product"; product: Product } | { type: "promo"; category: Category };
type CatalogClientProps = {
products: Product[];
categories?: Category[];
categoryGroups?: Record<string, string[]>;
initialCategory?: string;
initialCollection?: string;
initialQuery?: string;
};
function matchesQuery(product: Product, query: string): boolean {
if (!query) return true;
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
return search.includes(query.toLowerCase());
}
function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category);
}
function matchesCollection(product: Product, collection: string): boolean {
return !collection || product.collection === collection;
}
function matchesBrand(product: Product, brand: string): boolean {
return brand === "Todas" || product.brand === brand;
}
function matchesAge(product: Product, age: string): boolean {
return age === "Todas" || product.ageRange === age;
}
function matchesPrice(product: Product, maxPrice: number): boolean {
return product.price === null || maxPrice === 0 || product.price <= maxPrice;
}
// Camina parentId hacia arriba desde la categoria seleccionada para armar
// el breadcrumb real (Inicio > raiz > hija), igual que en Amazon.
function buildBreadcrumb(categories: Category[], name: string): Category[] {
const node = categories.find((category) => category.name === name);
if (!node) return [];
const trail = [node];
let current = node;
while (current.parentId) {
const parent = categories.find((category) => category.id === current.parentId);
if (!parent) break;
trail.unshift(parent);
current = parent;
}
return trail;
}
export function CatalogClient({
products,
categories: categoryNodes = [],
categoryGroups = {},
initialCategory = "Todos",
initialCollection = "",
initialQuery = "",
}: CatalogClientProps) {
const [query, setQuery] = useState(initialQuery);
const [category, setCategory] = useState(initialCategory);
const [collection, setCollection] = useState(initialCollection);
const [brand, setBrand] = useState("Todas");
const [age, setAge] = useState("Todas");
const [sort, setSort] = useState("featured");
const categories = ["Todos", ...new Set(products.map((product) => product.category))];
const brands = ["Todas", ...new Set(products.map((product) => product.brand))];
const ages = ["Todas", ...new Set(products.map((product) => product.ageRange))];
const pricedProducts = products.filter((product) => product.price !== null);
const maxCatalogPrice = pricedProducts.length
? Math.max(...pricedProducts.map((product) => product.price ?? 0))
: 0;
const [maxPrice, setMaxPrice] = useState(maxCatalogPrice);
// Next.js conserva la instancia del componente cuando solo cambia el query string.
// Sin esta sincronización, el catálogo anterior permanece visible hasta presionar F5.
useEffect(() => {
setQuery(initialQuery);
setCategory(initialCategory || "Todos");
setCollection(initialCollection || "");
setBrand("Todas");
setAge("Todas");
setSort("featured");
setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, maxCatalogPrice]);
// Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto
// tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category];
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => {
const excludeRootId = breadcrumb[0]?.id;
return categoryNodes.filter((node) => !node.parentId && node.id !== excludeRootId);
}, [categoryNodes, breadcrumb]);
const filtered = useMemo(
() =>
products
.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
)
.sort((a, b) =>
sort === "name"
? a.name.localeCompare(b.name, "es")
: sort === "new"
? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured),
),
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort],
);
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
// ciclando por las categorias raiz -- si no hay jerarquia de
// categorias disponible, la grilla queda igual que antes (solo
// productos).
const gridItems = useMemo<GridItem[]>(() => {
if (promoRoots.length === 0) return filtered.map((product) => ({ type: "product", product }));
const items: GridItem[] = [];
let promoIndex = 0;
filtered.forEach((product, index) => {
items.push({ type: "product", product });
const isLast = index === filtered.length - 1;
const promoCategory = promoRoots[promoIndex % promoRoots.length];
if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0 && promoCategory) {
items.push({ type: "promo", category: promoCategory });
promoIndex += 1;
}
});
return items;
}, [filtered, promoRoots]);
// 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. 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),
);
const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) {
if (name === "Todos") continue;
const allowed = categoryGroups[name] ?? [name];
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
}
return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
const fallbackBrandCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesAge(product, age) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.brand === value).length;
}
return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
const fallbackAgeCounts = useMemo(() => {
const base = products.filter(
(product) =>
matchesQuery(product, query) &&
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) &&
matchesBrand(product, brand) &&
matchesPrice(product, maxPrice),
);
const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) {
if (value === "Todas") continue;
counts[value] = base.filter((product) => product.ageRange === value).length;
}
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<string, number> | null;
categoryTotal: number | null;
brand: Record<string, number> | null;
brandTotal: number | null;
ageRange: Record<string, number> | 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");
setCollection("");
setBrand("Todas");
setAge("Todas");
setMaxPrice(maxCatalogPrice);
setSort("featured");
};
return (
<div className="mx-auto max-w-7xl px-4 py-10">
<div className="mb-8">
{breadcrumb.length > 0 && (
<nav aria-label="Breadcrumb" className="mb-4 flex flex-wrap items-center gap-1.5 text-xs font-bold text-slate-500">
<Link href="/catalog" prefetch={false} className="hover:text-fuchsia-600">
Inicio
</Link>
{breadcrumb.map((node, index) => (
<span key={node.id} className="flex items-center gap-1.5">
<ChevronRight className="size-3" />
{index === breadcrumb.length - 1 ? (
<span aria-current="page" className="text-slate-900">
{node.name}
</span>
) : (
<Link href={`/catalog?category=${encodeURIComponent(node.name)}`} prefetch={false} className="hover:text-fuchsia-600">
{node.name}
</Link>
)}
</span>
))}
</nav>
)}
<span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">
Catálogo completo
</span>
<h1 className="mt-2 text-5xl font-black tracking-tight">Todos los tesoros</h1>
<p className="mt-3 text-slate-500">
Filtra por categoría, marca o edad sin recargar la página.
</p>
</div>
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
<aside className="h-fit space-y-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-lg shadow-slate-900/5 lg:sticky lg:top-44">
<div className="flex items-center justify-between">
<strong className="flex items-center gap-2 text-lg font-black">
<SlidersHorizontal className="size-5 text-cyan-600" />
Filtros
</strong>
<button onClick={reset} className="text-xs font-black text-fuchsia-600">
<X className="mr-1 inline size-3" />
Limpiar
</button>
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar…"
/>
<Filter
title="Categoría"
values={categories}
selected={category}
onChange={setCategory}
counts={categoryCounts}
/>
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
<fieldset>
<legend className="mb-3 text-sm font-black">Precio máximo</legend>
{maxCatalogPrice > 0 ? (
<>
<input
aria-label="Precio máximo"
type="range"
min={0}
max={maxCatalogPrice}
step={5000}
value={maxPrice}
onChange={(event) => setMaxPrice(Number(event.target.value))}
className="w-full accent-fuchsia-500"
/>
<p className="mt-2 text-xs font-bold text-slate-500">
Hasta ${maxPrice.toLocaleString("es-CO")}
</p>
</>
) : (
<p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">
El filtro se activa automáticamente cuando el backend entregue precios.
</p>
)}
</fieldset>
</aside>
<section>
<div className="mb-5 flex items-center justify-between gap-4">
<p className="text-sm font-bold text-slate-500">{filtered.length} productos</p>
<select
value={sort}
onChange={(event) => setSort(event.target.value)}
className="h-11 rounded-full border border-slate-200 bg-white px-4 text-sm font-bold"
>
<option value="featured">Destacados</option>
<option value="new">Novedades</option>
<option value="name">Nombre</option>
</select>
</div>
{filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{gridItems.map((item, index) =>
item.type === "product" ? (
<ProductCard key={item.product.id} product={item.product} />
) : (
<PromoTile key={`promo-${index}`} category={item.category} className="min-h-[22rem]" />
),
)}
</div>
) : (
<div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center">
<div>
<p className="text-xl font-black">No encontramos productos</p>
<Button variant="magenta" className="mt-4" onClick={reset}>
Restablecer filtros
</Button>
</div>
</div>
)}
</section>
</div>
</div>
);
}
function Filter({
title,
values,
selected,
onChange,
counts,
}: {
title: string;
values: string[];
selected: string;
onChange: (value: string) => void;
counts?: Record<string, number>;
}) {
return (
<fieldset>
<legend className="mb-3 text-sm font-black">{title}</legend>
<div className="flex flex-wrap gap-2">
{values.map((value) => (
<button
type="button"
key={value}
onClick={() => onChange(value)}
className={`rounded-full px-3 py-2 text-xs font-bold transition ${
selected === value
? "bg-slate-950 text-white"
: "bg-slate-100 text-slate-600 hover:bg-cyan-100"
}`}
>
{value}
{counts && <span className="ml-1 opacity-60">({counts[value] ?? 0})</span>}
</button>
))}
</div>
</fieldset>
);
}