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.
This commit is contained in:
1 parent
609fc19252
commit
83d448b282
4 files changed
+106
-6
No files matched your search
@@ -20,6 +20,8 @@ export async function GET(request: Request) {
|
|||||||
if (minPrice) filters.minPrice = Number(minPrice);
|
if (minPrice) filters.minPrice = Number(minPrice);
|
||||||
const maxPrice = searchParams.get("maxPrice");
|
const maxPrice = searchParams.get("maxPrice");
|
||||||
if (maxPrice) filters.maxPrice = Number(maxPrice);
|
if (maxPrice) filters.maxPrice = Number(maxPrice);
|
||||||
|
const limit = searchParams.get("limit");
|
||||||
|
if (limit) filters.limit = Number(limit);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await searchWithFacets(filters);
|
const result = await searchWithFacets(filters);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { PromoTile } from "@/components/home/category-tiles";
|
|||||||
import { ProductCard } from "@/components/product/product-card";
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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
|
// Cada cuantas tarjetas de producto se intercala un tile promocional en
|
||||||
// la grilla (patron Walmart, ADR 0005 seccion 3): grid con tiles
|
// 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
|
// Conteos por valor de filtro (patron Amazon: "Marca (12)"), calculados
|
||||||
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
// sobre los productos que ya pasan el resto de filtros activos -- cada
|
||||||
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
// dimension se cuenta excluyendose a si misma para reflejar cuantos
|
||||||
// resultados habria si se eligiera ese valor.
|
// resultados habria si se eligiera ese valor. Sirven de fallback cuando
|
||||||
const categoryCounts = useMemo(() => {
|
// 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(
|
const base = products.filter(
|
||||||
(product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice),
|
(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;
|
return counts;
|
||||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const brandCounts = useMemo(() => {
|
const fallbackBrandCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -187,7 +189,7 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||||
|
|
||||||
const ageCounts = useMemo(() => {
|
const fallbackAgeCounts = useMemo(() => {
|
||||||
const base = products.filter(
|
const base = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
matchesQuery(product, query) &&
|
matchesQuery(product, query) &&
|
||||||
@@ -204,6 +206,98 @@ export function CatalogClient({
|
|||||||
return counts;
|
return counts;
|
||||||
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]);
|
}, [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 = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setCategory("Todos");
|
setCategory("Todos");
|
||||||
|
|||||||
@@ -473,6 +473,6 @@ export const medusaCatalogProvider: CatalogProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async searchWithFacets(filters) {
|
async searchWithFacets(filters) {
|
||||||
return fetchWithFacets(filters);
|
return fetchWithFacets(filters, filters?.limit ?? 24);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -38,6 +38,10 @@ export interface ProductFilters {
|
|||||||
ageRange?: string;
|
ageRange?: string;
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: 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 {
|
export interface Category {
|
||||||
|
|||||||
Reference in new issue
Block a user