- 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
231 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|