fix(ecommerce): panel de zoom dinámico y CTA móvil con revelado progresivo
- 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:
1 parent
d605417452
commit
9d4553f3a4
3 files changed
+84
-15
No files matched your search
@@ -1,15 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ShoppingBag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Product } from "@/lib/headless/types";
|
||||
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 }) {
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
const [visible, setVisible] = useState(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 (
|
||||
<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
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
||||
@@ -7,7 +7,17 @@ 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 }
|
||||
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
|
||||
@@ -26,8 +36,21 @@ interface HoverZoomState { lensLeft: number; lensTop: number; lensSize: number;
|
||||
// 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] ?? "");
|
||||
@@ -44,12 +67,22 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -97,25 +130,25 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* Lupa: recuadro que marca la region ampliada, visible desde
|
||||
lg (donde también hay lugar para el panel de al lado). Por
|
||||
debajo de lg se usa el overlay in-place (misma imagen
|
||||
completa como fondo, sin lupa ni panel aparte) porque no
|
||||
hay espacio horizontal para un panel flotante. */}
|
||||
{hoverZoom && (
|
||||
{/* 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 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 }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
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={{
|
||||
opacity: hoverZoom ? 1 : 0,
|
||||
opacity: hoverZoom && !hoverZoom.showPanel ? 1 : 0,
|
||||
backgroundImage: `url(${selected})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${HOVER_ZOOM_SCALE * 100}%`,
|
||||
@@ -144,12 +177,16 @@ export function ProductGallery({ images, name }: { images: string[]; name: strin
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{hoverZoom && (
|
||||
{hoverZoom?.showPanel && (
|
||||
<div
|
||||
aria-hidden
|
||||
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={{
|
||||
left: hoverZoom.panelLeft,
|
||||
top: hoverZoom.panelTop,
|
||||
width: hoverZoom.panelSize,
|
||||
height: hoverZoom.panelSize,
|
||||
backgroundImage: `url(${selected})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
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>
|
||||
{product.compareAtPrice && <del className="text-slate-400">{formatMoney(product.compareAtPrice, product.currency)}</del>}
|
||||
</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
|
||||
id="product-buy-button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="hidden w-full lg:inline-flex"
|
||||
className="w-full"
|
||||
disabled={outOfStock}
|
||||
onClick={() => addItem(product, variantId)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user