fix(ecommerce): zoom de producto estilo lupa de Amazon (lupa + panel lateral)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m44s

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
This commit is contained in:
devopsandClaude Sonnet 5 committed 2026-09-13 19:51:10 +00:00
1 parent f0b1d7b745
commit 8d16f2b854
1 file changed
+89 -51
@@ -7,38 +7,49 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
interface Offset { x: number; y: number } interface Offset { x: number; y: number }
interface DragState { pointerId: number; startX: number; startY: number; originX: number; originY: 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) // Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la
// sobre la imagen principal -- solo mouse (mousemove no dispara de forma // imagen (la lupa) marca la region que se esta mirando, y un panel
// continua en touch, asi que en mobile simplemente no aparece el efecto, // aparte a la derecha muestra esa misma region ampliada -- sin recortar
// sin romper nada). Independiente del zoom interactivo del modal // nada de la pantalla ni pedirle al usuario que haga scroll para verlo
// (click -> Dialog con scale/drag), que sigue igual. // 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 // El panel usa background-image con la foto completa (no la version ya
// <Image object-cover>) a proposito: escalar la imagen ya recortada por // recortada por object-cover) a proposito -- escalar la imagen recortada
// object-cover nunca iba a revelar las partes recortadas (ej. la cabeza // nunca iba a revelar las partes recortadas (ej. la cabeza de una figura
// de una figura de accion en una foto vertical) -- el recorte pasa antes // de accion en una foto vertical). background-size en un solo valor
// del zoom, en el propio render. Con background-size en un solo valor // (ancho%, alto en "auto") preserva el aspect ratio real de la foto
// (ancho%, alto en "auto") se preserva el aspect ratio real de la foto // completa.
// 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.
const HOVER_ZOOM_SCALE = 2.2; const HOVER_ZOOM_SCALE = 2.2;
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE;
export function ProductGallery({ images, name }: { images: string[]; name: string }) { export function ProductGallery({ images, name }: { images: string[]; name: string }) {
const [selected, setSelected] = useState(images[0] ?? ""); const [selected, setSelected] = useState(images[0] ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 }); const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null); const [hoverZoom, setHoverZoom] = useState<HoverZoomState | null>(null);
const drag = useRef<DragState | null>(null); const drag = useRef<DragState | null>(null);
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => { const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect(); 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({ setHoverZoom({
x: ((event.clientX - rect.left) / rect.width) * 100, lensLeft,
y: ((event.clientY - rect.top) / rect.height) * 100, 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
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col"> <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(); }} onMouseEnter={() => { 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>)} {images.map((image) => <button type="button" key={image} onClick={() => { setSelected(image); reset(); }} onMouseEnter={() => { 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> </div>
<Dialog open={open} onOpenChange={changeOpen}> <div className="relative order-1 lg:order-2">
<DialogTrigger asChild> <Dialog open={open} onOpenChange={changeOpen}>
<button <DialogTrigger asChild>
type="button" <button
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2" type="button"
onMouseMove={trackHoverZoom} className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
onMouseLeave={() => setHoverZoom(null)} onMouseMove={trackHoverZoom}
> onMouseLeave={() => setHoverZoom(null)}
<Image >
src={selected} <Image
alt={name} src={selected}
fill alt={name}
priority fill
sizes="(max-width: 1024px) 100vw, 50vw" priority
className="object-cover" sizes="(max-width: 1024px) 100vw, 50vw"
/> className="object-cover"
<div />
aria-hidden {/* Lupa: recuadro que marca la region ampliada, visible desde
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out" lg (donde también hay lugar para el panel de al lado). Por
style={{ debajo de lg se usa el overlay in-place (misma imagen
opacity: hoverZoom ? 1 : 0, completa como fondo, sin lupa ni panel aparte) porque no
backgroundImage: `url(${selected})`, hay espacio horizontal para un panel flotante. */}
backgroundRepeat: "no-repeat", {hoverZoom && (
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`, <div
backgroundPosition: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "center", aria-hidden
}} className="pointer-events-none absolute hidden border-2 border-white bg-white/25 shadow-lg lg:block"
/> style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
<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> <div
<DialogContent className="max-w-6xl overflow-hidden bg-slate-950 p-0"> aria-hidden
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out lg:hidden"
style={{
opacity: hoverZoom ? 1 : 0,
backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: hoverZoom ? `${hoverZoom.bgX}% ${hoverZoom.bgY}%` : "center",
}}
/>
<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="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="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})` }}> <div className="absolute inset-6 transition-transform duration-100 will-change-transform" style={{ transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${scale})` }}>
@@ -115,8 +139,22 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
<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> <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>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{hoverZoom && (
<div
aria-hidden
className="pointer-events-none absolute left-full top-0 z-30 ml-4 hidden aspect-square w-[26rem] max-w-[40vw] overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl lg:block"
style={{
backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
}}
/>
)}
</div>
</div> </div>
); );
} }