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,11 @@
|
||||
import { getMyOrders } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||
try {
|
||||
return Response.json({ orders: await getMyOrders(header.slice(7)) });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getMyReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return Response.json({ error: "No autenticado" }, { status: 401 });
|
||||
try {
|
||||
return Response.json({ reviews: await getMyReviews(header.slice(7)) });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: (error as { status?: number }).status ?? 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createReview, getProductReviews } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
function bearer(request: Request): string | null {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
return header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const productId = new URL(request.url).searchParams.get("product_id");
|
||||
if (!productId) return Response.json({ error: "product_id es obligatorio" }, { status: 400 });
|
||||
try {
|
||||
return Response.json(await getProductReviews(productId));
|
||||
} catch {
|
||||
// Backend sin el modulo todavia (o caido): la ficha muestra "sin
|
||||
// reseñas" en vez de romperse.
|
||||
return Response.json({ reviews: [], average: 0, count: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = bearer(request);
|
||||
if (!token) return Response.json({ error: "Inicia sesión para dejar una reseña" }, { status: 401 });
|
||||
const body = await request.json().catch(() => ({}));
|
||||
try {
|
||||
return Response.json(await createReview(token, body), { status: 201 });
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status ?? 400;
|
||||
return Response.json({ error: (error as Error).message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getReviewSummary } from "@/lib/headless/providers/medusa-reviews";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json({ summary: await getReviewSummary() });
|
||||
} catch {
|
||||
return Response.json({ summary: {} });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { OrdersClient } from "@/components/account/orders-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mis pedidos",
|
||||
description: "Tus pedidos en ARI Shopping y las reseñas de lo que compraste.",
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
return <OrdersClient />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { CardRating } from "@/components/product/card-rating";
|
||||
import { ProductCard } from "@/components/product/product-card";
|
||||
import { ProductReviews } from "@/components/product/product-reviews";
|
||||
import { ProductCarousel } from "@/components/product/product-carousel";
|
||||
import { ProductGallery } from "@/components/product/product-gallery";
|
||||
import { ProductPurchase } from "@/components/product/product-purchase";
|
||||
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span>
|
||||
<h1 className="mt-3 text-4xl font-black tracking-tight sm:text-6xl">{product.name}</h1>
|
||||
<CardRating productId={product.id} href="#resenas" />
|
||||
<p className="mt-5 text-sm leading-6 text-slate-600">{product.description}</p>
|
||||
{/* Antes escondido detrás de un click en el acordeón --
|
||||
siempre visible, estilo Amazon, con los mismos datos
|
||||
@@ -87,6 +90,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<ProductReviews productId={product.id} />
|
||||
</div>
|
||||
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia
|
||||
tarjeta, sticky desde xl mientras el resto de la pagina
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -315,6 +315,7 @@ function CategoryNavBar({ tree, collections, onOpenMenu, onOpenAccount, state }:
|
||||
{(close) => (
|
||||
<ul className="grid min-w-60 gap-1">
|
||||
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li>
|
||||
<li><Link href="/cuenta/pedidos" prefetch={false} onClick={close} className={helpLinkClass}>Tus pedidos y reseñas</Link></li>
|
||||
<li><Link href="/envios" prefetch={false} onClick={close} className={helpLinkClass}>Envíos y tarifas</Link></li>
|
||||
<li><Link href="/cambios" prefetch={false} onClick={close} className={helpLinkClass}>Cambios y devoluciones</Link></li>
|
||||
<li><Link href="/preguntas-frecuentes" prefetch={false} onClick={close} className={helpLinkClass}>Preguntas frecuentes</Link></li>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
// Promedio/cantidad de reseñas aprobadas por producto, para las estrellas
|
||||
// de las tarjetas. Se pide UNA vez por sesion (/api/reviews/summary) en
|
||||
// vez de una llamada por tarjeta, y sin depender del cache ISR de las
|
||||
// paginas (una reseña recien aprobada aparece al recargar).
|
||||
interface ReviewSummaryState {
|
||||
summary: Record<string, { average: number; count: number }>;
|
||||
status: "idle" | "loading" | "ready";
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
export const useReviewSummaryStore = create<ReviewSummaryState>()((set, get) => ({
|
||||
summary: {},
|
||||
status: "idle",
|
||||
load: () => {
|
||||
if (get().status !== "idle") return;
|
||||
set({ status: "loading" });
|
||||
fetch("/api/reviews/summary")
|
||||
.then((response) => response.json())
|
||||
.then((data) => set({ summary: data.summary ?? {}, status: "ready" }))
|
||||
.catch(() => set({ status: "ready" }));
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user