feat(storefront): franquicias, sagas y personajes (Disney > Toy Story > Rex)
Taxonomía desde tags de Medusa con prefijo (franquicia:/saga:/personaje:),
muchos-a-muchos (ej. "Minnie y Mickey" tiene dos personajes). Pixar va
dentro de Disney.
- lib/taxonomy.ts: helpers + árbol franquicia > saga > personaje.
- Navbar: "Personajes" pasa a mega panel por franquicia (con totales,
sagas y personajes); el menú lateral mobile muestra franquicias y sagas.
Fallback a colecciones si el provider no trae tags.
- Catálogo: filtros "Franquicia" y "Personaje" (acotado a la franquicia),
chip removible de "Saga", ?franquicia/?saga/?personaje en la URL, título
según la taxonomía de la URL, búsqueda normalizada ("spiderman" encuentra
"Spider-Man"). Conteos client-side (no son facetas de Meilisearch).
- allowedCategories memoizado (elimina warnings exhaustive-deps).
Revisado por frontend-critic (contraste de personajes /80, H1 solo desde la
URL, Escape devuelve el foco al trigger) + Playwright: check-taxonomy
17/17 + extras, shot-navbar sin regresiones.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
This commit is contained in:
1 parent
271ea30f44
commit
472c12ec0a
4 files changed
+290
-33
No files matched your search
@@ -8,6 +8,7 @@ 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
|
||||
@@ -23,12 +24,19 @@ type CatalogClientProps = {
|
||||
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 search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
||||
return search.includes(query.toLowerCase());
|
||||
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);
|
||||
@@ -69,8 +77,18 @@ export function CatalogClient({
|
||||
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<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 [collection, setCollection] = useState(initialCollection);
|
||||
const [brand, setBrand] = useState("Todas");
|
||||
@@ -96,13 +114,30 @@ export function CatalogClient({
|
||||
setAge("Todas");
|
||||
setSort("featured");
|
||||
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
|
||||
// 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 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(() => {
|
||||
@@ -120,7 +155,8 @@ export function CatalogClient({
|
||||
matchesCollection(product, collection) &&
|
||||
matchesBrand(product, brand) &&
|
||||
matchesAge(product, age) &&
|
||||
matchesPrice(product, maxPrice),
|
||||
matchesPrice(product, maxPrice) &&
|
||||
matchesTaxonomy(product, taxonomy),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
sort === "name"
|
||||
@@ -129,7 +165,7 @@ export function CatalogClient({
|
||||
? Number(b.isNew) - Number(a.isNew)
|
||||
: 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,
|
||||
@@ -161,7 +197,7 @@ export function CatalogClient({
|
||||
// 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),
|
||||
(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 };
|
||||
for (const name of categories) {
|
||||
@@ -170,7 +206,7 @@ export function CatalogClient({
|
||||
counts[name] = base.filter((product) => matchesCategory(product, allowed)).length;
|
||||
}
|
||||
return counts;
|
||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query]);
|
||||
}, [age, brand, categories, categoryGroups, collection, maxPrice, products, query, taxonomy]);
|
||||
|
||||
const fallbackBrandCounts = useMemo(() => {
|
||||
const base = products.filter(
|
||||
@@ -179,7 +215,8 @@ export function CatalogClient({
|
||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||
matchesCollection(product, collection) &&
|
||||
matchesAge(product, age) &&
|
||||
matchesPrice(product, maxPrice),
|
||||
matchesPrice(product, maxPrice) &&
|
||||
matchesTaxonomy(product, taxonomy),
|
||||
);
|
||||
const counts: Record<string, number> = { Todas: base.length };
|
||||
for (const value of brands) {
|
||||
@@ -187,7 +224,7 @@ export function CatalogClient({
|
||||
counts[value] = base.filter((product) => product.brand === value).length;
|
||||
}
|
||||
return counts;
|
||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query]);
|
||||
}, [age, allowedCategories, brands, category, collection, maxPrice, products, query, taxonomy]);
|
||||
|
||||
const fallbackAgeCounts = useMemo(() => {
|
||||
const base = products.filter(
|
||||
@@ -196,7 +233,8 @@ export function CatalogClient({
|
||||
(category === "Todos" || matchesCategory(product, allowedCategories)) &&
|
||||
matchesCollection(product, collection) &&
|
||||
matchesBrand(product, brand) &&
|
||||
matchesPrice(product, maxPrice),
|
||||
matchesPrice(product, maxPrice) &&
|
||||
matchesTaxonomy(product, taxonomy),
|
||||
);
|
||||
const counts: Record<string, number> = { Todas: base.length };
|
||||
for (const value of ages) {
|
||||
@@ -204,7 +242,7 @@ export function CatalogClient({
|
||||
counts[value] = base.filter((product) => product.ageRange === value).length;
|
||||
}
|
||||
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
|
||||
// como valor indexado en Meilisearch (ningun producto lo tiene como
|
||||
@@ -296,18 +334,33 @@ export function CatalogClient({
|
||||
}, [age, brand, category, categoryIsLeafOrNone, collection, maxPrice, query]);
|
||||
|
||||
const categoryCounts = useMemo(
|
||||
() => (liveFacets.category ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal],
|
||||
() => (liveFacets.category && !taxonomyActive ? { Todos: liveFacets.categoryTotal ?? 0, ...liveFacets.category } : fallbackCategoryCounts),
|
||||
[fallbackCategoryCounts, liveFacets.category, liveFacets.categoryTotal, taxonomyActive],
|
||||
);
|
||||
const brandCounts = useMemo(
|
||||
() => (liveFacets.brand ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal],
|
||||
() => (liveFacets.brand && !taxonomyActive ? { Todas: liveFacets.brandTotal ?? 0, ...liveFacets.brand } : fallbackBrandCounts),
|
||||
[fallbackBrandCounts, liveFacets.brand, liveFacets.brandTotal, taxonomyActive],
|
||||
);
|
||||
const ageCounts = useMemo(
|
||||
() => (liveFacets.ageRange ? { Todas: liveFacets.ageTotal ?? 0, ...liveFacets.ageRange } : fallbackAgeCounts),
|
||||
[fallbackAgeCounts, liveFacets.ageRange, liveFacets.ageTotal],
|
||||
() => (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<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 = () => {
|
||||
setQuery("");
|
||||
setCategory("Todos");
|
||||
@@ -316,6 +369,7 @@ export function CatalogClient({
|
||||
setAge("Todas");
|
||||
setMaxPrice(maxCatalogPrice);
|
||||
setSort("featured");
|
||||
setTaxonomy({});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -343,9 +397,9 @@ export function CatalogClient({
|
||||
</nav>
|
||||
)}
|
||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">
|
||||
Catálogo completo
|
||||
{urlTaxonomyTitle ? "Personajes" : "Catálogo completo"}
|
||||
</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">
|
||||
Filtra por categoría, marca o edad sin recargar la página.
|
||||
</p>
|
||||
@@ -381,6 +435,33 @@ export function CatalogClient({
|
||||
onChange={setCategory}
|
||||
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="Edad" values={ages} selected={age} onChange={setAge} counts={ageCounts} />
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
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 { useCustomerStore } from "@/store/customer-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]);
|
||||
// Derivado de los productos reales (no de un endpoint de colecciones):
|
||||
// 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(
|
||||
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
|
||||
[products],
|
||||
@@ -131,8 +136,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
||||
<DesktopCategoryNavigation 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} franchises={franchises} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
||||
</Suspense>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
@@ -148,7 +153,28 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
||||
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
||||
</Suspense>
|
||||
</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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -174,12 +200,16 @@ function useCategoryNavigationState() {
|
||||
const searchParams = useSearchParams();
|
||||
const currentCategory = searchParams.get("category") ?? "";
|
||||
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";
|
||||
|
||||
return {
|
||||
isAllActive: isCatalog && !currentCategory && !currentCollection,
|
||||
isAllActive: isCatalog && !currentCategory && !currentCollection && !anyTaxonomy,
|
||||
isActive: (name: string) => isCatalog && currentCategory === 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,
|
||||
isActive: () => false,
|
||||
isCollectionActive: () => false,
|
||||
isTaxonomyActive: () => false,
|
||||
anyTaxonomyActive: false,
|
||||
};
|
||||
|
||||
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) {
|
||||
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
|
||||
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
||||
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
||||
function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||
const { isActive, isAllActive, isCollectionActive } = state;
|
||||
const anyCollectionActive = collections.some(isCollectionActive);
|
||||
function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||
const { isActive, isAllActive, isCollectionActive, isTaxonomyActive, anyTaxonomyActive } = state;
|
||||
const anyCollectionActive = collections.some(isCollectionActive) || anyTaxonomyActive;
|
||||
|
||||
return (
|
||||
<nav className="bg-ari-marino text-white">
|
||||
@@ -289,9 +321,11 @@ function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }:
|
||||
</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">
|
||||
{collections.length > 0 && (
|
||||
{(franchises.length > 0 || collections.length > 0) && (
|
||||
<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">
|
||||
{collections.map((collection) => (
|
||||
<Link
|
||||
@@ -347,6 +381,58 @@ function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
|
||||
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";
|
||||
|
||||
// Desplegable con la misma "intencion de hover" que DepartmentsMegaMenu
|
||||
@@ -372,6 +458,7 @@ function NavDropdown({
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoverOpenedAt = useRef(0);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelClose = () => {
|
||||
@@ -391,7 +478,13 @@ function NavDropdown({
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
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("keydown", handleKeyDown);
|
||||
@@ -406,6 +499,7 @@ function NavDropdown({
|
||||
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(); }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||
@@ -430,6 +524,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoverOpenedAt = useRef(0);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
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("keydown", handleKeyDown);
|
||||
@@ -476,6 +577,7 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
||||
return (
|
||||
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||
aria-expanded={open}
|
||||
|
||||
Reference in new issue
Block a user