El buy-box (precio/carrito/sugeridos) es sticky y suele terminar siendo la columna más alta del grid de producto -- al ser un grid de una sola fila, el carrusel de recomendaciones de abajo (sección aparte) solo empezaba una vez terminaba la columna MÁS alta, dejando un hueco vacío enorme debajo de galería+descripción cuando esas dos son más cortas que el buy-box. Se reestructura a un grid de 2 filas desde xl: el buy-box ahora ocupa ambas filas (row-span-2, sigue sticky), y "También te puede gustar" pasa a ser la 2da fila pero solo debajo de galería+descripción (col-span-2) -- corre en paralelo al buy-box en vez de esperarlo. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
106 lines
6.4 KiB
TypeScript
106 lines
6.4 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
import { MobileAddToCart } from "@/components/product/mobile-add-to-cart";
|
|
import { ProductCard } from "@/components/product/product-card";
|
|
import { ProductCarousel } from "@/components/product/product-carousel";
|
|
import { ProductGallery } from "@/components/product/product-gallery";
|
|
import { ProductPurchase } from "@/components/product/product-purchase";
|
|
import { getProductBySlug, getProducts } from "@/lib/headless/client";
|
|
|
|
interface ProductPageProps { params: Promise<{ slug: string }> }
|
|
|
|
// generateStaticParams + revalidate quedaban aquí para pre-renderizar en
|
|
// build time, pero HEADLESS_PROVIDER/MEDUSA_* no existen en el contexto del
|
|
// Docker build: el snapshot estático quedaba congelado con el catálogo mock
|
|
// (todos los productos con price: null) hasta que ISR lo revalidara. Con
|
|
// frontend-deploy corriendo 2 réplicas y sin cacheHandler compartido, cada
|
|
// pod revalida su propia caché de forma independiente, así que un producto
|
|
// podía mostrar "Consultar precio" en un pod y el precio real en el otro
|
|
// según cuál hubiera sido "calentado". force-dynamic elimina esa caché por
|
|
// pod y deja esta ruta en vivo contra Medusa, igual que el listado
|
|
// (dinámico por searchParams).
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const product = await getProductBySlug(slug);
|
|
return product ? { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } } : { title: "Producto" };
|
|
}
|
|
|
|
export default async function ProductPage({ params }: ProductPageProps) {
|
|
const { slug } = await params;
|
|
const product = await getProductBySlug(slug);
|
|
if (!product) notFound();
|
|
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 suggestions = recommended.slice(0, 2);
|
|
|
|
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).
|
|
El buy box ocupa las 2 filas (xl:row-span-2) para no quedar
|
|
atado a la altura de galería+descripción -- "También te puede
|
|
gustar" pasa a ser la 2da fila debajo de esas dos columnas
|
|
(xl:col-span-2), en paralelo al buy box sticky, en vez de
|
|
esperar a que termine la columna más alta antes de aparecer. */}
|
|
<section className="mx-auto grid max-w-[1600px] gap-x-10 gap-y-12 px-4 py-10 lg:grid-cols-[1.08fr_.92fr] lg:py-16 xl:grid-cols-[1.1fr_.75fr_360px] xl:grid-rows-[auto_auto]">
|
|
<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>
|
|
{/* Antes escondido detrás de un click en el acordeón --
|
|
siempre visible, estilo Amazon, con los mismos datos
|
|
reales que ya existían (marca/edad/categoría/colección),
|
|
sin inventar specs que el catálogo no tiene. */}
|
|
<h2 className="mb-4 mt-8 text-sm font-black uppercase tracking-wide text-slate-500">Detalles del producto</h2>
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-2xl border border-slate-200 p-5 text-sm sm:grid-cols-4">
|
|
<div><dt className="text-slate-400">Marca</dt><dd className="mt-0.5 font-bold text-slate-900">{product.brand}</dd></div>
|
|
<div><dt className="text-slate-400">Edad recomendada</dt><dd className="mt-0.5 font-bold text-slate-900">{product.ageRange}</dd></div>
|
|
<div><dt className="text-slate-400">Categoría</dt><dd className="mt-0.5 font-bold text-slate-900">{product.category}</dd></div>
|
|
<div><dt className="text-slate-400">Colección</dt><dd className="mt-0.5 font-bold text-slate-900">{product.collection}</dd></div>
|
|
</dl>
|
|
<Accordion type="single" collapsible className="mt-6">
|
|
<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. Los sugeridos (hasta 2) usan 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:row-span-2 xl:self-start">
|
|
<div className="rounded-2xl border border-slate-200 p-6 shadow-sm">
|
|
<ProductPurchase product={product} />
|
|
</div>
|
|
{suggestions.length > 0 && (
|
|
<div className="mt-6">
|
|
<h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2>
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
|
{suggestions.map((item) => <ProductCard key={item.id} product={item} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{recommended.length > 0 && (
|
|
<div className="pb-16 lg:col-span-2">
|
|
<h2 className="mb-7 text-3xl font-black">También te puede gustar</h2>
|
|
<ProductCarousel products={recommended} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
<MobileAddToCart product={product} />
|
|
</>
|
|
);
|
|
}
|