feat(ecommerce): aprovechar mejor el espacio en la página de producto
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m36s

- "Detalles del producto" (marca, edad, categoría, colección) pasa de
  acordeón colapsado a una mini tabla siempre visible -- mismos datos
  reales de siempre, solo que ya no hace falta un click para verlos.
- Buy-box: nueva franja de confianza real con enlaces a /envios y
  /cambios (5 días hábiles, envío de vuelta gratis) -- mismas
  políticas ya publicadas en el footer, no texto inventado.
- "También te puede interesar" ahora muestra hasta 2 sugerencias en
  vez de 1, mismo cálculo real por categoría/colección que ya
  alimentaba el carrusel de abajo.

Investigado de paso (sin fix): la discrepancia de stock reportada
("Quedan 1" vs available:false) no es un bug de ProductPurchase -- la
página de producto usa /store/products de Medusa (en vivo, correcto),
mientras que /store/search (Meilisearch) devuelve inventario
desactualizado para el mismo producto. Eso vive en commerce-backend,
fuera de alcance de este cambio de frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y4df3Z7odr9Cg8F2nw6uV
This commit is contained in:
devopsandClaude Sonnet 5 committed 2026-09-13 22:20:41 +00:00
1 parent 9b545dd850
commit c3f3857a92
2 files changed
+37 -12

No files matched your search

+20 -11
View File
@@ -34,7 +34,7 @@ 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);
const suggested = recommended[0]; const suggestions = recommended.slice(0, 2);
return ( return (
<> <>
@@ -51,11 +51,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span> <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> <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> <p className="mt-5 text-lg leading-8 text-slate-600">{product.description}</p>
<Accordion type="single" collapsible className="mt-8"> {/* Antes escondido detrás de un click en el acordeón --
<AccordionItem value="details"> siempre visible, estilo Amazon, con los mismos datos
<AccordionTrigger>Detalles del producto</AccordionTrigger> reales que ya existían (marca/edad/categoría/colección),
<AccordionContent>Marca: {product.brand}. Edad recomendada: {product.ageRange}. La disponibilidad y presentación final se confirman con Ari Shopping.</AccordionContent> sin inventar specs que el catálogo no tiene. */}
</AccordionItem> <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"> <AccordionItem value="shipping">
<AccordionTrigger>Envíos y entregas</AccordionTrigger> <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> <AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
@@ -64,17 +71,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia {/* Buy box estilo Amazon: precio/variantes/carrito en su propia
tarjeta, sticky desde xl mientras el resto de la pagina tarjeta, sticky desde xl mientras el resto de la pagina
scrollea. El sugerido usa el mismo cálculo de "recommended" scrollea. Los sugeridos (hasta 2) usan el mismo cálculo de
que ya alimenta el carrusel de abajo -- mismo criterio real "recommended" que ya alimenta el carrusel de abajo -- mismo
(categoría/colección), no un dato inventado. */} criterio real (categoría/colección), no un dato inventado. */}
<div className="xl:sticky xl:top-24 xl:self-start"> <div className="xl:sticky xl:top-24 xl:self-start">
<div className="rounded-2xl border border-slate-200 p-6 shadow-sm"> <div className="rounded-2xl border border-slate-200 p-6 shadow-sm">
<ProductPurchase product={product} /> <ProductPurchase product={product} />
</div> </div>
{suggested && ( {suggestions.length > 0 && (
<div className="mt-6"> <div className="mt-6">
<h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2> <h2 className="mb-4 text-xs font-black uppercase tracking-[.14em] text-slate-500">También te puede interesar</h2>
<ProductCard product={suggested} /> <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>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { CheckCircle2, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react"; import { CheckCircle2, RotateCcw, ShoppingBag, TriangleAlert, Truck, XCircle } from "lucide-react";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Product } from "@/lib/headless/types"; import type { Product } from "@/lib/headless/types";
@@ -79,6 +80,21 @@ export function ProductPurchase({ product }: { product: Product }) {
Envíos nacionales Envíos nacionales
</span> </span>
</div> </div>
{/* Franja de confianza real (estilo Walmart) -- enlaza a las
páginas reales de /envios y /cambios en vez de repetir texto
suelto, con los datos reales que confirmó el usuario (5 días
hábiles, envío de vuelta a cargo de ARI Shopping). Nada
inventado: mismas políticas ya publicadas en el footer. */}
<div className="mt-3 grid gap-2 text-sm">
<Link href="/envios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
<Truck className="size-4 text-cyan-600" />
Envíos calculados en el checkout · ver detalles
</Link>
<Link href="/cambios" className="flex items-center gap-2 font-bold text-slate-600 hover:text-ari-marino hover:underline">
<RotateCcw className="size-4 text-ari-marino" />
Cambios en 5 días hábiles, envío de vuelta gratis
</Link>
</div>
</div> </div>
); );
} }