feat(ecommerce): página de producto estilo Amazon (buy-box + más ancho)
- max-w de la página de producto pasa de 1280px a 1600px -- aprovecha pantallas grandes en vez de dejar margenes vacíos enormes. - Nueva 3ra columna desde xl: buy-box propio (tarjeta con borde, sticky) para precio/variantes/carrito, separado del texto descriptivo. Por debajo de xl cae al flujo normal de la 2da columna sin duplicar el componente (mismo <ProductPurchase>, un solo id="product-buy-button" en el DOM). - Dentro del buy-box: un producto sugerido real de la misma categoría/colección (mismo cálculo que ya alimenta el carrusel de abajo, no un dato inventado). - MobileAddToCart: la barra fija ahora también requiere haber scrolleado más de 80px además de que el botón real no esté en pantalla -- antes aparecía inmediatamente sobre la vista inicial del producto (imagen + variantes) apenas cargaba la página. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
This commit is contained in:
1 parent
b23f19ac6b
commit
401f43a136
2 files changed
+77
-8
No files matched your search
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
|
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
|
||||||
|
import { ProductCard } from "@/components/product/product-card";
|
||||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||||
import { ProductGallery } from "@/components/product/product-gallery";
|
import { ProductGallery } from "@/components/product/product-gallery";
|
||||||
import { ProductPurchase } from "@/components/product/product-purchase";
|
import { ProductPurchase } from "@/components/product/product-purchase";
|
||||||
@@ -33,5 +34,58 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
const products = await getProducts();
|
const products = await getProducts();
|
||||||
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
|
const recommended = products.filter((candidate) => candidate.id !== product.id && (candidate.category === product.category || candidate.collection === product.collection)).slice(0, 8);
|
||||||
return <><section className="mx-auto grid max-w-7xl gap-10 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16"><ProductGallery images={product.images} name={product.name} /><div><span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span><h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1><p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p><div className="mt-8"><ProductPurchase product={product} /></div><Accordion type="single" collapsible className="mt-8"><AccordionItem value="details"><AccordionTrigger>Detalles del producto</AccordionTrigger><AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent></AccordionItem><AccordionItem value="shipping"><AccordionTrigger>Envíos y entregas</AccordionTrigger><AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent></AccordionItem></Accordion></div></section>{recommended.length > 0 && <section className="mx-auto max-w-7xl px-4 pb-24"><h2 className="mb-7 text-3xl font-black">También te puede gustar</h2><ProductCarousel products={recommended} /></section>}<MobileAddToCart product={product} /></>;
|
const suggested = recommended[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* max-w-[1600px] (antes max-w-7xl/1280px) y una 3ra columna desde
|
||||||
|
xl -- aprovecha pantallas grandes en vez de dejar el buy box
|
||||||
|
apretado contra el texto o cortado por el panel de zoom de al
|
||||||
|
lado (ver ProductGallery). Por debajo de xl, el buy box cae al
|
||||||
|
flujo normal en la 2da columna, sin duplicar el componente en
|
||||||
|
el DOM (evita el id="product-buy-button" repetido que rompería
|
||||||
|
el IntersectionObserver de MobileAddToCart). */}
|
||||||
|
<section className="mx-auto grid max-w-[1600px] gap-10 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16 xl:grid-cols-[1.1fr_.75fr_360px]">
|
||||||
|
<ProductGallery images={product.images} name={product.name} />
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
|
||||||
|
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
|
||||||
|
<p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p>
|
||||||
|
<Accordion type="single" collapsible className="mt-8">
|
||||||
|
<AccordionItem value="details">
|
||||||
|
<AccordionTrigger>Detalles del producto</AccordionTrigger>
|
||||||
|
<AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="shipping">
|
||||||
|
<AccordionTrigger>Envíos y entregas</AccordionTrigger>
|
||||||
|
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
|
||||||
|
tarjeta, sticky desde xl mientras el resto de la pagina
|
||||||
|
scrollea. El sugerido usa el mismo cálculo de "recommended"
|
||||||
|
que ya alimenta el carrusel de abajo -- mismo criterio real
|
||||||
|
(categoría/colección), no un dato inventado. */}
|
||||||
|
<div className="xl:sticky xl:top-24 xl:self-start">
|
||||||
|
<div className="rounded-2xl border border-slate-200 p-6 shadow-sm">
|
||||||
|
<ProductPurchase product={product} />
|
||||||
|
</div>
|
||||||
|
{suggested && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2>
|
||||||
|
<ProductCard product={suggested} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{recommended.length > 0 && (
|
||||||
|
<section className="mx-auto max-w-[1600px] px-4 pb-24">
|
||||||
|
<h2 className="mb-7 text-3xl font-black">También te puede gustar</h2>
|
||||||
|
<ProductCarousel products={recommended} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<MobileAddToCart product={product} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,22 @@ 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";
|
||||||
|
|
||||||
|
const SCROLL_THRESHOLD_PX = 80;
|
||||||
|
|
||||||
// Antes esta barra quedaba fija desde el primer render en mobile,
|
// Antes esta barra quedaba fija desde el primer render en mobile,
|
||||||
// tapando permanentemente parte de la imagen del producto solo para
|
// tapando permanentemente parte de la imagen del producto solo para
|
||||||
// duplicar un boton que ya está visible más abajo. Ahora se muestra
|
// duplicar un boton que ya está visible más abajo. Ahora combina dos
|
||||||
// recien cuando el boton real (#product-buy-button en ProductPurchase,
|
// condiciones, igual que el "Buy Now" sticky de Amazon:
|
||||||
// visible tambien en mobile) sale del viewport -- mismo patron que el
|
// - scrolled: recien aparece despues de que el usuario empieza a
|
||||||
// "Buy Now" sticky de Amazon, que solo aparece al scrollear mas alla
|
// scrollear (nunca sobre la vista inicial de la imagen/hero).
|
||||||
// del boton real.
|
// - !buttonVisible: el boton real (#product-buy-button en
|
||||||
|
// ProductPurchase, visible tambien en mobile) no esta en pantalla --
|
||||||
|
// ya sea porque todavia no se llego a el, o porque se scrolleo mas
|
||||||
|
// alla.
|
||||||
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 [buttonVisible, setButtonVisible] = useState(true);
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const outOfStock = product.variants[0]?.available === false;
|
const outOfStock = product.variants[0]?.available === false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,12 +29,21 @@ export function MobileAddToCart({ product }: { product: Product }) {
|
|||||||
if (!target) return;
|
if (!target) return;
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry) setVisible(!entry.isIntersecting);
|
if (entry) setButtonVisible(entry.isIntersecting);
|
||||||
}, { threshold: 0 });
|
}, { threshold: 0 });
|
||||||
observer.observe(target);
|
observer.observe(target);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > SCROLL_THRESHOLD_PX);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visible = scrolled && !buttonVisible;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden={!visible}
|
aria-hidden={!visible}
|
||||||
|
|||||||
Reference in new issue
Block a user