Files
apps-registry/workloads/ecommerce/components/product/product-gallery.tsx
devopsandClaude Sonnet 5 545062c3ef
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m28s
fix(ecommerce): imagen principal cortaba cabeza + fuente grande + sugerido duplicado
- Imagen principal del producto pasa de object-cover a object-contain
  (mismo motivo que las miniaturas del commit anterior): la foto
  vertical de Buzz Lightyear (933x1500) aparecía sin cabeza al
  seleccionarla, reportado en vivo con captura real.
- Descripción del producto baja de 16px a 14px (text-sm leading-6,
  mismo tamaño que los párrafos secundarios de /envios y /cambios) --
  seguía viéndose grande al lado del resto de la columna.
- El buy-box mostraba los mismos 2 productos que ya aparecían en el
  carrusel "También te puede gustar" de abajo (ambos usaban el mismo
  cálculo de categoría/colección). Ahora el buy-box sugiere 1 producto
  de una categoría DISTINTA -- cross-sell real en vez de duplicado.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
2026-09-14 02:11:12 +00:00

231 lines
13 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 HoverZoomState {
lensLeft: number;
lensTop: number;
lensSize: number;
bgX: number;
bgY: number;
showPanel: boolean;
panelLeft: number;
panelTop: number;
panelSize: number;
}
// 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 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.
//
// El tamaño/posicion del panel se calculan en cada mousemove contra el
// viewport real (window.innerWidth/innerHeight), no con un breakpoint
// fijo tipo "lg:block" -- a resoluciones altas con la ventana angosta
// (o el layout de la pagina dejando poco espacio a la derecha de la
// imagen) un tamaño fijo terminaba saliéndose de la pantalla. Si no
// entra un panel de al menos PANEL_MIN_SIZE, se cae automáticamente al
// overlay in-place (misma imagen completa como fondo, dentro de la
// propia caja) en vez de mostrar un panel cortado.
const HOVER_ZOOM_SCALE = 2.2;
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE;
const PANEL_MARGIN = 16;
const PANEL_MAX_SIZE = 480;
const PANEL_MIN_SIZE = 220;
const VIEWPORT_EDGE_PADDING = 16;
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<HoverZoomState | null>(null);
const drag = useRef<DragState | null>(null);
const trackHoverZoom = (event: MouseEvent<HTMLButtonElement>) => {
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);
const availableWidth = window.innerWidth - rect.right - PANEL_MARGIN - VIEWPORT_EDGE_PADDING;
const availableHeight = window.innerHeight - VIEWPORT_EDGE_PADDING * 2;
const panelSize = Math.min(PANEL_MAX_SIZE, availableWidth, availableHeight);
const panelTop = Math.min(Math.max(rect.top, VIEWPORT_EDGE_PADDING), window.innerHeight - panelSize - VIEWPORT_EDGE_PADDING);
setHoverZoom({
lensLeft,
lensTop,
lensSize,
bgX: (lensLeft / (rect.width - lensSize)) * 100,
bgY: (lensTop / (rect.height - lensSize)) * 100,
showPanel: panelSize >= PANEL_MIN_SIZE,
panelLeft: rect.right + PANEL_MARGIN,
panelTop,
panelSize,
});
};
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="min-w-0 grid gap-4 lg:grid-cols-[86px_1fr]">
{/* min-w-0: esta tira de miniaturas es un overflow-x-auto (scroll
horizontal) igual que el carrusel de "También te puede gustar"
en app/product/[slug]/page.tsx -- mismo bug si este componente
termina como item de un grid externo sin min-w-0: para productos
con muchas fotos, el ancho minimo intrinseco de TODAS las
miniaturas sin wrappear puede forzar overflow horizontal en toda
la pagina en mobile. */}
<div className="order-2 flex gap-3 overflow-x-auto lg:order-1 lg:flex-col">
{/* object-contain (no object-cover): fotos de producto con relacion
de aspecto muy vertical (ej. 933x1500, confirmado en vivo para
este catalogo) perdian la cabeza o los pies recortados al forzar
el cuadrado con cover -- reportado por el usuario viendo las
miniaturas. contain deja la miniatura completa siempre, con
"letterbox" del mismo bg-slate-50 que usa la imagen principal. */}
{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 bg-slate-50 ${selected === image ? "border-fuchsia-500" : "border-transparent"}`}><Image src={image} alt="" fill sizes="80px" className="object-contain" /></button>)}
</div>
<div className="relative order-1 lg:order-2">
<Dialog open={open} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<button
type="button"
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-[2rem] bg-slate-50"
onMouseMove={trackHoverZoom}
onMouseLeave={() => setHoverZoom(null)}
>
{/* unoptimized a proposito: confirmado en vivo (createImageBitmap
sobre los bytes reales vs. el <img> renderizado) que a DPR
alto (2.6+, cualquier Android real) Chromium reporta
naturalWidth/Height de un candidato del srcset mucho más
chico del que dice currentSrc -- bug del propio motor de
Chromium en la selección de candidato con sizes="100vw" en
fill, no algo corrupto en el archivo ni en nuestro caché
(el archivo decodifica perfecto vía fetch+createImageBitmap
fuera de <img>). Reproducido igual en Chrome y Edge Android
(ambos Chromium) y en Chromium headless. unoptimized deja
un solo recurso real por foto -- sin candidatos entre los
que el bug pueda elegir mal. */}
{/* object-contain (no object-cover): mismo motivo que las
miniaturas -- fotos verticales (933x1500 confirmado en
este catalogo) perdian la cabeza al forzar el cuadrado.
Reportado en vivo: al seleccionar esa foto, Buzz aparecia
sin cabeza en la imagen principal. */}
<Image
src={selected}
alt={name}
fill
priority
unoptimized
className="object-contain"
/>
{/* Lupa: recuadro que marca la region ampliada -- solo cuando
hay lugar real para el panel de al lado (showPanel,
calculado contra el viewport real, no un breakpoint
fijo). Sin espacio para el panel se usa el overlay
in-place como fallback, para cualquier resolucion. */}
{hoverZoom?.showPanel && (
<div
aria-hidden
data-testid="gallery-lens"
className="pointer-events-none absolute border-2 border-white bg-white/25 shadow-lg"
style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
/>
)}
<div
aria-hidden
data-testid="gallery-inplace-overlay"
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
style={{
opacity: hoverZoom && !hoverZoom.showPanel ? 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="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 unoptimized 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>
{hoverZoom?.showPanel && (
<div
aria-hidden
data-testid="gallery-zoom-panel"
className="pointer-events-none fixed z-30 overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl"
style={{
left: hoverZoom.panelLeft,
top: hoverZoom.panelTop,
width: hoverZoom.panelSize,
height: hoverZoom.panelSize,
backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
backgroundPosition: `${hoverZoom.bgX}% ${hoverZoom.bgY}%`,
}}
/>
)}
</div>
</div>
);
}