feat(reseñas): reseñas de productos de compradores verificados, moderadas
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
This commit is contained in:
1 parent
271ea30f44
commit
cfd9a7ec96
28 files changed
+1276
-2
No files matched your search
@@ -0,0 +1,11 @@
|
||||
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||
try {
|
||||
return Response.json({ orders: await getMyOrders(header.slice(7)) });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||
try {
|
||||
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
function bearer(request: Request): string | null {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
return header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const productId = new URL(request.url).searchParams.get("product_id");
|
||||
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
|
||||
try {
|
||||
return Response.json(await getProductReviews(productId));
|
||||
} catch {
|
||||
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
|
||||
// reseñas" en vez de romperse.
|
||||
return Response.json({ reviews: [], average: 0, count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = bearer(request);
|
||||
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
|
||||
const body = await request.json().catch(() => ({}));
|
||||
try {
|
||||
return Response.json(await createReview(token, body), { status: 201 });
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status ?? 400;
|
||||
return Response.json({ error: (error as Error).message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json({ summary: await getReviewSummary() });
|
||||
} catch {
|
||||
return Response.json({ summary: {} });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { OrdersClient } from "@/components/account/orders-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mis pedidos",
|
||||
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
return <OrdersClient />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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";
|
||||
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<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
|
||||
@@ -87,6 +90,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<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
|
||||
|
||||
Reference in new issue
Block a user