import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { CardRating } from "@/components/product/card-rating"; import { ProductCard } from "@/components/product/product-card"; import { ProductReviews } from "@/components/product/product-reviews"; 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 { const { slug } = await params; const product = await getProductBySlug(slug); // notFound() se llama aquí (no solo en el componente de abajo) porque // generateMetadata se resuelve por completo ANTES de que Next empiece a // transmitir la respuesta -- el del navbar (categorías, ver // navbar.tsx) hace que el layout raíz empiece a stream-ear con status 200 // antes de que un notFound() lanzado dentro del componente de la página // pueda cambiarlo (confirmado en vivo: cualquier slug inexistente devolvía // HTTP 200 con el contenido de app/not-found.tsx, rompiendo SEO/crawlers // que sí miran el status code). Llamarlo aquí evita el problema porque esta // fase bloquea el stream por diseño. if (!product) notFound(); return { title: product.name, description: product.shortDescription, openGraph: { images: [product.image] } }; } 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); // Categoría distinta a propósito -- antes repetía los primeros 2 del // mismo "recommended" que ya alimenta el carrusel de abajo (mismos // productos en dos lugares de la página, reportado por el usuario). // Un cross-sell real de otra categoría aporta algo que el carrusel no // muestra, en vez de duplicarlo. const crossCategory = products.filter((candidate) => candidate.id !== product.id && candidate.category !== product.category); const crossSell = crossCategory.find((candidate) => candidate.available) ?? crossCategory[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. 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. */}
{product.category} · {product.collection}

{product.name}

{product.description}

{/* 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. */}

Detalles del producto

Marca
{product.brand}
Edad recomendada
{product.ageRange}
Categoría
{product.category}
Colección
{product.collection}
Envíos y entregas Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia tarjeta, sticky desde xl mientras el resto de la pagina scrollea. El cross-sell es de OTRA categoría a propósito -- evita repetir lo que ya muestra el carrusel de "También te puede gustar" de abajo (mismo criterio de categoría/colección que ese carrusel), aportando un producto genuinamente distinto en vez de duplicado. */}
{crossSell && (

También te puede interesar

)}
{/* min-w-0 es necesario: sin esto, el carrusel horizontal (overflow-x-auto interno) contaba como item de este grid, y su ancho minimo intrinseco (la suma de TODAS las cards sin wrappear, ya que el scroller no tiene flex-wrap) empujaba todo el grid completo a ~800px en mobile -- afectaba a TODA la columna compartida (imagen, titulo, specs), no solo al carrusel. Confirmado en vivo: sin min-w-0 el grid medía 783px en un viewport real de 380px; con min-w-0 vuelve a 380px. */} {recommended.length > 0 && (

También te puede gustar

)}
); }