Merge branch 'feat/resenas-productos'
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 1m21s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 38m24s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 49s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 33s

This commit is contained in:
devops committed 2026-09-27 12:23:49 +00:00
commit 619228168d
28 files changed
+1276 -2

No files matched your search

@@ -133,6 +133,11 @@ module.exports = defineConfig({
}, },
modules: [ modules: [
// Reseñas de productos (compradores verificados + moderacion del
// dueño): src/modules/product-review, rutas en src/api/*/reviews.
{
resolve: "./src/modules/product-review",
},
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto // ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual // un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js // (confirmado leyendo @medusajs/utils/dist/common/define-config.js
@@ -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;
@@ -0,0 +1,24 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
const STATUSES = ["pending", "approved", "rejected"] as const;
// POST /admin/reviews/:id { status } -- aprobar / rechazar / volver a
// pendiente. DELETE /admin/reviews/:id -- borrar (ej. reseñas de prueba).
export async function POST(req: MedusaRequest<{ status?: string }>, res: MedusaResponse) {
const status = req.body?.status;
if (!status || !STATUSES.includes(status as (typeof STATUSES)[number])) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `status debe ser uno de: ${STATUSES.join(", ")}`);
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const review = await reviews.updateProductReviews({ id: req.params.id, status: status as (typeof STATUSES)[number] });
res.json({ review });
}
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
await reviews.deleteProductReviews(req.params.id);
res.json({ id: req.params.id, deleted: true });
}
@@ -0,0 +1,25 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /admin/reviews?status=pending -- cola de moderacion para la
// pagina "Reseñas" del admin, con el titulo del producto resuelto.
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const status = typeof req.query.status === "string" ? req.query.status : undefined;
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(status ? { status: status as "pending" | "approved" | "rejected" } : {}, {
order: { created_at: "DESC" },
take: 200,
});
const productIds = [...new Set(list.map((review) => review.product_id))];
const titles: Record<string, string> = {};
if (productIds.length) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: products } = await query.graph({ entity: "product", fields: ["id", "title"], filters: { id: productIds } });
for (const product of products) titles[product.id] = product.title;
}
res.json({ reviews: list.map((review) => ({ ...review, product_title: titles[review.product_id] ?? review.product_id })) });
}
@@ -1,4 +1,4 @@
import { defineMiddlewares } from "@medusajs/medusa" import { authenticate, defineMiddlewares } from "@medusajs/framework/http"
// GET /store/products descarta en silencio la relacion `categories` // GET /store/products descarta en silencio la relacion `categories`
// aunque `/admin/products` si la expone -- confirmado en vivo en la // aunque `/admin/products` si la expone -- confirmado en vivo en la
@@ -9,6 +9,20 @@ import { defineMiddlewares } from "@medusajs/medusa"
// https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links // https://docs.medusajs.com/learn/fundamentals/api-routes/retrieve-custom-links
export default defineMiddlewares({ export default defineMiddlewares({
routes: [ routes: [
// Reseñas: crear una y ver las propias exige cliente autenticado --
// la verificacion de compra usa auth_context.actor_id (ver
// src/api/store/reviews/route.ts). GET /store/reviews y /summary
// siguen publicos.
{
matcher: "/store/reviews",
method: ["POST"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{
matcher: "/store/reviews/me",
method: ["GET"],
middlewares: [authenticate("customer", ["session", "bearer"])],
},
{ {
matcher: "/store/products", matcher: "/store/products",
middlewares: [ middlewares: [
@@ -0,0 +1,23 @@
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/me -- reseñas del cliente autenticado (cualquier
// estado), para que "Mis pedidos" sepa que productos ya califico y en
// que estado de moderacion quedo cada una.
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const mine = await reviews.listProductReviews({ customer_id: req.auth_context.actor_id }, { order: { created_at: "DESC" } });
res.json({
reviews: mine.map((review) => ({
id: review.id,
product_id: review.product_id,
order_id: review.order_id,
rating: review.rating,
title: review.title,
content: review.content,
status: review.status,
created_at: review.created_at,
})),
});
}
@@ -0,0 +1,106 @@
import type { AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils";
import { PRODUCT_REVIEW_MODULE } from "../../../modules/product-review";
import type ProductReviewModuleService from "../../../modules/product-review/service";
// GET /store/reviews?product_id=... -- reseñas APROBADAS de un producto,
// mas promedio y cantidad. Publica (solo publishable key).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const productId = typeof req.query.product_id === "string" ? req.query.product_id : "";
if (!productId) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id es obligatorio");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const list = await reviews.listProductReviews(
{ product_id: productId, status: "approved" },
{ order: { created_at: "DESC" }, take: 50 },
);
const count = list.length;
const average = count ? list.reduce((sum, review) => sum + review.rating, 0) / count : 0;
res.json({
reviews: list.map((review) => ({
id: review.id,
author_name: review.author_name,
rating: review.rating,
title: review.title,
content: review.content,
created_at: review.created_at,
verified_purchase: true,
})),
average: Math.round(average * 10) / 10,
count,
});
}
type CreateReviewBody = {
product_id?: string;
order_id?: string;
rating?: number;
title?: string;
content?: string;
};
// POST /store/reviews -- solo clientes autenticados (middleware) y solo
// si el pedido indicado es SUYO, no esta cancelado y contiene el
// producto: eso es lo que respalda el sello "Compra verificada". Nace
// "pending" hasta que el dueño la apruebe en el admin.
export async function POST(req: AuthenticatedMedusaRequest<CreateReviewBody>, res: MedusaResponse) {
const customerId = req.auth_context.actor_id;
const { product_id, order_id, title } = req.body ?? {};
const rating = Number(req.body?.rating);
const content = (req.body?.content ?? "").trim();
if (!product_id || !order_id) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "product_id y order_id son obligatorios");
}
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "La calificación debe ser un número entero de 1 a 5");
}
if (content.length < 10 || content.length > 2000) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, "El comentario debe tener entre 10 y 2000 caracteres");
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: orders } = await query.graph({
entity: "order",
fields: ["id", "customer_id", "status", "items.product_id"],
filters: { id: order_id },
});
const order = orders[0];
const purchased =
order &&
order.customer_id === customerId &&
order.status !== "canceled" &&
(order.items ?? []).some((item) => item?.product_id === product_id);
if (!purchased) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Solo puedes reseñar productos que compraste");
}
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const existing = await reviews.listProductReviews({ product_id, customer_id: customerId });
if (existing.length > 0) {
throw new MedusaError(MedusaError.Types.DUPLICATE_ERROR, "Ya dejaste una reseña para este producto");
}
const { data: customers } = await query.graph({
entity: "customer",
fields: ["first_name", "last_name"],
filters: { id: customerId },
});
const customer = customers[0];
const lastInitial = customer?.last_name ? ` ${customer.last_name.trim().charAt(0)}.` : "";
const authorName = `${customer?.first_name?.trim() || "Cliente"}${lastInitial}`;
const review = await reviews.createProductReviews({
product_id,
order_id,
customer_id: customerId,
author_name: authorName,
rating,
title: title?.trim() || null,
content,
status: "pending",
});
res.status(201).json({ review: { id: review.id, status: review.status } });
}
@@ -0,0 +1,21 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { PRODUCT_REVIEW_MODULE } from "../../../../modules/product-review";
import type ProductReviewModuleService from "../../../../modules/product-review/service";
// GET /store/reviews/summary -- promedio y cantidad de reseñas aprobadas
// por producto, para las estrellas de las tarjetas del listado (una sola
// llamada para todo el catalogo en vez de una por tarjeta).
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const reviews = req.scope.resolve<ProductReviewModuleService>(PRODUCT_REVIEW_MODULE);
const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 });
const summary: Record<string, { average: number; count: number }> = {};
for (const review of approved) {
const entry = (summary[review.product_id] ??= { average: 0, count: 0 });
entry.average += review.rating;
entry.count += 1;
}
for (const entry of Object.values(summary)) {
entry.average = Math.round((entry.average / entry.count) * 10) / 10;
}
res.json({ summary });
}
@@ -0,0 +1,8 @@
import { Module } from "@medusajs/framework/utils"
import ProductReviewModuleService from "./service"
export const PRODUCT_REVIEW_MODULE = "productReview"
export default Module(PRODUCT_REVIEW_MODULE, {
service: ProductReviewModuleService,
})
@@ -0,0 +1,262 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": true,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"product_id": {
"name": "product_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"customer_id": {
"name": "customer_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"order_id": {
"name": "order_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"author_name": {
"name": "author_name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"rating": {
"name": "rating",
"type": "integer",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "integer"
},
"title": {
"name": "title",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"content": {
"name": "content",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "text"
},
"status": {
"name": "status",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": null,
"precision": null,
"scale": null,
"default": "'pending'",
"comment": null,
"enumItems": [
"pending",
"approved",
"rejected"
],
"mappedType": "enum"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": "now()",
"comment": null,
"enumItems": [],
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"unique": false,
"length": 6,
"precision": null,
"scale": null,
"default": null,
"comment": null,
"enumItems": [],
"mappedType": "datetime"
}
},
"name": "product_review",
"schema": "public",
"indexes": [
{
"keyName": "IDX_product_review_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_deleted_at\" ON \"product_review\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_status",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_product_id_status\" ON \"product_review\" (\"product_id\", \"status\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_customer_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_product_review_customer_id\" ON \"product_review\" (\"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_product_review_product_id_customer_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_product_review_product_id_customer_id_unique\" ON \"product_review\" (\"product_id\", \"customer_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "product_review_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
}
@@ -0,0 +1,18 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260927062845 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "product_review" drop constraint if exists "product_review_product_id_customer_id_unique";`);
this.addSql(`create table if not exists "product_review" ("id" text not null, "product_id" text not null, "customer_id" text not null, "order_id" text not null, "author_name" text not null, "rating" integer not null, "title" text null, "content" text not null, "status" text check ("status" in ('pending', 'approved', 'rejected')) not null default 'pending', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "product_review_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_deleted_at" ON "product_review" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_product_id_status" ON "product_review" ("product_id", "status") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_product_review_customer_id" ON "product_review" ("customer_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_product_review_product_id_customer_id_unique" ON "product_review" ("product_id", "customer_id") WHERE deleted_at IS NULL;`);
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "product_review" cascade;`);
}
}
@@ -0,0 +1,25 @@
import { model } from "@medusajs/framework/utils"
// Reseña de producto (ADR pendiente, pedido del dueño 2026-09-27):
// solo compradores verificados -- customer_id + order_id se validan en
// POST /store/reviews contra un pedido real del cliente que contenga el
// producto -- y moderadas: nacen "pending" y solo se publican cuando el
// dueño las aprueba desde el admin (pagina "Reseñas").
export const ProductReview = model
.define("product_review", {
id: model.id({ prefix: "prev" }).primaryKey(),
product_id: model.text(),
customer_id: model.text(),
order_id: model.text(),
author_name: model.text(),
rating: model.number(),
title: model.text().nullable(),
content: model.text(),
status: model.enum(["pending", "approved", "rejected"]).default("pending"),
})
.indexes([
{ on: ["product_id", "status"] },
{ on: ["customer_id"] },
// Una reseña por cliente por producto.
{ on: ["product_id", "customer_id"], unique: true, where: "deleted_at IS NULL" },
])
@@ -0,0 +1,6 @@
import { MedusaService } from "@medusajs/framework/utils"
import { ProductReview } from "./models/product-review"
class ProductReviewModuleService extends MedusaService({ ProductReview }) {}
export default ProductReviewModuleService
@@ -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 type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { CardRating } from "@/components/product/card-rating";
import { ProductCard } from "@/components/product/product-card"; import { ProductCard } from "@/components/product/product-card";
import { ProductReviews } from "@/components/product/product-reviews";
import { ProductCarousel } from "@/components/product/product-carousel"; import { ProductCarousel } from "@/components/product/product-carousel";
import { ProductGallery } from "@/components/product/product-gallery"; import { ProductGallery } from "@/components/product/product-gallery";
import { ProductPurchase } from "@/components/product/product-purchase"; import { ProductPurchase } from "@/components/product/product-purchase";
@@ -69,6 +71,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="min-w-0"> <div className="min-w-0">
<span className="text-xs font-black uppercase tracking-[.18em] text-ari-coral">{product.category} · {product.collection}</span> <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> <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> <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 -- {/* Antes escondido detrás de un click en el acordeón --
siempre visible, estilo Amazon, con los mismos datos 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> <AccordionContent>Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización.</AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
<ProductReviews productId={product.id} />
</div> </div>
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia {/* Buy box estilo Amazon: precio/variantes/carrito en su propia
tarjeta, sticky desde xl mientras el resto de la pagina 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> <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> <p className="mt-3 text-sm leading-6 text-slate-600">{customer.email}</p>
<div className="mt-6 grid gap-3"> <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> <Button variant="ghost" onClick={logout}><LogOut className="size-4" />Cerrar sesión</Button>
</div> </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>
);
}
@@ -349,6 +349,7 @@ function CategoryNavBar({ tree, collections, franchises, onOpenMenu, onOpenAccou
{(close) => ( {(close) => (
<ul className="grid min-w-60 gap-1"> <ul className="grid min-w-60 gap-1">
<li><button type="button" onClick={() => { close(); onOpenAccount(); }} className={helpLinkClass}>Tu cuenta</button></li> <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="/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="/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> <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 Link from "next/link";
import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react"; import { Heart, ShoppingBag, Sparkles, TriangleAlert } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { CardRating } from "@/components/product/card-rating";
import { ProductCardGallery } from "@/components/product/product-card-gallery"; import { ProductCardGallery } from "@/components/product/product-card-gallery";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 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> <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> <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> <p className="line-clamp-2 min-h-10 text-sm leading-5 text-slate-500">{product.shortDescription}</p>
{product.variants.length > 1 && ( {product.variants.length > 1 && (
<div className="flex flex-wrap gap-1.5"> <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" }));
},
}));