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.
335 lines
14 KiB
TypeScript
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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|