Files
devops 3b748cc014 feat(ecommerce): cuentas de cliente reales (registro + login)
Conecta el dialogo 'Tu cuenta ARI' (antes solo ofrecia WhatsApp) al
Store API real de Medusa. Flujo verificado en vivo con un cliente de
prueba real antes de escribir el codigo: registro -> crear customer
-> login -> /store/customers/me, todo confirmado funcionando.

Detalle no documentado que se confirmo en vivo: el token que devuelve
/auth/customer/emailpass/register sirve para crear el customer via
POST /store/customers, pero no queda como sesion real -- hay que
loguear de nuevo (/auth/customer/emailpass) para obtener el token de
sesion. Mensajes de error tambien verificados: correo repetido -> 401
'Identity with email already exists'; credenciales invalidas -> 401
'Invalid email or password'.

Mismo patron arquitectonico que el carrito real (ADR 0009): rutas
puente /api/customer/* (MEDUSA_BACKEND_URL es DNS interno, no resuelve
desde el navegador), store de Zustand con sesion persistida y
resincronizada contra el servidor al rehidratar. WhatsApp se mantiene
como opcion secundaria, ya no es la unica salida.
2026-08-29 14:24:43 +00:00

335 lines
14 KiB
TypeScript

"use client";
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 { AccountDialog } from "@/components/account/account-dialog";
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 { useCartStore } from "@/store/cart-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 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.
const collections = useMemo(
() => [...new Set(products.map((product) => product.collection).filter(Boolean))],
[products],
);
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">
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 className="relative overflow-visible bg-white">
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-ari-coral-claro" />
<div className="mx-auto grid max-w-7xl grid-cols-[auto_1fr_auto] items-center gap-4 px-4 py-4 lg:gap-8">
{/* 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} />
</Link>
<PredictiveSearch />
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-ari-coral hover:bg-ari-coral/10 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
<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>}
</Button>
<Button variant="primary" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative">
<ShoppingCart />
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-ari-marino text-[11px] font-black text-white">{count}</span>
</Button>
</div>
</div>
</div>
<Suspense fallback={<DesktopCategoryNavigationFallback tree={tree} onOpenMenu={() => setMenuOpen(true)} />}>
<DesktopCategoryNavigation tree={tree} onOpenMenu={() => setMenuOpen(true)} />
</Suspense>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="left" className="max-w-sm">
<SheetHeader>
<SheetTitle>Explora ARI</SheetTitle>
<SheetDescription>Categorías y colecciones del catálogo.</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-ari-coral">Categorías</p>
<div className="grid gap-2">
<Suspense fallback={<MobileCategoryLinksFallback tree={tree} onNavigate={() => setMenuOpen(false)} />}>
<MobileCategoryLinks tree={tree} onNavigate={() => setMenuOpen(false)} />
</Suspense>
</div>
{collections.length > 0 && (
<>
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-ari-marino">Colecciones</p>
<div className="flex flex-wrap gap-2">
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => 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}</Link>)}
</div>
</>
)}
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
</div>
</div>
</SheetContent>
</Sheet>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</header>
);
}
function useCategoryNavigationState() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentCategory = searchParams.get("category") ?? "";
const isCatalog = pathname === "/catalog";
return {
isAllActive: isCatalog && !currentCategory,
isActive: (name: string) => isCatalog && currentCategory === name,
};
}
function pillClass(active: boolean): string {
return `shrink-0 rounded-full 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"
}`;
}
function DesktopCategoryNavigation({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={isActive} isAllActive={isAllActive} />;
}
function DesktopCategoryNavigationFallback({ tree, onOpenMenu }: { tree: CategoryNode[]; onOpenMenu: () => void }) {
return <CategoryNavBar tree={tree} onOpenMenu={onOpenMenu} isActive={() => false} isAllActive={false} />;
}
function CategoryNavBar({
tree,
onOpenMenu,
isActive,
isAllActive,
}: {
tree: CategoryNode[];
onOpenMenu: () => void;
isActive: (name: string) => boolean;
isAllActive: boolean;
}) {
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">
<Button
variant="ghost"
size="icon"
onClick={onOpenMenu}
aria-label="Abrir menú principal"
className="mr-2 shrink-0 text-white hover:bg-white/10"
>
<Menu />
</Button>
<DepartmentsMegaMenu tree={tree} isActive={isActive} />
<Link href="/catalog" prefetch={false} aria-current={isAllActive ? "page" : undefined} className={pillClass(isAllActive)}>
Todo
</Link>
</div>
</nav>
);
}
function DepartmentsMegaMenu({ tree, isActive }: { tree: CategoryNode[]; isActive: (name: string) => boolean }) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
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]);
return (
<div ref={containerRef} className="shrink-0" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="true"
className={`flex items-center gap-1.5 ${pillClass(open)}`}
>
<LayoutGrid className="size-4" />
Departamentos
<ChevronDown className={`size-3.5 opacity-70 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 max-h-[80vh] w-[min(92vw,64rem)] overflow-y-auto rounded-2xl border border-white/10 bg-ari-marino p-6 shadow-2xl">
<div className="grid grid-cols-2 gap-x-6 gap-y-5 sm:grid-cols-3 lg:grid-cols-6">
{tree.map((root) => (
<div key={root.id}>
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={() => 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}
</Link>
{root.children.length > 0 && (
<ul className="mt-2.5 grid gap-1.5">
{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={() => setOpen(false)}
className={`block text-sm transition ${isActive(child.name) ? "font-bold text-ari-coral-claro" : "text-white/70 hover:text-white"}`}
>
{child.name}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function MobileCategoryLinks({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
const { isActive, isAllActive } = useCategoryNavigationState();
return (
<>
<Link
href="/catalog"
prefetch={false}
aria-current={isAllActive ? "page" : undefined}
onClick={onNavigate}
className={mobileLinkClass(isAllActive)}
>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link
href={`/catalog?category=${encodeURIComponent(root.name)}`}
prefetch={false}
aria-current={isActive(root.name) ? "page" : undefined}
onClick={onNavigate}
className={mobileLinkClass(isActive(root.name))}
>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
aria-current={isActive(child.name) ? "page" : undefined}
onClick={onNavigate}
className={`${mobileLinkClass(isActive(child.name))} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}
function MobileCategoryLinksFallback({ tree, onNavigate }: { tree: CategoryNode[]; onNavigate: () => void }) {
return (
<>
<Link href="/catalog" prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
Todo el catálogo
</Link>
{tree.map((root) => (
<div key={root.id} className="contents">
<Link href={`/catalog?category=${encodeURIComponent(root.name)}`} prefetch={false} onClick={onNavigate} className={mobileLinkClass(false)}>
{root.name}
</Link>
{root.children.map((child) => (
<Link
key={child.id}
href={`/catalog?category=${encodeURIComponent(child.name)}`}
prefetch={false}
onClick={onNavigate}
className={`${mobileLinkClass(false)} ml-4 text-sm`}
>
{child.name}
</Link>
))}
</div>
))}
</>
);
}