feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 5m39s
@@ -0,0 +1,59 @@
|
||||
# ARI Shopping Headless Storefront v4.0
|
||||
|
||||
Código base moderno para la tienda online ARI Shopping.
|
||||
|
||||
## Stack
|
||||
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Variables headless
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Arquitectura tecnológica
|
||||
|
||||
```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`.
|
||||
@@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok", service: "ari-shopping-frontend", version: "4.0.0" }, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CatalogClient } from "@/components/catalog/catalog-client";
|
||||
import { getProducts } from "@/lib/headless/client";
|
||||
|
||||
export const metadata: Metadata = { title: "Catálogo", description: "Explora todos los productos de Ari Shopping." };
|
||||
|
||||
type CatalogPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
function single(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
}
|
||||
|
||||
export default async function CatalogPage({ searchParams }: CatalogPageProps) {
|
||||
const params = await searchParams;
|
||||
const products = await getProducts();
|
||||
return (
|
||||
<CatalogClient
|
||||
products={products}
|
||||
initialCategory={single(params.category) || "Todos"}
|
||||
initialCollection={single(params.collection)}
|
||||
initialQuery={single(params.q)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ari-cyan: #22d3ee;
|
||||
--color-ari-magenta: #ec4899;
|
||||
--color-ari-yellow: #fde047;
|
||||
--color-ari-blue: #0e7490;
|
||||
--animate-accordion-down: accordion-down .2s ease-out;
|
||||
--animate-accordion-up: accordion-up .2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes accordion-down { from { height: 0; } to { height: var(--radix-accordion-content-height); } }
|
||||
@keyframes accordion-up { from { height: var(--radix-accordion-content-height); } to { height: 0; } }
|
||||
|
||||
: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; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
::selection { background: #fde047; color: #0f172a; }
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { CartDrawer } from "@/components/cart/cart-drawer";
|
||||
import { Footer } from "@/components/layout/footer";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { getProducts } from "@/lib/headless/client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://shop.cruzcloud.net"),
|
||||
title: { default: "ARI Shopping | Juguetes, regalos y variedades", template: "%s | ARI Shopping" },
|
||||
description: "Tienda online de juguetes, personajes, mochilas y regalos con atención personalizada.",
|
||||
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 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>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
return <section className="grid min-h-[60vh] place-items-center px-4 text-center"><div><span className="text-7xl font-black text-cyan-500">404</span><h1 className="mt-3 text-3xl font-black">Este tesoro no existe</h1><p className="mt-3 text-slate-500">Regresa al catálogo para continuar explorando.</p><Button asChild variant="magenta" className="mt-6"><Link href="/catalog">Ir al catálogo</Link></Button></div></section>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { CategoryTiles } from "@/components/home/category-tiles";
|
||||
import { HeroBento } from "@/components/home/hero-bento";
|
||||
import { TrustBar } from "@/components/home/trust-bar";
|
||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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></>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
|
||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||
import { ProductGallery } from "@/components/product/product-gallery";
|
||||
import { ProductPurchase } from "@/components/product/product-purchase";
|
||||
import { getProductBySlug, getProducts } from "@/lib/headless/client";
|
||||
|
||||
interface ProductPageProps { params: Promise<{ slug: string }> }
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await getProducts();
|
||||
return products.map((product) => ({ slug: product.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug);
|
||||
return product ? { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } } : { title: "Producto" };
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
const products = await getProducts();
|
||||
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
|
||||
return <><section className="mx-auto grid max-w-7xl gap-10 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16"><ProductGallery images={product.images} name={product.name} /><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">{product.category} · {product.collection}</span><h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1><p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p><div className="mt-8"><ProductPurchase product={product} /></div><Accordion type="single" collapsible className="mt-8"><AccordionItem value="details"><AccordionTrigger>Detalles del producto</AccordionTrigger><AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent></AccordionItem><AccordionItem value="shipping"><AccordionTrigger>Envíos y entregas</AccordionTrigger><AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent></AccordionItem></Accordion></div></section>{recommended.length > 0 && <section className="mx-auto max-w-7xl px-4 pb-24"><h2 className="mb-7 text-3xl font-black">También te puede gustar</h2><ProductCarousel products={recommended} /></section>}<MobileAddToCart product={product} /></>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Minus, Plus, ShoppingBag, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
const FREE_SHIPPING_THRESHOLD = 250_000;
|
||||
|
||||
export function CartDrawer() {
|
||||
const { items, isOpen, closeCart, updateQuantity, removeItem, clearCart } = useCartStore();
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.product.price ?? 0) * item.quantity, 0);
|
||||
const pricedItems = items.some((item) => item.product.price !== null);
|
||||
const progress = pricedItems ? Math.min(100, (subtotal / FREE_SHIPPING_THRESHOLD) * 100) : 0;
|
||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
const checkout = () => {
|
||||
const lines = items.map((item) => `• ${item.quantity} x ${item.product.name}`).join("\n");
|
||||
const message = `Hola Ari Shopping 👋\nQuiero consultar disponibilidad para:\n\n${lines}\n\nNombre:\nCiudad:\nForma de entrega:`;
|
||||
void navigator.clipboard?.writeText(message);
|
||||
window.open(process.env.NEXT_PUBLIC_WHATSAPP_URL ?? "https://wa.link/rvcqqc", "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) closeCart(); }}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Tu carrito</SheetTitle>
|
||||
<SheetDescription>{count} producto{count === 1 ? "" : "s"} seleccionado{count === 1 ? "" : "s"}.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="grid min-h-72 place-items-center rounded-3xl border border-dashed border-slate-300 bg-slate-50 text-center">
|
||||
<div><ShoppingBag className="mx-auto mb-3 size-10 text-cyan-500" /><p className="font-black">Tu carrito está vacío</p><p className="mt-1 text-sm text-slate-500">Agrega un tesoro para comenzar.</p></div>
|
||||
</div>
|
||||
) : items.map((item) => (
|
||||
<article key={`${item.product.id}-${item.variantId ?? "default"}`} className="grid grid-cols-[82px_1fr_auto] gap-4 rounded-2xl border border-slate-100 p-3 shadow-sm">
|
||||
<div className="relative aspect-square overflow-hidden rounded-xl bg-slate-100"><Image src={item.product.image} alt={item.product.name} fill sizes="82px" className="object-cover" /></div>
|
||||
<div><h3 className="text-sm font-black leading-5">{item.product.name}</h3><p className="mt-1 text-xs text-slate-500">{formatMoney(item.product.price, item.product.currency)}</p><div className="mt-3 inline-flex items-center rounded-full border border-slate-200"><button aria-label="Restar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity - 1)}><Minus className="size-3" /></button><span className="min-w-7 text-center text-xs font-black">{item.quantity}</span><button aria-label="Sumar" className="p-2" onClick={() => updateQuantity(item.product.id, item.quantity + 1)}><Plus className="size-3" /></button></div></div>
|
||||
<button className="self-start rounded-full p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600" onClick={() => removeItem(item.product.id)} aria-label="Eliminar"><Trash2 className="size-4" /></button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 border-t border-slate-100 p-6">
|
||||
<div><div className="mb-2 flex justify-between text-xs font-bold text-slate-600"><span>{pricedItems ? "Progreso para envío gratis" : "Cotización personalizada"}</span><span>{pricedItems ? `${Math.round(progress)}%` : "Por confirmar"}</span></div><Progress value={progress} /></div>
|
||||
<div className="flex items-center justify-between"><span className="font-bold text-slate-600">Subtotal</span><strong className="text-xl font-black">{pricedItems ? formatMoney(subtotal) : "Por confirmar"}</strong></div>
|
||||
<Button variant="magenta" size="lg" className="w-full" disabled={!items.length} onClick={checkout}>Continuar por WhatsApp</Button>
|
||||
<Button variant="ghost" className="w-full" disabled={!items.length} onClick={clearCart}>Vaciar carrito</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ProductCard } from "@/components/product/product-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function CatalogClient({ products, initialCategory = "Todos", initialCollection = "", initialQuery = "" }: { products: Product[]; initialCategory?: string; initialCollection?: string; initialQuery?: string }) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [category, setCategory] = useState(initialCategory);
|
||||
const [collection, setCollection] = useState(initialCollection);
|
||||
const [brand, setBrand] = useState("Todas");
|
||||
const [age, setAge] = useState("Todas");
|
||||
const [sort, setSort] = useState("featured");
|
||||
const categories = ["Todos", ...new Set(products.map((product) => product.category))];
|
||||
const brands = ["Todas", ...new Set(products.map((product) => product.brand))];
|
||||
const ages = ["Todas", ...new Set(products.map((product) => product.ageRange))];
|
||||
const pricedProducts = products.filter((product) => product.price !== null);
|
||||
const maxCatalogPrice = pricedProducts.length ? Math.max(...pricedProducts.map((product) => product.price ?? 0)) : 0;
|
||||
const [maxPrice, setMaxPrice] = useState(maxCatalogPrice);
|
||||
|
||||
const filtered = useMemo(() => products.filter((product) => {
|
||||
const search = [product.name, product.category, product.collection, product.brand].join(" ").toLowerCase();
|
||||
return (!query || search.includes(query.toLowerCase())) && (category === "Todos" || product.category === category) && (!collection || product.collection === collection) && (brand === "Todas" || product.brand === brand) && (age === "Todas" || product.ageRange === age) && (product.price === null || maxPrice === 0 || product.price <= maxPrice);
|
||||
}).sort((a, b) => sort === "name" ? a.name.localeCompare(b.name, "es") : sort === "new" ? Number(b.isNew) - Number(a.isNew) : Number(b.featured) - Number(a.featured)), [age, brand, category, products, query, sort]);
|
||||
|
||||
const reset = () => { setQuery(""); setCategory("Todos"); setCollection(""); setBrand("Todas"); setAge("Todas"); setMaxPrice(maxCatalogPrice); setSort("featured"); };
|
||||
|
||||
return <div className="mx-auto max-w-7xl px-4 py-10"><div className="mb-8"><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Catálogo completo</span><h1 className="mt-2 text-5xl font-black tracking-tight">Todos los tesoros</h1><p className="mt-3 text-slate-500">Filtra por categoría, marca o edad sin recargar la página.</p></div><div className="grid gap-8 lg:grid-cols-[260px_1fr]"><aside className="h-fit space-y-6 rounded-3xl border border-slate-100 bg-white p-6 shadow-lg shadow-slate-900/5 lg:sticky lg:top-44"><div className="flex items-center justify-between"><strong className="flex items-center gap-2 text-lg font-black"><SlidersHorizontal className="size-5 text-cyan-600" />Filtros</strong><button onClick={reset} className="text-xs font-black text-fuchsia-600"><X className="mr-1 inline size-3" />Limpiar</button></div><Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar…" /><Filter title="Categoría" values={categories} selected={category} onChange={setCategory} /><Filter title="Marca" values={brands} selected={brand} onChange={setBrand} /><Filter title="Edad" values={ages} selected={age} onChange={setAge} />
|
||||
<fieldset><legend className="mb-3 text-sm font-black">Precio máximo</legend>{maxCatalogPrice > 0 ? <><input aria-label="Precio máximo" type="range" min={0} max={maxCatalogPrice} step={5000} value={maxPrice} onChange={(event) => setMaxPrice(Number(event.target.value))} className="w-full accent-fuchsia-500" /><p className="mt-2 text-xs font-bold text-slate-500">Hasta ${maxPrice.toLocaleString("es-CO")}</p></> : <p className="rounded-2xl bg-yellow-50 p-3 text-xs font-bold leading-5 text-yellow-800">El filtro se activa automáticamente cuando el backend entregue precios.</p>}</fieldset>
|
||||
</aside><section><div className="mb-5 flex items-center justify-between gap-4"><p className="text-sm font-bold text-slate-500">{filtered.length} productos</p><select value={sort} onChange={(event) => setSort(event.target.value)} className="h-11 rounded-full border border-slate-200 bg-white px-4 text-sm font-bold"><option value="featured">Destacados</option><option value="new">Novedades</option><option value="name">Nombre</option></select></div>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{filtered.map((product) => <ProductCard key={product.id} product={product} />)}</div> : <div className="grid min-h-80 place-items-center rounded-3xl border border-dashed border-slate-300 text-center"><div><p className="text-xl font-black">No encontramos productos</p><Button variant="magenta" className="mt-4" onClick={reset}>Restablecer filtros</Button></div></div>}</section></div></div>;
|
||||
}
|
||||
|
||||
function Filter({ title, values, selected, onChange }: { title: string; values: string[]; selected: string; onChange: (value: string) => void }) {
|
||||
return <fieldset><legend className="mb-3 text-sm font-black">{title}</legend><div className="flex flex-wrap gap-2">{values.map((value) => <button type="button" key={value} onClick={() => onChange(value)} className={`rounded-full px-3 py-2 text-xs font-bold transition ${selected === value ? "bg-slate-950 text-white" : "bg-slate-100 text-slate-600 hover:bg-cyan-100"}`}>{value}</button>)}</div></fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
const categories = [
|
||||
{ title: "Juguetes", subtitle: "Personajes que inspiran", image: "/images/categories/figuras.webp", href: "/catalog?category=Figuras", color: "from-yellow-300 to-orange-400" },
|
||||
{ title: "Peluches", subtitle: "Compañeros para regalar", image: "/images/categories/peluches.webp", href: "/catalog?category=Peluches", color: "from-pink-400 to-fuchsia-500" },
|
||||
{ title: "Mochilas", subtitle: "Estilo para cada aventura", image: "/images/categories/mochilas.webp", href: "/catalog?category=Mochilas", color: "from-cyan-400 to-blue-500" },
|
||||
{ title: "Marvel", subtitle: "Tu héroe favorito", image: "/images/categories/marvel.webp", href: "/catalog?collection=Marvel", color: "from-red-500 to-rose-700" },
|
||||
];
|
||||
|
||||
export function CategoryTiles() {
|
||||
return <section className="mx-auto max-w-7xl px-4 py-14"><div className="mb-8 flex items-end justify-between gap-4"><div><span className="text-xs font-black uppercase tracking-[.18em] text-fuchsia-600">Explora por mundos</span><h2 className="mt-2 text-4xl font-black tracking-tight sm:text-5xl">Encuentra tu próximo tesoro</h2></div><Link href="/catalog" className="hidden font-black text-cyan-700 sm:block">Ver todo →</Link></div><div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">{categories.map((item) => <Link key={item.title} href={item.href} className={`group relative min-h-80 overflow-hidden rounded-[2rem] bg-gradient-to-br ${item.color} shadow-xl`}><Image src={item.image} alt={item.title} fill sizes="(max-width: 640px) 100vw, 25vw" className="object-cover transition duration-500 group-hover:scale-105" /><div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-white/5" /><div className="absolute inset-x-0 bottom-0 flex items-end justify-between p-6 text-white"><div><h3 className="text-2xl font-black">{item.title}</h3><p className="mt-1 text-sm font-semibold text-white/80">{item.subtitle}</p></div><span className="grid size-11 place-items-center rounded-full bg-white text-slate-950 transition group-hover:rotate-45"><ArrowUpRight /></span></div></Link>)}</div></section>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, BadgePercent, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function HeroBento() {
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-4 py-7 lg:py-10">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.55fr_.8fr]">
|
||||
<motion.article initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} className="relative min-h-[430px] overflow-hidden rounded-[2rem] bg-gradient-to-br from-fuchsia-500 via-pink-500 to-rose-500 p-7 text-white shadow-2xl lg:min-h-[540px] lg:p-12">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_15%_20%,rgba(255,255,255,.32),transparent_22%),radial-gradient(circle_at_80%_15%,rgba(255,231,0,.35),transparent_18%)]" />
|
||||
<div className="relative z-10 max-w-lg"><div className="mb-5 inline-flex items-center gap-2 rounded-full bg-yellow-300 px-4 py-2 text-xs font-black uppercase tracking-wider text-slate-950"><Sparkles className="size-4" />Celebración ARI</div><h1 className="text-5xl font-black leading-[.92] tracking-[-.06em] sm:text-6xl lg:text-8xl"><span className="block">Hasta</span><span className="text-yellow-300">50% OFF</span><span className="mt-3 block text-3xl tracking-tight sm:text-4xl">en tesoros seleccionados</span></h1><p className="mt-6 max-w-md text-base font-semibold text-white/90 sm:text-lg">Personajes, mochilas y regalos que convierten cualquier día en una celebración.</p><Button asChild variant="yellow" size="lg" className="mt-8"><Link href="/catalog">Comprar ahora <ArrowRight className="size-5" /></Link></Button></div>
|
||||
<Image src="/images/promo/hero-vibrant.webp" alt="Promoción principal de Ari Shopping" fill priority sizes="(max-width: 1024px) 100vw, 65vw" className="object-cover object-right opacity-55 mix-blend-screen lg:opacity-70" />
|
||||
</motion.article>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .1 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-red-500 to-rose-700 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[55%]"><span className="text-xs font-black uppercase tracking-wider text-yellow-300">Héroes favoritos</span><h2 className="mt-2 text-3xl font-black leading-none">Acción y aventura</h2><Button asChild variant="yellow" size="sm" className="mt-5"><Link href="/catalog?collection=Marvel">Comprar ahora</Link></Button></div><Image src="/images/promo/promo-marvel.webp" alt="Colección Marvel" fill sizes="35vw" className="object-cover opacity-65 mix-blend-screen" /></motion.article>
|
||||
<motion.article initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: .18 }} className="relative min-h-52 overflow-hidden rounded-[2rem] bg-gradient-to-br from-violet-500 to-cyan-500 p-6 text-white shadow-xl"><div className="relative z-10 max-w-[58%]"><div className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-fuchsia-600"><BadgePercent className="size-4" />Novedad</div><h2 className="mt-3 text-3xl font-black leading-none">Tesoros para regalar</h2><Button asChild variant="outline" size="sm" className="mt-5 border-white bg-white"><Link href="/catalog">Descubrir</Link></Button></div><Image src="/images/promo/promo-characters.webp" alt="Personajes y peluches" fill sizes="35vw" className="object-cover opacity-55 mix-blend-screen" /></motion.article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { CreditCard, Headphones, ShieldCheck, Truck } from "lucide-react";
|
||||
|
||||
const benefits = [
|
||||
{ icon: Truck, title: "Envíos nacionales", text: "Coordinación rápida y segura." },
|
||||
{ icon: CreditCard, title: "Pagos flexibles", text: "Opciones adaptadas a tu compra." },
|
||||
{ icon: ShieldCheck, title: "Compra protegida", text: "Confirmamos antes de pagar." },
|
||||
{ icon: Headphones, title: "Atención cercana", text: "Acompañamiento por WhatsApp." },
|
||||
];
|
||||
|
||||
export function TrustBar() {
|
||||
return <section className="mx-auto grid max-w-7xl gap-4 px-4 py-10 sm:grid-cols-2 lg:grid-cols-4">{benefits.map(({ icon: Icon, title, text }, index) => <article key={title} className="flex items-center gap-4 rounded-3xl border border-slate-100 bg-white p-5 shadow-lg shadow-slate-900/5"><span className={`grid size-12 shrink-0 place-items-center rounded-2xl ${["bg-yellow-300", "bg-cyan-300", "bg-pink-300", "bg-violet-300"][index]}`}><Icon className="size-6 text-slate-950" /></span><div><strong className="block font-black">{title}</strong><span className="mt-1 block text-xs text-slate-500">{text}</span></div></article>)}</section>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Facebook, Instagram, MessageCircle } from "lucide-react";
|
||||
|
||||
export function Footer() {
|
||||
return <footer className="bg-slate-950 text-white"><div className="mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:grid-cols-2 lg:grid-cols-4"><div><div className="flex items-center gap-4"><div className="relative size-16 overflow-hidden rounded-full"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill sizes="64px" className="object-cover" /></div><div><strong className="text-xl font-black">ARI Shopping</strong><p className="text-xs text-white/60">Tesoros para cada aventura.</p></div></div><div className="mt-5 flex gap-3"><Instagram /><Facebook /><MessageCircle /></div></div><FooterColumn title="Compra" links={["Novedades", "Juguetes", "Mochilas", "Regalos"]} /><FooterColumn title="Ayuda" links={["Preguntas frecuentes", "Envíos", "Cambios", "Contacto"]} /><div><h3 className="font-black">Newsletter</h3><p className="mt-3 text-sm leading-6 text-white/60">Recibe lanzamientos y promociones especiales.</p><div className="mt-4 flex rounded-full bg-white p-1"><input className="min-w-0 flex-1 bg-transparent px-4 text-sm text-slate-950 outline-none" placeholder="Tu correo" /><button className="rounded-full bg-yellow-300 px-4 text-sm font-black text-slate-950">Unirme</button></div></div></div><div className="border-t border-white/10 py-5 text-center text-xs text-white/50">© 2026 ARI Shopping · Frontend headless v4.0 · GitOps</div></footer>;
|
||||
}
|
||||
|
||||
function FooterColumn({ title, links }: { title: string; links: string[] }) {
|
||||
return <div><h3 className="font-black">{title}</h3><div className="mt-4 grid gap-3 text-sm text-white/60">{links.map((link) => <Link key={link} href="/catalog" className="hover:text-yellow-300">{link}</Link>)}</div></div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, Menu, ShoppingCart, UserRound } from "lucide-react";
|
||||
import { PredictiveSearch } from "@/components/search/predictive-search";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
const categories = [
|
||||
{ label: "Juguetes para bebés", href: "/catalog?category=Figuras" },
|
||||
{ label: "Educativos", href: "/catalog?category=Figuras" },
|
||||
{ label: "Muñecas", href: "/catalog?category=Peluches" },
|
||||
{ label: "Acción y aventura", href: "/catalog?collection=Marvel" },
|
||||
{ label: "Mochilas", href: "/catalog?category=Mochilas" },
|
||||
{ label: "Variedades", href: "/catalog" },
|
||||
];
|
||||
|
||||
export function Navbar({ products }: { products: Product[] }) {
|
||||
const items = useCartStore((state) => state.items);
|
||||
const openCart = useCartStore((state) => state.openCart);
|
||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 shadow-md">
|
||||
<div className="bg-yellow-300 px-4 py-2 text-center text-xs font-black text-slate-950 sm:text-sm">Paga tus compras a cuotas · Envíos nacionales · <span className="underline">Compra segura con ARI</span></div>
|
||||
<div className="bg-cyan-400 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,.18)_0_12%,transparent_13%),radial-gradient(circle_at_80%_70%,rgba(255,255,255,.12)_0_10%,transparent_11%)]">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-[72px_1fr_auto] items-center gap-4 px-4 py-4 lg:grid-cols-[100px_minmax(420px,1fr)_auto] lg:gap-8">
|
||||
<Link href="/" className="relative size-16 overflow-hidden rounded-full border-4 border-white bg-black shadow-xl lg:size-20"><Image src="/images/brand/logo-ari.png" alt="ARI Shopping" fill priority sizes="80px" className="object-cover" /></Link>
|
||||
<PredictiveSearch products={products} />
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Button variant="ghost" size="icon" aria-label="Mi cuenta" className="bg-white/15 text-white hover:bg-white/25"><UserRound /></Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Favoritos" className="bg-white/15 text-white hover:bg-white/25"><Heart /></Button>
|
||||
<Button variant="magenta" size="icon" onClick={openCart} aria-label={`Carrito con ${count} productos`} className="relative"><ShoppingCart /><span className="absolute -right-2 -top-2 grid size-6 place-items-center rounded-full bg-yellow-300 text-[11px] font-black text-slate-950">{count}</span></Button>
|
||||
</div>
|
||||
<Button variant="magenta" size="icon" className="sm:hidden" onClick={openCart}><ShoppingCart /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="bg-cyan-700 text-white">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-1 overflow-x-auto px-4 py-2 [scrollbar-width:none]">
|
||||
<Button variant="ghost" size="icon" className="shrink-0 text-white hover:bg-white/10 lg:mr-5"><Menu /></Button>
|
||||
{categories.map((category) => <Link key={category.label} href={category.href} className="shrink-0 rounded-full px-4 py-2 text-sm font-extrabold hover:bg-white/10">{category.label}</Link>)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ShoppingBag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
export function MobileAddToCart({ product }: { product: Product }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
return <div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag />Añadir al carrito</Button></div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
export function ProductCard({ product, priority = false }: { product: Product; priority?: boolean }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
return (
|
||||
<motion.article whileHover={{ y: -8 }} transition={{ type: "spring", stiffness: 320, damping: 24 }} className="group overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-lg shadow-slate-900/5">
|
||||
<div className="relative aspect-square overflow-hidden bg-gradient-to-br from-cyan-50 via-white to-pink-50">
|
||||
<Link href={`/product/${product.slug}`}><Image src={product.image} alt={product.name} fill priority={priority} sizes="(max-width: 640px) 80vw, (max-width: 1024px) 40vw, 24vw" className="object-cover transition duration-500 group-hover:scale-105" /></Link>
|
||||
<div className="absolute left-3 top-3 flex gap-2">{product.isNew && <Badge className="bg-fuchsia-500 text-white">Nuevo</Badge>}{product.featured && <Badge className="bg-yellow-300 text-slate-950"><Sparkles className="mr-1 size-3" />Top</Badge>}</div>
|
||||
<button aria-label="Agregar a favoritos" className="absolute right-3 top-3 rounded-full bg-white/90 p-2.5 shadow-lg backdrop-blur hover:text-pink-500"><Heart className="size-4" /></button>
|
||||
</div>
|
||||
<div className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-full bg-cyan-50 px-2 py-1 text-cyan-700">{product.collection}</span></div>
|
||||
<Link href={`/product/${product.slug}`}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-slate-950 group-hover:text-fuchsia-600">{product.name}</h3></Link>
|
||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||
<div className="flex items-center justify-between gap-3 pt-1"><strong className="text-sm font-black">{formatMoney(product.price, product.currency)}</strong><Button variant="magenta" size="sm" onClick={() => addItem(product, product.variants[0]?.id)}><ShoppingBag className="size-4" />Agregar</Button></div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { ProductCard } from "@/components/product/product-card";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function ProductCarousel({ products }: { products: Product[] }) {
|
||||
return <motion.div initial="hidden" whileInView="visible" viewport={{ once: true, margin: "-80px" }} variants={{ hidden: {}, visible: { transition: { staggerChildren: 0.07 } } }} className="grid auto-cols-[82%] grid-flow-col gap-5 overflow-x-auto pb-5 pr-5 [scrollbar-width:none] sm:auto-cols-[44%] lg:auto-cols-[28%] xl:auto-cols-[23%]">{products.map((product, index) => <motion.div key={product.id} variants={{ hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0 } }}><ProductCard product={product} priority={index < 2} /></motion.div>)}</motion.div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ZoomIn } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
export function ProductGallery({ images, name }: { images: string[]; name: string }) {
|
||||
const [selected, setSelected] = useState(images[0] ?? "");
|
||||
return <div className="grid gap-4 lg:grid-cols-[86px_1fr]"><div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">{images.map((image) => <button key={image} onClick={() => setSelected(image)} className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-2xl border-2 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-cover" /></button>)}</div><Dialog><DialogTrigger asChild><button className="group relative order-1 aspect-square overflow-hidden rounded-[2rem] bg-gradient-to-br from-cyan-50 via-white to-pink-50 lg:order-2"><Image src={selected} alt={name} fill priority sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover transition duration-500 group-hover:scale-110" /><span className="absolute bottom-5 right-5 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-black shadow-xl"><ZoomIn className="size-4" />Ampliar</span></button></DialogTrigger><DialogContent><div className="relative aspect-square"><Image src={selected} alt={name} fill sizes="90vw" className="object-contain" /></div></DialogContent></Dialog></div>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, ShoppingBag, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
export function ProductPurchase({ product }: { product: Product }) {
|
||||
const [variantId, setVariantId] = useState(product.variants[0]?.id);
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
return <div><div className="mb-5 flex flex-wrap gap-2">{product.variants.map((variant) => <button key={variant.id} onClick={() => setVariantId(variant.id)} className={`rounded-full border px-4 py-2 text-sm font-bold ${variantId === variant.id ? "border-fuchsia-500 bg-fuchsia-50 text-fuchsia-700" : "border-slate-200"}`}>{variant.value}</button>)}</div><div className="mb-6 flex items-end gap-3"><strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}</div><Button variant="magenta" size="lg" className="w-full" onClick={() => addItem(product, variantId)}><ShoppingBag />Añadir al carrito</Button><div className="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm"><span className="flex items-center gap-2 font-bold"><CheckCircle2 className="size-4 text-emerald-500" />Disponibilidad por confirmar</span><span className="flex items-center gap-2 font-bold"><Truck className="size-4 text-cyan-600" />Envíos nacionales</span></div></div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export function PredictiveSearch({ products }: { products: Product[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const normalized = query.trim().toLocaleLowerCase("es");
|
||||
const matches = useMemo(() => normalized.length < 2 ? [] : products.filter((product) => [product.name, product.category, product.collection, product.brand].join(" ").toLocaleLowerCase("es").includes(normalized)).slice(0, 6), [normalized, products]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Search className="pointer-events-none absolute left-5 top-1/2 z-10 size-5 -translate-y-1/2 text-cyan-700" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Busca juguetes, mochilas, personajes…" className="h-[52px] border-white/80 bg-white pl-[52px] pr-12 shadow-xl" />
|
||||
{query && <button aria-label="Limpiar búsqueda" className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-100" onClick={() => setQuery("")}><X className="size-4" /></button>}
|
||||
{matches.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+10px)] z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl">
|
||||
{matches.map((product) => (
|
||||
<Link key={product.id} href={`/product/${product.slug}`} className="grid grid-cols-[54px_1fr] items-center gap-3 rounded-xl p-2 hover:bg-cyan-50" onClick={() => setQuery("")}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-slate-100"><Image src={product.image} alt="" fill sizes="54px" className="object-cover" /></div>
|
||||
<div><strong className="line-clamp-1 text-sm">{product.name}</strong><span className="text-xs text-slate-500">{product.category} · {product.collection}</span></div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Accordion = AccordionPrimitive.Root;
|
||||
export const AccordionItem = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Item>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b border-slate-200", className)} {...props} />
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
export const AccordionTrigger = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header>
|
||||
<AccordionPrimitive.Trigger ref={ref} className={cn("group flex w-full items-center justify-between py-5 text-left font-extrabold", className)} {...props}>
|
||||
{children}<ChevronDown className="size-4 transition group-data-[state=open]:rotate-180" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = "AccordionTrigger";
|
||||
|
||||
export const AccordionContent = React.forwardRef<React.ElementRef<typeof AccordionPrimitive.Content>, React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content ref={ref} className="overflow-hidden text-sm text-slate-600 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" {...props}>
|
||||
<div className={cn("pb-5 leading-7", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = "AccordionContent";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return <span className={cn("inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-slate-700", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-extrabold transition-all focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan-300/40 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-950 text-white shadow-lg hover:-translate-y-0.5 hover:bg-slate-800",
|
||||
magenta: "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-lg shadow-pink-500/25 hover:-translate-y-0.5",
|
||||
yellow: "bg-yellow-300 text-slate-950 shadow-lg shadow-yellow-400/25 hover:-translate-y-0.5 hover:bg-yellow-200",
|
||||
outline: "border border-slate-200 bg-white text-slate-950 hover:border-cyan-400 hover:bg-cyan-50",
|
||||
ghost: "text-slate-700 hover:bg-slate-100",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5",
|
||||
sm: "h-9 px-4 text-xs",
|
||||
lg: "h-13 px-7 text-base",
|
||||
icon: "size-11 p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
export function DialogContent({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-md" />
|
||||
<DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 max-h-[92vh] w-[calc(100%-1.5rem)] max-w-5xl -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-3xl bg-white shadow-2xl outline-none", className)} {...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-white p-2 shadow-lg" aria-label="Cerrar"><X className="size-5" /></DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn("h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Progress({ value = 0, className }: { value?: number; className?: string }) {
|
||||
return (
|
||||
<ProgressPrimitive.Root className={cn("relative h-2 w-full overflow-hidden rounded-full bg-slate-100", className)} value={value}>
|
||||
<ProgressPrimitive.Indicator className="h-full bg-gradient-to-r from-cyan-400 via-fuchsia-500 to-yellow-300 transition-transform" style={{ transform: `translateX(-${100 - Math.min(value, 100)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Sheet = DialogPrimitive.Root;
|
||||
export const SheetTrigger = DialogPrimitive.Trigger;
|
||||
export const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
export function SheetContent({ className, children, ...props }: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/55 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out" />
|
||||
<DialogPrimitive.Content className={cn("fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-slate-200 bg-white shadow-2xl outline-none data-[state=open]:animate-in data-[state=open]:slide-in-from-right data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right", className)} {...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-5 top-5 rounded-full border border-slate-200 bg-white p-2 text-slate-600 hover:bg-slate-100" aria-label="Cerrar">
|
||||
<X className="size-5" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeader(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("border-b border-slate-100 p-6", props.className)} {...props} />;
|
||||
}
|
||||
|
||||
export const SheetTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-2xl font-black text-slate-950", className)} {...props} />
|
||||
));
|
||||
SheetTitle.displayName = "SheetTitle";
|
||||
|
||||
export const SheetDescription = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("mt-1 text-sm text-slate-500", className)} {...props} />
|
||||
));
|
||||
SheetDescription.displayName = "SheetDescription";
|
||||
@@ -0,0 +1,597 @@
|
||||
[
|
||||
{
|
||||
"id": "ari-001-toy-story-5-rex",
|
||||
"slug": "toy-story-5-rex",
|
||||
"name": "Toy Story 5 - Rex",
|
||||
"shortDescription": "Toy Story 5 - Rex",
|
||||
"description": "Toy Story 5 - Rex Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Figuras",
|
||||
"collection": "Toy Story",
|
||||
"brand": "Disney Pixar",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-001-toy-story-5-rex.webp",
|
||||
"images": [
|
||||
"/images/products/ari-001-toy-story-5-rex.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Figuras",
|
||||
"Toy Story",
|
||||
"Disney Pixar"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-001-toy-story-5-rex-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-002-toy-story-5-buzz-lightyear",
|
||||
"slug": "toy-story-5-buzz-lightyear",
|
||||
"name": "Toy Story 5 - Buzz Lightyear",
|
||||
"shortDescription": "Toy Store 5 - Buzz Lightyear",
|
||||
"description": "Toy Store 5 - Buzz Lightyear Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Figuras",
|
||||
"collection": "Toy Story",
|
||||
"brand": "Disney Pixar",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-002-toy-story-5-buzz-lightyear.webp",
|
||||
"images": [
|
||||
"/images/products/ari-002-toy-story-5-buzz-lightyear.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Figuras",
|
||||
"Toy Story",
|
||||
"Disney Pixar"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-002-toy-story-5-buzz-lightyear-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-003-toy-story-5-jessie",
|
||||
"slug": "toy-story-5-jessie",
|
||||
"name": "Toy Story 5 - Jessie",
|
||||
"shortDescription": "Toy Store 5 - Jessie",
|
||||
"description": "Toy Store 5 - Jessie Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Figuras",
|
||||
"collection": "Toy Story",
|
||||
"brand": "Disney Pixar",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-003-toy-story-5-jessie.webp",
|
||||
"images": [
|
||||
"/images/products/ari-003-toy-story-5-jessie.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Figuras",
|
||||
"Toy Story",
|
||||
"Disney Pixar"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-003-toy-story-5-jessie-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-004-toy-story-5-woody",
|
||||
"slug": "toy-story-5-woody",
|
||||
"name": "Toy Story 5 - Woody",
|
||||
"shortDescription": "Toy Store 5 - Woody",
|
||||
"description": "Toy Store 5 - Woody Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Figuras",
|
||||
"collection": "Toy Story",
|
||||
"brand": "Disney Pixar",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-004-toy-story-5-woody.webp",
|
||||
"images": [
|
||||
"/images/products/ari-004-toy-story-5-woody.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Figuras",
|
||||
"Toy Story",
|
||||
"Disney Pixar"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-004-toy-story-5-woody-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-005-intensamente-furia",
|
||||
"slug": "intensamente-furia",
|
||||
"name": "Intensamente Furia",
|
||||
"shortDescription": "Intensamente Furia Disney Aprox 11 “ ",
|
||||
"description": "Intensamente Furia Disney Aprox 11 “ Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Peluches",
|
||||
"collection": "Intensamente",
|
||||
"brand": "Disney",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-005-intensamente-furia.webp",
|
||||
"images": [
|
||||
"/images/products/ari-005-intensamente-furia.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Peluches",
|
||||
"Intensamente",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-005-intensamente-furia-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-006-intensamente-tristeza",
|
||||
"slug": "intensamente-tristeza",
|
||||
"name": "Intensamente Tristeza",
|
||||
"shortDescription": "Intensamente Tristeza Disney Aprox 11 “ ",
|
||||
"description": "Intensamente Tristeza Disney Aprox 11 “ Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Peluches",
|
||||
"collection": "Intensamente",
|
||||
"brand": "Disney",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": true,
|
||||
"image": "/images/products/ari-006-intensamente-tristeza.webp",
|
||||
"images": [
|
||||
"/images/products/ari-006-intensamente-tristeza.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Peluches",
|
||||
"Intensamente",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-006-intensamente-tristeza-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-007-minnie-y-mickey-mouse",
|
||||
"slug": "minnie-y-mickey-mouse",
|
||||
"name": "Minnie y Mickey Mouse",
|
||||
"shortDescription": "Minnie y Mickey Mouse Disney 🧸 Edición especial Aprox 15 “ ",
|
||||
"description": "Minnie y Mickey Mouse Disney 🧸 Edición especial Aprox 15 “ Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Peluches",
|
||||
"collection": "Mickey & Minnie",
|
||||
"brand": "Disney",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-007-minnie-y-mickey-mouse.webp",
|
||||
"images": [
|
||||
"/images/products/ari-007-minnie-y-mickey-mouse.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Peluches",
|
||||
"Mickey & Minnie",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-007-minnie-y-mickey-mouse-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-008-angel-lilo-y-stitch",
|
||||
"slug": "angel-lilo-y-stitch",
|
||||
"name": "Angel Lilo y Stitch",
|
||||
"shortDescription": "Angel Lilo y Stitch Disney Aprox 13 “ ",
|
||||
"description": "Angel Lilo y Stitch Disney Aprox 13 “ Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Peluches",
|
||||
"collection": "Lilo & Stitch",
|
||||
"brand": "Disney",
|
||||
"ageRange": "3+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": true,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-008-angel-lilo-y-stitch.webp",
|
||||
"images": [
|
||||
"/images/products/ari-008-angel-lilo-y-stitch.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Peluches",
|
||||
"Lilo & Stitch",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-008-angel-lilo-y-stitch-default",
|
||||
"name": "Presentación",
|
||||
"value": "Edición original",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-009-mochilas-disney-coleccion-marvel",
|
||||
"slug": "mochilas-disney-coleccion-marvel",
|
||||
"name": "Mochilas Disney colección Marvel",
|
||||
"shortDescription": "Mochilas Disney colección Marvel ",
|
||||
"description": "Mochilas Disney colección Marvel Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Marvel",
|
||||
"brand": "Marvel",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-009-mochilas-disney-coleccion-marvel.webp",
|
||||
"images": [
|
||||
"/images/products/ari-009-mochilas-disney-coleccion-marvel.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Marvel",
|
||||
"Marvel"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-009-mochilas-disney-coleccion-marvel-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d",
|
||||
"slug": "mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa",
|
||||
"name": "Mochila ligera y resistente al agua de Disney Frozen, mochila escolar y de viaje con múltiples compa",
|
||||
"shortDescription": "Mochila ligera y resistente al agua de Disney Frozen, mochila escolar y de viaje con múltiples compartimentos y degradado, mochila de poliéster para estudiantes, tamaño pequeño en color rosa y azul celeste",
|
||||
"description": "Mochila ligera y resistente al agua de Disney Frozen, mochila escolar y de viaje con múltiples compartimentos y degradado, mochila de poliéster para estudiantes, tamaño pequeño en color rosa y azul celeste Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Frozen",
|
||||
"brand": "Disney",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp",
|
||||
"images": [
|
||||
"/images/products/ari-010-mochila-ligera-y-resistente-al-agua-de-d.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Frozen",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-010-mochila-ligera-y-resistente-al-agua-de-d-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-011-mochila-escolar-de-spider-man-de-disney-",
|
||||
"slug": "mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera",
|
||||
"name": "Mochila escolar de Spider-Man de Disney, moda estudiantil, libro de gran capacidad, mochila ligera, ",
|
||||
"shortDescription": "Mochila escolar de Spider-Man de Disney, moda estudiantil, libro de gran capacidad, mochila ligera, antibacteriana y transpirable, resistente a salpicaduras, Dimensiones 28x18x30",
|
||||
"description": "Mochila escolar de Spider-Man de Disney, moda estudiantil, libro de gran capacidad, mochila ligera, antibacteriana y transpirable, resistente a salpicaduras, Dimensiones 28x18x30 Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Marvel",
|
||||
"brand": "Marvel",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp",
|
||||
"images": [
|
||||
"/images/products/ari-011-mochila-escolar-de-spider-man-de-disney-.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Marvel",
|
||||
"Marvel"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-011-mochila-escolar-de-spider-man-de-disney--default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-012-disney-mochila-de-poliester-ultraligera-",
|
||||
"slug": "disney-mochila-de-poliester-ultraligera-y-transpirable-de-gran-tamano-y-multiples-colores-dimension",
|
||||
"name": "Disney Mochila de poliéster ultraligera y transpirable, de gran tamaño y múltiples colores Dimension",
|
||||
"shortDescription": "Disney Mochila de poliéster ultraligera y transpirable, de gran tamaño y múltiples colores Dimensiones 39x16x46",
|
||||
"description": "Disney Mochila de poliéster ultraligera y transpirable, de gran tamaño y múltiples colores Dimensiones 39x16x46 Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Disney",
|
||||
"brand": "Disney",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-012-disney-mochila-de-poliester-ultraligera-.webp",
|
||||
"images": [
|
||||
"/images/products/ari-012-disney-mochila-de-poliester-ultraligera-.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Disney",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-012-disney-mochila-de-poliester-ultraligera--default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-013-mochila-pequena-bob-esponja",
|
||||
"slug": "mochila-pequena-bob-esponja",
|
||||
"name": "Mochila pequeña Bob Esponja",
|
||||
"shortDescription": "Mochila pequeña Bob Esponja disponibles en color blanco, negro y verde",
|
||||
"description": "Mochila pequeña Bob Esponja disponibles en color blanco, negro y verde Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Bob Esponja",
|
||||
"brand": "Disney",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-013-mochila-pequena-bob-esponja.webp",
|
||||
"images": [
|
||||
"/images/products/ari-013-mochila-pequena-bob-esponja.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Bob Esponja",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-013-mochila-pequena-bob-esponja-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est",
|
||||
"slug": "mochila-casual-de-gran-capacidad-con-estampado-de-bob-esponja-unisex-bolsa-escolar-de-dibujos-anim",
|
||||
"name": "Mochila casual de gran capacidad con estampado de Bob Esponja, unisex, bolsa escolar de dibujos anim",
|
||||
"shortDescription": "Mochila casual de gran capacidad con estampado de Bob Esponja, unisex, bolsa escolar de dibujos animados linda, esencial para volver a la escuela Dimensiones 20 cm x 15 cm x 10 cm ",
|
||||
"description": "Mochila casual de gran capacidad con estampado de Bob Esponja, unisex, bolsa escolar de dibujos animados linda, esencial para volver a la escuela Dimensiones 20 cm x 15 cm x 10 cm Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Bob Esponja",
|
||||
"brand": "Disney",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-014-mochila-casual-de-gran-capacidad-con-est.webp",
|
||||
"images": [
|
||||
"/images/products/ari-014-mochila-casual-de-gran-capacidad-con-est.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Bob Esponja",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-014-mochila-casual-de-gran-capacidad-con-est-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn",
|
||||
"slug": "mochila-escolar-estilo-britanico-de-disney-con-proteccion-de-la-columna-vertebral-nueva-mochila-de",
|
||||
"name": "Mochila escolar estilo britanico de Disney con protección de la columna vertebral, nueva mochila de ",
|
||||
"shortDescription": "Mochila escolar estilo britanico de Disney con protección de la columna vertebral, nueva mochila de gran capacidad y ultraligera para el inicio del colegio dimensiones 38 cm x 27 cm x 14.5 cm ",
|
||||
"description": "Mochila escolar estilo britanico de Disney con protección de la columna vertebral, nueva mochila de gran capacidad y ultraligera para el inicio del colegio dimensiones 38 cm x 27 cm x 14.5 cm Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Disney",
|
||||
"brand": "Disney",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-015-mochila-escolar-estilo-britanico-de-disn.webp",
|
||||
"images": [
|
||||
"/images/products/ari-015-mochila-escolar-estilo-britanico-de-disn.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Disney",
|
||||
"Disney"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-015-mochila-escolar-estilo-britanico-de-disn-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult",
|
||||
"slug": "disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x",
|
||||
"name": "Disney Mochila Iron Man, de poliéster ultraligera y transpirable, de gran tamaño dimensiones 46 cm x",
|
||||
"shortDescription": "Disney Mochila Iron Man, de poliéster ultraligera y transpirable, de gran tamaño dimensiones 46 cm x 33 cm x 19 cm ",
|
||||
"description": "Disney Mochila Iron Man, de poliéster ultraligera y transpirable, de gran tamaño dimensiones 46 cm x 33 cm x 19 cm Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Marvel",
|
||||
"brand": "Marvel",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp",
|
||||
"images": [
|
||||
"/images/products/ari-016-disney-mochila-iron-man-de-poliester-ult.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Marvel",
|
||||
"Marvel"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-016-disney-mochila-iron-man-de-poliester-ult-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ari-017-mochila-de-disney-marvel-capitan-america",
|
||||
"slug": "mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par",
|
||||
"name": "Mochila de Disney Marvel Capitán America, de gran capacidad, ligera y resistente a salpicaduras, par",
|
||||
"shortDescription": "Mochila de Disney Marvel Capitán America, de gran capacidad, ligera y resistente a salpicaduras, para estudiantes de secundaria dimensiones 45 cm x 30 cm x 14 cm ",
|
||||
"description": "Mochila de Disney Marvel Capitán America, de gran capacidad, ligera y resistente a salpicaduras, para estudiantes de secundaria dimensiones 45 cm x 30 cm x 14 cm Producto disponible para cotización y confirmación de inventario con Ari Shopping.",
|
||||
"category": "Mochilas",
|
||||
"collection": "Marvel",
|
||||
"brand": "Marvel",
|
||||
"ageRange": "6+",
|
||||
"price": null,
|
||||
"compareAtPrice": null,
|
||||
"currency": "COP",
|
||||
"priceLabel": "Consultar disponibilidad",
|
||||
"available": true,
|
||||
"featured": false,
|
||||
"isNew": false,
|
||||
"image": "/images/products/ari-017-mochila-de-disney-marvel-capitan-america.webp",
|
||||
"images": [
|
||||
"/images/products/ari-017-mochila-de-disney-marvel-capitan-america.webp"
|
||||
],
|
||||
"tags": [
|
||||
"Mochilas",
|
||||
"Marvel",
|
||||
"Marvel"
|
||||
],
|
||||
"variants": [
|
||||
{
|
||||
"id": "ari-017-mochila-de-disney-marvel-capitan-america-default",
|
||||
"name": "Presentación",
|
||||
"value": "Rosa",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CatalogProvider, ProductFilters } from "@/lib/headless/types";
|
||||
import { medusaCatalogProvider } from "@/lib/headless/providers/medusa";
|
||||
import { mockCatalogProvider } from "@/lib/headless/providers/mock";
|
||||
import { shopifyCatalogProvider } from "@/lib/headless/providers/shopify";
|
||||
|
||||
function provider(): CatalogProvider {
|
||||
switch (process.env.HEADLESS_PROVIDER) {
|
||||
case "shopify": return shopifyCatalogProvider;
|
||||
case "medusa": return medusaCatalogProvider;
|
||||
default: return mockCatalogProvider;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProducts(filters?: ProductFilters) {
|
||||
return provider().getProducts(filters);
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string) {
|
||||
return provider().getProductBySlug(slug);
|
||||
}
|
||||
|
||||
export async function searchProducts(query: string, limit?: number) {
|
||||
return provider().searchProducts(query, limit);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CatalogProvider, Product } from "@/lib/headless/types";
|
||||
|
||||
async function medusaFetch<T>(path: string): Promise<T> {
|
||||
const baseUrl = process.env.MEDUSA_BACKEND_URL;
|
||||
if (!baseUrl) throw new Error("Medusa no está configurado.");
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
|
||||
headers: process.env.MEDUSA_PUBLISHABLE_KEY
|
||||
? { "x-publishable-api-key": process.env.MEDUSA_PUBLISHABLE_KEY }
|
||||
: undefined,
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Medusa respondió HTTP ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
function notImplemented(): never {
|
||||
throw new Error("Completa el mapeo Medusa → Product en lib/headless/providers/medusa.ts.");
|
||||
}
|
||||
|
||||
export const medusaCatalogProvider: CatalogProvider = {
|
||||
async getProducts() {
|
||||
await medusaFetch<unknown>("/store/products?limit=24");
|
||||
return notImplemented();
|
||||
},
|
||||
async getProductBySlug(): Promise<Product | null> {
|
||||
return notImplemented();
|
||||
},
|
||||
async searchProducts(): Promise<Product[]> {
|
||||
return notImplemented();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import catalog from "@/data/products.json";
|
||||
import type { CatalogProvider, Product, ProductFilters } from "@/lib/headless/types";
|
||||
|
||||
const products = catalog satisfies Product[];
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.toLocaleLowerCase("es").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
function matches(product: Product, filters: ProductFilters): boolean {
|
||||
if (filters.category && filters.category !== "Todos" && product.category !== filters.category) return false;
|
||||
if (filters.collection && product.collection !== filters.collection) return false;
|
||||
if (filters.brand && product.brand !== filters.brand) return false;
|
||||
if (filters.ageRange && product.ageRange !== filters.ageRange) return false;
|
||||
if (filters.minPrice !== undefined && product.price !== null && product.price < filters.minPrice) return false;
|
||||
if (filters.maxPrice !== undefined && product.price !== null && product.price > filters.maxPrice) return false;
|
||||
if (filters.query) {
|
||||
const query = normalize(filters.query);
|
||||
const haystack = normalize([
|
||||
product.name,
|
||||
product.description,
|
||||
product.category,
|
||||
product.collection,
|
||||
product.brand,
|
||||
...product.tags,
|
||||
].join(" "));
|
||||
if (!haystack.includes(query)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const mockCatalogProvider: CatalogProvider = {
|
||||
async getProducts(filters = {}) {
|
||||
return products.filter((product) => matches(product, filters));
|
||||
},
|
||||
async getProductBySlug(slug) {
|
||||
return products.find((product) => product.slug === slug) ?? null;
|
||||
},
|
||||
async searchProducts(query, limit = 6) {
|
||||
return products.filter((product) => matches(product, { query })).slice(0, limit);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CatalogProvider, Product } from "@/lib/headless/types";
|
||||
|
||||
interface ShopifyGraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
async function shopifyFetch<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||
const url = process.env.SHOPIFY_STOREFRONT_URL;
|
||||
const token = process.env.SHOPIFY_STOREFRONT_TOKEN;
|
||||
if (!url || !token) throw new Error("Shopify Storefront API no está configurada.");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Shopify-Storefront-Access-Token": token,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Shopify respondió HTTP ${response.status}`);
|
||||
const payload = (await response.json()) as ShopifyGraphQLResponse<T>;
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((error) => error.message).join("; "));
|
||||
if (!payload.data) throw new Error("Shopify no devolvió datos.");
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function notImplemented(): never {
|
||||
throw new Error("Completa el mapeo Shopify → Product en lib/headless/providers/shopify.ts.");
|
||||
}
|
||||
|
||||
export const shopifyCatalogProvider: CatalogProvider = {
|
||||
async getProducts() {
|
||||
await shopifyFetch<{ products: unknown }>(`query Products { products(first: 24) { nodes { id title handle } } }`);
|
||||
return notImplemented();
|
||||
},
|
||||
async getProductBySlug(): Promise<Product | null> {
|
||||
return notImplemented();
|
||||
},
|
||||
async searchProducts(): Promise<Product[]> {
|
||||
return notImplemented();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface ProductVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
category: string;
|
||||
collection: string;
|
||||
brand: string;
|
||||
ageRange: string;
|
||||
price: number | null;
|
||||
compareAtPrice: number | null;
|
||||
currency: string;
|
||||
priceLabel: string;
|
||||
available: boolean;
|
||||
featured: boolean;
|
||||
isNew: boolean;
|
||||
image: string;
|
||||
images: string[];
|
||||
tags: string[];
|
||||
variants: ProductVariant[];
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
query?: string;
|
||||
category?: string;
|
||||
collection?: string;
|
||||
brand?: string;
|
||||
ageRange?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
}
|
||||
|
||||
export interface CatalogProvider {
|
||||
getProducts(filters?: ProductFilters): Promise<Product[]>;
|
||||
getProductBySlug(slug: string): Promise<Product | null>;
|
||||
searchProducts(query: string, limit?: number): Promise<Product[]>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatMoney(value: number | null, currency = "COP"): string {
|
||||
if (value === null) return "Consultar precio";
|
||||
return new Intl.NumberFormat("es-CO", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "ari-shopping-headless-storefront",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
node --check /dev/null >/dev/null 2>&1 || true
|
||||
python3 -m json.tool package.json >/dev/null
|
||||
python3 -m json.tool data/products.json >/dev/null
|
||||
test -f app/page.tsx
|
||||
test -f components/layout/navbar.tsx
|
||||
test -f components/product/product-card.tsx
|
||||
test -f store/cart-store.ts
|
||||
test -f public/images/brand/logo-ari.png
|
||||
echo "OK: estructura ARI Shopping Next.js v4 validada."
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
|
||||
export interface CartItem {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
variantId?: string;
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
addItem: (product: Product, variantId?: string) => void;
|
||||
removeItem: (productId: string) => void;
|
||||
updateQuantity: (productId: string, quantity: number) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
isOpen: false,
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
addItem: (product, variantId) => set((state) => {
|
||||
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) })),
|
||||
updateQuantity: (productId, quantity) => set((state) => ({
|
||||
items: quantity <= 0
|
||||
? state.items.filter((item) => item.product.id !== productId)
|
||||
: state.items.map((item) => item.product.id === productId ? { ...item, quantity } : item),
|
||||
})),
|
||||
clearCart: () => set({ items: [] }),
|
||||
}),
|
||||
{
|
||||
name: "ari-shopping-cart-v4",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||