From 123cbbd87a650f7cf5487058a4f2e073c9282b73 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sun, 27 Sep 2026 01:37:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(storefront):=20galer=C3=ADa=20deslizable?= =?UTF-8?q?=20en=20las=20tarjetas=20de=20producto=20del=20listado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP --- .../product/product-card-gallery.tsx | 112 ++++++++++++++++++ .../components/product/product-card.tsx | 7 +- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 workloads/ecommerce/components/product/product-card-gallery.tsx diff --git a/workloads/ecommerce/components/product/product-card-gallery.tsx b/workloads/ecommerce/components/product/product-card-gallery.tsx new file mode 100644 index 0000000..608b398 --- /dev/null +++ b/workloads/ecommerce/components/product/product-card-gallery.tsx @@ -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(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 ( + <> +
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) => ( + 1 ? `${name}, imagen ${slide + 1} de ${total}` : name} + tabIndex={slide === 0 ? 0 : -1} + > + {slide + + ))} +
+ + {total > 1 && ( + <> + + +
+
+ {images.map((src, dot) => ( + + ))} +
+
+ + )} + + ); +} diff --git a/workloads/ecommerce/components/product/product-card.tsx b/workloads/ecommerce/components/product/product-card.tsx index 8fd4dd2..3000218 100644 --- a/workloads/ecommerce/components/product/product-card.tsx +++ b/workloads/ecommerce/components/product/product-card.tsx @@ -1,9 +1,9 @@ "use client"; -import Image from "next/image"; import Link from "next/link"; import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react"; import { useState } from "react"; +import { ProductCardGallery } from "@/components/product/product-card-gallery"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Product } from "@/lib/headless/types"; @@ -22,6 +22,9 @@ export function ProductCard({ product, priority = false }: { product: Product; p ? Math.round((1 - product.price / product.compareAtPrice) * 100) : null; + const galleryImages = Array.from(new Set(product.images.filter(Boolean))); + if (galleryImages.length === 0) galleryImages.push(product.thumbnail); + const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0]?.id); const selectedVariant = product.variants.find((variant) => variant.id === selectedVariantId) ?? product.variants[0]; const outOfStock = selectedVariant?.available === false; @@ -37,7 +40,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p return (
- {product.name} +
{outOfStock && Agotado}{discountPercent && discountPercent > 0 && -{discountPercent}%}{product.isNew && Nuevo}{product.featured && Top}
{outOfStock &&
}