feat(storefront): galería deslizable en las tarjetas de producto del listado
Permite ver todas las imágenes de un producto desde el catálogo y los carruseles sin entrar a la ficha: swipe con scroll-snap nativo en móvil, flechas al pasar el mouse en escritorio (pointer-fine) y puntos de posición. Cada imagen sigue enlazando a la ficha; productos con una sola imagen no muestran controles. overscroll-behavior-x: contain evita que el swipe de imágenes mueva el carrusel de productos del inicio. Validado antes del merge con build de producción local (datos mock) y Playwright con eventos táctiles reales a 390/320 px y 1280 px. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
This commit is contained in:
1 parent
496ad982f3
commit
123cbbd87a
2 files changed
+117
-2
No files matched your search
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
// Galería deslizable de la tarjeta de producto: permite ver todas las
|
||||
// imágenes desde el listado sin entrar al producto. En móvil se desliza
|
||||
// con el dedo (scroll-snap nativo); en escritorio aparecen flechas al
|
||||
// pasar el mouse. Cada imagen sigue siendo un link a la ficha.
|
||||
export function ProductCardGallery({
|
||||
images,
|
||||
name,
|
||||
href,
|
||||
priority = false,
|
||||
}: {
|
||||
images: string[];
|
||||
name: string;
|
||||
href: string;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [index, setIndex] = useState(0);
|
||||
const total = images.length;
|
||||
|
||||
const goTo = (target: number) => {
|
||||
const node = scroller.current;
|
||||
if (!node) return;
|
||||
const next = Math.max(0, Math.min(total - 1, target));
|
||||
node.scrollTo({ left: next * node.clientWidth, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const node = scroller.current;
|
||||
if (!node || node.clientWidth === 0) return;
|
||||
setIndex(Math.round(node.scrollLeft / node.clientWidth));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={total > 1 ? onScroll : undefined}
|
||||
className="flex h-full snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((src, slide) => (
|
||||
<Link
|
||||
key={src}
|
||||
href={href}
|
||||
prefetch={false}
|
||||
className="relative block h-full w-full shrink-0 snap-center snap-always"
|
||||
aria-label={total > 1 ? `${name}, imagen ${slide + 1} de ${total}` : name}
|
||||
tabIndex={slide === 0 ? 0 : -1}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={slide === 0 ? name : `${name} (imagen ${slide + 1})`}
|
||||
fill
|
||||
priority={priority && slide === 0}
|
||||
sizes="(max-width: 640px) 82vw, (max-width: 1024px) 44vw, 280px"
|
||||
className="object-cover transition duration-500 group-hover:scale-[1.035]"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{total > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Imagen anterior"
|
||||
disabled={index === 0}
|
||||
onClick={() => goTo(index - 1)}
|
||||
className="absolute left-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Imagen siguiente"
|
||||
disabled={index === total - 1}
|
||||
onClick={() => goTo(index + 1)}
|
||||
className="absolute right-2 top-1/2 hidden size-9 -translate-y-1/2 place-items-center rounded-full bg-white/95 text-ari-marino opacity-0 shadow-lg transition group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:!opacity-0 pointer-fine:grid"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<div className="flex items-center rounded-full bg-white/85 px-1 shadow-sm backdrop-blur">
|
||||
{images.map((src, dot) => (
|
||||
<button
|
||||
key={src}
|
||||
type="button"
|
||||
aria-label={`Ver imagen ${dot + 1} de ${total}`}
|
||||
aria-current={dot === index}
|
||||
onClick={() => goTo(dot)}
|
||||
className="grid h-5 place-items-center px-[3px]"
|
||||
>
|
||||
<span
|
||||
className={`block h-1.5 rounded-full transition-all ${
|
||||
dot === index ? "w-4 bg-ari-coral" : "w-1.5 bg-ari-marino/35"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user