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:
devopsandClaude Opus 5.5 committed 2026-09-27 07:06:32 +00:00
1 parent 271ea30f44
commit 472c12ec0a
4 files changed
+290 -33

No files matched your search

+114 -12
View File
@@ -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}