"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, 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; 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(() => { 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; if (!isLast && (index + 1) % PROMO_TILE_INTERVAL === 0) { items.push({ type: "promo", category: promoRoots[promoIndex % promoRoots.length] }); 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. const categoryCounts = useMemo(() => { const base = products.filter( (product) => matchesQuery(product, query) && matchesCollection(product, collection) && matchesBrand(product, brand) && matchesAge(product, age) && matchesPrice(product, maxPrice), ); 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]); const brandCounts = 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 = { 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 ageCounts = 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 = { 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]); const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); }; return (
{breadcrumb.length > 0 && ( )} Catálogo completo

Todos los tesoros

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

{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) => ( ))}
); }