"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 (
Paga tus compras a cuotas · Envíos nacionales · Compra segura con ARI
{/* 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. */}
{/* 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. */} {/* eslint-disable-next-line @next/next/no-img-element -- vector propio, next/image no optimiza SVG sin habilitar dangerouslyAllowSVG */} ARI shopping
{/* 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). */} {/* 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. */} Cambios y devoluciones
setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}> setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} /> Explora ARI Categorías y colecciones del catálogo.

Categorías

setMenuOpen(false)} />}> setMenuOpen(false)} />
{franchises.length > 0 && ( <>

Personajes

{franchises.map((franchise) => (
setMenuOpen(false)} className="font-black text-ari-marino hover:text-ari-coral"> {franchise.name} ({franchise.count})
{franchise.sagas.map((saga) => ( 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} ))}
))}
)} {franchises.length === 0 && collections.length > 0 && ( <>

Colecciones

{collections.map((collection) => 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})}
)}
); } 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; 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 ; } function DesktopCategoryNavigationFallback(props: NavBarProps) { return ; } // 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 ( ); } // 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) => ( {name} ); return (
{franchises.map((franchise) => (

{link("franquicia", franchise.name, "text-sm font-black uppercase tracking-wide text-white hover:text-ari-coral-claro")} {franchise.count}

    {franchise.sagas.map((saga) => (
  • {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) ? (
    {saga.characters.map((character) => ( {link("personaje", character.name, "text-xs text-white/80 hover:text-white")} ))}
    ) : null}
  • ))}
))}
); } 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(null); const hoverOpenedAt = useRef(0); const triggerRef = useRef(null); const closeTimer = useRef | 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 // 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 (
{ if (event.pointerType !== "mouse") return; cancelClose(); if (!open) hoverOpenedAt.current = Date.now(); setOpen(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}> {open && (
{children(() => setOpen(false))}
)}
); } function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) { const [open, setOpen] = useState(false); const containerRef = useRef(null); const hoverOpenedAt = useRef(0); const triggerRef = useRef(null); const closeTimer = useRef | 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 // 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 (
{ if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}> {open && (
{tree.map((root) => (
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} {root.children.length > 0 && (
    {root.children.map((child) => (
  • setOpen(false)} className={`block text-sm transition ${isActive(child.name) ? "font-bold text-ari-coral-claro" : "text-white/70 hover:text-white"}`} > {child.name}
  • ))}
)}
))}
)}
); } function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) { const { isActive, isAllActive } = useCategoryNavigationState(); return ( <> Todo el catálogo {tree.map((root) => (
{root.name} {root.children.map((child) => ( {child.name} ))}
))} ); } function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) { return ( <> Todo el catálogo {tree.map((root) => (
{root.name} {root.children.map((child) => ( {child.name} ))}
))} ); }