From 8d16f2b8546fe51ac908250d9b1860a350cda1d2 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sun, 13 Sep 2026 19:51:10 +0000 Subject: [PATCH] fix(ecommerce): zoom de producto estilo lupa de Amazon (lupa + panel lateral) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El zoom in-place anterior (overlay de fondo dentro de la misma caja) seguía sintiéndose confuso: el usuario esperaba el patrón clásico de e-commerce (Amazon) -- un recuadro semitransparente ("lupa") que marca qué región de la foto se está mirando, y un panel aparte, más grande, que muestra esa región ampliada. Sin scroll ni superposición dentro de la misma caja. - Lupa: recuadro que sigue al cursor sobre la imagen principal, clampeado para no salirse de los bordes (visible en lg+). - bgX/bgY ahora se calculan por fracción de recorrido de la lupa (posición del borde de la lupa entre 0 y el ancho disponible), no por posición cruda del cursor -- mapeo correcto incluso cuando la lupa queda pegada a un borde. - Panel: a la derecha de la imagen (position absolute, se superpone al contenido de al lado igual que en Amazon), mismo mecanismo de background-image con la foto completa que ya evitaba el problema de object-cover recortando partes de la imagen. - Por debajo de lg (sin espacio para un panel aparte) se conserva el overlay in-place como fallback. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV --- .../components/product/product-gallery.tsx | 140 +++++++++++------- 1 file changed, 89 insertions(+), 51 deletions(-) diff --git a/workloads/ecommerce/components/product/product-gallery.tsx b/workloads/ecommerce/components/product/product-gallery.tsx index cf2e7f3..e75689a 100644 --- a/workloads/ecommerce/components/product/product-gallery.tsx +++ b/workloads/ecommerce/components/product/product-gallery.tsx @@ -7,38 +7,49 @@ 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 } -interface HoverZoomOrigin { x: number; y: number } +interface HoverZoomState { lensLeft: number; lensTop: number; lensSize: number; bgX: number; bgY: number } -// Zoom automatico al pasar el cursor (estilo lupa clasico de e-commerce) -// sobre la imagen principal -- solo mouse (mousemove no dispara de forma -// continua en touch, asi que en mobile simplemente no aparece el efecto, -// sin romper nada). Independiente del zoom interactivo del modal -// (click -> Dialog con scale/drag), que sigue igual. +// Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la +// imagen (la lupa) marca la region que se esta mirando, y un panel +// aparte a la derecha muestra esa misma region ampliada -- sin recortar +// nada de la pantalla ni pedirle al usuario que haga scroll para verlo +// completo (a diferencia de un primer intento que escalaba la imagen +// in-place con transform, donde para ver el borde superior había que +// mover el cursor fuera del viewport visible). Solo mouse (mousemove no +// dispara de forma continua en touch); en mobile no aparece el efecto, +// sin romper nada. Independiente del zoom interactivo del modal (click +// -> Dialog con scale/drag), que sigue igual. // -// El overlay de hover usa background-image (no transform:scale sobre el -// ) a proposito: escalar la imagen ya recortada por -// object-cover nunca iba a revelar las partes recortadas (ej. la cabeza -// de una figura de accion en una foto vertical) -- el recorte pasa antes -// del zoom, en el propio render. Con background-size en un solo valor -// (ancho%, alto en "auto") se preserva el aspect ratio real de la foto -// completa, y background-position 0%-100% permite recorrer TODA la -// imagen con el cursor, no solo la porcion que object-cover dejaba -// visible en reposo. +// El panel usa background-image con la foto completa (no la version ya +// recortada por object-cover) a proposito -- escalar la imagen recortada +// nunca iba a revelar las partes recortadas (ej. la cabeza de una figura +// de accion en una foto vertical). background-size en un solo valor +// (ancho%, alto en "auto") preserva el aspect ratio real de la foto +// completa. const HOVER_ZOOM_SCALE = 2.2; +const LENS_RATIO = 1 / HOVER_ZOOM_SCALE; export function ProductGallery({ images, name }: { images: string[]; name: string }) { const [selected, setSelected] = useState(images[0] ?? ""); const [open, setOpen] = useState(false); const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); - const [hoverZoom, setHoverZoom] = useState(null); + const [hoverZoom, setHoverZoom] = useState(null); const drag = useRef(null); const trackHoverZoom = (event: MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); + const lensSize = rect.width * LENS_RATIO; + const cursorX = event.clientX - rect.left; + const cursorY = event.clientY - rect.top; + const lensLeft = Math.min(Math.max(cursorX - lensSize / 2, 0), rect.width - lensSize); + const lensTop = Math.min(Math.max(cursorY - lensSize / 2, 0), rect.height - lensSize); setHoverZoom({ - x: ((event.clientX - rect.left) / rect.width) * 100, - y: ((event.clientY - rect.top) / rect.height) * 100, + lensLeft, + lensTop, + lensSize, + bgX: (lensLeft / (rect.width - lensSize)) * 100, + bgY: (lensTop / (rect.height - lensSize)) * 100, }); }; @@ -69,37 +80,50 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
{images.map((image) => )}
- - - - - +
+ + + + +
applyScale(scale > 1 ? 1 : 2)} style={{ touchAction: "none", cursor: scale > 1 ? "grab" : "zoom-in" }}>
@@ -115,8 +139,22 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin {Math.round(scale * 100)}%
- -
+ +
+ + {hoverZoom && ( +
+ )} +
); }