Files
apps-registry/workloads/ecommerce/components/product/product-gallery.tsx
T
devopsandClaude Sonnet 5 9ab779e781
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 10m35s
fix(ecommerce): zoom-hover del producto revela la imagen completa
El hover-zoom escalaba (transform:scale) la misma <Image> ya recortada
por object-cover -- para fotos verticales (ej. figura de acción de pie)
eso dejaba partes del producto (la cabeza) fuera del recorte desde el
propio render, así que ningún nivel de zoom las mostraba. Se reemplaza
por un overlay con background-image usando la foto completa
(background-size en un solo valor preserva el aspect ratio real) y
background-position siguiendo el cursor, que sí permite recorrer toda
la imagen.

De paso: las miniaturas ahora cambian la imagen principal también con
hover (onMouseEnter), no solo con click.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-13 19:21:39 +00:00

123 lines
7.5 KiB
TypeScript

"use client";
import Image from "next/image";
import { Maximize2, Minus, Plus, RotateCcw, ZoomIn } from "lucide-react";
import { MouseEvent, PointerEvent, WheelEvent, useRef, useState } from "react";
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 }
// 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.
//
// El overlay de hover usa background-image (no transform:scale sobre el
// <Image object-cover>) 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.
const HOVER_ZOOM_SCALE = 2.2;
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<Offset>({ x: 0, y: 0 });
const [hoverZoom, setHoverZoom] = useState<HoverZoomOrigin | null>(null);
const drag = useRef<DragState | null>(null);
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setHoverZoom({
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
});
};
const applyScale = (next: number) => {
const value = Math.min(3, Math.max(1, Number(next.toFixed(2))));
setScale(value);
if (value === 1) setOffset({ x: 0, y: 0 });
};
const reset = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
const changeOpen = (value: boolean) => { setOpen(value); if (!value) reset(); };
const wheel = (event: WheelEvent<HTMLDivElement>) => { event.preventDefault(); applyScale(scale + (event.deltaY < 0 ? .2 : -.2)); };
const pointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (scale <= 1) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offset.x, originY: offset.y };
};
const pointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!drag.current || drag.current.pointerId !== event.pointerId) return;
setOffset({ x: drag.current.originX + event.clientX - drag.current.startX, y: drag.current.originY + event.clientY - drag.current.startY });
};
const pointerEnd = (event: PointerEvent<HTMLDivElement>) => {
if (drag.current?.pointerId === event.pointerId) drag.current = null;
};
return (
<div className="grid gap-4 lg:grid-cols-[86px_1fr]">
<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>)}
</div>
<Dialog open={open} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<button
type="button"
className="group relative order-1 aspect-square cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50 lg:order-2"
onMouseMove={trackHoverZoom}
onMouseLeave={() => setHoverZoom(null)}
>
<Image
src={selected}
alt={name}
fill
priority
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
style={{
opacity: hoverZoom ? 1 : 0,
backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: hoverZoom ? `${hoverZoom.x}% ${hoverZoom.y}%` : "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="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})` }}>
<Image src={selected} alt={name} fill sizes="95vw" className="select-none object-contain" draggable={false} />
</div>
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-black/65 px-3 py-1.5 text-xs font-black text-white backdrop-blur">Rueda o botones para acercar · Arrastra para mover</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-white/10 bg-slate-900 p-4 text-white">
<button type="button" aria-label="Alejar" onClick={() => applyScale(scale - .25)} disabled={scale <= 1} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Minus /></button>
<input aria-label="Nivel de zoom" type="range" min="1" max="3" step="0.1" value={scale} onChange={(event) => applyScale(Number(event.target.value))} className="w-44 accent-fuchsia-500 sm:w-64" />
<button type="button" aria-label="Acercar" onClick={() => applyScale(scale + .25)} disabled={scale >= 3} className="grid size-11 place-items-center rounded-full bg-white/10 hover:bg-white/20 disabled:opacity-35"><Plus /></button>
<button type="button" onClick={reset} className="inline-flex h-11 items-center gap-2 rounded-full bg-white/10 px-4 text-sm font-black hover:bg-white/20"><RotateCcw className="size-4" />Restablecer</button>
<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>
</DialogContent>
</Dialog>
</div>
);
}