feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 9m42s
Build and Push Frontend / build (push) Failing after 9m42s
This commit is contained in:
@@ -39,7 +39,7 @@ export function CartDrawer() {
|
||||
</div>
|
||||
) : items.map((item) => (
|
||||
<article key={`${item.product.id}-${item.variantId ?? "default"}`} className="grid grid-cols-[82px_1fr_auto] gap-4 rounded-2xl border border-slate-100 p-3 shadow-sm">
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.image} alt={item.product.name} fill sizes="82px" className="object-cover" /></div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.thumbnail} alt={item.product.name} fill sizes="82px" className="object-cover" /></div>
|
||||
<div><h3 className="text-sm font-black leading-5">{item.product.name}</h3><p className="mt-1 text-xs text-slate-500">{formatMoney(item.product.price, item.product.currency)}</p><div className="mt-3 inline-flex items-center rounded-full border border-slate-200"><button aria-label="Restar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity - 1)}><Minus className="size-3" /></button><span className="min-w-7 text-center text-xs font-black">{item.quantity}</span><button aria-label="Sumar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity + 1)}><Plus className="size-3" /></button></div></div>
|
||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeItem(item.product.id)} aria-label="Eliminar"><Trash2 className="size-4" /></button>
|
||||
</article>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function CatalogClient({ products, initialCategory = "Todos", initialColl
|
||||
const filtered = useMemo(() => products.filter((product) => {
|
||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
||||
return (!query || search.includes(query.toLowerCase())) && (category === "Todos" || product.category === category) && (!collection || product.collection === collection) && (brand === "Todas" || product.brand === brand) && (age === "Todas" || product.ageRange === age) && (product.price === null || maxPrice === 0 || product.price <= maxPrice);
|
||||
}).sort((a, b) => sort === "name" ? a.name.localeCompare(b.name, "es") : sort === "new" ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured)), [age, brand, category, products, query, sort]);
|
||||
}).sort((a, b) => sort === "name" ? a.name.localeCompare(b.name, "es") : sort === "new" ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured)), [age, brand, category, collection, maxPrice, products, query, sort]);
|
||||
|
||||
const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); };
|
||||
|
||||
|
||||
@@ -3,21 +3,38 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function HeroBento() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const initial = reduceMotion ? false : { opacity: 0, y: 20 };
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.55fr_.8fr]">
|
||||
<motion.article initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} className="relative min-h-[430px] overflow-hidden rounded-[2rem] bg-gradient-to-br from-fuchsia-500 via-pink-500 to-rose-500 p-7 text-white shadow-2xl lg:min-h-[540px] lg:p-12">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_15%_20%,rgba(255,255,255,.32),transparent_22%),radial-gradient(circle_at_80%_15%,rgba(255,231,0,.35),transparent_18%)]" />
|
||||
<div className="relative z-10 max-w-lg"><div className="mb-5 inline-flex items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div><h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-8xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1><p className="mt-6 max-w-md text-base font-semibold text-white/90 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p><Button asChild variant="yellow" size="lg" className="mt-8"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button></div>
|
||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 65vw" className="object-cover object-right opacity-55 mix-blend-screen lg:opacity-70" />
|
||||
<motion.article initial={initial} animate={{ opacity: 1, y: 0 }} className="grid min-h-[470px] overflow-hidden rounded-[2rem] bg-fuchsia-500 text-white shadow-2xl lg:min-h-[560px] lg:grid-cols-[.82fr_1.18fr]">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-fuchsia-600 via-pink-500 to-rose-500 p-7 lg:p-11">
|
||||
<div className="mb-5 inline-flex w-fit items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div>
|
||||
<h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-7xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1>
|
||||
<p className="mt-6 max-w-md text-base font-semibold text-white/95 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p>
|
||||
<Button asChild variant="yellow" size="lg" className="mt-8 w-fit"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button>
|
||||
</div>
|
||||
<div className="relative min-h-[360px] bg-white lg:min-h-full">
|
||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 42vw" className="object-cover object-center" />
|
||||
</div>
|
||||
</motion.article>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .1 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-red-500 to-rose-700 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[55%]"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="35vw" className="object-cover opacity-65 mix-blend-screen" /></motion.article>
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .18 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-violet-500 to-cyan-500 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[58%]"><div className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="35vw" className="object-cover opacity-55 mix-blend-screen" /></motion.article>
|
||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .08 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-red-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-red-600 to-rose-700 p-6"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5 w-fit"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div>
|
||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
||||
</motion.article>
|
||||
|
||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .14 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-violet-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-violet-600 to-cyan-600 p-6"><div className="inline-flex w-fit items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 w-fit border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div>
|
||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
||||
</motion.article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Facebook, Instagram, MessageCircle } from "lucide-react";
|
||||
|
||||
export function Footer() {
|
||||
return <footer className="bg-slate-950 text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-yellow-300 px-4 text-sm font-black text-slate-950">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.0 · GitOps</div></footer>;
|
||||
return <footer className="bg-slate-950 text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-yellow-300 px-4 text-sm font-black text-slate-950">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.1 · GitOps</div></footer>;
|
||||
}
|
||||
|
||||
function FooterColumn({ title, links }: { title: string; links: string[] }) {
|
||||
|
||||
@@ -2,47 +2,113 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, Menu, ShoppingCart, UserRound } from "lucide-react";
|
||||
import { Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
const categories = [
|
||||
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras" },
|
||||
{ label: "Educativos", href: "/catalog?category=Figuras" },
|
||||
{ label: "Muñecas", href: "/catalog?category=Peluches" },
|
||||
{ label: "Muñecas y peluches", href: "/catalog?category=Peluches" },
|
||||
{ label: "Acción y aventura", href: "/catalog?collection=Marvel" },
|
||||
{ label: "Mochilas", href: "/catalog?category=Mochilas" },
|
||||
{ label: "Variedades", href: "/catalog" },
|
||||
];
|
||||
|
||||
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"];
|
||||
|
||||
export function Navbar({ products }: { products: Product[] }) {
|
||||
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 count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
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;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 shadow-md">
|
||||
<div className="bg-yellow-300 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">Paga tus compras a cuotas · Envíos nacionales · <span className="underline">Compra segura con ARI</span></div>
|
||||
<div className="bg-cyan-400 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,.18)_0_12%,transparent_13%),radial-gradient(circle_at_80%_70%,rgba(255,255,255,.12)_0_10%,transparent_11%)]">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[100px_minmax(420px,1fr)_auto] lg:gap-8">
|
||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-4 border-white bg-black shadow-xl lg:size-20"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" /></Link>
|
||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
||||
<div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
|
||||
Paga tus compras a cuotas · Envíos nacionales · <span className="underline 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-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500" />
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[92px_minmax(420px,1fr)_auto] lg:gap-8">
|
||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
|
||||
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
||||
</Link>
|
||||
<PredictiveSearch products={products} />
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Button variant="ghost" size="icon" aria-label="Mi cuenta" className="bg-white/15 text-white hover:bg-white/25"><UserRound /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Favoritos" className="bg-white/15 text-white hover:bg-white/25"><Heart /></Button>
|
||||
<Button variant="magenta" 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-yellow-300 text-[11px] font-black text-slate-950">{count}</span></Button>
|
||||
<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-violet-400 hover:bg-violet-50 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-fuchsia-400 hover:bg-fuchsia-50 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-violet-600 text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||
</Button>
|
||||
<Button variant="magenta" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative shadow-lg shadow-fuchsia-500/20">
|
||||
<ShoppingCart />
|
||||
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-yellow-300 text-[11px] font-black text-slate-950">{count}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="magenta" size="icon" className="sm:hidden" onClick={openCart}><ShoppingCart /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="bg-cyan-700 text-white">
|
||||
|
||||
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
|
||||
<Button variant="ghost" size="icon" className="shrink-0 text-white hover:bg-white/10 lg:mr-5"><Menu /></Button>
|
||||
{categories.map((category) => <Link key={category.label} href={category.href} className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold hover:bg-white/10">{category.label}</Link>)}
|
||||
<Button variant="ghost" size="icon" onClick={() => setMenuOpen(true)} aria-label="Abrir menú principal" className="mr-3 shrink-0 text-white hover:bg-white/10"><Menu /></Button>
|
||||
{categories.map((category) => (
|
||||
<Link key={category.label} href={category.href} className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold text-white/90 transition hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white">
|
||||
{category.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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-fuchsia-600">Categorías</p>
|
||||
<div className="grid gap-2">
|
||||
{categories.map((category) => <Link key={category.label} href={category.href} onClick={() => setMenuOpen(false)} className="rounded-2xl border border-slate-200 px-4 py-3 font-extrabold hover:border-cyan-400 hover:bg-cyan-50">{category.label}</Link>)}
|
||||
</div>
|
||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">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-violet-100 hover:text-violet-700">{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>
|
||||
|
||||
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
|
||||
<DialogContent className="max-w-lg p-7">
|
||||
<div className="pr-10">
|
||||
<span className="text-xs font-black uppercase tracking-[.16em] text-violet-600">Atención personalizada</span>
|
||||
<h2 className="mt-3 text-3xl font-black">Tu cuenta ARI</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.</p>
|
||||
<div className="mt-6 grid gap-3">
|
||||
<Button asChild variant="magenta" size="lg"><a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer"><MessageCircle />Hablar con ARI</a></Button>
|
||||
<Button asChild variant="outline"><Link href="/catalog" onClick={() => setAccountOpen(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,28 +3,33 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
const wishlistProducts = useWishlistStore((state) => state.products);
|
||||
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
|
||||
const toggleProduct = useWishlistStore((state) => state.toggleProduct);
|
||||
const isFavorite = wishlistHydrated && wishlistProducts.some((item) => item.id === product.id);
|
||||
|
||||
return (
|
||||
<motion.article whileHover={{ y: -8 }} transition={{ type: "spring", stiffness: 320, damping: 24 }} className="group overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5">
|
||||
<div className="relative aspect-square overflow-hidden bg-gradient-to-br from-cyan-50 via-white to-pink-50">
|
||||
<Link href={`/product/${product.slug}`}><Image src={product.image} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 80vw, (max-width: 1024px) 40vw, 24vw" className="object-cover transition duration-500 group-hover:scale-105" /></Link>
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
|
||||
<div className="relative aspect-square overflow-hidden bg-slate-50">
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<button aria-label="Agregar a favoritos" className="absolute right-3 top-3 rounded-full bg-white/90 p-2.5 shadow-lg backdrop-blur hover:text-pink-500"><Heart className="size-4" /></button>
|
||||
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
||||
</div>
|
||||
<div className="space-y-3 p-5">
|
||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-full bg-cyan-50 px-2 py-1 text-cyan-700">{product.collection}</span></div>
|
||||
<Link href={`/product/${product.slug}`}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||
<div className="flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
</div>
|
||||
</motion.article>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ProductCard } from "@/components/product/product-card";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function ProductCarousel({ products }: { products: Product[] }) {
|
||||
return <motion.div initial="hidden" whileInView="visible" viewport={{ once: true, margin: "-80px" }} variants={{ hidden: {}, visible: { transition: { staggerChildren: 0.07 } } }} className="grid auto-cols-[82%] grid-flow-col gap-5 overflow-x-auto pb-5 pr-5 [scrollbar-width:none] sm:auto-cols-[44%] lg:auto-cols-[28%] xl:auto-cols-[23%]">{products.map((product, index) => <motion.div key={product.id} variants={{ hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0 } }}><ProductCard product={product} priority={index < 2} /></motion.div>)}</motion.div>;
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [canPrevious, setCanPrevious] = useState(false);
|
||||
const [canNext, setCanNext] = useState(products.length > 1);
|
||||
|
||||
const updateControls = useCallback(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
setCanPrevious(node.scrollLeft > 8);
|
||||
setCanNext(node.scrollLeft + node.clientWidth < node.scrollWidth - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
updateControls();
|
||||
node.addEventListener("scroll", updateControls, { passive: true });
|
||||
const observer = new ResizeObserver(updateControls);
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
node.removeEventListener("scroll", updateControls);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [updateControls]);
|
||||
|
||||
const move = (direction: -1 | 1) => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
node.scrollBy({ left: direction * Math.max(280, node.clientWidth * 0.82), behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button type="button" aria-label="Productos anteriores" disabled={!canPrevious} onClick={() => move(-1)} className="absolute -left-3 top-[38%] z-10 grid size-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-950 shadow-xl transition hover:bg-violet-50 disabled:pointer-events-none disabled:opacity-0 sm:-left-6"><ChevronLeft className="size-6" /></button>
|
||||
<div ref={scroller} role="region" aria-label="Carrusel de productos" tabIndex={0} onKeyDown={(event) => { if (event.key === "ArrowLeft") move(-1); if (event.key === "ArrowRight") move(1); }} className="flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-smooth pb-5 pr-5 outline-none [overscroll-behavior-inline:contain] [scrollbar-width:none]">
|
||||
{products.map((product, index) => <div key={product.id} className="w-[82%] shrink-0 snap-start sm:w-[44%] lg:w-[28%] xl:w-[23%]"><ProductCard product={product} priority={index < 2} /></div>)}
|
||||
</div>
|
||||
<button type="button" aria-label="Productos siguientes" disabled={!canNext} onClick={() => move(1)} className="absolute -right-3 top-[38%] z-10 grid size-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-950 shadow-xl transition hover:bg-violet-50 disabled:pointer-events-none disabled:opacity-0 sm:-right-6"><ChevronRight className="size-6" /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ZoomIn } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
|
||||
import { PointerEvent, WheelEvent, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Offset { x: number; y: number }
|
||||
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
||||
|
||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||
const [selected, setSelected] = useState(images[0] ?? "");
|
||||
return <div className="grid gap-4 lg:grid-cols-[86px_1fr]"><div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">{images.map((image) => <button key={image} onClick={() => setSelected(image)} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}</div><Dialog><DialogTrigger asChild><button className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-gradient-to-br from-cyan-50 via-white to-pink-50 lg:order-2"><Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-110" /><span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Ampliar</span></button></DialogTrigger><DialogContent><div className="relative aspect-square"><Image src={selected} alt={name} fill sizes="90vw" className="object-contain" /></div></DialogContent></Dialog></div>;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
||||
const drag = useRef<DragState | null>(null);
|
||||
|
||||
const applyScale = (next: number) => {
|
||||
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
|
||||
setScale(value);
|
||||
if (value === 1) setOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
const changeOpen = (value: boolean) => { setOpen(value); if (!value) reset(); };
|
||||
const wheel = (event: WheelEvent<HTMLDivElement>) => { event.preventDefault(); applyScale(scale + (event.deltaY < 0 ? .2 : -.2)); };
|
||||
const pointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (scale <= 1) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offset.x, originY: offset.y };
|
||||
};
|
||||
const pointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!drag.current || drag.current.pointerId !== event.pointerId) return;
|
||||
setOffset({ x: drag.current.originX + event.clientX - drag.current.startX, y: drag.current.originY + event.clientY - drag.current.startY });
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (drag.current?.pointerId === event.pointerId) drag.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
|
||||
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
||||
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
|
||||
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
||||
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
|
||||
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
|
||||
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
||||
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 bg-slate-900 p-4 text-white">
|
||||
<button type="button" aria-label="Alejar" onClick={() => applyScale(scale - .25)} disabled={scale <= 1} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Minus /></button>
|
||||
<input aria-label="Nivel de zoom" type="range" min="1" max="3" step="0.1" value={scale} onChange={(event) => applyScale(Number(event.target.value))} className="w-44 accent-fuchsia-500 sm:w-64" />
|
||||
<button type="button" aria-label="Acercar" onClick={() => applyScale(scale + .25)} disabled={scale >= 3} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Plus /></button>
|
||||
<button type="button" onClick={reset} className="inline-flex h-11 items-center gap-2 rounded-full bg-white/10 px-4 text-sm font-black hover:bg-white/20"><RotateCcw className="size-4" />Restablecer</button>
|
||||
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,31 +2,51 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const normalized = query.trim().toLocaleLowerCase("es");
|
||||
const matches = useMemo(() => normalized.length < 2 ? [] : products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6), [normalized, products]);
|
||||
const matches = useMemo(
|
||||
() => normalized.length < 2
|
||||
? []
|
||||
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
|
||||
[normalized, products],
|
||||
);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
router.push(`/catalog?q=${encodeURIComponent(value)}`);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-cyan-700" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-white/80 bg-white pl-[52px] pr-12 shadow-xl" />
|
||||
{query && <button aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||
<form onSubmit={submit} className="relative w-full">
|
||||
<div className="rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 p-[2px] shadow-lg shadow-violet-500/10">
|
||||
<div className="relative rounded-full bg-white">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-violet-600" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-0 bg-transparent pl-[52px] pr-12 shadow-none focus-visible:ring-violet-300/30" />
|
||||
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||
</div>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
||||
{matches.map((product) => (
|
||||
<Link key={product.id} href={`/product/${product.slug}`} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-cyan-50" onClick={() => setQuery("")}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.image} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-violet-50" onClick={() => setQuery("")}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
||||
</Link>
|
||||
))}
|
||||
<button type="submit" className="mt-1 w-full rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white hover:bg-violet-700">Buscar “{query.trim()}” en todo el catálogo</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,31 @@ export const Sheet = DialogPrimitive.Root;
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
export function SheetContent({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
side?: "left" | "right";
|
||||
};
|
||||
|
||||
export function SheetContent({ side = "right", className, children, ...props }: SheetContentProps) {
|
||||
const sideClasses = side === "left"
|
||||
? "inset-y-0 left-0 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left"
|
||||
: "inset-y-0 right-0 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right";
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/55 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out" />
|
||||
<DialogPrimitive.Content className={cn("fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl outline-none data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right", className)} {...props}>
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 flex h-full w-full max-w-md flex-col border-slate-200 bg-white shadow-2xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
sideClasses,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-5 top-5 rounded-full border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-100" aria-label="Cerrar">
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-5 top-5 rounded-full border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-100"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
@@ -23,16 +41,22 @@ export function SheetContent({ className, children, ...props }: React.ComponentP
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeader(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("border-b border-slate-100 p-6", props.className)} {...props} />;
|
||||
export function SheetHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("border-b border-slate-100 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export const SheetTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
|
||||
export const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-2xl font-black text-slate-950", className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = "SheetTitle";
|
||||
|
||||
export const SheetDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
|
||||
export const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("mt-1 text-sm text-slate-500", className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = "SheetDescription";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
export function WishlistDrawer() {
|
||||
const products = useWishlistStore((state) => state.products);
|
||||
const isOpen = useWishlistStore((state) => state.isOpen);
|
||||
const closeWishlist = useWishlistStore((state) => state.closeWishlist);
|
||||
const removeProduct = useWishlistStore((state) => state.removeProduct);
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeWishlist(); }}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mis favoritos</SheetTitle>
|
||||
<SheetDescription>{products.length} producto{products.length === 1 ? "" : "s"} guardado{products.length === 1 ? "" : "s"}.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{products.length === 0 ? (
|
||||
<div className="grid min-h-72 place-items-center rounded-3xl border border-dashed border-slate-300 bg-slate-50 text-center">
|
||||
<div><Heart className="mx-auto mb-3 size-10 text-fuchsia-500" /><p className="font-black">Aún no guardas favoritos</p><p className="mt-1 text-sm text-slate-500">Pulsa el corazón de cualquier producto.</p></div>
|
||||
</div>
|
||||
) : products.map((product) => (
|
||||
<article key={product.id} className="grid grid-cols-[84px_1fr_auto] gap-4 rounded-2xl border border-slate-100 p-3 shadow-sm">
|
||||
<Link href={`/product/${product.slug}`} onClick={closeWishlist} className="relative aspect-square overflow-hidden rounded-xl bg-slate-100" prefetch={false}>
|
||||
<Image src={product.thumbnail} alt={product.name} fill sizes="84px" className="object-cover" />
|
||||
</Link>
|
||||
<div>
|
||||
<Link href={`/product/${product.slug}`} onClick={closeWishlist} prefetch={false} className="text-sm font-black leading-5 hover:text-fuchsia-600">{product.name}</Link>
|
||||
<p className="mt-1 text-xs text-slate-500">{product.category} · {product.collection}</p>
|
||||
<Button size="sm" variant="magenta" className="mt-3" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-3.5" />Agregar</Button>
|
||||
</div>
|
||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeProduct(product.id)} aria-label="Quitar favorito"><Trash2 className="size-4" /></button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user