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:
1 parent
503f56977d
commit
e6f031a366
4 files changed
+398
-32
No files matched your search
@@ -3,12 +3,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, Landmark, MapPin, MessageCircle, Truck } from "lucide-react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
import { ciudadesPorDepartamento, departamentos } from "@/lib/data/colombia";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
import { useCartStore } from "@/store/cart-store";
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { useLocationStore } from "@/store/location-store";
|
||||||
|
|
||||||
const SELECT_CLASS =
|
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";
|
"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 [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
|
||||||
const [docNumber, setDocNumber] = useState("");
|
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 itemsSubtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
||||||
const currency = items[0]?.product.currency;
|
const currency = items[0]?.product.currency;
|
||||||
|
|
||||||
async function submitAddress(event: FormEvent) {
|
async function submitAddress(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
if (!cartId || !selectedCiudad || !selectedDepartamento) return;
|
||||||
|
saveLocation(selectedDepartamento.id, selectedCiudad.id);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { ChevronDown, Heart, LayoutGrid, Menu, ShoppingCart, UserRound } from "lucide-react";
|
import { ChevronDown, Heart, LayoutGrid, LifeBuoy, Menu, ShoppingCart, Sparkles, UserRound } from "lucide-react";
|
||||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { AccountDialog } from "@/components/account/account-dialog";
|
import { AccountDialog } from "@/components/account/account-dialog";
|
||||||
|
import { LocationPicker } from "@/components/layout/location-picker";
|
||||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
@@ -47,9 +48,25 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
[products],
|
[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 (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
<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">
|
<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>
|
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>
|
||||||
|
|
||||||
@@ -61,16 +78,19 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
angosto/recortado). En desktop (lg+) se mantiene la fila unica
|
||||||
original -- ahi sobra espacio y partirla en dos solo alargaria
|
original -- ahi sobra espacio y partirla en dos solo alargaria
|
||||||
el header sin necesidad. */}
|
el header sin necesidad. */}
|
||||||
<div className="mx-auto max-w-7xl px-4 py-3 lg:py-4">
|
<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_1fr_auto] lg:gap-8">
|
<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
|
{/* Isotipo circular con icono + wordmark integrados (como en la
|
||||||
version previa a ADR 0008), pedido explicito del usuario en
|
version previa a ADR 0008), pedido explicito del usuario en
|
||||||
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
vez del lockup rectangular. logo-ari.svg (rectangular) se
|
||||||
mantiene para el footer. */}
|
mantiene para el footer. */}
|
||||||
<Link href="/" aria-label="ARI shopping" className="shrink-0">
|
<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 */}
|
{/* 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>
|
</Link>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<LocationPicker firstName={isLoggedIn ? customer?.firstName ?? null : null} />
|
||||||
|
</div>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<PredictiveSearch />
|
<PredictiveSearch />
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +104,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori
|
|||||||
<UserRound />
|
<UserRound />
|
||||||
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
{isLoggedIn && <span className="absolute -right-1 -top-1 size-3 rounded-full border-2 border-white bg-emerald-500" />}
|
||||||
</Button>
|
</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}>
|
<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 />
|
<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>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
|
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}>
|
||||||
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
|
<DesktopCategoryNavigation tree={tree} collections={collections} onOpenMenu={() => setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
@@ -142,16 +173,26 @@ function useCategoryNavigationState() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const currentCategory = searchParams.get("category") ?? "";
|
const currentCategory = searchParams.get("category") ?? "";
|
||||||
|
const currentCollection = searchParams.get("collection") ?? "";
|
||||||
const isCatalog = pathname === "/catalog";
|
const isCatalog = pathname === "/catalog";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAllActive: isCatalog && !currentCategory,
|
isAllActive: isCatalog && !currentCategory && !currentCollection,
|
||||||
isActive: (name: string) => isCatalog && currentCategory === name,
|
isActive: (name: string) => isCatalog && currentCategory === name,
|
||||||
|
isCollectionActive: (name: string) => isCatalog && currentCollection === name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pillClass(active: boolean): string {
|
type NavigationState = ReturnType<typeof useCategoryNavigationState>;
|
||||||
return `shrink-0 rounded-full px-4 py-2 text-sm font-extrabold transition ${
|
|
||||||
|
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
|
active
|
||||||
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
? "bg-white text-ari-marino shadow-lg shadow-black/10"
|
||||||
: "text-white/90 hover:bg-white/10 hover:text-white"
|
: "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 }) {
|
type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void };
|
||||||
const { isActive, isAllActive } = useCategoryNavigationState();
|
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
|
function DesktopCategoryNavigation(props: NavBarProps) {
|
||||||
|
return <CategoryNavBar {...props} state={useCategoryNavigationState()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
|
function DesktopCategoryNavigationFallback(props: NavBarProps) {
|
||||||
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
|
return <CategoryNavBar {...props} state={INACTIVE_NAVIGATION} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryNavBar({
|
// Desktop: con 1280px+ las 6 categorias raiz entran directo en la barra
|
||||||
tree,
|
// (cada una con su desplegable de subcategorias) -- antes solo se veian
|
||||||
onOpenMenu,
|
// "Departamentos" y "Todo" y el resto de la barra quedaba vacia. Entre
|
||||||
isActive,
|
// 1024 y 1280px no entran todas, asi que ahi se mantiene el mega menu de
|
||||||
isAllActive,
|
// "Departamentos". "Personajes" (colecciones) va a la derecha desde lg.
|
||||||
}: {
|
// La hamburguesa solo en mobile: en desktop repetia lo mismo que la barra.
|
||||||
tree: CategoryNode[];
|
function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }: NavBarProps & { state: NavigationState }) {
|
||||||
onOpenMenu: () => void;
|
const { isActive, isAllActive, isCollectionActive } = state;
|
||||||
isActive: (name: string) => boolean;
|
const anyCollectionActive = collections.some(isCollectionActive);
|
||||||
isAllActive: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-ari-marino text-white">
|
<nav className="bg-ari-marino text-white">
|
||||||
<div className="relative mx-auto flex max-w-7xl items-center gap-1 px-4 py-2">
|
<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"
|
size="icon"
|
||||||
onClick={onOpenMenu}
|
onClick={onOpenMenu}
|
||||||
aria-label="Abrir menú principal"
|
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 />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="shrink-0 xl:hidden">
|
||||||
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
|
||||||
Todo
|
Todo
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
</nav>
|
</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 }) {
|
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverOpenedAt = useRef(0);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelClose = () => {
|
const cancelClose = () => {
|
||||||
@@ -251,14 +469,15 @@ function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActiv
|
|||||||
|
|
||||||
const openNow = () => {
|
const openNow = () => {
|
||||||
cancelClose();
|
cancelClose();
|
||||||
|
if (!open) hoverOpenedAt.current = Date.now();
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => keepOpenAfterHover(value, hoverOpenedAt.current))}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Departamentos"
|
aria-label="Departamentos"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
// Ciudad de envio elegida desde el header ("Enviar a ... · Cali"), mismo
|
||||||
|
// patron persistido que wishlist/cart. Se guardan los ids DIVIPOLA (no
|
||||||
|
// texto libre, ver lib/data/colombia.ts) para que el checkout arranque con
|
||||||
|
// el mismo departamento/ciudad ya seleccionados.
|
||||||
|
interface LocationState {
|
||||||
|
departmentId: number | null;
|
||||||
|
cityId: number | null;
|
||||||
|
hasHydrated: boolean;
|
||||||
|
setHasHydrated: (value: boolean) => void;
|
||||||
|
setLocation: (departmentId: number, cityId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocationStore = create<LocationState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
departmentId: null,
|
||||||
|
cityId: null,
|
||||||
|
hasHydrated: false,
|
||||||
|
setHasHydrated: (value) => set({ hasHydrated: value }),
|
||||||
|
setLocation: (departmentId, cityId) => set({ departmentId, cityId }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ari-shopping-location-v1",
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({ departmentId: state.departmentId, cityId: state.cityId }),
|
||||||
|
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in new issue
Block a user