feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 9m42s
Build and Push Frontend / build (push) Failing after 9m42s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user