"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"; import { matchesTaxonomy, taxonomyValues, type TaxonomyFilter } from "@/lib/taxonomy"; // 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; initialCategory?: string; initialCollection?: string; initialQuery?: string; initialTaxonomy?: TaxonomyFilter; }; // Sin guiones ni espacios: "spiderman" encuentra "Spider-Man". function normalizeSearch(value: string): string { return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s-]+/g, ""); } function matchesQuery(product: Product, query: string): boolean { if (!query) return true; const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")]; const search = normalizeSearch([product.name, product.category, product.collection, product.brand, ...characters].join(" ")); return search.includes(normalizeSearch(query)); } 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 = "", initialTaxonomy = {}, }: CatalogClientProps) { const [query, setQuery] = useState(initialQuery); // Franquicia/saga/personaje (lib/taxonomy.ts): se filtra 100% client-side // sobre los tags -- Meilisearch no tiene estos campos como facetas. const [taxonomy, setTaxonomy] = useState(initialTaxonomy); const taxonomyActive = Boolean(taxonomy.franquicia || taxonomy.saga || taxonomy.personaje); // El titulo solo refleja la franquicia/saga/personaje que viene en la URL // (menu "Personajes"), igual que la barra de navegacion: si reaccionara // al filtro lateral (que no cambia la URL), el H1 diria "Marvel" con // "Todo" marcado activo arriba (reportado por frontend-critic). const urlTaxonomyTitle = initialTaxonomy.personaje || initialTaxonomy.saga || initialTaxonomy.franquicia || ""; 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); setTaxonomy({ franquicia: initialTaxonomy.franquicia, saga: initialTaxonomy.saga, personaje: initialTaxonomy.personaje }); }, [initialCategory, initialCollection, initialQuery, maxCatalogPrice, initialTaxonomy.franquicia, initialTaxonomy.saga, initialTaxonomy.personaje]); // Valores de franquicia/personaje presentes en el catalogo. Los // personajes se acotan a la franquicia elegida (si hay una), para no // ofrecer "Spider-Man" mientras se mira Disney. const franchises = useMemo(() => ["Todas", ...new Set(products.flatMap((product) => taxonomyValues(product, "franquicia")))], [products]); const characters = useMemo( () => [ "Todos", ...new Set( products .filter((product) => matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga })) .flatMap((product) => taxonomyValues(product, "personaje")), ), ], [products, taxonomy.franquicia, taxonomy.saga], ); // 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 = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, 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) && matchesTaxonomy(product, taxonomy), ) .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, taxonomy], ); // 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(() => { 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) && matchesTaxonomy(product, taxonomy), ); const counts: Record = { 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, taxonomy]); 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) && matchesTaxonomy(product, taxonomy), ); const counts: Record = { 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, taxonomy]); 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) && matchesTaxonomy(product, taxonomy), ); const counts: Record = { 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, taxonomy]); // "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 }; } // El "count"/estimatedTotalHits que devuelve /api/search viene en 0 con // limit=0 (confirmado en vivo contra Meilisearch real -- limit>=1 lo // reporta bien, limit=0 no). facetDistribution si es correcto con // limit=0, y cada producto tiene siempre un valor para cada dimension // (mapProduct usa defaults como "ARI Shopping"/"Todas", nunca null), asi // que sumar sus valores da el total real sin depender de ese campo. function sumFacetValues(facet: Record | null): number { return facet ? Object.values(facet).reduce((sum, value) => sum + value, 0) : 0; } 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 ? sumFacetValues(categoryFacet) : null, brand: categoryIsLeafOrNone ? brandFacet : null, brandTotal: categoryIsLeafOrNone && brandFacet ? sumFacetValues(brandFacet) : null, ageRange: categoryIsLeafOrNone ? ageFacet : null, ageTotal: categoryIsLeafOrNone && ageFacet ? sumFacetValues(ageFacet) : 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 && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive], ); const brandCounts = useMemo( () => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive], ); const ageCounts = useMemo( () => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), [fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal, taxonomyActive], ); // Conteos de franquicia/personaje: siempre client-side (no son facetas // de Meilisearch), excluyendo su propia dimension como el resto. const franchiseCounts = useMemo(() => { const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { saga: taxonomy.saga, personaje: taxonomy.personaje })); const counts: Record = { Todas: base.length }; for (const value of franchises) if (value !== "Todas") counts[value] = base.filter((product) => taxonomyValues(product, "franquicia").includes(value)).length; return counts; }, [age, allowedCategories, brand, category, collection, franchises, maxPrice, products, query, taxonomy.saga, taxonomy.personaje]); const characterCounts = useMemo(() => { const base = products.filter((product) => matchesQuery(product, query) && (category === "Todos" || matchesCategory(product, allowedCategories)) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice) && matchesTaxonomy(product, { franquicia: taxonomy.franquicia, saga: taxonomy.saga })); const counts: Record = { Todos: base.length }; for (const value of characters) if (value !== "Todos") counts[value] = base.filter((product) => taxonomyValues(product, "personaje").includes(value)).length; return counts; }, [age, allowedCategories, brand, category, characters, collection, maxPrice, products, query, taxonomy.franquicia, taxonomy.saga]); const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); setTaxonomy({}); }; return (
{breadcrumb.length > 0 && ( )} {urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}

{urlTaxonomyTitle || "Todos los tesoros"}

Filtra por categoría, marca o edad sin recargar la página.

{/* overflow-y-auto + max-h propio en lg: sticky no le da scroll propio al contenido, y con 30+ categorias mas marca/edad/precio el panel se pasa del viewport sin forma de llegar al resto salvo bajando el zoom del navegador (reportado en vivo). */}

{filtered.length} productos

{filtered.length ? (
{gridItems.map((item, index) => item.type === "product" ? ( ) : ( ), )}
) : (

No encontramos productos

)}
); } function Filter({ title, values, selected, onChange, counts, }: { title: string; values: string[]; selected: string; onChange: (value: string) => void; counts?: Record; }) { return (
{title}
{values.map((value) => ( ))}
); }