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

This commit is contained in:
2026-07-19 21:36:35 -05:00
parent 2e7abc7e72
commit fe7b2019c2
79 changed files with 7686 additions and 187 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
FROM node:22-alpine AS deps FROM node:22-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json package-lock.json ./
RUN npm install --no-audit --no-fund RUN npm ci --no-audit --no-fund
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
+21 -50
View File
@@ -1,59 +1,30 @@
# ARI Shopping Headless Storefront v4.0 # ARI Shopping Headless Storefront v4.1
Código base moderno para la tienda online ARI Shopping. Actualización enfocada en UX, interactividad y rendimiento sobre Next.js 15.5.10.
## Stack ## Correcciones principales
- Next.js 15 con App Router y Server Components. - Encabezado blanco premium con acentos cyan, violeta, fucsia y navegación negra.
- React 19 y TypeScript estricto. - Imágenes promocionales sin `opacity`, `mix-blend` ni filtros de color.
- Tailwind CSS 4. - Menú principal funcional mediante Drawer accesible de Radix UI.
- Componentes accesibles estilo shadcn/ui sobre Radix UI. - Cuenta y favoritos funcionales.
- Motion for React para microinteracciones. - Carrusel con flechas, teclado, scroll suave y `scroll-snap`.
- Zustand con persistencia local para el carrito. - Zoom real de producto entre 100% y 300%, rueda, botones, doble clic y arrastre.
- Adaptadores headless para catálogo local, Shopify Storefront API o Medusa. - Thumbnails WebP dedicados para tarjetas, búsqueda, carrito y favoritos.
- `next/image` para optimización de imágenes. - Recursos originales recomprimidos y caché HTTP para `/images`.
- Docker multi-stage con salida standalone. - `next/image` en modo `unoptimized` porque los recursos ya vienen procesados; evita latencia del optimizador durante la primera visita.
- Menos componentes animados con JavaScript; las tarjetas usan transiciones CSS.
- `content-visibility` para contenido fuera de pantalla.
- Pantalla `loading.tsx` para transiciones de ruta.
## Funcionalidad incluida ## Validación
- Header promocional cyan/amarillo/magenta.
- Búsqueda predictiva.
- Hero promocional Bento.
- Carrusel horizontal de novedades.
- Catálogo con búsqueda y filtros sin recarga.
- PDP dinámica con galería, zoom, variantes y recomendados.
- Botón móvil persistente para añadir al carrito.
- Carrito lateral con Zustand y persistencia.
- SEO y metadata con App Router.
- Endpoint `/api/health`.
- Arquitectura preparada para Shopify o Medusa.
## Desarrollo
```bash ```bash
cp .env.example .env.local npm ci
npm install npm run typecheck
npm run dev npm run build
``` ```
## Variables headless ## Flujo
El valor predeterminado es: Gitea → Gitea Actions → Registry → actualización de `frontend.yaml` → Argo CD → Kubernetes.
```text
HEADLESS_PROVIDER=mock
```
Proveedores preparados:
```text
mock
shopify
medusa
```
Los adaptadores Shopify y Medusa incluyen la conexión inicial y un punto claro para completar el mapeo al modelo `Product`.
## Compatibilidad GitOps
El contenedor Next.js escucha en el puerto `80`, por lo que mantiene compatibilidad con el Service y Deployment existentes que apuntaban al frontend Nginx.
+10 -28
View File
@@ -1,29 +1,11 @@
# Arquitectura tecnológica # Tecnología — ARI Shopping v4.1
```text - Next.js 15.5.10.5.10 App Router y React 19.
Browser - TypeScript estricto.
- Tailwind CSS 4.
Next.js 15 App Router - Radix UI para Drawer y Dialog.
├─ Server Components / SSR / metadata SEO - Motion usado selectivamente en el hero; las tarjetas usan CSS para reducir JavaScript.
├─ React Client Components - Zustand persistente para carrito y favoritos.
│ ├─ Motion microinteractions - Catálogo headless intercambiable: mock, Shopify o Medusa.
│ ├─ Radix/shadcn UI - Imágenes WebP preoptimizadas con thumbnails de 560 px y fuentes PDP de hasta 1200 px.
│ └─ Zustand persisted cart - Contenedor Next.js standalone, no-root, puerto 80 y `/api/health`.
├─ Headless catalog interface
│ ├─ Local mock JSON
│ ├─ Shopify Storefront GraphQL
│ └─ Medusa Store REST
└─ next/image optimization
Docker standalone :80
Gitea Actions → Registry → Argo CD → Kubernetes
```
## Decisiones
- Mobile-first y componentes interactivos aislados como Client Components.
- Datos y layout principal renderizados desde Server Components.
- Modelo de dominio independiente del proveedor e-commerce.
- Carrito local persistente; posteriormente puede sincronizarse con checkout remoto.
- Las imágenes reales de Ari Shopping viven en `public/images`.
+14 -3
View File
@@ -1,10 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-ari-cyan: #22d3ee; --color-ari-cyan: #06b6d4;
--color-ari-magenta: #ec4899; --color-ari-magenta: #ec4899;
--color-ari-yellow: #fde047; --color-ari-yellow: #fde047;
--color-ari-blue: #0e7490; --color-ari-blue: #0f172a;
--animate-accordion-down: accordion-down .2s ease-out; --animate-accordion-down: accordion-down .2s ease-out;
--animate-accordion-up: accordion-up .2s ease-out; --animate-accordion-up: accordion-up .2s ease-out;
} }
@@ -15,6 +15,17 @@
:root { color-scheme: light; } :root { color-scheme: light; }
* { border-color: rgb(226 232 240); } * { border-color: rgb(226 232 240); }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { margin: 0; background: #fff; color: #0f172a; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } body { margin: 0; background: #fff; color: #0f172a; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-rendering: optimizeLegibility; }
button, a { -webkit-tap-highlight-color: transparent; } button, a { -webkit-tap-highlight-color: transparent; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgb(167 139 250 / .55); outline-offset: 3px; }
::selection { background: #fde047; color: #0f172a; } ::selection { background: #fde047; color: #0f172a; }
.content-auto {
content-visibility: auto;
contain-intrinsic-size: 1px 900px;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+3 -2
View File
@@ -3,6 +3,7 @@ import "./globals.css";
import { CartDrawer } from "@/components/cart/cart-drawer"; import { CartDrawer } from "@/components/cart/cart-drawer";
import { Footer } from "@/components/layout/footer"; import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer";
import { getProducts } from "@/lib/headless/client"; import { getProducts } from "@/lib/headless/client";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -12,9 +13,9 @@ export const metadata: Metadata = {
openGraph: { title: "ARI Shopping", description: "Tesoros para cada aventura.", type: "website", locale: "es_CO" }, openGraph: { title: "ARI Shopping", description: "Tesoros para cada aventura.", type: "website", locale: "es_CO" },
}; };
export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#22d3ee" }; export const viewport: Viewport = { width: "device-width", initialScale: 1, themeColor: "#0f172a" };
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const products = await getProducts(); const products = await getProducts();
return <html lang="es"><body><Navbar products={products} /><main>{children}</main><Footer /><CartDrawer /></body></html>; return <html lang="es"><body><Navbar products={products} /><main>{children}</main><Footer /><CartDrawer /><WishlistDrawer /></body></html>;
} }
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="mx-auto max-w-7xl animate-pulse space-y-8 px-4 py-10" aria-label="Cargando tienda">
<div className="h-[420px] rounded-[2rem] bg-slate-100" />
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => <div key={index} className="h-96 rounded-3xl bg-slate-100" />)}
</div>
</div>
);
}
+2 -2
View File
@@ -9,6 +9,6 @@ import { getProducts } from "@/lib/headless/client";
export default async function HomePage() { export default async function HomePage() {
const products = await getProducts(); const products = await getProducts();
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 10); const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
return <><HeroBento /><TrustBar /><CategoryTiles /><section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>; return <><HeroBento /><TrustBar /><div className="content-auto"><CategoryTiles /></div><section className="content-auto mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-5"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Novedades</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Tesoros recién llegados</h2><p className="mt-3 text-slate-500">Desliza y encuentra nuevos favoritos.</p></div><Button asChild variant="outline" className="hidden sm:inline-flex"><Link href="/catalog">Ver catálogo <ArrowRight className="size-4" /></Link></Button></div><ProductCarousel products={treasures} /></section><section className="content-auto mx-auto max-w-7xl px-4 pb-20"><div className="grid overflow-hidden rounded-[2rem] bg-gradient-to-r from-cyan-500 via-blue-600 to-violet-600 text-white shadow-2xl lg:grid-cols-[1fr_.7fr]"><div className="p-8 sm:p-12"><span className="text-xs font-black uppercase tracking-[.18em] text-yellow-300">Club ARI</span><h2 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">Promociones, lanzamientos y mucha diversión.</h2><p className="mt-5 max-w-xl text-white/80">Únete a nuestra comunidad y recibe primero las novedades del catálogo.</p><Button variant="yellow" size="lg" className="mt-7">Quiero unirme</Button></div><div className="min-h-64 bg-[url('/images/promo/promo-backpacks.webp')] bg-cover bg-center" /></div></section></>;
} }
@@ -39,7 +39,7 @@ export function CartDrawer() {
</div> </div>
) : items.map((item) => ( ) : 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"> <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> <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> <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> </article>
@@ -24,7 +24,7 @@ export function CatalogClient({ products, initialCategory = "Todos", initialColl
const filtered = useMemo(() => products.filter((product) => { const filtered = useMemo(() => products.filter((product) => {
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase(); 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); 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"); }; const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); };
@@ -3,21 +3,38 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react"; import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
import { motion } from "motion/react"; import { motion, useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
export function HeroBento() { export function HeroBento() {
const reduceMotion = useReducedMotion();
const initial = reduceMotion ? false : { opacity: 0, y: 20 };
return ( return (
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10"> <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]"> <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"> <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="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 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="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> <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>
<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" /> <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> </motion.article>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1"> <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={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">
<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 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>
</div> </div>
</section> </section>
@@ -3,7 +3,7 @@ import Link from "next/link";
import { Facebook, Instagram, MessageCircle } from "lucide-react"; import { Facebook, Instagram, MessageCircle } from "lucide-react";
export function Footer() { 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[] }) { function FooterColumn({ title, links }: { title: string; links: string[] }) {
@@ -2,47 +2,113 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; 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 { PredictiveSearch } from "@/components/search/predictive-search";
import { Button } from "@/components/ui/button"; 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 type { Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store";
const categories = [ const categories = [
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras" }, { label: "Juguetes para bebés", href: "/catalog?category=Figuras" },
{ label: "Educativos", 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: "Acción y aventura", href: "/catalog?collection=Marvel" },
{ label: "Mochilas", href: "/catalog?category=Mochilas" }, { label: "Mochilas", href: "/catalog?category=Mochilas" },
{ label: "Variedades", href: "/catalog" }, { label: "Variedades", href: "/catalog" },
]; ];
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"];
export function Navbar({ products }: { products: Product[] }) { export function Navbar({ products }: { products: Product[] }) {
const [menuOpen, setMenuOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const items = useCartStore((state) => state.items); const items = useCartStore((state) => state.items);
const cartHydrated = useCartStore((state) => state.hasHydrated);
const openCart = useCartStore((state) => state.openCart); 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 ( return (
<header className="sticky top-0 z-40 shadow-md"> <header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
<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-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">
<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%)]"> Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-2 underline-offset-2">Compra segura con ARI</span>
<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"> </div>
<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>
<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} /> <PredictiveSearch products={products} />
<div className="hidden items-center gap-2 sm:flex"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" aria-label="Mi cuenta" className="bg-white/15 text-white hover:bg-white/25"><UserRound /></Button> <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="ghost" size="icon" aria-label="Favoritos" className="bg-white/15 text-white hover:bg-white/25"><Heart /></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}>
<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> <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> </div>
<Button variant="magenta" size="icon" className="sm:hidden" onClick={openCart}><ShoppingCart /></Button>
</div> </div>
</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]"> <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> <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 hover:bg-white/10">{category.label}</Link>)} {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> </div>
</nav> </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> </header>
); );
} }
@@ -3,28 +3,33 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Heart, ShoppingBag, Sparkles } from "lucide-react"; import { Heart, ShoppingBag, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
import { formatMoney } from "@/lib/utils"; import { formatMoney } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
import { useWishlistStore } from "@/store/wishlist-store";
export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) { export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) {
const addItem = useCartStore((state) => state.addItem); 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 ( 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"> <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-gradient-to-br from-cyan-50 via-white to-pink-50"> <div className="relative aspect-square overflow-hidden bg-slate-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> <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> <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>
<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> <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> <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> </div>
</motion.article> </article>
); );
} }
@@ -1,9 +1,48 @@
"use client"; "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 { ProductCard } from "@/components/product/product-card";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
export function ProductCarousel({ products }: { products: Product[] }) { 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"; "use client";
import Image from "next/image"; import Image from "next/image";
import { ZoomIn } from "lucide-react"; import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
import { useState } from "react"; import { PointerEvent, WheelEvent, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 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 }) { export function ProductGallery({ images, name }: { images: string[]; name: string }) {
const [selected, setSelected] = useState(images[0] ?? ""); 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 Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useMemo, useState } from "react"; import { FormEvent, useMemo, useState } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
export function PredictiveSearch({ products }: { products: Product[] }) { export function PredictiveSearch({ products }: { products: Product[] }) {
const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const normalized = query.trim().toLocaleLowerCase("es"); 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 ( return (
<div className="relative w-full"> <form onSubmit={submit} 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" /> <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">
<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" /> <div className="relative rounded-full bg-white">
{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>} <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 && ( {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"> <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) => ( {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("")}> <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.image} alt="" fill sizes="54px" className="object-cover" /></div> <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> <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> </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>
)} )}
</div> </form>
); );
} }
+31 -7
View File
@@ -9,13 +9,31 @@ export const Sheet = DialogPrimitive.Root;
export const SheetTrigger = DialogPrimitive.Trigger; export const SheetTrigger = DialogPrimitive.Trigger;
export const SheetClose = DialogPrimitive.Close; 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 ( return (
<DialogPrimitive.Portal> <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.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} {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" /> <X className="size-5" />
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -23,16 +41,22 @@ export function SheetContent({ className, children, ...props }: React.ComponentP
); );
} }
export function SheetHeader(props: React.HTMLAttributes<HTMLDivElement>) { export function SheetHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("border-b border-slate-100 p-6", props.className)} {...props} />; 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} /> <DialogPrimitive.Title ref={ref} className={cn("text-2xl font-black text-slate-950", className)} {...props} />
)); ));
SheetTitle.displayName = "SheetTitle"; 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} /> <DialogPrimitive.Description ref={ref} className={cn("mt-1 text-sm text-slate-500", className)} {...props} />
)); ));
SheetDescription.displayName = "SheetDescription"; 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>
);
}
+34 -17
View File
@@ -32,7 +32,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
}, },
{ {
"id": "ari-002-toy-story-5-buzz-lightyear", "id": "ari-002-toy-story-5-buzz-lightyear",
@@ -67,7 +68,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
}, },
{ {
"id": "ari-003-toy-story-5-jessie", "id": "ari-003-toy-story-5-jessie",
@@ -102,7 +104,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
}, },
{ {
"id": "ari-004-toy-story-5-woody", "id": "ari-004-toy-story-5-woody",
@@ -137,7 +140,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
}, },
{ {
"id": "ari-005-intensamente-furia", "id": "ari-005-intensamente-furia",
@@ -172,7 +176,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
}, },
{ {
"id": "ari-006-intensamente-tristeza", "id": "ari-006-intensamente-tristeza",
@@ -207,7 +212,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
}, },
{ {
"id": "ari-007-minnie-y-mickey-mouse", "id": "ari-007-minnie-y-mickey-mouse",
@@ -242,7 +248,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
}, },
{ {
"id": "ari-008-angel-lilo-y-stitch", "id": "ari-008-angel-lilo-y-stitch",
@@ -277,7 +284,8 @@
"value": "Edición original", "value": "Edición original",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
}, },
{ {
"id": "ari-009-mochilas-disney-coleccion-marvel", "id": "ari-009-mochilas-disney-coleccion-marvel",
@@ -312,7 +320,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
}, },
{ {
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d", "id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d",
@@ -347,7 +356,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
}, },
{ {
"id": "ari-011-mochila-escolar-de-spider-man-de-disney-", "id": "ari-011-mochila-escolar-de-spider-man-de-disney-",
@@ -382,7 +392,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
}, },
{ {
"id": "ari-012-disney-mochila-de-poliester-ultraligera-", "id": "ari-012-disney-mochila-de-poliester-ultraligera-",
@@ -417,7 +428,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
}, },
{ {
"id": "ari-013-mochila-pequena-bob-esponja", "id": "ari-013-mochila-pequena-bob-esponja",
@@ -452,7 +464,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
}, },
{ {
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est", "id": "ari-014-mochila-casual-de-gran-capacidad-con-est",
@@ -487,7 +500,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
}, },
{ {
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn", "id": "ari-015-mochila-escolar-estilo-britanico-de-disn",
@@ -522,7 +536,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
}, },
{ {
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult", "id": "ari-016-disney-mochila-iron-man-de-poliester-ult",
@@ -557,7 +572,8 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
}, },
{ {
"id": "ari-017-mochila-de-disney-marvel-capitan-america", "id": "ari-017-mochila-de-disney-marvel-capitan-america",
@@ -592,6 +608,7 @@
"value": "Rosa", "value": "Rosa",
"available": true "available": true
} }
] ],
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
} }
] ]
@@ -23,6 +23,7 @@ export interface Product {
featured: boolean; featured: boolean;
isNew: boolean; isNew: boolean;
image: string; image: string;
thumbnail: string;
images: string[]; images: string[];
tags: string[]; tags: string[];
variants: ProductVariant[]; variants: ProductVariant[];
+4
View File
@@ -1,2 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+17
View File
@@ -4,13 +4,30 @@ const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
poweredByHeader: false, poweredByHeader: false,
compress: true, compress: true,
reactStrictMode: true,
images: { images: {
// Los recursos del catálogo ya vienen preoptimizados en WebP.
// Evita el costo de transformar imágenes durante la primera visita.
unoptimized: true,
formats: ["image/avif", "image/webp"], formats: ["image/avif", "image/webp"],
deviceSizes: [360, 640, 768, 1024, 1280, 1536], deviceSizes: [360, 640, 768, 1024, 1280, 1536],
}, },
experimental: { experimental: {
optimizePackageImports: ["lucide-react", "motion"], optimizePackageImports: ["lucide-react", "motion"],
}, },
async headers() {
return [
{
source: "/images/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=604800, stale-while-revalidate=2592000",
},
],
},
];
},
}; };
export default nextConfig; export default nextConfig;
File diff suppressed because it is too large Load Diff
+26 -25
View File
@@ -1,38 +1,39 @@
{ {
"name": "ari-shopping-headless-storefront", "name": "ari-shopping-headless-storefront",
"version": "4.0.0", "version": "4.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run lint && npm run build"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "0.7.1",
"clsx": "^2.1.1", "clsx": "2.1.1",
"lucide-react": "^0.544.0", "lucide-react": "0.544.0",
"motion": "^12.23.24", "motion": "12.23.24",
"next": "^15.5.0", "next": "15.5.10",
"react": "^19.1.0", "react": "19.1.5",
"react-dom": "^19.1.0", "react-dom": "19.1.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "3.3.1",
"zustand": "^5.0.8" "zustand": "5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.14", "@tailwindcss/postcss": "4.1.14",
"@types/node": "^22.18.0", "@types/node": "22.18.0",
"@types/react": "^19.1.0", "@types/react": "19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "19.1.0",
"eslint": "^9.35.0", "eslint": "8.57.1",
"eslint-config-next": "^15.5.0", "eslint-config-next": "15.5.10",
"postcss": "^8.5.6", "postcss": "8.5.6",
"tailwindcss": "^4.1.14", "tailwindcss": "4.1.14",
"typescript": "^5.9.0" "typescript": "5.9.3"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+11 -2
View File
@@ -13,8 +13,10 @@ export interface CartItem {
interface CartState { interface CartState {
items: CartItem[]; items: CartItem[];
isOpen: boolean; isOpen: boolean;
hasHydrated: boolean;
openCart: () => void; openCart: () => void;
closeCart: () => void; closeCart: () => void;
setHasHydrated: (value: boolean) => void;
addItem: (product: Product, variantId?: string) => void; addItem: (product: Product, variantId?: string) => void;
removeItem: (productId: string) => void; removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void; updateQuantity: (productId: string, quantity: number) => void;
@@ -26,16 +28,22 @@ export const useCartStore = create<CartState>()(
(set) => ({ (set) => ({
items: [], items: [],
isOpen: false, isOpen: false,
hasHydrated: false,
openCart: () => set({ isOpen: true }), openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }), closeCart: () => set({ isOpen: false }),
setHasHydrated: (value) => set({ hasHydrated: value }),
addItem: (product, variantId) => set((state) => { addItem: (product, variantId) => set((state) => {
const existing = state.items.find((item) => item.product.id === product.id && item.variantId === variantId); const existing = state.items.find(
(item) => item.product.id === product.id && item.variantId === variantId,
);
const items = existing const items = existing
? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item) ? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item)
: [...state.items, { product, quantity: 1, variantId }]; : [...state.items, { product, quantity: 1, variantId }];
return { items, isOpen: true }; return { items, isOpen: true };
}), }),
removeItem: (productId) => set((state) => ({ items: state.items.filter((item) => item.product.id !== productId) })), removeItem: (productId) => set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
})),
updateQuantity: (productId, quantity) => set((state) => ({ updateQuantity: (productId, quantity) => set((state) => ({
items: quantity <= 0 items: quantity <= 0
? state.items.filter((item) => item.product.id !== productId) ? state.items.filter((item) => item.product.id !== productId)
@@ -47,6 +55,7 @@ export const useCartStore = create<CartState>()(
name: "ari-shopping-cart-v4", name: "ari-shopping-cart-v4",
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items }), partialize: (state) => ({ items: state.items }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
}, },
), ),
); );
@@ -0,0 +1,45 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { Product } from "@/lib/headless/types";
interface WishlistState {
products: Product[];
isOpen: boolean;
hasHydrated: boolean;
openWishlist: () => void;
closeWishlist: () => void;
setHasHydrated: (value: boolean) => void;
toggleProduct: (product: Product) => void;
removeProduct: (productId: string) => void;
contains: (productId: string) => boolean;
}
export const useWishlistStore = create<WishlistState>()(
persist(
(set, get) => ({
products: [],
isOpen: false,
hasHydrated: false,
openWishlist: () => set({ isOpen: true }),
closeWishlist: () => set({ isOpen: false }),
setHasHydrated: (value) => set({ hasHydrated: value }),
toggleProduct: (product) => set((state) => ({
products: state.products.some((item) => item.id === product.id)
? state.products.filter((item) => item.id !== product.id)
: [...state.products, product],
})),
removeProduct: (productId) => set((state) => ({
products: state.products.filter((item) => item.id !== productId),
})),
contains: (productId) => get().products.some((item) => item.id === productId),
}),
{
name: "ari-shopping-wishlist-v4-1",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ products: state.products }),
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
},
),
);
File diff suppressed because one or more lines are too long