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
112 lines
4.0 KiB
TypeScript
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,
|
|
})),
|
|
}));
|
|
}
|