Files
devopsandClaude Opus 5.5 cfd9a7ec96 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
2026-09-27 07:57:20 +00:00

112 lines
4.0 KiB
TypeScript

// Reseñas de productos + pedidos del cliente (Store API de Medusa, modulo
// propio commerce-backend/src/modules/product-review). Server-side y sin
// cache: las reseñas cambian al aprobarse en el admin y los pedidos son
// estado de sesion.
function backendUrl(): string {
const value = process.env.MEDUSA_BACKEND_URL;
if (!value) throw new Error("MEDUSA_BACKEND_URL no está configurado.");
return value.replace(/\/$/, "");
}
async function medusaFetch<T>(path: string, init?: { method?: string; body?: unknown; token?: string }): Promise<T> {
const publishableKey = process.env.MEDUSA_PUBLISHABLE_KEY;
const response = await fetch(`${backendUrl()}${path}`, {
method: init?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...(publishableKey ? { "x-publishable-api-key": publishableKey } : {}),
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init?.body ? JSON.stringify(init.body) : undefined,
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof data.message === "string" ? data.message : `Error ${response.status}`;
throw Object.assign(new Error(message), { status: response.status });
}
return data as T;
}
export interface PublicReview {
id: string;
author_name: string;
rating: number;
title: string | null;
content: string;
created_at: string;
verified_purchase: boolean;
}
export interface ReviewSummary {
average: number;
count: number;
}
export interface MyReview {
id: string;
product_id: string;
order_id: string;
rating: number;
status: "pending" | "approved" | "rejected";
}
export interface CustomerOrder {
id: string;
displayId: number | null;
createdAt: string;
status: string;
total: number | null;
currency: string;
items: { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number }[];
}
export function getProductReviews(productId: string) {
return medusaFetch<{ reviews: PublicReview[]; average: number; count: number }>(`/store/reviews?product_id=${encodeURIComponent(productId)}`);
}
export async function getReviewSummary(): Promise<Record<string, ReviewSummary>> {
return (await medusaFetch<{ summary: Record<string, ReviewSummary> }>("/store/reviews/summary")).summary;
}
export async function getMyReviews(token: string): Promise<MyReview[]> {
return (await medusaFetch<{ reviews: MyReview[] }>("/store/reviews/me", { token })).reviews;
}
export function createReview(token: string, body: { product_id: string; order_id: string; rating: number; title?: string; content: string }) {
return medusaFetch<{ review: { id: string; status: string } }>("/store/reviews", { method: "POST", body, token });
}
type MedusaOrder = {
id: string;
display_id?: number | null;
created_at: string;
status: string;
total?: number | null;
currency_code?: string | null;
items?: { product_id?: string | null; product_title?: string | null; title?: string | null; thumbnail?: string | null; product_handle?: string | null; quantity?: number }[];
};
export async function getMyOrders(token: string): Promise<CustomerOrder[]> {
const fields = "id,display_id,created_at,status,total,currency_code,*items";
const data = await medusaFetch<{ orders: MedusaOrder[] }>(`/store/orders?limit=50&order=-created_at&fields=${encodeURIComponent(fields)}`, { token });
return data.orders.map((order) => ({
id: order.id,
displayId: order.display_id ?? null,
createdAt: order.created_at,
status: order.status,
total: typeof order.total === "number" ? order.total : null,
currency: (order.currency_code ?? "cop").toUpperCase(),
items: (order.items ?? [])
.filter((item) => item.product_id)
.map((item) => ({
productId: item.product_id as string,
title: item.product_title || item.title || "Producto",
thumbnail: item.thumbnail ?? null,
handle: item.product_handle ?? null,
quantity: item.quantity ?? 1,
})),
}));
}