"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 */}
{/* 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 ydevoluciones
setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
Explora ARICategorías y colecciones del catálogo.
);
}
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 (
);
}
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 && (