fix(ecommerce): panel de zoom dinámico y CTA móvil con revelado progresivo
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 24s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m33s

- El panel de zoom lateral ahora calcula tamaño/posición contra el
  viewport real (window.innerWidth/innerHeight) en cada mousemove, en
  vez de un breakpoint fijo (lg:block) con ancho fijo -- a resoluciones
  altas con poco espacio a la derecha de la imagen 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 (antes
  atado a "por debajo de lg", ahora a "no hay espacio real").
- La barra fija de "Añadir al carrito" en mobile quedaba fija desde el
  primer render, tapando permanentemente parte de la imagen del
  producto. Ahora el botón real (ProductPurchase) también se muestra
  en mobile, y la barra sticky solo aparece cuando ese botón sale del
  viewport (IntersectionObserver) -- mismo patrón que el "Buy Now"
  sticky de Amazon.

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 20:34:43 +00:00
1 parent d605417452
commit 9d4553f3a4
3 files changed
+84 -15

No files matched your search

@@ -1,15 +1,41 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { ShoppingBag } from "lucide-react"; import { ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
import { useCartStore } from "@/store/cart-store"; import { useCartStore } from "@/store/cart-store";
// Antes esta barra quedaba fija desde el primer render en mobile,
// tapando permanentemente parte de la imagen del producto solo para
// duplicar un boton que ya está visible más abajo. Ahora se muestra
// recien cuando el boton real (#product-buy-button en ProductPurchase,
// visible tambien en mobile) sale del viewport -- mismo patron que el
// "Buy Now" sticky de Amazon, que solo aparece al scrollear mas alla
// del boton real.
export function MobileAddToCart({ product }: { product: Product }) { export function MobileAddToCart({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem); const addItem = useCartStore((state) => state.addItem);
const [visible, setVisible] = useState(false);
const outOfStock = product.variants[0]?.available === false; const outOfStock = product.variants[0]?.available === false;
useEffect(() => {
const target = document.getElementById("product-buy-button");
if (!target) return;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry) setVisible(!entry.isIntersecting);
}, { threshold: 0 });
observer.observe(target);
return () => observer.disconnect();
}, []);
return ( return (
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur lg:hidden"> <div
aria-hidden={!visible}
className={`fixed inset-x-0 bottom-0 z-40 border-t border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur transition-transform duration-200 lg:hidden ${
visible ? "translate-y-0" : "pointer-events-none translate-y-full"
}`}
>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
@@ -7,7 +7,17 @@ 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 HoverZoomState { lensLeft: number; lensTop: number; lensSize: number; bgX: number; bgY: 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 // Zoom estilo "lupa" de Amazon: un recuadro semitransparente sobre la
// imagen (la lupa) marca la region que se esta mirando, y un panel // imagen (la lupa) marca la region que se esta mirando, y un panel
@@ -26,8 +36,21 @@ interface HoverZoomState { lensLeft: number; lensTop: number; lensSize: number;
// de accion en una foto vertical). background-size en un solo valor // de accion en una foto vertical). background-size en un solo valor
// (ancho%, alto en "auto") preserva el aspect ratio real de la foto // (ancho%, alto en "auto") preserva el aspect ratio real de la foto
// completa. // 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 HOVER_ZOOM_SCALE = 2.2;
const LENS_RATIO = 1 / HOVER_ZOOM_SCALE; 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 }) { export function ProductGallery({ images, name }: { images: string[]; name: string }) {
const [selected, setSelected] = useState(images[0] ?? ""); const [selected, setSelected] = useState(images[0] ?? "");
@@ -44,12 +67,22 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
const cursorY = event.clientY - rect.top; const cursorY = event.clientY - rect.top;
const lensLeft = Math.min(Math.max(cursorX - lensSize / 2, 0), rect.width - lensSize); 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 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({ setHoverZoom({
lensLeft, lensLeft,
lensTop, lensTop,
lensSize, lensSize,
bgX: (lensLeft / (rect.width - lensSize)) * 100, bgX: (lensLeft / (rect.width - lensSize)) * 100,
bgY: (lensTop / (rect.height - lensSize)) * 100, bgY: (lensTop / (rect.height - lensSize)) * 100,
showPanel: panelSize >= PANEL_MIN_SIZE,
panelLeft: rect.right + PANEL_MARGIN,
panelTop,
panelSize,
}); });
}; };
@@ -97,25 +130,25 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover" className="object-cover"
/> />
{/* Lupa: recuadro que marca la region ampliada, visible desde {/* Lupa: recuadro que marca la region ampliada -- solo cuando
lg (donde también hay lugar para el panel de al lado). Por hay lugar real para el panel de al lado (showPanel,
debajo de lg se usa el overlay in-place (misma imagen calculado contra el viewport real, no un breakpoint
completa como fondo, sin lupa ni panel aparte) porque no fijo). Sin espacio para el panel se usa el overlay
hay espacio horizontal para un panel flotante. */} in-place como fallback, para cualquier resolucion. */}
{hoverZoom && ( {hoverZoom?.showPanel && (
<div <div
aria-hidden aria-hidden
data-testid="gallery-lens" data-testid="gallery-lens"
className="pointer-events-none absolute hidden border-2 border-white bg-white/25 shadow-lg lg:block" 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 }} style={{ left: hoverZoom.lensLeft, top: hoverZoom.lensTop, width: hoverZoom.lensSize, height: hoverZoom.lensSize }}
/> />
)} )}
<div <div
aria-hidden aria-hidden
data-testid="gallery-inplace-overlay" data-testid="gallery-inplace-overlay"
className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out lg:hidden" className="pointer-events-none absolute inset-0 bg-slate-50 transition-opacity duration-150 ease-out"
style={{ style={{
opacity: hoverZoom ? 1 : 0, opacity: hoverZoom && !hoverZoom.showPanel ? 1 : 0,
backgroundImage: `url(${selected})`, backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`, backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
@@ -144,12 +177,16 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{hoverZoom && ( {hoverZoom?.showPanel && (
<div <div
aria-hidden aria-hidden
data-testid="gallery-zoom-panel" data-testid="gallery-zoom-panel"
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" className="pointer-events-none fixed z-30 overflow-hidden rounded-[2rem] border border-slate-200 bg-white shadow-2xl"
style={{ style={{
left: hoverZoom.panelLeft,
top: hoverZoom.panelTop,
width: hoverZoom.panelSize,
height: hoverZoom.panelSize,
backgroundImage: `url(${selected})`, backgroundImage: `url(${selected})`,
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`, backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
@@ -40,11 +40,17 @@ export function ProductPurchase({ product }: { product: Product }) {
<strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong> <strong className="text-3xl font-black">{formatMoney(product.price, product.currency)}</strong>
{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>} {product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}
</div> </div>
{/* oculto en mobile: MobileAddToCart (barra fija abajo, lg:hidden) ya cubre esa misma accion ahi -- sin el hidden aqui, ambos se mostraban a la vez. */} {/* Visible tambien en mobile (antes quedaba oculto <lg porque
MobileAddToCart quedaba fijo abajo desde el primer render,
tapando parte de la imagen sin necesidad). id usado por
MobileAddToCart para saber cuando este boton real sale del
viewport y recien ahi mostrar su barra sticky -- mismo patron
que el "Buy Now" sticky de Amazon en mobile. */}
<Button <Button
id="product-buy-button"
variant="primary" variant="primary"
size="lg" size="lg"
className="hidden w-full lg:inline-flex" className="w-full"
disabled={outOfStock} disabled={outOfStock}
onClick={() => addItem(product, variantId)} onClick={() => addItem(product, variantId)}
> >