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,140 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk";
|
||||
import { Star } from "@medusajs/icons";
|
||||
import { Badge, Button, Container, Heading, Text, toast } from "@medusajs/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { sdk } from "../../lib/sdk";
|
||||
|
||||
// Moderacion de reseñas: todas nacen "pendiente" (solo compradores
|
||||
// verificados pueden dejarlas, ver src/api/store/reviews/route.ts) y
|
||||
// recien se publican en la tienda cuando se aprueban aca.
|
||||
|
||||
type Status = "pending" | "approved" | "rejected";
|
||||
|
||||
type Review = {
|
||||
id: string;
|
||||
product_title: string;
|
||||
author_name: string;
|
||||
rating: number;
|
||||
title: string | null;
|
||||
content: string;
|
||||
status: Status;
|
||||
order_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const TABS: { status: Status; label: string }[] = [
|
||||
{ status: "pending", label: "Pendientes" },
|
||||
{ status: "approved", label: "Publicadas" },
|
||||
{ status: "rejected", label: "Rechazadas" },
|
||||
];
|
||||
|
||||
const BADGE: Record<Status, "orange" | "green" | "red"> = { pending: "orange", approved: "green", rejected: "red" };
|
||||
const STATUS_LABEL: Record<Status, string> = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" };
|
||||
|
||||
function stars(rating: number): string {
|
||||
return "★".repeat(rating) + "☆".repeat(5 - rating);
|
||||
}
|
||||
|
||||
const ReviewsPage = () => {
|
||||
const [tab, setTab] = useState<Status>("pending");
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (status: Status) => {
|
||||
setLoading(true);
|
||||
const data = await sdk.client.fetch<{ reviews: Review[] }>(`/admin/reviews?status=${status}`);
|
||||
setReviews(data.reviews);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(tab);
|
||||
}, [load, tab]);
|
||||
|
||||
async function setStatus(review: Review, status: Status) {
|
||||
setBusyId(review.id);
|
||||
try {
|
||||
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "post", body: { status } });
|
||||
toast.success(status === "approved" ? "Reseña publicada en la tienda" : status === "rejected" ? "Reseña rechazada" : "Reseña devuelta a pendientes");
|
||||
await load(tab);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(review: Review) {
|
||||
if (!window.confirm(`¿Borrar la reseña de ${review.author_name}? No se puede deshacer.`)) return;
|
||||
setBusyId(review.id);
|
||||
try {
|
||||
await sdk.client.fetch(`/admin/reviews/${review.id}`, { method: "delete" });
|
||||
toast.success("Reseña borrada");
|
||||
await load(tab);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h2">Reseñas</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{TABS.map((item) => (
|
||||
<Button key={item.status} size="small" variant={tab === item.status ? "primary" : "secondary"} onClick={() => setTab(item.status)}>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="px-6 py-8"><Text>Cargando…</Text></div>
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="px-6 py-8"><Text className="text-ui-fg-subtle">No hay reseñas en esta pestaña.</Text></div>
|
||||
) : (
|
||||
reviews.map((review) => (
|
||||
<div key={review.id} className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Text weight="plus">{review.product_title}</Text>
|
||||
<Badge color={BADGE[review.status]} size="2xsmall">{STATUS_LABEL[review.status]}</Badge>
|
||||
</div>
|
||||
<Text size="small" className="mt-1 text-ui-tag-orange-icon" aria-label={`${review.rating} de 5 estrellas`}>{stars(review.rating)}</Text>
|
||||
{review.title && <Text weight="plus" className="mt-1">{review.title}</Text>}
|
||||
<Text className="mt-1 whitespace-pre-line">{review.content}</Text>
|
||||
<Text size="small" className="mt-2 text-ui-fg-subtle">
|
||||
{review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{review.status !== "approved" && (
|
||||
<Button size="small" isLoading={busyId === review.id} onClick={() => setStatus(review, "approved")}>Aprobar</Button>
|
||||
)}
|
||||
{review.status !== "rejected" && (
|
||||
<Button size="small" variant="secondary" disabled={busyId === review.id} onClick={() => setStatus(review, "rejected")}>Rechazar</Button>
|
||||
)}
|
||||
<Button size="small" variant="danger" disabled={busyId === review.id} onClick={() => remove(review)}>Borrar</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Reseñas",
|
||||
icon: Star,
|
||||
});
|
||||
|
||||
export default ReviewsPage;
|
||||
Reference in new issue
Block a user