Merge branch 'feat/franquicias-personajes'

This commit is contained in:
devops committed 2026-09-27 12:23:49 +00:00
commit 7f0c1571d9
4 files changed
+290 -33

No files matched your search

+7 -1
View File
@@ -22,6 +22,11 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const category = single(params.category) || "Todos"; const category = single(params.category) || "Todos";
const collection = single(params.collection); const collection = single(params.collection);
const query = single(params.q); const query = single(params.q);
const taxonomy = {
franquicia: single(params.franquicia),
saga: single(params.saga),
personaje: single(params.personaje),
};
// Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y // Los links de navbar/tiles apuntan a categorias raiz (ej. "Mochilas y
// morrales"), pero ningun producto tiene esa categoria asignada // morrales"), pero ningun producto tiene esa categoria asignada
@@ -39,13 +44,14 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps) {
return ( return (
<CatalogClient <CatalogClient
key={`${category}:${collection}:${query}`} key={`${category}:${collection}:${query}:${taxonomy.franquicia}:${taxonomy.saga}:${taxonomy.personaje}`}
products={products} products={products}
categories={categories} categories={categories}
categoryGroups={categoryGroups} categoryGroups={categoryGroups}
initialCategory={category} initialCategory={category}
initialCollection={collection} initialCollection={collection}
initialQuery={query} initialQuery={query}
initialTaxonomy={taxonomy}
/> />
); );
} }
@@ -8,6 +8,7 @@ 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, FacetDistribution, Product } from "@/lib/headless/types"; 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 // 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
@@ -23,12 +24,19 @@ type CatalogClientProps = {
initialCategory?: string; initialCategory?: string;
initialCollection?: string; initialCollection?: string;
initialQuery?: 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 { function matchesQuery(product: Product, query: string): boolean {
if (!query) return true; if (!query) return true;
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase(); const characters = [...taxonomyValues(product, "franquicia"), ...taxonomyValues(product, "saga"), ...taxonomyValues(product, "personaje")];
return search.includes(query.toLowerCase()); 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 { function matchesCategory(product: Product, allowedCategories: string[]): boolean {
return allowedCategories.includes(product.category); return allowedCategories.includes(product.category);
@@ -69,8 +77,18 @@ export function CatalogClient({
initialCategory = "Todos", initialCategory = "Todos",
initialCollection = "", initialCollection = "",
initialQuery = "", initialQuery = "",
initialTaxonomy = {},
}: CatalogClientProps) { }: CatalogClientProps) {
const [query, setQuery] = useState(initialQuery); 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<TaxonomyFilter>(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 [category, setCategory] = useState(initialCategory);
const [collection, setCollection] = useState(initialCollection); const [collection, setCollection] = useState(initialCollection);
const [brand, setBrand] = useState("Todas"); const [brand, setBrand] = useState("Todas");
@@ -96,13 +114,30 @@ export function CatalogClient({
setAge("Todas"); setAge("Todas");
setSort("featured"); setSort("featured");
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
}, [initialCategory, initialCollection, initialQuery, 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 // Si `category` es una raiz con hijos (ej. "Mochilas y morrales"), se
// expande a la lista de nombres reales de sus hijos -- ningun producto // expande a la lista de nombres reales de sus hijos -- ningun producto
// tiene la raiz asignada directamente. Si no es una raiz conocida, se // tiene la raiz asignada directamente. Si no es una raiz conocida, se
// trata como el nombre de categoria exacto de siempre. // trata como el nombre de categoria exacto de siempre.
const allowedCategories = categoryGroups[category] ?? [category]; const allowedCategories = useMemo(() => categoryGroups[category] ?? [category], [categoryGroups, category]);
const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]); const breadcrumb = useMemo(() => (category === "Todos" ? [] : buildBreadcrumb(categoryNodes, category)), [categoryNodes, category]);
// No promociona la misma categoria raiz que el usuario ya esta viendo. // No promociona la misma categoria raiz que el usuario ya esta viendo.
const promoRoots = useMemo(() => { const promoRoots = useMemo(() => {
@@ -120,7 +155,8 @@ export function CatalogClient({
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesBrand(product, brand) && matchesBrand(product, brand) &&
matchesAge(product, age) && matchesAge(product, age) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
) )
.sort((a, b) => .sort((a, b) =>
sort === "name" sort === "name"
@@ -129,7 +165,7 @@ export function CatalogClient({
? Number(b.isNew) - Number(a.isNew) ? Number(b.isNew) - Number(a.isNew)
: Number(b.featured) - Number(a.featured), : Number(b.featured) - Number(a.featured),
), ),
[age, allowedCategories, brand, category, collection, maxPrice, products, query, sort], [age, allowedCategories, brand, category, collection, maxPrice, products, query, sort, taxonomy],
); );
// Intercala un tile promocional cada PROMO_TILE_INTERVAL productos, // Intercala un tile promocional cada PROMO_TILE_INTERVAL productos,
@@ -161,7 +197,7 @@ export function CatalogClient({
// la busqueda en vivo todavia no respondio. // la busqueda en vivo todavia no respondio.
const fallbackCategoryCounts = useMemo(() => { 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) && matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todos: base.length }; const counts: Record<string, number> = { Todos: base.length };
for (const name of categories) { for (const name of categories) {
@@ -170,7 +206,7 @@ export function CatalogClient({
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length; counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
} }
return counts; return counts;
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]); }, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
const fallbackBrandCounts = useMemo(() => { const fallbackBrandCounts = useMemo(() => {
const base = products.filter( const base = products.filter(
@@ -179,7 +215,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) && (category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesAge(product, age) && matchesAge(product, age) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todas: base.length }; const counts: Record<string, number> = { Todas: base.length };
for (const value of brands) { for (const value of brands) {
@@ -187,7 +224,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.brand === value).length; counts[value] = base.filter((product) => product.brand === value).length;
} }
return counts; return counts;
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]); }, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
const fallbackAgeCounts = useMemo(() => { const fallbackAgeCounts = useMemo(() => {
const base = products.filter( const base = products.filter(
@@ -196,7 +233,8 @@ export function CatalogClient({
(category === "Todos" || matchesCategory(product, allowedCategories)) && (category === "Todos" || matchesCategory(product, allowedCategories)) &&
matchesCollection(product, collection) && matchesCollection(product, collection) &&
matchesBrand(product, brand) && matchesBrand(product, brand) &&
matchesPrice(product, maxPrice), matchesPrice(product, maxPrice) &&
matchesTaxonomy(product, taxonomy),
); );
const counts: Record<string, number> = { Todas: base.length }; const counts: Record<string, number> = { Todas: base.length };
for (const value of ages) { for (const value of ages) {
@@ -204,7 +242,7 @@ export function CatalogClient({
counts[value] = base.filter((product) => product.ageRange === value).length; counts[value] = base.filter((product) => product.ageRange === value).length;
} }
return counts; return counts;
}, [ages, allowedCategories, brand, category, collection, maxPrice, products, query]); }, [ages, allowedCategories, brand, category, collection, maxPrice, products, query, taxonomy]);
// "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe // "Mochilas y morrales" etc.: nombre sintetico de grupo que no existe
// como valor indexado en Meilisearch (ningun producto lo tiene como // como valor indexado en Meilisearch (ningun producto lo tiene como
@@ -296,18 +334,33 @@ export function CatalogClient({
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]); }, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
const categoryCounts = useMemo( const categoryCounts = useMemo(
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts), () => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal], [fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
); );
const brandCounts = useMemo( const brandCounts = useMemo(
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts), () => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal], [fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
); );
const ageCounts = useMemo( const ageCounts = useMemo(
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts), () => (liveFacets.ageRange && !taxonomyActive ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal], [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<string, number> = { 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<string, number> = { 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 = () => { const reset = () => {
setQuery(""); setQuery("");
setCategory("Todos"); setCategory("Todos");
@@ -316,6 +369,7 @@ export function CatalogClient({
setAge("Todas"); setAge("Todas");
setMaxPrice(maxCatalogPrice); setMaxPrice(maxCatalogPrice);
setSort("featured"); setSort("featured");
setTaxonomy({});
}; };
return ( return (
@@ -343,9 +397,9 @@ export function CatalogClient({
</nav> </nav>
)} )}
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral"> <span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
Catálogo completo {urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
</span> </span>
<h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">Todos los tesoros</h1> <h1 className="font-display mt-2 text-5xl font-bold tracking-tight text-ari-marino">{urlTaxonomyTitle || "Todos los tesoros"}</h1>
<p className="mt-3 text-slate-500"> <p className="mt-3 text-slate-500">
Filtra por categoría, marca o edad sin recargar la página. Filtra por categoría, marca o edad sin recargar la página.
</p> </p>
@@ -381,6 +435,33 @@ export function CatalogClient({
onChange={setCategory} onChange={setCategory}
counts={categoryCounts} counts={categoryCounts}
/> />
{franchises.length > 1 && (
<Filter
title="Franquicia"
values={franchises}
selected={taxonomy.franquicia || "Todas"}
onChange={(value) => setTaxonomy({ franquicia: value === "Todas" ? undefined : value })}
counts={franchiseCounts}
/>
)}
{taxonomy.saga && (
<div>
<p className="mb-3 text-sm font-black">Saga</p>
<button type="button" onClick={() => setTaxonomy({ ...taxonomy, saga: undefined, personaje: undefined })} className="inline-flex items-center gap-1.5 rounded-md bg-ari-marino px-3 py-2 text-xs font-bold text-white">
{taxonomy.saga}
<X className="size-3" aria-label="Quitar saga" />
</button>
</div>
)}
{characters.length > 1 && (
<Filter
title="Personaje"
values={characters}
selected={taxonomy.personaje || "Todos"}
onChange={(value) => setTaxonomy({ ...taxonomy, personaje: value === "Todos" ? undefined : value })}
counts={characterCounts}
/>
)}
<Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} /> <Filter title="Marca" values={brands} selected={brand} onChange={setBrand} counts={brandCounts} />
<Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} /> <Filter title="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
+114 -12
View File
@@ -10,6 +10,7 @@ import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Category, Product } from "@/lib/headless/types"; import type { Category, Product } from "@/lib/headless/types";
import { buildFranchiseTree, taxonomyHref, type FranchiseNode, type TaxonomyKind } from "@/lib/taxonomy";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useCustomerStore } from "@/store/customer-store"; import { useCustomerStore } from "@/store/customer-store";
import { useWishlistStore } from "@/store/wishlist-store"; import { useWishlistStore } from "@/store/wishlist-store";
@@ -43,6 +44,10 @@ export function Navbar({ products, categories }: { products: Product[]; categori
const tree = useMemo(() => buildCategoryTree(categories), [categories]); const tree = useMemo(() => buildCategoryTree(categories), [categories]);
// Derivado de los productos reales (no de un endpoint de colecciones): // Derivado de los productos reales (no de un endpoint de colecciones):
// solo aparecen colecciones que de verdad tienen productos hoy. // solo aparecen colecciones que de verdad tienen productos hoy.
// Franquicia > saga > personaje desde los tags (lib/taxonomy.ts). Si el
// provider no trae tags (mock/shopify), "Personajes" cae a la lista plana
// de colecciones de siempre.
const franchises = useMemo(() => buildFranchiseTree(products), [products]);
const collections = useMemo( const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))], () => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products], [products],
@@ -131,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</div> </div>
</div> </div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}> <Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} /> <DesktopCategoryNavigation tree={tree} collections={collections} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
</Suspense> </Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}> <Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -148,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} /> <MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense> </Suspense>
</div> </div>
{collections.length > 0 && ( {franchises.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Personajes</p>
<div className="grid gap-4">
{franchises.map((franchise) => (
<div key={franchise.name}>
<Link href={taxonomyHref("franquicia", franchise.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral">
{franchise.name} <span className="font-bold text-slate-400">({franchise.count})</span>
</Link>
<div className="mt-2 flex flex-wrap gap-2">
{franchise.sagas.map((saga) => (
<Link key={saga.name} href={taxonomyHref("saga", saga.name)} prefetch={false} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-3 py-1.5 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">
{saga.name}
</Link>
))}
</div>
</div>
))}
</div>
</>
)}
{franchises.length === 0 && collections.length > 0 && (
<> <>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p> <p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -174,12 +200,16 @@ function useCategoryNavigationState() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? ""; const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? ""; const currentCollection = searchParams.get("collection") ?? "";
const currentTaxonomy = { franquicia: searchParams.get("franquicia") ?? "", saga: searchParams.get("saga") ?? "", personaje: searchParams.get("personaje") ?? "" };
const anyTaxonomy = Boolean(currentTaxonomy.franquicia || currentTaxonomy.saga || currentTaxonomy.personaje);
const isCatalog = pathname === "/catalog"; const isCatalog = pathname === "/catalog";
return { return {
isAllActive: isCatalog && !currentCategory && !currentCollection, isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
isActive: (name: string) => isCatalog && currentCategory === name, isActive: (name: string) => isCatalog && currentCategory === name,
isCollectionActive: (name: string) => isCatalog && currentCollection === name, isCollectionActive: (name: string) => isCatalog && currentCollection === name,
isTaxonomyActive: (kind: TaxonomyKind, name: string) => isCatalog && currentTaxonomy[kind] === name,
anyTaxonomyActive: isCatalog && anyTaxonomy,
}; };
} }
@@ -189,6 +219,8 @@ const INACTIVE_NAVIGATION: NavigationState = {
isAllActive: false, isAllActive: false,
isActive: () => false, isActive: () => false,
isCollectionActive: () => false, isCollectionActive: () => false,
isTaxonomyActive: () => false,
anyTaxonomyActive: false,
}; };
function pillClass(active: boolean, dense = false): string { function pillClass(active: boolean, dense = false): string {
@@ -207,7 +239,7 @@ function mobileLinkClass(active: boolean): string {
}`; }`;
} }
type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void }; type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
function DesktopCategoryNavigation(props: NavBarProps) { function DesktopCategoryNavigation(props: NavBarProps) {
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />; return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
@@ -223,9 +255,9 @@ function DesktopCategoryNavigationFallback(props: NavBarProps) {
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de // 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg. // "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra. // La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) { function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
const { isActive, isAllActive, isCollectionActive } = state; const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
const anyCollectionActive = collections.some(isCollectionActive); const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
return ( return (
<nav className="bg-ari-marino text-white"> <nav className="bg-ari-marino text-white">
@@ -289,9 +321,11 @@ function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }:
</div> </div>
<div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2"> <div className="ml-auto hidden shrink-0 items-center gap-0.5 border-l border-white/20 pl-2 lg:flex xl:ml-2">
{collections.length > 0 && ( {(franchises.length > 0 || collections.length > 0) && (
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}> <NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
{(close) => ( {(close) => franchises.length > 0 ? (
<FranchisePanel franchises={franchises} isTaxonomyActive={isTaxonomyActive} close={close} />
) : (
<div className="grid w-[28rem] grid-cols-2 gap-1.5"> <div className="grid w-[28rem] grid-cols-2 gap-1.5">
{collections.map((collection) => ( {collections.map((collection) => (
<Link <Link
@@ -347,6 +381,58 @@ function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
return !open; return !open;
} }
// Mega panel de "Personajes" (inspirado en el menu "Theme" de Entertainment
// Earth): una columna por franquicia con su total, sus sagas y los
// personajes de cada saga -- "Disney" trae todo Disney (Pixar incluido),
// "Spider-Man" solo lo de Spider-Man.
function FranchisePanel({
franchises,
isTaxonomyActive,
close,
}: {
franchises: FranchiseNode[];
isTaxonomyActive: (kind: TaxonomyKind, name: string) => boolean;
close: () => void;
}) {
const link = (kind: TaxonomyKind, name: string, base: string) => (
<Link
href={taxonomyHref(kind, name)}
prefetch={false}
aria-current={isTaxonomyActive(kind, name) ? "page" : undefined}
onClick={close}
className={`${base} ${isTaxonomyActive(kind, name) ? "text-ari-coral-claro" : ""}`}
>
{name}
</Link>
);
return (
<div className="grid w-max max-w-[min(92vw,56rem)] gap-x-8 gap-y-5 p-2" style={{ gridTemplateColumns: `repeat(${Math.min(franchises.length, 4)}, minmax(10rem, 1fr))` }}>
{franchises.map((franchise) => (
<div key={franchise.name}>
<p className="flex items-baseline gap-1.5 border-b border-white/15 pb-2">
{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")}
<span className="text-xs font-bold text-white/50">{franchise.count}</span>
</p>
<ul className="mt-2.5 grid gap-2.5">
{franchise.sagas.map((saga) => (
<li key={saga.name}>
{link("saga", saga.name, "text-sm font-bold text-white hover:text-ari-coral-claro")}
{saga.characters.length > 1 || (saga.characters[0] && saga.characters[0].name !== saga.name) ? (
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
{saga.characters.map((character) => (
<span key={character.name}>{link("personaje", character.name, "text-xs text-white/80 hover:text-white")}</span>
))}
</div>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
);
}
const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white"; const helpLinkClass = "block w-full rounded-lg px-3 py-2 text-left text-sm text-white/80 transition hover:bg-white/10 hover:text-white";
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu // Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
@@ -372,6 +458,7 @@ function NavDropdown({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0); const hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => { const cancelClose = () => {
@@ -391,7 +478,13 @@ function NavDropdown({
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false); if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false); if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
} }
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
@@ -406,6 +499,7 @@ function NavDropdown({
return ( return (
<div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}> <div ref={containerRef} className="relative shrink-0" onPointerEnter={(event) => { if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button <button
ref={triggerRef}
type="button" type="button"
aria-label={ariaLabel} aria-label={ariaLabel}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))} onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
@@ -430,6 +524,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const hoverOpenedAt = useRef(0); const hoverOpenedAt = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => { const cancelClose = () => {
@@ -455,7 +550,13 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false); if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false); if (event.key === "Escape") {
setOpen(false);
// Devolver el foco al boton que abrio el menu: si no, queda en
// <body> y quien navega con teclado pierde su posicion (reportado
// por frontend-critic).
triggerRef.current?.focus();
}
} }
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
@@ -476,6 +577,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
return ( return (
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}> <div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))} onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open} aria-expanded={open}
+68
View File
@@ -0,0 +1,68 @@
import type { Product } from "@/lib/headless/types";
// Taxonomia Franquicia > Saga > Personaje (ej. Disney > Toy Story > Rex,
// Marvel > Spider-Man > Spider-Man). Vive en los tags de Medusa con
// prefijo ("franquicia:Disney", "saga:Toy Story", "personaje:Rex") porque
// la coleccion de Medusa es una sola por producto y mezclaba niveles
// ("Disney" y "Toy Story" como pares). Los tags son muchos-a-muchos: un
// producto puede tener varios personajes ("Minnie y Mickey").
export type TaxonomyKind = "franquicia" | "saga" | "personaje";
export const TAXONOMY_KINDS: TaxonomyKind[] = ["franquicia", "saga", "personaje"];
export function taxonomyValues(product: Product, kind: TaxonomyKind): string[] {
const prefix = `${kind}:`;
return product.tags.filter((tag) => tag.startsWith(prefix)).map((tag) => tag.slice(prefix.length));
}
export type TaxonomyFilter = Partial<Record<TaxonomyKind, string>>;
export function matchesTaxonomy(product: Product, filter: TaxonomyFilter): boolean {
return TAXONOMY_KINDS.every((kind) => {
const value = filter[kind];
return !value || taxonomyValues(product, kind).includes(value);
});
}
export type SagaNode = { name: string; count: number; characters: { name: string; count: number }[] };
export type FranchiseNode = { name: string; count: number; sagas: SagaNode[] };
function byCountThenName<T extends { name: string; count: number }>(a: T, b: T): number {
return b.count - a.count || a.name.localeCompare(b.name, "es");
}
export function buildFranchiseTree(products: Product[]): FranchiseNode[] {
const franchises = new Map<string, { count: number; sagas: Map<string, { count: number; characters: Map<string, number> }> }>();
for (const product of products) {
const sagas = taxonomyValues(product, "saga");
const characters = taxonomyValues(product, "personaje");
for (const franchise of taxonomyValues(product, "franquicia")) {
const node = franchises.get(franchise) ?? { count: 0, sagas: new Map() };
node.count += 1;
for (const saga of sagas) {
const sagaNode = node.sagas.get(saga) ?? { count: 0, characters: new Map() };
sagaNode.count += 1;
for (const character of characters) sagaNode.characters.set(character, (sagaNode.characters.get(character) ?? 0) + 1);
node.sagas.set(saga, sagaNode);
}
franchises.set(franchise, node);
}
}
return [...franchises.entries()]
.map(([name, node]) => ({
name,
count: node.count,
sagas: [...node.sagas.entries()]
.map(([sagaName, sagaNode]) => ({
name: sagaName,
count: sagaNode.count,
characters: [...sagaNode.characters.entries()].map(([characterName, count]) => ({ name: characterName, count })).sort(byCountThenName),
}))
.sort(byCountThenName),
}))
.sort(byCountThenName);
}
export function taxonomyHref(kind: TaxonomyKind, value: string): string {
return `/catalog?${kind}=${encodeURIComponent(value)}`;
}