Backend (commerce-backend):
- Módulo product-review (tabla nueva product_review; migración generada con
medusa db:generate, no toca tablas existentes).
- GET /store/reviews?product_id (solo aprobadas), /store/reviews/summary,
GET /store/reviews/me y POST /store/reviews con auth de cliente: exige que
el pedido sea del cliente, no esté cancelado y contenga el producto
("Compra verificada"); 1 reseña por cliente y producto; nace "pending".
- /admin/reviews (+ /:id POST status, DELETE) y página "Reseñas" en el admin
para aprobar/rechazar/borrar.
Storefront (ecommerce):
- /cuenta/pedidos ("Mis pedidos"): historial real + diálogo "Califica tu
compra" (radiogroup accesible, flechas de teclado).
- Ficha: sección de reseñas con "Compra verificada" y estrellas junto al
título; tarjetas: promedio y cantidad (una llamada /api/reviews/summary).
- Accesos: "Mis pedidos y reseñas" en la cuenta y en Ayuda.
Validado en entorno descartable (Postgres/Redis/Meilisearch/Medusa propios):
E2E de API 20/20 (incluye pedido interno por draft order, cancelación y
borrado), E2E de tienda con navegador 15/15, admin "Reseñas" verificado en
build de producción. Revisado por frontend-critic (contraste de estrellas
amber-600, patrón radiogroup).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018r19s4H8MBGegqToqw5guP
131 lines
8.1 KiB
TypeScript
131 lines
8.1 KiB
TypeScript
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<Metadata> {
|
|
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 <Suspense> 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. */}
|
|
<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 className="min-w-0">
|
|
<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>
|
|
<CardRating productId={product.id} href="#resenas" />
|
|
<p className="mt-5 text-sm leading-6 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>
|
|
<ProductReviews productId={product.id} />
|
|
</div>
|
|
{/* 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. */}
|
|
<div className="min-w-0 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>
|
|
{crossSell && (
|
|
<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={crossSell} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* 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 && (
|
|
<div className="min-w-0 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>
|
|
</>
|
|
);
|
|
}
|