From e6f031a3665e324afbe75561781117b719b90f7f Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sun, 27 Sep 2026 05:22:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(storefront):=20barra=20de=20navegaci=C3=B3?= =?UTF-8?q?n=20desktop=20con=20categor=C3=ADas,=20personajes,=20ayuda=20y?= =?UTF-8?q?=20ubicaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 · " (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 Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP --- .../components/checkout/checkout-client.tsx | 15 +- .../components/layout/location-picker.tsx | 100 +++++++ .../ecommerce/components/layout/navbar.tsx | 283 ++++++++++++++++-- workloads/ecommerce/store/location-store.ts | 34 +++ 4 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 workloads/ecommerce/components/layout/location-picker.tsx create mode 100644 workloads/ecommerce/store/location-store.ts diff --git a/workloads/ecommerce/components/checkout/checkout-client.tsx b/workloads/ecommerce/components/checkout/checkout-client.tsx index fee2431..98e69e5 100644 --- a/workloads/ecommerce/components/checkout/checkout-client.tsx +++ b/workloads/ecommerce/components/checkout/checkout-client.tsx @@ -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 { diff --git a/workloads/ecommerce/components/layout/location-picker.tsx b/workloads/ecommerce/components/layout/location-picker.tsx new file mode 100644 index 0000000..c9a08db --- /dev/null +++ b/workloads/ecommerce/components/layout/location-picker.tsx @@ -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 · " 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(null); + const [draftCity, setDraftCity] = useState(null); + const containerRef = useRef(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 ( +
+ + {open && ( +
+

¿A dónde enviamos?

+

Hacemos envíos a toda Colombia. Tu ciudad queda lista en el checkout.

+
+ + + +
+
+ )} +
+ ); +} diff --git a/workloads/ecommerce/components/layout/navbar.tsx b/workloads/ecommerce/components/layout/navbar.tsx index 20c8546..c77f484 100644 --- a/workloads/ecommerce/components/layout/navbar.tsx +++ b/workloads/ecommerce/components/layout/navbar.tsx @@ -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 ( -
-
+
+
Paga tus compras a cuotas · Envíos nacionales · Compra segura con ARI
@@ -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. */} -
-
+
+
{/* 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 + ARI shopping +
+ +
@@ -84,6 +104,17 @@ export function Navbar({ products, categories }: { products: Product[]; categori {isLoggedIn && } + {/* 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)} />}> - setMenuOpen(true)} /> + setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} />}> + setMenuOpen(true)} onOpenAccount={() => setAccountOpen(true)} /> @@ -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; + +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 ; +type NavBarProps = { tree: CategoryNode[]; collections: string[]; onOpenMenu: () => void; onOpenAccount: () => void }; + +function DesktopCategoryNavigation(props: NavBarProps) { + return ; } -function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) { - return false} isAllActive={false} />; +function DesktopCategoryNavigationFallback(props: NavBarProps) { + return ; } -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 ( ); } +// 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(null); + const hoverOpenedAt = useRef(0); + 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); + } + 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 closeTimer = useRef | 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 ( -
+
{ if (event.pointerType === "mouse") openNow(); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") scheduleClose(); }}>