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,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { StarRating } from "@/components/product/star-rating";
|
||||
import { useReviewSummaryStore } from "@/store/review-summary-store";
|
||||
|
||||
// Promedio + cantidad de reseñas aprobadas. No renderiza nada si el
|
||||
// producto aun no tiene reseñas (ni un link vacio, en la ficha).
|
||||
export function CardRating({ productId, href }: { productId: string; href?: string }) {
|
||||
const load = useReviewSummaryStore((state) => state.load);
|
||||
const entry = useReviewSummaryStore((state) => state.summary[productId]);
|
||||
useEffect(() => load(), [load]);
|
||||
if (!entry || entry.count === 0) return null;
|
||||
const content = (
|
||||
<>
|
||||
<StarRating value={entry.average} />
|
||||
{entry.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })} ({entry.count} {entry.count === 1 ? "reseña" : "reseñas"})
|
||||
</>
|
||||
);
|
||||
const className = "flex items-center gap-1.5 text-xs font-bold text-slate-500";
|
||||
return href ? <a href={href} className={`${className} mt-3 hover:text-ari-coral`}>{content}</a> : <span className={className}>{content}</span>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CardRating } from "@/components/product/card-rating";
|
||||
import { ProductCardGallery } from "@/components/product/product-card-gallery";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +49,7 @@ export function ProductCard({ product, priority = false }: { product: Product; p
|
||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs font-bold text-slate-500"><span>{product.category}</span><span className="rounded-md bg-ari-coral-claro/10 px-2 py-1 text-ari-marino">{product.collection}</span></div>
|
||||
<Link href={`/product/${product.slug}`} prefetch={false}><h3 className="line-clamp-2 min-h-12 text-lg font-black leading-6 text-ari-marino group-hover:text-ari-coral">{product.name}</h3></Link>
|
||||
<CardRating productId={product.id} />
|
||||
<p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
|
||||
{product.variants.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { BadgeCheck, MessageSquareText } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StarRating } from "@/components/product/star-rating";
|
||||
|
||||
type Review = { id: string; author_name: string; rating: number; title: string | null; content: string; created_at: string; verified_purchase: boolean };
|
||||
|
||||
// Reseñas de la ficha: solo las aprobadas por el dueño (el backend no
|
||||
// devuelve las pendientes), siempre de compradores verificados.
|
||||
export function ProductReviews({ productId }: { productId: string }) {
|
||||
const [data, setData] = useState<{ reviews: Review[]; average: number; count: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/reviews?product_id=${encodeURIComponent(productId)}`)
|
||||
.then((response) => response.json())
|
||||
.then(setData)
|
||||
.catch(() => setData({ reviews: [], average: 0, count: 0 }));
|
||||
}, [productId]);
|
||||
|
||||
return (
|
||||
<section id="resenas" aria-labelledby="resenas-titulo" className="mt-10 scroll-mt-40">
|
||||
<h2 id="resenas-titulo" className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-slate-500">
|
||||
<MessageSquareText className="size-4" />
|
||||
Reseñas de clientes
|
||||
</h2>
|
||||
{!data ? (
|
||||
<p className="mt-4 text-sm text-slate-400">Cargando reseñas…</p>
|
||||
) : data.count === 0 ? (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 p-5 text-sm text-slate-600">
|
||||
Aún no hay reseñas de este producto. ¿Ya lo compraste?{" "}
|
||||
<Link href="/cuenta/pedidos" className="font-bold text-ari-marino underline decoration-ari-coral-claro underline-offset-2">Califícalo desde Mis pedidos</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<StarRating value={data.average} size="lg" />
|
||||
<span className="text-2xl font-black text-ari-marino">{data.average.toLocaleString("es-CO", { minimumFractionDigits: 1 })}</span>
|
||||
<span className="text-sm text-slate-500">de 5 · {data.count} {data.count === 1 ? "reseña" : "reseñas"}</span>
|
||||
</div>
|
||||
<ul className="mt-6 grid gap-4">
|
||||
{data.reviews.map((review) => (
|
||||
<li key={review.id} className="rounded-2xl border border-slate-200 p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<StarRating value={review.rating} />
|
||||
{review.title && <strong className="text-slate-900">{review.title}</strong>}
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-6 text-slate-700">{review.content}</p>
|
||||
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
<span className="font-bold text-slate-700">{review.author_name}</span>
|
||||
<span>{new Date(review.created_at).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })}</span>
|
||||
{review.verified_purchase && (
|
||||
<span className="inline-flex items-center gap-1 font-bold text-emerald-700"><BadgeCheck className="size-3.5" />Compra verificada</span>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
// Estrellas de solo lectura. El relleno parcial (ej. 4.5) se dibuja
|
||||
// recortando una capa de estrellas llenas encima de las vacias. amber-600
|
||||
// (no 400): contraste ~3.2:1 sobre blanco, minimo WCAG 1.4.11 para un
|
||||
// grafico informativo (amber-400 daba 1.7:1, reportado por frontend-critic).
|
||||
export function StarRating({ value, size = "sm", className = "" }: { value: number; size?: "sm" | "md" | "lg"; className?: string }) {
|
||||
const px = size === "lg" ? "size-6" : size === "md" ? "size-5" : "size-4";
|
||||
const pct = Math.max(0, Math.min(100, (value / 5) * 100));
|
||||
return (
|
||||
<span className={`relative inline-flex ${className}`} role="img" aria-label={`${value.toLocaleString("es-CO")} de 5 estrellas`}>
|
||||
<span className="flex text-slate-300">{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} fill-current`} />)}</span>
|
||||
<span className="absolute inset-0 flex overflow-hidden text-amber-600" style={{ width: `${pct}%` }}>
|
||||
{[0, 1, 2, 3, 4].map((i) => <Star key={i} className={`${px} shrink-0 fill-current`} />)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user