feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 5m39s

This commit is contained in:
2026-07-19 19:37:41 -05:00
parent 383b644929
commit f263a9cc55
76 changed files with 1607 additions and 0 deletions
@@ -0,0 +1,58 @@
"use client";
import Image from "next/image";
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
const FREE_SHIPPING_THRESHOLD = 250_000;
export function CartDrawer() {
const { items, isOpen, closeCart, updateQuantity, removeItem, clearCart } = useCartStore();
const subtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
const pricedItems = items.some((item) => item.product.price !== null);
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const checkout = () => {
const lines = items.map((item) => `${item.quantity} x ${item.product.name}`).join("\n");
const message = `Hola Ari Shopping 👋\nQuiero consultar disponibilidad para:\n\n${lines}\n\nNombre:\nCiudad:\nForma de entrega:`;
void navigator.clipboard?.writeText(message);
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
<SheetContent>
<SheetHeader>
<SheetTitle>Tu carrito</SheetTitle>
<SheetDescription>{count} producto{count === 1 ? "" : "s"} seleccionado{count === 1 ? "" : "s"}.</SheetDescription>
</SheetHeader>
<div className="flex-1 space-y-5 overflow-y-auto p-6">
{items.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><ShoppingBag className="mx-auto mb-3 size-10 text-cyan-500" /><p className="font-black">Tu carrito está vacío</p><p className="mt-1 text-sm text-slate-500">Agrega un tesoro para comenzar.</p></div>
</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><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>
))}
</div>
<div className="space-y-4 border-t border-slate-100 p-6">
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
<Button variant="magenta" size="lg" className="w-full" disabled={!items.length} onClick={checkout}>Continuar por WhatsApp</Button>
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,38 @@
"use client";
import { SlidersHorizontal, X } from "lucide-react";
import { useMemo, useState } from "react";
import { ProductCard } from "@/components/product/product-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
export function CatalogClient({ products, initialCategory = "Todos", initialCollection = "", initialQuery = "" }: { products: Product[]; initialCategory?: string; initialCollection?: string; initialQuery?: string }) {
const [query, setQuery] = useState(initialQuery);
const [category, setCategory] = useState(initialCategory);
const [collection, setCollection] = useState(initialCollection);
const [brand, setBrand] = useState("Todas");
const [age, setAge] = useState("Todas");
const [sort, setSort] = useState("featured");
const categories = ["Todos", ...new Set(products.map((product) => product.category))];
const brands = ["Todas", ...new Set(products.map((product) => product.brand))];
const ages = ["Todas", ...new Set(products.map((product) => product.ageRange))];
const pricedProducts = products.filter((product) => product.price !== null);
const maxCatalogPrice = pricedProducts.length ? Math.max(...pricedProducts.map((product) => product.price ?? 0)) : 0;
const [maxPrice, setMaxPrice] = useState(maxCatalogPrice);
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]);
const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); };
return <div className="mx-auto max-w-7xl px-4 py-10"><div className="mb-8"><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Catálogo completo</span><h1 className="mt-2 text-5xl font-black tracking-tight">Todos los tesoros</h1><p className="mt-3 text-slate-500">Filtra por categoría, marca o edad sin recargar la página.</p></div><div className="grid gap-8 lg:grid-cols-[260px_1fr]"><aside className="h-fit space-y-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-lg shadow-slate-900/5 lg:sticky lg:top-44"><div className="flex items-center justify-between"><strong className="flex items-center gap-2 text-lg font-black"><SlidersHorizontal className="size-5 text-cyan-600" />Filtros</strong><button onClick={reset} className="text-xs font-black text-fuchsia-600"><X className="mr-1 inline size-3" />Limpiar</button></div><Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar…" /><Filter title="Categoría" values={categories} selected={category} onChange={setCategory} /><Filter title="Marca" values={brands} selected={brand} onChange={setBrand} /><Filter title="Edad" values={ages} selected={age} onChange={setAge} />
<fieldset><legend className="mb-3 text-sm font-black">Precio máximo</legend>{maxCatalogPrice > 0 ? <><input aria-label="Precio máximo" type="range" min={0} max={maxCatalogPrice} step={5000} value={maxPrice} onChange={(event) => setMaxPrice(Number(event.target.value))} className="w-full accent-fuchsia-500" /><p className="mt-2 text-xs font-bold text-slate-500">Hasta ${maxPrice.toLocaleString("es-CO")}</p></> : <p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">El filtro se activa automáticamente cuando el backend entregue precios.</p>}</fieldset>
</aside><section><div className="mb-5 flex items-center justify-between gap-4"><p className="text-sm font-bold text-slate-500">{filtered.length} productos</p><select value={sort} onChange={(event) => setSort(event.target.value)} className="h-11 rounded-full border border-slate-200 bg-white px-4 text-sm font-bold"><option value="featured">Destacados</option><option value="new">Novedades</option><option value="name">Nombre</option></select></div>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{filtered.map((product) => <ProductCard key={product.id} product={product} />)}</div> : <div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center"><div><p className="text-xl font-black">No encontramos productos</p><Button variant="magenta" className="mt-4" onClick={reset}>Restablecer filtros</Button></div></div>}</section></div></div>;
}
function Filter({ title, values, selected, onChange }: { title: string; values: string[]; selected: string; onChange: (value: string) => void }) {
return <fieldset><legend className="mb-3 text-sm font-black">{title}</legend><div className="flex flex-wrap gap-2">{values.map((value) => <button type="button" key={value} onClick={() => onChange(value)} className={`rounded-full px-3 py-2 text-xs font-bold transition ${selected === value ? "bg-slate-950 text-white" : "bg-slate-100 text-slate-600 hover:bg-cyan-100"}`}>{value}</button>)}</div></fieldset>;
}
@@ -0,0 +1,14 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
const categories = [
{ title: "Juguetes", subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", href: "/catalog?category=Figuras", color: "from-yellow-300 to-orange-400" },
{ title: "Peluches", subtitle: "Compañeros para regalar", image: "/images/categories/peluches.webp", href: "/catalog?category=Peluches", color: "from-pink-400 to-fuchsia-500" },
{ title: "Mochilas", subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", href: "/catalog?category=Mochilas", color: "from-cyan-400 to-blue-500" },
{ title: "Marvel", subtitle: "Tu héroe favorito", image: "/images/categories/marvel.webp", href: "/catalog?collection=Marvel", color: "from-red-500 to-rose-700" },
];
export function CategoryTiles() {
return <section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-4"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2></div><Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo </Link></div><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">{categories.map((item) => <Link key={item.title} href={item.href} className={`group relative min-h-80 overflow-hidden rounded-[2rem] bg-gradient-to-br ${item.color} shadow-xl`}><Image src={item.image} alt={item.title} fill sizes="(max-width: 640px) 100vw, 25vw" className="object-cover transition duration-500 group-hover:scale-105" /><div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" /><div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white"><div><h3 className="text-2xl font-black">{item.title}</h3><p className="mt-1 text-sm font-semibold text-white/80">{item.subtitle}</p></div><span className="grid size-11 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45"><ArrowUpRight /></span></div></Link>)}</div></section>;
}
@@ -0,0 +1,25 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
export function HeroBento() {
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>
<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>
</div>
</div>
</section>
);
}
@@ -0,0 +1,12 @@
import { CreditCard, Headphones, ShieldCheck, Truck } from "lucide-react";
const benefits = [
{ icon: Truck, title: "Envíos nacionales", text: "Coordinación rápida y segura." },
{ icon: CreditCard, title: "Pagos flexibles", text: "Opciones adaptadas a tu compra." },
{ icon: ShieldCheck, title: "Compra protegida", text: "Confirmamos antes de pagar." },
{ icon: Headphones, title: "Atención cercana", text: "Acompañamiento por WhatsApp." },
];
export function TrustBar() {
return <section className="mx-auto grid max-w-7xl gap-4 px-4 py-10 sm:grid-cols-2 lg:grid-cols-4">{benefits.map(({ icon: Icon, title, text }, index) => <article key={title} className="flex items-center gap-4 rounded-3xl border border-slate-100 bg-white p-5 shadow-lg shadow-slate-900/5"><span className={`grid size-12 shrink-0 place-items-center rounded-2xl ${["bg-yellow-300", "bg-cyan-300", "bg-pink-300", "bg-violet-300"][index]}`}><Icon className="size-6 text-slate-950" /></span><div><strong className="block font-black">{title}</strong><span className="mt-1 block text-xs text-slate-500">{text}</span></div></article>)}</section>;
}
@@ -0,0 +1,11 @@
import Image from "next/image";
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>;
}
function FooterColumn({ title, links }: { title: string; links: string[] }) {
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link} href="/catalog" className="hover:text-yellow-300">{link}</Link>)}</div></div>;
}
@@ -0,0 +1,48 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Heart, Menu, ShoppingCart, UserRound } from "lucide-react";
import { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-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: "Acción y aventura", href: "/catalog?collection=Marvel" },
{ label: "Mochilas", href: "/catalog?category=Mochilas" },
{ label: "Variedades", href: "/catalog" },
];
export function Navbar({ products }: { products: Product[] }) {
const items = useCartStore((state) => state.items);
const openCart = useCartStore((state) => state.openCart);
const count = items.reduce((sum, item) => sum + item.quantity, 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>
<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>
<Button variant="magenta" size="icon" className="sm:hidden" onClick={openCart}><ShoppingCart /></Button>
</div>
</div>
<nav className="bg-cyan-700 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>)}
</div>
</nav>
</header>
);
}
@@ -0,0 +1,11 @@
"use client";
import { ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store";
export function MobileAddToCart({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem);
return <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
}
@@ -0,0 +1,30 @@
"use client";
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";
export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) {
const addItem = useCartStore((state) => state.addItem);
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>
<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>
</div>
<div className="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>
<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>
</motion.article>
);
}
@@ -0,0 +1,9 @@
"use client";
import { motion } from "motion/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>;
}
@@ -0,0 +1,11 @@
"use client";
import Image from "next/image";
import { ZoomIn } from "lucide-react";
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
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>;
}
@@ -0,0 +1,14 @@
"use client";
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
export function ProductPurchase({ product }: { product: Product }) {
const [variantId, setVariantId] = useState(product.variants[0]?.id);
const addItem = useCartStore((state) => state.addItem);
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
}
@@ -0,0 +1,32 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types";
export function PredictiveSearch({ products }: { products: Product[] }) {
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]);
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>}
{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>
<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>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export const Accordion = AccordionPrimitive.Root;
export const AccordionItem = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Item>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b border-slate-200", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
export const AccordionTrigger = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger ref={ref} className={cn("group flex w-full items-center justify-between py-5 text-left font-extrabold", className)} {...props}>
{children}<ChevronDown className="size-4 transition group-data-[state=open]:rotate-180" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = "AccordionTrigger";
export const AccordionContent = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Content>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content ref={ref} className="overflow-hidden text-sm text-slate-600 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" {...props}>
<div className={cn("pb-5 leading-7", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = "AccordionContent";
@@ -0,0 +1,6 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return <span className={cn("inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-slate-700", className)} {...props} />;
}
@@ -0,0 +1,36 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-extrabold transition-all focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan-300/40 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-slate-950 text-white shadow-lg hover:-translate-y-0.5 hover:bg-slate-800",
magenta: "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-lg shadow-pink-500/25 hover:-translate-y-0.5",
yellow: "bg-yellow-300 text-slate-950 shadow-lg shadow-yellow-400/25 hover:-translate-y-0.5 hover:bg-yellow-200",
outline: "border border-slate-200 bg-white text-slate-950 hover:border-cyan-400 hover:bg-cyan-50",
ghost: "text-slate-700 hover:bg-slate-100",
},
size: {
default: "h-11 px-5",
sm: "h-9 px-4 text-xs",
lg: "h-13 px-7 text-base",
icon: "size-11 p-0",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
});
Button.displayName = "Button";
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export function DialogContent({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-md" />
<DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 max-h-[92vh] w-[calc(100%-1.5rem)] max-w-5xl -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-3xl bg-white shadow-2xl outline-none", className)} {...props}>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-white p-2 shadow-lg" aria-label="Cerrar"><X className="size-5" /></DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
@@ -0,0 +1,12 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn("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", className)}
{...props}
/>
));
Input.displayName = "Input";
@@ -0,0 +1,12 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
export function Progress({ value = 0, className }: { value?: number; className?: string }) {
return (
<ProgressPrimitive.Root className={cn("relative h-2 w-full overflow-hidden rounded-full bg-slate-100", className)} value={value}>
<ProgressPrimitive.Indicator className="h-full bg-gradient-to-r from-cyan-400 via-fuchsia-500 to-yellow-300 transition-transform" style={{ transform: `translateX(-${100 - Math.min(value, 100)}%)` }} />
</ProgressPrimitive.Root>
);
}
@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
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>) {
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}>
{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">
<X className="size-5" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function SheetHeader(props: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("border-b border-slate-100 p-6", props.className)} {...props} />;
}
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) => (
<DialogPrimitive.Description ref={ref} className={cn("mt-1 text-sm text-slate-500", className)} {...props} />
));
SheetDescription.displayName = "SheetDescription";