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:
devopsandClaude Opus 5.5 committed 2026-09-27 01:37:25 +00:00
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>
</>
)}
</>
);
}