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

This commit is contained in:
2026-07-19 21:36:35 -05:00
parent 2e7abc7e72
commit fe7b2019c2
79 changed files with 7686 additions and 187 deletions
@@ -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>
);
}