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:
devopsandClaude Opus 5.5 committed 2026-09-27 07:57:20 +00:00
1 parent 271ea30f44
commit cfd9a7ec96
28 files changed
+1276 -2

No files matched your search

@@ -46,7 +46,7 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
<h2 className="mt-3 text-3xl font-black">Hola, {customer.firstName ?? customer.email}</h2>
<p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3">
<Button asChild variant="outline"><Link href="/catalog" onClick={() => onOpenChange(false)}><PackageCheck />Ver mis opciones de compra</Link></Button>
<Button asChild variant="outline"><Link href="/cuenta/pedidos" onClick={() => onOpenChange(false)}><PackageCheck />Mis pedidos y reseñas</Link></Button>
<Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div>
</>
@@ -0,0 +1,276 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Clock, PackageCheck, Star } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { AccountDialog } from "@/components/account/account-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { formatMoney } from "@/lib/utils";
import { useCustomerStore } from "@/store/customer-store";
type OrderItem = { productId: string; title: string; thumbnail: string | null; handle: string | null; quantity: number };
type Order = { id: string; displayId: number | null; createdAt: string; status: string; total: number | null; currency: string; items: OrderItem[] };
type MyReview = { id: string; product_id: string; status: "pending" | "approved" | "rejected" };
const REVIEW_LABEL: Record<MyReview["status"], string> = {
pending: "Reseña enviada · en revisión",
approved: "Reseña publicada",
rejected: "Reseña no publicada",
};
// "Mis pedidos": historial real del cliente (Store API) y, por cada
// producto comprado, el boton para calificarlo. Solo desde aca se puede
// reseñar: el backend exige que el pedido sea del cliente y contenga el
// producto ("Compra verificada").
export function OrdersClient() {
const { token, customer, hasHydrated } = useCustomerStore();
const [orders, setOrders] = useState<Order[] | null>(null);
const [reviews, setReviews] = useState<MyReview[]>([]);
const [error, setError] = useState<string | null>(null);
const [accountOpen, setAccountOpen] = useState(false);
const [reviewing, setReviewing] = useState<{ order: Order; item: OrderItem } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setError(null);
try {
const headers = { Authorization: `Bearer ${token}` };
const [ordersRes, reviewsRes] = await Promise.all([fetch("/api/customer/orders", { headers }), fetch("/api/reviews/me", { headers })]);
const ordersData = await ordersRes.json();
if (!ordersRes.ok) throw new Error(ordersData.error ?? "No pudimos cargar tus pedidos");
const reviewsData = await reviewsRes.json().catch(() => ({ reviews: [] }));
setOrders(ordersData.orders);
setReviews(reviewsRes.ok ? reviewsData.reviews : []);
} catch (loadError) {
setError((loadError as Error).message);
setOrders([]);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
if (!hasHydrated) return <Shell><p className="text-slate-500">Cargando…</p></Shell>;
if (!customer || !token) {
return (
<Shell>
<div className="rounded-2xl border border-slate-200 p-8 text-center">
<p className="text-lg font-black text-ari-marino">Inicia sesión para ver tus pedidos</p>
<p className="mt-2 text-sm text-slate-500">Desde aquí puedes calificar los productos que compraste.</p>
<Button variant="primary" className="mt-5" onClick={() => setAccountOpen(true)}>Iniciar sesión</Button>
</div>
<AccountDialog open={accountOpen} onOpenChange={setAccountOpen} />
</Shell>
);
}
const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId);
return (
<Shell>
{error && <p className="mb-4 rounded-2xl bg-rose-50 p-4 text-sm font-bold text-rose-700">{error}</p>}
{orders === null ? (
<p className="text-slate-500">Cargando tus pedidos…</p>
) : orders.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 p-8 text-center">
<p className="font-black text-ari-marino">Todavía no tienes pedidos</p>
<Button asChild variant="primary" className="mt-4"><Link href="/catalog">Ver catálogo</Link></Button>
</div>
) : (
<ul className="grid gap-5">
{orders.map((order) => (
<li key={order.id} className="rounded-2xl border border-slate-200 p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 pb-3 text-sm">
<span className="font-black text-ari-marino">Pedido #{order.displayId ?? order.id.slice(-6)}</span>
<span className="text-slate-500">
{new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}
{order.total !== null && <> · {formatMoney(order.total, order.currency)}</>}
{order.status === "canceled" && <span className="ml-2 font-bold text-rose-600">Cancelado</span>}
</span>
</div>
<ul className="mt-3 grid gap-3">
{order.items.map((item) => {
const review = reviewFor(item.productId);
return (
<li key={`${order.id}-${item.productId}`} className="flex flex-wrap items-center gap-4">
<div className="relative size-16 shrink-0 overflow-hidden rounded-xl bg-slate-50">
{item.thumbnail && <Image src={item.thumbnail} alt="" fill sizes="64px" className="object-contain" unoptimized />}
</div>
<div className="min-w-0 flex-1">
{item.handle ? (
<Link href={`/product/${item.handle}`} className="line-clamp-2 font-bold text-slate-900 hover:text-ari-coral">{item.title}</Link>
) : (
<span className="line-clamp-2 font-bold text-slate-900">{item.title}</span>
)}
<span className="text-xs text-slate-500">Cantidad: {item.quantity}</span>
</div>
{review ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-bold ${review.status === "approved" ? "text-emerald-700" : review.status === "pending" ? "text-amber-700" : "text-slate-500"}`}>
{review.status === "pending" ? <Clock className="size-3.5" /> : <PackageCheck className="size-3.5" />}
{REVIEW_LABEL[review.status]}
</span>
) : order.status === "canceled" ? null : (
<Button size="sm" variant="primary" onClick={() => setReviewing({ order, item })}>
<Star className="size-4" />Calificar
</Button>
)}
</li>
);
})}
</ul>
</li>
))}
</ul>
)}
<ReviewDialog
target={reviewing}
token={token}
onClose={() => setReviewing(null)}
onSubmitted={() => {
setReviewing(null);
load();
}}
/>
</Shell>
);
}
function Shell({ children }: { children: ReactNode }) {
return (
<div className="mx-auto max-w-3xl px-4 py-10">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">Mi cuenta</span>
<h1 className="font-display mt-2 text-4xl font-bold tracking-tight text-ari-marino">Mis pedidos</h1>
<div className="mt-8">{children}</div>
</div>
);
}
function ReviewDialog({
target,
token,
onClose,
onSubmitted,
}: {
target: { order: Order; item: OrderItem } | null;
token: string;
onClose: () => void;
onSubmitted: () => void;
}) {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!target) return;
setRating(0);
setHover(0);
setTitle("");
setContent("");
setError(null);
}, [target]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!target) return;
if (rating < 1) return setError("Elige de 1 a 5 estrellas");
if (content.trim().length < 10) return setError("Cuéntanos un poco más (mínimo 10 caracteres)");
setSending(true);
setError(null);
try {
const response = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ product_id: target.item.productId, order_id: target.order.id, rating, title: title.trim() || undefined, content: content.trim() }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error ?? "No pudimos enviar tu reseña");
onSubmitted();
} catch (submitError) {
setError((submitError as Error).message);
} finally {
setSending(false);
}
}
const shown = hover || rating;
return (
<Dialog open={target !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-lg p-6 sm:p-8">
<DialogPrimitive.Title className="pr-10 text-2xl font-black text-ari-marino">Califica tu compra</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1 line-clamp-2 text-sm text-slate-500">{target?.item.title}</DialogPrimitive.Description>
<form onSubmit={submit} className="mt-6 grid gap-4">
<fieldset>
<legend className="mb-2 text-sm font-black">Tu calificación</legend>
{/* Patron ARIA de radiogroup (no toggles sueltos): el lector de
pantalla anuncia "3 estrellas, seleccionado, 3 de 5", un solo
tab-stop y flechas para cambiar -- con aria-pressed por
estrella solo se anunciaba la estrella exacta, no la
calificacion (reportado por frontend-critic). */}
<div
role="radiogroup"
aria-label="Tu calificación"
className="flex gap-1"
onMouseLeave={() => setHover(0)}
onKeyDown={(event) => {
const delta = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowDown" ? -1 : 0;
if (!delta) return;
event.preventDefault();
// Como un radiogroup nativo: avanza desde la opcion con foco
// (si aun no hay calificacion elegida) o desde la elegida.
const buttons = [...event.currentTarget.querySelectorAll("button")];
const focused = buttons.indexOf(document.activeElement as HTMLButtonElement) + 1;
const next = Math.min(5, Math.max(1, (rating || focused || 0) + delta));
setRating(next);
setError(null);
(event.currentTarget.querySelectorAll("button")[next - 1] as HTMLButtonElement | undefined)?.focus();
}}
>
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
role="radio"
aria-checked={rating === value}
aria-label={`${value} ${value === 1 ? "estrella" : "estrellas"}`}
tabIndex={rating ? (rating === value ? 0 : -1) : value === 1 ? 0 : -1}
onMouseEnter={() => setHover(value)}
onClick={() => { setRating(value); setError(null); }}
className="rounded-md p-0.5 transition hover:scale-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ari-marino"
>
<Star className={`size-9 ${value <= shown ? "fill-amber-500 text-amber-600" : "text-slate-400"}`} />
</button>
))}
</div>
</fieldset>
<label className="grid gap-1.5 text-sm font-black">
Título (opcional)
<Input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={80} placeholder="Ej: ¡A mi hijo le encantó!" />
</label>
<label className="grid gap-1.5 text-sm font-black">
Tu reseña
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
rows={5}
maxLength={2000}
placeholder="¿Qué te pareció el producto? ¿Llegó bien?"
className="rounded-2xl border border-slate-200 bg-white p-4 text-sm font-normal text-slate-950 outline-none focus:border-ari-coral focus:ring-4 focus:ring-ari-coral/15"
/>
</label>
<p className="text-xs text-slate-500">Tu reseña se publica después de que la revisemos. Mostramos tu nombre y la inicial de tu apellido.</p>
{error && <p className="rounded-xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}
<Button type="submit" variant="primary" size="lg" disabled={sending}>{sending ? "Enviando…" : "Enviar reseña"}</Button>
</form>
</DialogContent>
</Dialog>
);
}