feat(storefront): barra de navegación desktop con categorías, personajes, ayuda y ubicación

- Desktop xl+: las 6 categorías raíz directo en la barra con desplegable
  de subcategorías; lg-xl mantiene "Departamentos"; "Personajes"
  (colecciones) y "Ayuda" a la derecha; hamburguesa solo en mobile.
- "Enviar a <nombre> · <ciudad>" (location-store persistido) que precarga
  departamento/ciudad en el checkout; "Hola X / Mi cuenta" y "Cambios y
  devoluciones" en xl+.
- Header compacto al hacer scroll en lg+ (histéresis 160/40px).
- Fix de menús (también en DepartmentsMegaMenu, ya presente en prod): el
  click de mouse ya no cierra el menú recién abierto por hover
  (keepOpenAfterHover) y el hover solo reacciona a pointerType "mouse",
  así el mouseleave sintético de touch no autocierra en tablets.

Revisado por el subagente frontend-critic (3 hallazgos, todos corregidos y
re-verificados) + Playwright contra build de producción local: sin solapes
a 1024/1280/1440/1920, click real, touch, mobile 390/320 sin cambios.

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 05:22:21 +00:00
1 parent 503f56977d
commit e6f031a366
4 files changed
+399 -33

No files matched your search

@@ -3,12 +3,13 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
import { type FormEvent, useState } from "react";
import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
import { useLocationStore } from "@/store/location-store";
const SELECT_CLASS =
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
@@ -72,12 +73,24 @@ export function CheckoutClient() {
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState("");
// Ciudad elegida en el header ("Enviar a ... · Cali"): precarga el
// departamento/ciudad solo si el cliente todavia no eligio nada aca.
const savedDepartmentId = useLocationStore((state) => state.departmentId);
const savedCityId = useLocationStore((state) => state.cityId);
const locationHydrated = useLocationStore((state) => state.hasHydrated);
const saveLocation = useLocationStore((state) => state.setLocation);
useEffect(() => {
if (!locationHydrated || !savedDepartmentId || !savedCityId) return;
setForm((current) => (current.departmentId ? current : { ...current, departmentId: savedDepartmentId, cityId: savedCityId }));
}, [locationHydrated, savedDepartmentId, savedCityId]);
const itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const currency = items[0]?.product.currency;
async function submitAddress(event: FormEvent) {
event.preventDefault();
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
saveLocation(selectedDepartamento.id, selectedCiudad.id);
setLoading(true);
setError(null);
try {
@@ -0,0 +1,100 @@
"use client";
import { MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ciudades, ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
import { useLocationStore } from "@/store/location-store";
const selectClass =
"h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15";
// "Enviar a <nombre> · <ciudad>" del header (inspirado en el bloque de
// ubicacion de Amazon). La ciudad elegida se reusa en el checkout.
export function LocationPicker({ firstName }: { firstName: string | null }) {
const departmentId = useLocationStore((state) => state.departmentId);
const cityId = useLocationStore((state) => state.cityId);
const hydrated = useLocationStore((state) => state.hasHydrated);
const setLocation = useLocationStore((state) => state.setLocation);
const [open, setOpen] = useState(false);
const [draftDepartment, setDraftDepartment] = useState<number | null>(null);
const [draftCity, setDraftCity] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const city = hydrated && cityId ? ciudades.find((item) => item.id === cityId)?.name : undefined;
useEffect(() => {
if (!open) return;
setDraftDepartment(departmentId);
setDraftCity(cityId);
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);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, departmentId, cityId]);
const draftCities = draftDepartment ? ciudadesPorDepartamento(draftDepartment) : [];
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-label={city ? `Enviar a ${city}. Cambiar ciudad` : "Elegir ciudad de envío"}
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-left transition hover:bg-slate-100"
>
<MapPin className="size-5 shrink-0 text-ari-coral" />
<span className="leading-tight">
<span className="block text-xs text-slate-500">Enviar a {firstName || "Colombia"}</span>
<span className="block max-w-36 truncate text-sm font-black text-ari-marino">{city ?? "Elige tu ciudad"}</span>
</span>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-5 shadow-2xl">
<p className="font-black text-ari-marino">¿A dónde enviamos?</p>
<p className="mt-1 text-xs text-slate-500">Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.</p>
<div className="mt-4 grid gap-3">
<select
aria-label="Departamento"
className={selectClass}
value={draftDepartment ?? ""}
onChange={(event) => { setDraftDepartment(Number(event.target.value) || null); setDraftCity(null); }}
>
<option value="">Departamento</option>
{departamentos.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select
aria-label="Ciudad"
className={selectClass}
value={draftCity ?? ""}
disabled={!draftDepartment}
onChange={(event) => setDraftCity(Number(event.target.value) || null)}
>
<option value="">Ciudad o municipio</option>
{draftCities.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Button
variant="primary"
disabled={!draftDepartment || !draftCity}
onClick={() => {
if (draftDepartment && draftCity) setLocation(draftDepartment, draftCity);
setOpen(false);
}}
>
Guardar ciudad
</Button>
</div>
</div>
)}
</div>
);
}
+251 -32
View File
@@ -2,9 +2,10 @@
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
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";
@@ -47,9 +48,25 @@ export function Navbar({ products, categories }: { products: Product[]; categori
[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 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">
<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>
@@ -61,16 +78,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
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 lg:py-4">
<div className="flex items-center justify-between gap-4 lg:grid lg:grid-cols-[auto_1fr_auto] lg:gap-8">
<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 lg:size-20" width={200} height={200} />
<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>
@@ -84,6 +104,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
<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>}
@@ -100,8 +131,8 @@ export function Navbar({ products, categories }: { products: Product[]; categori
</div>
</div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
<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>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
@@ -142,16 +173,26 @@ function useCategoryNavigationState() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? "";
const currentCollection = searchParams.get("collection") ?? "";
const isCatalog = pathname === "/catalog";
return {
isAllActive: isCatalog && !currentCategory,
isAllActive: isCatalog && !currentCategory && !currentCollection,
isActive: (name: string) => isCatalog && currentCategory === name,
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
};
}
function pillClass(active: boolean): string {
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
const INACTIVE_NAVIGATION: NavigationState = {
isAllActive: false,
isActive: () => false,
isCollectionActive: () => 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"
@@ -166,26 +207,26 @@ function mobileLinkClass(active: boolean): string {
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void };
function DesktopCategoryNavigation(props: NavBarProps) {
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
function DesktopCategoryNavigationFallback(props: NavBarProps) {
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
// 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, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
const { isActive, isAllActive, isCollectionActive } = state;
const anyCollectionActive = collections.some(isCollectionActive);
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">
@@ -194,24 +235,201 @@ function CategoryNavBar({
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-2 shrink-0 text-white hover:bg-white/10"
className="mr-2 shrink-0 text-white hover:bg-white/10 lg:hidden"
>
<Menu />
</Button>
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
<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">
{collections.length > 0 && (
<NavDropdown label="Personajes" active={anyCollectionActive} align="right" icon={<Sparkles className="size-4" />}>
{(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="/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;
}
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 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);
}
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
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 closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = () => {
@@ -251,14 +469,15 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
const openNow = () => {
cancelClose();
if (!open) hoverOpenedAt.current = Date.now();
setOpen(true);
};
return (
<div ref={containerRef} className="shrink-0" onMouseEnter={openNow} onMouseLeave={scheduleClose}>
<div ref={containerRef} className="shrink-0" onPointerEnter={(event) => { if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
aria-expanded={open}
aria-haspopup="true"
aria-label="Departamentos"