Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 1m21s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 38m24s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 49s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 33s
715 lines
34 KiB
TypeScript
715 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
|
|
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { AccountDialog } from "@/components/account/account-dialog";
|
|
import { LocationPicker } from "@/components/layout/location-picker";
|
|
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";
|
|
|
|
type CategoryNode = Category & { children: Category[] };
|
|
|
|
function buildCategoryTree(categories: Category[]): CategoryNode[] {
|
|
return categories
|
|
.filter((category) => !category.parentId)
|
|
.map((root) => ({
|
|
...root,
|
|
children: categories.filter((category) => category.parentId === root.id),
|
|
}));
|
|
}
|
|
|
|
export function Navbar({ products, categories }: { products: Product[]; categories: Category[] }) {
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [accountOpen, setAccountOpen] = useState(false);
|
|
const items = useCartStore((state) => state.items);
|
|
const cartHydrated = useCartStore((state) => state.hasHydrated);
|
|
const openCart = useCartStore((state) => state.openCart);
|
|
const wishlistProducts = useWishlistStore((state) => state.products);
|
|
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
|
|
const openWishlist = useWishlistStore((state) => state.openWishlist);
|
|
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
|
|
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
|
|
const customer = useCustomerStore((state) => state.customer);
|
|
const customerHydrated = useCustomerStore((state) => state.hasHydrated);
|
|
const isLoggedIn = customerHydrated && customer !== null;
|
|
|
|
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],
|
|
);
|
|
|
|
// Header compacto al hacer scroll (solo desktop, lg+): oculta la franja
|
|
// promocional y achica logo/padding. Histeresis (compacta pasado 160px,
|
|
// se expande recien por debajo de 40px) porque achicar un header sticky
|
|
// cambia la altura del documento -- con un solo umbral el header
|
|
// parpadea entre los dos estados justo en el borde.
|
|
const [compact, setCompact] = useState(false);
|
|
useEffect(() => {
|
|
const onScroll = () => {
|
|
const y = window.scrollY;
|
|
setCompact((current) => (current ? y > 40 : y > 160));
|
|
};
|
|
onScroll();
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => window.removeEventListener("scroll", onScroll);
|
|
}, []);
|
|
|
|
return (
|
|
<header data-compact={compact} className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
|
<div className={`bg-ari-marino px-4 py-2 text-center text-xs font-black text-white sm:text-sm ${compact ? "lg:hidden" : ""}`}>
|
|
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-ari-coral-claro decoration-2 underline-offset-2">Compra segura con ARI</span>
|
|
</div>
|
|
|
|
<div className="relative overflow-visible bg-white">
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
|
|
{/* ADR 0010: en mobile/tablet el buscador se movio a una segunda
|
|
fila a todo el ancho, debajo de logo+iconos (antes competia por
|
|
espacio en una sola fila con el grid_[auto_1fr_auto] y quedaba
|
|
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
|
original -- ahi sobra espacio y partirla en dos solo alargaria
|
|
el header sin necesidad. */}
|
|
<div className={`mx-auto max-w-7xl px-4 py-3 transition-[padding] ${compact ? "lg:py-2" : "lg:py-4"}`}>
|
|
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_auto_1fr_auto] lg:gap-5">
|
|
{/* Isotipo circular con icono + wordmark integrados (como en la
|
|
version previa a ADR 0008), pedido explicito del usuario en
|
|
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
|
mantiene para el footer. */}
|
|
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */}
|
|
<img src="/images/brand/logo-ari-circular.svg" alt="ARI shopping" className={`size-16 transition-[width,height] ${compact ? "lg:size-12" : "lg:size-20"}`} width={200} height={200} />
|
|
</Link>
|
|
<div className="hidden lg:block">
|
|
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
|
|
</div>
|
|
<div className="hidden lg:block">
|
|
<PredictiveSearch />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Antes escondido en mobile (solo alcanzable desde el menu
|
|
hamburguesa) -- pedido explicito del usuario: visible
|
|
siempre en el header, con un punto verde cuando hay sesion
|
|
iniciada (mismo patron que el badge de cantidad del
|
|
carrito/favoritos, pero de presencia, no de conteo). */}
|
|
<Button variant="outline" size="icon" aria-label={isLoggedIn ? "Mi cuenta (sesión iniciada)" : "Mi cuenta"} className="relative inline-flex border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10" onClick={() => setAccountOpen(true)}>
|
|
<UserRound />
|
|
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
|
</Button>
|
|
{/* Texto al lado de los iconos solo en desktop (estilo "Hola
|
|
Cristian / Cuenta y listas" de Amazon): en mobile no hay
|
|
lugar y los iconos solos ya se entienden. */}
|
|
<button type="button" onClick={() => setAccountOpen(true)} className="hidden rounded-xl px-2 py-1 text-left leading-tight transition hover:bg-slate-100 xl:block">
|
|
<span className="block text-xs text-slate-500">{isLoggedIn ? `Hola, ${customer?.firstName || "cliente"}` : "Hola, inicia sesión"}</span>
|
|
<span className="block text-sm font-black text-ari-marino">Mi cuenta</span>
|
|
</button>
|
|
<Link href="/cambios" prefetch={false} className="mr-1 hidden rounded-xl px-2 py-1 leading-tight transition hover:bg-slate-100 xl:block">
|
|
<span className="block text-xs text-slate-500">Cambios y</span>
|
|
<span className="block text-sm font-black text-ari-marino">devoluciones</span>
|
|
</Link>
|
|
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={openWishlist}>
|
|
<Heart />
|
|
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-ari-marino text-[10px] font-black text-white">{wishlistCount}</span>}
|
|
</Button>
|
|
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
|
|
<ShoppingCart />
|
|
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 lg:hidden">
|
|
<PredictiveSearch />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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}>
|
|
<SheetContent side="left" className="max-w-sm">
|
|
<SheetHeader>
|
|
<SheetTitle>Explora ARI</SheetTitle>
|
|
<SheetDescription>Categorías y colecciones del catálogo.</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-ari-coral">Categorías</p>
|
|
<div className="grid gap-2">
|
|
<Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
|
|
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
|
|
</Suspense>
|
|
</div>
|
|
{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">
|
|
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-ari-coral/10 hover:text-ari-coral">{collection}</Link>)}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
|
|
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
|
|
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function useCategoryNavigationState() {
|
|
const pathname = usePathname();
|
|
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 && !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,
|
|
};
|
|
}
|
|
|
|
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
|
|
|
|
const INACTIVE_NAVIGATION: NavigationState = {
|
|
isAllActive: false,
|
|
isActive: () => false,
|
|
isCollectionActive: () => false,
|
|
isTaxonomyActive: () => false,
|
|
anyTaxonomyActive: false,
|
|
};
|
|
|
|
function pillClass(active: boolean, dense = false): string {
|
|
return `shrink-0 rounded-full ${dense ? "px-2.5" : "px-4"} py-2 text-sm font-extrabold transition ${
|
|
active
|
|
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
|
: "text-white/90 hover:bg-white/10 hover:text-white"
|
|
}`;
|
|
}
|
|
|
|
function mobileLinkClass(active: boolean): string {
|
|
return `rounded-2xl border px-4 py-3 font-extrabold transition ${
|
|
active
|
|
? "border-ari-coral bg-ari-coral/5 text-ari-coral"
|
|
: "border-slate-200 hover:border-ari-coral/50 hover:bg-ari-coral/5"
|
|
}`;
|
|
}
|
|
|
|
type NavBarProps = { tree: CategoryNode[]; collections: string[]; franchises: FranchiseNode[]; onOpenMenu: () => void; onOpenAccount: () => void };
|
|
|
|
function DesktopCategoryNavigation(props: NavBarProps) {
|
|
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
|
}
|
|
|
|
function DesktopCategoryNavigationFallback(props: NavBarProps) {
|
|
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
|
|
}
|
|
|
|
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
|
|
// (cada una con su desplegable de subcategorias) -- antes solo se veian
|
|
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
|
|
// 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, 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">
|
|
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onOpenMenu}
|
|
aria-label="Abrir menú principal"
|
|
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
|
|
>
|
|
<Menu />
|
|
</Button>
|
|
|
|
<div className="shrink-0 xl:hidden">
|
|
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
|
</div>
|
|
|
|
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
|
Todo
|
|
</Link>
|
|
|
|
<div className="hidden items-center gap-0.5 xl:flex">
|
|
{tree.map((root) => {
|
|
const active = isActive(root.name) || root.children.some((child) => isActive(child.name));
|
|
const href = `/catalog?category=${encodeURIComponent(root.name)}`;
|
|
if (root.children.length === 0) {
|
|
return (
|
|
<Link key={root.id} href={href} prefetch={false} aria-current={active ? "page" : undefined} className={pillClass(active, true)}>
|
|
{root.name}
|
|
</Link>
|
|
);
|
|
}
|
|
return (
|
|
<NavDropdown key={root.id} label={root.name} active={active}>
|
|
{(close) => (
|
|
<ul className="grid min-w-56 gap-1">
|
|
{root.children.map((child) => (
|
|
<li key={child.id}>
|
|
<Link
|
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
|
prefetch={false}
|
|
aria-current={isActive(child.name) ? "page" : undefined}
|
|
onClick={close}
|
|
className={`block rounded-lg px-3 py-2 text-sm transition ${isActive(child.name) ? "bg-white/10 font-bold text-ari-coral-claro" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
|
|
>
|
|
{child.name}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
<li className="mt-1 border-t border-white/10 pt-2">
|
|
<Link href={href} prefetch={false} onClick={close} className="block rounded-lg px-3 py-2 text-sm font-black text-ari-coral-claro hover:bg-white/10">
|
|
Ver todo en {root.name}
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
)}
|
|
</NavDropdown>
|
|
);
|
|
})}
|
|
</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">
|
|
{(franchises.length > 0 || collections.length > 0) && (
|
|
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
|
|
{(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
|
|
key={collection}
|
|
href={`/catalog?collection=${encodeURIComponent(collection)}`}
|
|
prefetch={false}
|
|
aria-current={isCollectionActive(collection) ? "page" : undefined}
|
|
onClick={close}
|
|
className={`rounded-xl px-3 py-2.5 text-sm font-bold transition ${isCollectionActive(collection) ? "bg-white text-ari-marino" : "bg-white/5 text-white hover:bg-white/15"}`}
|
|
>
|
|
{collection}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</NavDropdown>
|
|
)}
|
|
{/* Equivalente al "Podemos ayudarte" de Amazon, con las paginas
|
|
de ayuda que la tienda ya tiene (antes solo en el footer). */}
|
|
<NavDropdown label="Ayuda" labelClassName="sr-only" ariaLabel="Ayuda" active={false} align="right" icon={<LifeBuoy className="size-4" />}>
|
|
{(close) => (
|
|
<ul className="grid min-w-60 gap-1">
|
|
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
|
|
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
|
|
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
|
|
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
|
|
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
|
|
<li><Link href="/contacto" prefetch={false} onClick={close} className={helpLinkClass}>Contacto</Link></li>
|
|
<li className="mt-1 border-t border-white/10 pt-2">
|
|
<a href="https://wa.me/573016138155" target="_blank" rel="noopener noreferrer" onClick={close} className={`${helpLinkClass} font-black text-ari-coral-claro`}>Escríbenos por WhatsApp</a>
|
|
</li>
|
|
</ul>
|
|
)}
|
|
</NavDropdown>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
// Con mouse, el mismo gesto dispara mouseenter (abre) y click: si el click
|
|
// alternara, cerraria el menu que se acaba de abrir por hover y para el
|
|
// usuario "no abre" (reportado por frontend-critic, reproducido 4/4 con
|
|
// Playwright; el bug ya existia en DepartmentsMegaMenu en prod). Un click
|
|
// dentro de los 600ms posteriores a abrir por hover lo deja abierto; en
|
|
// touch (sin hover) el click sigue alternando normal.
|
|
// El hover usa onPointerEnter/Leave filtrando pointerType === "mouse": en
|
|
// touch, Chromium emite un mouseenter+mouseleave sinteticos por cada tap, y
|
|
// ese mouseleave fantasma agendaba el cierre de 200ms -- el menu se cerraba
|
|
// solo en tablets (iPad horizontal cae en el rango del mega menu).
|
|
// Reportado por frontend-critic con log de eventos del navegador.
|
|
function keepOpenAfterHover(open: boolean, hoverOpenedAt: number): boolean {
|
|
if (open && Date.now() - hoverOpenedAt < 600) return true;
|
|
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
|
|
// (cierre con 200ms de gracia para movimientos en diagonal, click afuera y
|
|
// Escape cierran), pero anclado al propio boton en vez de a todo el ancho.
|
|
function NavDropdown({
|
|
label,
|
|
labelClassName,
|
|
ariaLabel,
|
|
active,
|
|
align = "left",
|
|
icon,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
labelClassName?: string;
|
|
ariaLabel?: string;
|
|
active: boolean;
|
|
align?: "left" | "right";
|
|
icon?: ReactNode;
|
|
children: (close: () => void) => ReactNode;
|
|
}) {
|
|
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 = () => {
|
|
if (closeTimer.current) {
|
|
clearTimeout(closeTimer.current);
|
|
closeTimer.current = null;
|
|
}
|
|
};
|
|
const scheduleClose = () => {
|
|
cancelClose();
|
|
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handlePointerDown(event: PointerEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
|
}
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
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);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => () => cancelClose(), []);
|
|
|
|
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))}
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
className={`flex items-center gap-1.5 ${pillClass(active || open, true)}`}
|
|
>
|
|
{icon}
|
|
<span className={labelClassName}>{label}</span>
|
|
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
</button>
|
|
{open && (
|
|
<div className={`absolute top-full z-50 pt-1 ${align === "right" ? "right-0" : "left-0"}`}>
|
|
<div className="rounded-2xl border border-white/10 bg-ari-marino p-3 shadow-2xl">{children(() => setOpen(false))}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
|
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 = () => {
|
|
if (closeTimer.current) {
|
|
clearTimeout(closeTimer.current);
|
|
closeTimer.current = null;
|
|
}
|
|
};
|
|
// Delay corto en vez de cerrar al toque: aunque el gap entre el boton y
|
|
// el panel ya se elimino (antes era la causa real -- el panel es
|
|
// absolute y no suma a la altura del contenedor, asi que un mt-1 ahi
|
|
// dejaba una franja muerta que cerraba el menu a mitad de camino,
|
|
// reportado en vivo), este margen extra tolera movimientos rapidos o en
|
|
// diagonal cerca del borde sin reabrir nada raro.
|
|
const scheduleClose = () => {
|
|
cancelClose();
|
|
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handlePointerDown(event: PointerEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false);
|
|
}
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
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);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => () => cancelClose(), []);
|
|
|
|
const openNow = () => {
|
|
cancelClose();
|
|
if (!open) hoverOpenedAt.current = Date.now();
|
|
setOpen(true);
|
|
};
|
|
|
|
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}
|
|
aria-haspopup="true"
|
|
aria-label="Departamentos"
|
|
className={`flex items-center gap-1.5 ${pillClass(open)}`}
|
|
>
|
|
<LayoutGrid className="size-4" />
|
|
{/* Texto oculto en mobile: los 3 items de esta fila (menu, este
|
|
boton, "Todo") tienen shrink-0 -- ninguno se achica, asi que si
|
|
los tres no entran, desbordan la pagina entera (confirmado en
|
|
vivo 2026-09-20 contra 320px real: 14px de overflow horizontal
|
|
con el texto visible). No es un caso de min-w-0 faltante (ese
|
|
fix asume que el item PUEDE achicarse) -- ocultar la palabra
|
|
mas larga de la fila es lo que de verdad libera espacio. */}
|
|
<span className="hidden sm:inline">Departamentos</span>
|
|
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full z-50 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
|
|
{tree.map((root) => (
|
|
<div key={root.id}>
|
|
<Link
|
|
href={`/catalog?category=${encodeURIComponent(root.name)}`}
|
|
prefetch={false}
|
|
aria-current={isActive(root.name) ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
className={`block text-sm font-black uppercase tracking-wide transition ${
|
|
isActive(root.name) ? "text-ari-coral-claro" : "text-white hover:text-ari-coral-claro"
|
|
}`}
|
|
>
|
|
{root.name}
|
|
</Link>
|
|
{root.children.length > 0 && (
|
|
<ul className="mt-2.5 grid gap-1.5">
|
|
{root.children.map((child) => (
|
|
<li key={child.id}>
|
|
<Link
|
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
|
prefetch={false}
|
|
aria-current={isActive(child.name) ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-ari-coral-claro" : "text-white/70 hover:text-white"}`}
|
|
>
|
|
{child.name}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
|
|
const { isActive, isAllActive } = useCategoryNavigationState();
|
|
|
|
return (
|
|
<>
|
|
<Link
|
|
href="/catalog"
|
|
prefetch={false}
|
|
aria-current={isAllActive ? "page" : undefined}
|
|
onClick={onNavigate}
|
|
className={mobileLinkClass(isAllActive)}
|
|
>
|
|
Todo el catálogo
|
|
</Link>
|
|
|
|
{tree.map((root) => (
|
|
<div key={root.id} className="contents">
|
|
<Link
|
|
href={`/catalog?category=${encodeURIComponent(root.name)}`}
|
|
prefetch={false}
|
|
aria-current={isActive(root.name) ? "page" : undefined}
|
|
onClick={onNavigate}
|
|
className={mobileLinkClass(isActive(root.name))}
|
|
>
|
|
{root.name}
|
|
</Link>
|
|
{root.children.map((child) => (
|
|
<Link
|
|
key={child.id}
|
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
|
prefetch={false}
|
|
aria-current={isActive(child.name) ? "page" : undefined}
|
|
onClick={onNavigate}
|
|
className={`${mobileLinkClass(isActive(child.name))} ml-4 text-sm`}
|
|
>
|
|
{child.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
|
|
return (
|
|
<>
|
|
<Link href="/catalog" prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
|
|
Todo el catálogo
|
|
</Link>
|
|
|
|
{tree.map((root) => (
|
|
<div key={root.id} className="contents">
|
|
<Link href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
|
|
{root.name}
|
|
</Link>
|
|
{root.children.map((child) => (
|
|
<Link
|
|
key={child.id}
|
|
href={`/catalog?category=${encodeURIComponent(child.name)}`}
|
|
prefetch={false}
|
|
onClick={onNavigate}
|
|
className={`${mobileLinkClass(false)} ml-4 text-sm`}
|
|
>
|
|
{child.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|