feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 9m42s
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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.
|
||||
- React 19 y TypeScript estricto.
|
||||
- Tailwind CSS 4.
|
||||
- Componentes accesibles estilo shadcn/ui sobre Radix UI.
|
||||
- Motion for React para microinteracciones.
|
||||
- Zustand con persistencia local para el carrito.
|
||||
- Adaptadores headless para catálogo local, Shopify Storefront API o Medusa.
|
||||
- `next/image` para optimización de imágenes.
|
||||
- Docker multi-stage con salida standalone.
|
||||
- Encabezado blanco premium con acentos cyan, violeta, fucsia y navegación negra.
|
||||
- Imágenes promocionales sin `opacity`, `mix-blend` ni filtros de color.
|
||||
- Menú principal funcional mediante Drawer accesible de Radix UI.
|
||||
- Cuenta y favoritos funcionales.
|
||||
- Carrusel con flechas, teclado, scroll suave y `scroll-snap`.
|
||||
- Zoom real de producto entre 100% y 300%, rueda, botones, doble clic y arrastre.
|
||||
- Thumbnails WebP dedicados para tarjetas, búsqueda, carrito y favoritos.
|
||||
- Recursos originales recomprimidos y caché HTTP para `/images`.
|
||||
- `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
|
||||
|
||||
- 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
|
||||
## Validación
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm run dev
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Variables headless
|
||||
## Flujo
|
||||
|
||||
El valor predeterminado es:
|
||||
|
||||
```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.
|
||||
Gitea → Gitea Actions → Registry → actualización de `frontend.yaml` → Argo CD → Kubernetes.
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
# Arquitectura tecnológica
|
||||
# Tecnología — ARI Shopping v4.1
|
||||
|
||||
```text
|
||||
Browser
|
||||
↓
|
||||
Next.js 15 App Router
|
||||
├─ Server Components / SSR / metadata SEO
|
||||
├─ React Client Components
|
||||
│ ├─ Motion microinteractions
|
||||
│ ├─ Radix/shadcn UI
|
||||
│ └─ Zustand persisted cart
|
||||
├─ 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`.
|
||||
- Next.js 15.5.10.5.10 App Router y React 19.
|
||||
- TypeScript estricto.
|
||||
- Tailwind CSS 4.
|
||||
- Radix UI para Drawer y Dialog.
|
||||
- Motion usado selectivamente en el hero; las tarjetas usan CSS para reducir JavaScript.
|
||||
- Zustand persistente para carrito y favoritos.
|
||||
- Catálogo headless intercambiable: mock, Shopify o Medusa.
|
||||
- Imágenes WebP preoptimizadas con thumbnails de 560 px y fuentes PDP de hasta 1200 px.
|
||||
- Contenedor Next.js standalone, no-root, puerto 80 y `/api/health`.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ari-cyan: #22d3ee;
|
||||
--color-ari-cyan: #06b6d4;
|
||||
--color-ari-magenta: #ec4899;
|
||||
--color-ari-yellow: #fde047;
|
||||
--color-ari-blue: #0e7490;
|
||||
--color-ari-blue: #0f172a;
|
||||
--animate-accordion-down: accordion-down .2s ease-out;
|
||||
--animate-accordion-up: accordion-up .2s ease-out;
|
||||
}
|
||||
@@ -15,6 +15,17 @@
|
||||
:root { color-scheme: light; }
|
||||
* { border-color: rgb(226 232 240); }
|
||||
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: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; }
|
||||
|
||||
.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,6 +3,7 @@ import "./globals.css";
|
||||
import { CartDrawer } from "@/components/cart/cart-drawer";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { WishlistDrawer } from "@/components/wishlist/wishlist-drawer";
|
||||
import { getProducts } from "@/lib/headless/client";
|
||||
|
||||
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" },
|
||||
};
|
||||
|
||||
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 }>) {
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,6 @@ import { getProducts } from "@/lib/headless/client";
|
||||
|
||||
export default async function HomePage() {
|
||||
const products = await getProducts();
|
||||
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 10);
|
||||
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></>;
|
||||
const treasures = products.filter((product) => product.isNew || product.featured).slice(0, 8);
|
||||
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>
|
||||
) : items.map((item) => (
|
||||
<article key={`${item.product.id}-${item.variantId ?? "default"}`} className="grid grid-cols-[82px_1fr_auto] gap-4 rounded-2xl border border-slate-100 p-3 shadow-sm">
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.image} alt={item.product.name} fill sizes="82px" className="object-cover" /></div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.thumbnail} alt={item.product.name} fill sizes="82px" className="object-cover" /></div>
|
||||
<div><h3 className="text-sm font-black leading-5">{item.product.name}</h3><p className="mt-1 text-xs text-slate-500">{formatMoney(item.product.price, item.product.currency)}</p><div className="mt-3 inline-flex items-center rounded-full border border-slate-200"><button aria-label="Restar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity - 1)}><Minus className="size-3" /></button><span className="min-w-7 text-center text-xs font-black">{item.quantity}</span><button aria-label="Sumar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity + 1)}><Plus className="size-3" /></button></div></div>
|
||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeItem(item.product.id)} aria-label="Eliminar"><Trash2 className="size-4" /></button>
|
||||
</article>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function CatalogClient({ products, initialCategory = "Todos", initialColl
|
||||
const filtered = useMemo(() => products.filter((product) => {
|
||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
||||
return (!query || search.includes(query.toLowerCase())) && (category === "Todos" || product.category === category) && (!collection || product.collection === collection) && (brand === "Todas" || product.brand === brand) && (age === "Todas" || product.ageRange === age) && (product.price === null || maxPrice === 0 || product.price <= maxPrice);
|
||||
}).sort((a, b) => sort === "name" ? a.name.localeCompare(b.name, "es") : sort === "new" ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured)), [age, brand, category, products, query, sort]);
|
||||
}).sort((a, b) => sort === "name" ? a.name.localeCompare(b.name, "es") : sort === "new" ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured)), [age, brand, category, collection, maxPrice, products, query, sort]);
|
||||
|
||||
const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); };
|
||||
|
||||
|
||||
@@ -3,21 +3,38 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function HeroBento() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const initial = reduceMotion ? false : { opacity: 0, y: 20 };
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.55fr_.8fr]">
|
||||
<motion.article initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} className="relative min-h-[430px] overflow-hidden rounded-[2rem] bg-gradient-to-br from-fuchsia-500 via-pink-500 to-rose-500 p-7 text-white shadow-2xl lg:min-h-[540px] lg:p-12">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_15%_20%,rgba(255,255,255,.32),transparent_22%),radial-gradient(circle_at_80%_15%,rgba(255,231,0,.35),transparent_18%)]" />
|
||||
<div className="relative z-10 max-w-lg"><div className="mb-5 inline-flex items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div><h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-8xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1><p className="mt-6 max-w-md text-base font-semibold text-white/90 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p><Button asChild variant="yellow" size="lg" className="mt-8"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button></div>
|
||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 65vw" className="object-cover object-right opacity-55 mix-blend-screen lg:opacity-70" />
|
||||
<motion.article initial={initial} animate={{ opacity: 1, y: 0 }} className="grid min-h-[470px] overflow-hidden rounded-[2rem] bg-fuchsia-500 text-white shadow-2xl lg:min-h-[560px] lg:grid-cols-[.82fr_1.18fr]">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-fuchsia-600 via-pink-500 to-rose-500 p-7 lg:p-11">
|
||||
<div className="mb-5 inline-flex w-fit items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div>
|
||||
<h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-7xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1>
|
||||
<p className="mt-6 max-w-md text-base font-semibold text-white/95 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p>
|
||||
<Button asChild variant="yellow" size="lg" className="mt-8 w-fit"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button>
|
||||
</div>
|
||||
<div className="relative min-h-[360px] bg-white lg:min-h-full">
|
||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 42vw" className="object-cover object-center" />
|
||||
</div>
|
||||
</motion.article>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .1 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-red-500 to-rose-700 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[55%]"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="35vw" className="object-cover opacity-65 mix-blend-screen" /></motion.article>
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .18 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-violet-500 to-cyan-500 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[58%]"><div className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="35vw" className="object-cover opacity-55 mix-blend-screen" /></motion.article>
|
||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .08 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-red-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-red-600 to-rose-700 p-6"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5 w-fit"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div>
|
||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
||||
</motion.article>
|
||||
|
||||
<motion.article initial={reduceMotion ? false : { opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .14 }} className="grid min-h-60 overflow-hidden rounded-[2rem] bg-violet-600 text-white shadow-xl sm:grid-cols-[.9fr_1.1fr] lg:min-h-0">
|
||||
<div className="relative z-10 flex flex-col justify-center bg-gradient-to-br from-violet-600 to-cyan-600 p-6"><div className="inline-flex w-fit items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 w-fit border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div>
|
||||
<div className="relative min-h-56 bg-white"><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="(max-width: 1024px) 50vw, 18vw" className="object-cover object-center" /></div>
|
||||
</motion.article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { Facebook, Instagram, MessageCircle } from "lucide-react";
|
||||
|
||||
export function Footer() {
|
||||
return <footer className="bg-slate-950 text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-yellow-300 px-4 text-sm font-black text-slate-950">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.0 · GitOps</div></footer>;
|
||||
return <footer className="bg-slate-950 text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-yellow-300 px-4 text-sm font-black text-slate-950">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.1 · GitOps</div></footer>;
|
||||
}
|
||||
|
||||
function FooterColumn({ title, links }: { title: string; links: string[] }) {
|
||||
|
||||
@@ -2,47 +2,113 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, Menu, ShoppingCart, UserRound } from "lucide-react";
|
||||
import { Heart, Menu, MessageCircle, PackageCheck, ShoppingCart, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
const categories = [
|
||||
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras" },
|
||||
{ label: "Educativos", href: "/catalog?category=Figuras" },
|
||||
{ label: "Muñecas", href: "/catalog?category=Peluches" },
|
||||
{ label: "Muñecas y peluches", href: "/catalog?category=Peluches" },
|
||||
{ label: "Acción y aventura", href: "/catalog?collection=Marvel" },
|
||||
{ label: "Mochilas", href: "/catalog?category=Mochilas" },
|
||||
{ label: "Variedades", href: "/catalog" },
|
||||
];
|
||||
|
||||
const collections = ["Toy Story", "Marvel", "Intensamente", "Lilo & Stitch", "Frozen"];
|
||||
|
||||
export function Navbar({ products }: { products: Product[] }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const items = useCartStore((state) => state.items);
|
||||
const cartHydrated = useCartStore((state) => state.hasHydrated);
|
||||
const openCart = useCartStore((state) => state.openCart);
|
||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const wishlistProducts = useWishlistStore((state) => state.products);
|
||||
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
|
||||
const openWishlist = useWishlistStore((state) => state.openWishlist);
|
||||
const count = cartHydrated ? items.reduce((sum, item) => sum + item.quantity, 0) : 0;
|
||||
const wishlistCount = wishlistHydrated ? wishlistProducts.length : 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 shadow-md">
|
||||
<div className="bg-yellow-300 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">Paga tus compras a cuotas · Envíos nacionales · <span className="underline">Compra segura con ARI</span></div>
|
||||
<div className="bg-cyan-400 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,.18)_0_12%,transparent_13%),radial-gradient(circle_at_80%_70%,rgba(255,255,255,.12)_0_10%,transparent_11%)]">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[100px_minmax(420px,1fr)_auto] lg:gap-8">
|
||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-4 border-white bg-black shadow-xl lg:size-20"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" /></Link>
|
||||
<header className="sticky top-0 z-40 border-b border-slate-200/80 bg-white/95 shadow-sm backdrop-blur-xl">
|
||||
<div className="bg-gradient-to-r from-amber-300 via-yellow-200 to-lime-200 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">
|
||||
Paga tus compras a cuotas · Envíos nacionales · <span className="underline decoration-2 underline-offset-2">Compra segura con ARI</span>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-visible bg-white">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500" />
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[92px_minmax(420px,1fr)_auto] lg:gap-8">
|
||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-2 border-slate-900 bg-black shadow-lg lg:size-20">
|
||||
<Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" />
|
||||
</Link>
|
||||
<PredictiveSearch products={products} />
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Button variant="ghost" size="icon" aria-label="Mi cuenta" className="bg-white/15 text-white hover:bg-white/25"><UserRound /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Favoritos" className="bg-white/15 text-white hover:bg-white/25"><Heart /></Button>
|
||||
<Button variant="magenta" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative"><ShoppingCart /><span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-yellow-300 text-[11px] font-black text-slate-950">{count}</span></Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" aria-label="Mi cuenta" className="hidden border-slate-300 bg-white text-slate-900 hover:border-violet-400 hover:bg-violet-50 sm:inline-flex" onClick={() => setAccountOpen(true)}><UserRound /></Button>
|
||||
<Button variant="outline" size="icon" aria-label={`Favoritos: ${wishlistCount}`} className="relative hidden border-slate-300 bg-white text-slate-900 hover:border-fuchsia-400 hover:bg-fuchsia-50 sm:inline-flex" onClick={openWishlist}>
|
||||
<Heart />
|
||||
{wishlistCount > 0 && <span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-violet-600 text-[10px] font-black text-white">{wishlistCount}</span>}
|
||||
</Button>
|
||||
<Button variant="magenta" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative shadow-lg shadow-fuchsia-500/20">
|
||||
<ShoppingCart />
|
||||
<span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-yellow-300 text-[11px] font-black text-slate-950">{count}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="magenta" size="icon" className="sm:hidden" onClick={openCart}><ShoppingCart /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="bg-cyan-700 text-white">
|
||||
|
||||
<nav className="border-b-4 border-fuchsia-500 bg-slate-950 text-white">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
|
||||
<Button variant="ghost" size="icon" className="shrink-0 text-white hover:bg-white/10 lg:mr-5"><Menu /></Button>
|
||||
{categories.map((category) => <Link key={category.label} href={category.href} className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold hover:bg-white/10">{category.label}</Link>)}
|
||||
<Button variant="ghost" size="icon" onClick={() => setMenuOpen(true)} aria-label="Abrir menú principal" className="mr-3 shrink-0 text-white hover:bg-white/10"><Menu /></Button>
|
||||
{categories.map((category) => (
|
||||
<Link key={category.label} href={category.href} className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold text-white/90 transition hover:bg-gradient-to-r hover:from-cyan-500 hover:to-violet-500 hover:text-white">
|
||||
{category.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<SheetContent side="left" className="max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Explora ARI</SheetTitle>
|
||||
<SheetDescription>Categorías y colecciones del catálogo.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<p className="mb-3 text-xs font-black uppercase tracking-[.16em] text-fuchsia-600">Categorías</p>
|
||||
<div className="grid gap-2">
|
||||
{categories.map((category) => <Link key={category.label} href={category.href} onClick={() => setMenuOpen(false)} className="rounded-2xl border border-slate-200 px-4 py-3 font-extrabold hover:border-cyan-400 hover:bg-cyan-50">{category.label}</Link>)}
|
||||
</div>
|
||||
<p className="mb-3 mt-8 text-xs font-black uppercase tracking-[.16em] text-violet-600">Colecciones</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{collections.map((collection) => <Link key={collection} href={`/catalog?collection=${encodeURIComponent(collection)}`} onClick={() => setMenuOpen(false)} className="rounded-full bg-slate-100 px-4 py-2 text-sm font-bold hover:bg-violet-100 hover:text-violet-700">{collection}</Link>)}
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-2 gap-3 sm:hidden">
|
||||
<Button variant="outline" onClick={() => { setMenuOpen(false); setAccountOpen(true); }}><UserRound className="size-4" />Cuenta</Button>
|
||||
<Button variant="outline" onClick={() => { setMenuOpen(false); openWishlist(); }}><Heart className="size-4" />Favoritos</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
|
||||
<DialogContent className="max-w-lg p-7">
|
||||
<div className="pr-10">
|
||||
<span className="text-xs font-black uppercase tracking-[.16em] text-violet-600">Atención personalizada</span>
|
||||
<h2 className="mt-3 text-3xl font-black">Tu cuenta ARI</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">El inicio de sesión quedará conectado al backend headless. Mientras tanto, puedes gestionar pedidos y disponibilidad directamente por WhatsApp.</p>
|
||||
<div className="mt-6 grid gap-3">
|
||||
<Button asChild variant="magenta" size="lg"><a href={process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc"} target="_blank" rel="noopener noreferrer"><MessageCircle />Hablar con ARI</a></Button>
|
||||
<Button asChild variant="outline"><Link href="/catalog" onClick={() => setAccountOpen(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,28 +3,33 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
const wishlistProducts = useWishlistStore((state) => state.products);
|
||||
const wishlistHydrated = useWishlistStore((state) => state.hasHydrated);
|
||||
const toggleProduct = useWishlistStore((state) => state.toggleProduct);
|
||||
const isFavorite = wishlistHydrated && wishlistProducts.some((item) => item.id === product.id);
|
||||
|
||||
return (
|
||||
<motion.article whileHover={{ y: -8 }} transition={{ type: "spring", stiffness: 320, damping: 24 }} className="group overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5">
|
||||
<div className="relative aspect-square overflow-hidden bg-gradient-to-br from-cyan-50 via-white to-pink-50">
|
||||
<Link href={`/product/${product.slug}`}><Image src={product.image} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 80vw, (max-width: 1024px) 40vw, 24vw" className="object-cover transition duration-500 group-hover:scale-105" /></Link>
|
||||
<article className="group flex h-full flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5 transition duration-300 hover:-translate-y-1.5 hover:shadow-2xl hover:shadow-violet-500/10">
|
||||
<div className="relative aspect-square overflow-hidden bg-slate-50">
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><Image src={product.thumbnail} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px" className="object-cover transition duration-500 group-hover:scale-[1.035]" /></Link>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<button aria-label="Agregar a favoritos" className="absolute right-3 top-3 rounded-full bg-white/90 p-2.5 shadow-lg backdrop-blur hover:text-pink-500"><Heart className="size-4" /></button>
|
||||
<button type="button" aria-label={isFavorite ? "Quitar de favoritos" : "Agregar a favoritos"} aria-pressed={isFavorite} className={`absolute right-3 top-3 rounded-full p-2.5 shadow-lg backdrop-blur transition ${isFavorite ? "bg-fuchsia-500 text-white" : "bg-white/95 text-slate-900 hover:text-fuchsia-500"}`} onClick={() => toggleProduct(product)}><Heart className={`size-4 ${isFavorite ? "fill-current" : ""}`} /></button>
|
||||
</div>
|
||||
<div className="space-y-3 p-5">
|
||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-full bg-cyan-50 px-2 py-1 text-cyan-700">{product.collection}</span></div>
|
||||
<Link href={`/product/${product.slug}`}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||
<div className="flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
</div>
|
||||
</motion.article>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ProductCard } from "@/components/product/product-card";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function ProductCarousel({ products }: { products: Product[] }) {
|
||||
return <motion.div initial="hidden" whileInView="visible" viewport={{ once: true, margin: "-80px" }} variants={{ hidden: {}, visible: { transition: { staggerChildren: 0.07 } } }} className="grid auto-cols-[82%] grid-flow-col gap-5 overflow-x-auto pb-5 pr-5 [scrollbar-width:none] sm:auto-cols-[44%] lg:auto-cols-[28%] xl:auto-cols-[23%]">{products.map((product, index) => <motion.div key={product.id} variants={{ hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0 } }}><ProductCard product={product} priority={index < 2} /></motion.div>)}</motion.div>;
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [canPrevious, setCanPrevious] = useState(false);
|
||||
const [canNext, setCanNext] = useState(products.length > 1);
|
||||
|
||||
const updateControls = useCallback(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
setCanPrevious(node.scrollLeft > 8);
|
||||
setCanNext(node.scrollLeft + node.clientWidth < node.scrollWidth - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
updateControls();
|
||||
node.addEventListener("scroll", updateControls, { passive: true });
|
||||
const observer = new ResizeObserver(updateControls);
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
node.removeEventListener("scroll", updateControls);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [updateControls]);
|
||||
|
||||
const move = (direction: -1 | 1) => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
node.scrollBy({ left: direction * Math.max(280, node.clientWidth * 0.82), behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button type="button" aria-label="Productos anteriores" disabled={!canPrevious} onClick={() => move(-1)} className="absolute -left-3 top-[38%] z-10 grid size-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-950 shadow-xl transition hover:bg-violet-50 disabled:pointer-events-none disabled:opacity-0 sm:-left-6"><ChevronLeft className="size-6" /></button>
|
||||
<div ref={scroller} role="region" aria-label="Carrusel de productos" tabIndex={0} onKeyDown={(event) => { if (event.key === "ArrowLeft") move(-1); if (event.key === "ArrowRight") move(1); }} className="flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-smooth pb-5 pr-5 outline-none [overscroll-behavior-inline:contain] [scrollbar-width:none]">
|
||||
{products.map((product, index) => <div key={product.id} className="w-[82%] shrink-0 snap-start sm:w-[44%] lg:w-[28%] xl:w-[23%]"><ProductCard product={product} priority={index < 2} /></div>)}
|
||||
</div>
|
||||
<button type="button" aria-label="Productos siguientes" disabled={!canNext} onClick={() => move(1)} className="absolute -right-3 top-[38%] z-10 grid size-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-950 shadow-xl transition hover:bg-violet-50 disabled:pointer-events-none disabled:opacity-0 sm:-right-6"><ChevronRight className="size-6" /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ZoomIn } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
|
||||
import { PointerEvent, WheelEvent, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Offset { x: number; y: number }
|
||||
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: number }
|
||||
|
||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||
const [selected, setSelected] = useState(images[0] ?? "");
|
||||
return <div className="grid gap-4 lg:grid-cols-[86px_1fr]"><div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">{images.map((image) => <button key={image} onClick={() => setSelected(image)} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}</div><Dialog><DialogTrigger asChild><button className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-gradient-to-br from-cyan-50 via-white to-pink-50 lg:order-2"><Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-110" /><span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Ampliar</span></button></DialogTrigger><DialogContent><div className="relative aspect-square"><Image src={selected} alt={name} fill sizes="90vw" className="object-contain" /></div></DialogContent></Dialog></div>;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
|
||||
const drag = useRef<DragState | null>(null);
|
||||
|
||||
const applyScale = (next: number) => {
|
||||
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
|
||||
setScale(value);
|
||||
if (value === 1) setOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
const changeOpen = (value: boolean) => { setOpen(value); if (!value) reset(); };
|
||||
const wheel = (event: WheelEvent<HTMLDivElement>) => { event.preventDefault(); applyScale(scale + (event.deltaY < 0 ? .2 : -.2)); };
|
||||
const pointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (scale <= 1) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offset.x, originY: offset.y };
|
||||
};
|
||||
const pointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!drag.current || drag.current.pointerId !== event.pointerId) return;
|
||||
setOffset({ x: drag.current.originX + event.clientX - drag.current.startX, y: drag.current.originY + event.clientY - drag.current.startY });
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (drag.current?.pointerId === event.pointerId) drag.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
|
||||
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
|
||||
{images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={changeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2">
|
||||
<Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Zoom interactivo</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0">
|
||||
<div className="grid min-h-[70vh] grid-rows-[1fr_auto]">
|
||||
<div className="relative overflow-hidden bg-slate-950" onWheel={wheel} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerEnd} onPointerCancel={pointerEnd} onDoubleClick={() => applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
|
||||
<div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
|
||||
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 bg-slate-900 p-4 text-white">
|
||||
<button type="button" aria-label="Alejar" onClick={() => applyScale(scale - .25)} disabled={scale <= 1} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Minus /></button>
|
||||
<input aria-label="Nivel de zoom" type="range" min="1" max="3" step="0.1" value={scale} onChange={(event) => applyScale(Number(event.target.value))} className="w-44 accent-fuchsia-500 sm:w-64" />
|
||||
<button type="button" aria-label="Acercar" onClick={() => applyScale(scale + .25)} disabled={scale >= 3} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Plus /></button>
|
||||
<button type="button" onClick={reset} className="inline-flex h-11 items-center gap-2 rounded-full bg-white/10 px-4 text-sm font-black hover:bg-white/20"><RotateCcw className="size-4" />Restablecer</button>
|
||||
<span className="inline-flex h-11 items-center gap-2 rounded-full bg-fuchsia-500 px-4 text-sm font-black"><Maximize2 className="size-4" />{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,31 +2,51 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const normalized = query.trim().toLocaleLowerCase("es");
|
||||
const matches = useMemo(() => normalized.length < 2 ? [] : products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6), [normalized, products]);
|
||||
const matches = useMemo(
|
||||
() => normalized.length < 2
|
||||
? []
|
||||
: products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6),
|
||||
[normalized, products],
|
||||
);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const value = query.trim();
|
||||
if (!value) return;
|
||||
router.push(`/catalog?q=${encodeURIComponent(value)}`);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-cyan-700" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-white/80 bg-white pl-[52px] pr-12 shadow-xl" />
|
||||
{query && <button aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||
<form onSubmit={submit} className="relative w-full">
|
||||
<div className="rounded-full bg-gradient-to-r from-cyan-400 via-violet-500 to-fuchsia-500 p-[2px] shadow-lg shadow-violet-500/10">
|
||||
<div className="relative rounded-full bg-white">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-violet-600" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-0 bg-transparent pl-[52px] pr-12 shadow-none focus-visible:ring-violet-300/30" />
|
||||
{query && <button type="button" aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||
</div>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
||||
{matches.map((product) => (
|
||||
<Link key={product.id} href={`/product/${product.slug}`} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-cyan-50" onClick={() => setQuery("")}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.image} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||
<Link key={product.id} href={`/product/${product.slug}`} prefetch={false} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-violet-50" onClick={() => setQuery("")}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.thumbnail} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
||||
</Link>
|
||||
))}
|
||||
<button type="submit" className="mt-1 w-full rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white hover:bg-violet-700">Buscar “{query.trim()}” en todo el catálogo</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,31 @@ export const Sheet = DialogPrimitive.Root;
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
export function SheetContent({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
side?: "left" | "right";
|
||||
};
|
||||
|
||||
export function SheetContent({ side = "right", className, children, ...props }: SheetContentProps) {
|
||||
const sideClasses = side === "left"
|
||||
? "inset-y-0 left-0 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left"
|
||||
: "inset-y-0 right-0 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right";
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/55 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out" />
|
||||
<DialogPrimitive.Content className={cn("fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl outline-none data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right", className)} {...props}>
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 flex h-full w-full max-w-md flex-col border-slate-200 bg-white shadow-2xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
sideClasses,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-5 top-5 rounded-full border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-100" aria-label="Cerrar">
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-5 top-5 rounded-full border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-100"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
@@ -23,16 +41,22 @@ export function SheetContent({ className, children, ...props }: React.ComponentP
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeader(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("border-b border-slate-100 p-6", props.className)} {...props} />;
|
||||
export function SheetHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("border-b border-slate-100 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export const SheetTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
|
||||
export const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-2xl font-black text-slate-950", className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = "SheetTitle";
|
||||
|
||||
export const SheetDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
|
||||
export const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("mt-1 text-sm text-slate-500", className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = "SheetDescription";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useWishlistStore } from "@/store/wishlist-store";
|
||||
|
||||
export function WishlistDrawer() {
|
||||
const products = useWishlistStore((state) => state.products);
|
||||
const isOpen = useWishlistStore((state) => state.isOpen);
|
||||
const closeWishlist = useWishlistStore((state) => state.closeWishlist);
|
||||
const removeProduct = useWishlistStore((state) => state.removeProduct);
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeWishlist(); }}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mis favoritos</SheetTitle>
|
||||
<SheetDescription>{products.length} producto{products.length === 1 ? "" : "s"} guardado{products.length === 1 ? "" : "s"}.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
{products.length === 0 ? (
|
||||
<div className="grid min-h-72 place-items-center rounded-3xl border border-dashed border-slate-300 bg-slate-50 text-center">
|
||||
<div><Heart className="mx-auto mb-3 size-10 text-fuchsia-500" /><p className="font-black">Aún no guardas favoritos</p><p className="mt-1 text-sm text-slate-500">Pulsa el corazón de cualquier producto.</p></div>
|
||||
</div>
|
||||
) : products.map((product) => (
|
||||
<article key={product.id} className="grid grid-cols-[84px_1fr_auto] gap-4 rounded-2xl border border-slate-100 p-3 shadow-sm">
|
||||
<Link href={`/product/${product.slug}`} onClick={closeWishlist} className="relative aspect-square overflow-hidden rounded-xl bg-slate-100" prefetch={false}>
|
||||
<Image src={product.thumbnail} alt={product.name} fill sizes="84px" className="object-cover" />
|
||||
</Link>
|
||||
<div>
|
||||
<Link href={`/product/${product.slug}`} onClick={closeWishlist} prefetch={false} className="text-sm font-black leading-5 hover:text-fuchsia-600">{product.name}</Link>
|
||||
<p className="mt-1 text-xs text-slate-500">{product.category} · {product.collection}</p>
|
||||
<Button size="sm" variant="magenta" className="mt-3" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-3.5" />Agregar</Button>
|
||||
</div>
|
||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeProduct(product.id)} aria-label="Quitar favorito"><Trash2 className="size-4" /></button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-001-toy-story-5-rex.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-002-toy-story-5-buzz-lightyear",
|
||||
@@ -67,7 +68,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-002-toy-story-5-buzz-lightyear.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-003-toy-story-5-jessie",
|
||||
@@ -102,7 +104,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-003-toy-story-5-jessie.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-004-toy-story-5-woody",
|
||||
@@ -137,7 +140,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-004-toy-story-5-woody.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-005-intensamente-furia",
|
||||
@@ -172,7 +176,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-005-intensamente-furia.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-006-intensamente-tristeza",
|
||||
@@ -207,7 +212,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-006-intensamente-tristeza.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-007-minnie-y-mickey-mouse",
|
||||
@@ -242,7 +248,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-007-minnie-y-mickey-mouse.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-008-angel-lilo-y-stitch",
|
||||
@@ -277,7 +284,8 @@
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-008-angel-lilo-y-stitch.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-009-mochilas-disney-coleccion-marvel",
|
||||
@@ -312,7 +320,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-009-mochilas-disney-coleccion-marvel.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d",
|
||||
@@ -347,7 +356,8 @@
|
||||
"value": "Rosa",
|
||||
"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-",
|
||||
@@ -382,7 +392,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-012-disney-mochila-de-poliester-ultraligera-",
|
||||
@@ -417,7 +428,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-013-mochila-pequena-bob-esponja",
|
||||
@@ -452,7 +464,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-013-mochila-pequena-bob-esponja.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est",
|
||||
@@ -487,7 +500,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn",
|
||||
@@ -522,7 +536,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult",
|
||||
@@ -557,7 +572,8 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
|
||||
},
|
||||
{
|
||||
"id": "ari-017-mochila-de-disney-marvel-capitan-america",
|
||||
@@ -592,6 +608,7 @@
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"thumbnail": "/images/thumbnails/ari-017-mochila-de-disney-marvel-capitan-america.webp"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Product {
|
||||
featured: boolean;
|
||||
isNew: boolean;
|
||||
image: string;
|
||||
thumbnail: string;
|
||||
images: string[];
|
||||
tags: string[];
|
||||
variants: ProductVariant[];
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <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.
|
||||
|
||||
@@ -4,13 +4,30 @@ const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
reactStrictMode: true,
|
||||
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"],
|
||||
deviceSizes: [360, 640, 768, 1024, 1280, 1536],
|
||||
},
|
||||
experimental: {
|
||||
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;
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
{
|
||||
"name": "ari-shopping-headless-storefront",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run typecheck && npm run lint && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "^15.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.544.0",
|
||||
"motion": "12.23.24",
|
||||
"next": "15.5.10",
|
||||
"react": "19.1.5",
|
||||
"react-dom": "19.1.5",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@types/node": "^22.18.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.9.0"
|
||||
"@tailwindcss/postcss": "4.1.14",
|
||||
"@types/node": "22.18.0",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "15.5.10",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "4.1.14",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -13,8 +13,10 @@ export interface CartItem {
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
hasHydrated: boolean;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
setHasHydrated: (value: boolean) => void;
|
||||
addItem: (product: Product, variantId?: string) => void;
|
||||
removeItem: (productId: string) => void;
|
||||
updateQuantity: (productId: string, quantity: number) => void;
|
||||
@@ -26,16 +28,22 @@ export const useCartStore = create<CartState>()(
|
||||
(set) => ({
|
||||
items: [],
|
||||
isOpen: false,
|
||||
hasHydrated: false,
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
setHasHydrated: (value) => set({ hasHydrated: value }),
|
||||
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
|
||||
? state.items.map((item) => item === existing ? { ...item, quantity: item.quantity + 1 } : item)
|
||||
: [...state.items, { product, quantity: 1, variantId }];
|
||||
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) => ({
|
||||
items: quantity <= 0
|
||||
? state.items.filter((item) => item.product.id !== productId)
|
||||
@@ -47,6 +55,7 @@ export const useCartStore = create<CartState>()(
|
||||
name: "ari-shopping-cart-v4",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
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),
|
||||
},
|
||||
),
|
||||
);
|
||||