diff --git a/workloads/commerce-backend/medusa-config.ts b/workloads/commerce-backend/medusa-config.ts index 55ffccd..a1255dd 100644 --- a/workloads/commerce-backend/medusa-config.ts +++ b/workloads/commerce-backend/medusa-config.ts @@ -133,6 +133,11 @@ module.exports = defineConfig({ }, 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 // un modulo de fulfillment con el provider manual_manual // (confirmado leyendo @medusajs/utils/dist/common/define-config.js diff --git a/workloads/commerce-backend/src/admin/routes/resenas/page.tsx b/workloads/commerce-backend/src/admin/routes/resenas/page.tsx new file mode 100644 index 0000000..852e87d --- /dev/null +++ b/workloads/commerce-backend/src/admin/routes/resenas/page.tsx @@ -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 = { pending: "orange", approved: "green", rejected: "red" }; +const STATUS_LABEL: Record = { pending: "Pendiente", approved: "Publicada", rejected: "Rechazada" }; + +function stars(rating: number): string { + return "★".repeat(rating) + "☆".repeat(5 - rating); +} + +const ReviewsPage = () => { + const [tab, setTab] = useState("pending"); + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(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 ( + +
+
+ Reseñas + + Solo compradores verificados pueden reseñar. Nada se publica sin tu aprobación. + +
+
+ {TABS.map((item) => ( + + ))} +
+
+ + {loading ? ( +
Cargando…
+ ) : reviews.length === 0 ? ( +
No hay reseñas en esta pestaña.
+ ) : ( + reviews.map((review) => ( +
+
+
+ {review.product_title} + {STATUS_LABEL[review.status]} +
+ {stars(review.rating)} + {review.title && {review.title}} + {review.content} + + {review.author_name} · {new Date(review.created_at).toLocaleDateString("es-CO")} · pedido {review.order_id} + +
+
+ {review.status !== "approved" && ( + + )} + {review.status !== "rejected" && ( + + )} + +
+
+ )) + )} +
+ ); +}; + +export const config = defineRouteConfig({ + label: "Reseñas", + icon: Star, +}); + +export default ReviewsPage; diff --git a/workloads/commerce-backend/src/api/admin/reviews/[id]/route.ts b/workloads/commerce-backend/src/api/admin/reviews/[id]/route.ts new file mode 100644 index 0000000..78ed579 --- /dev/null +++ b/workloads/commerce-backend/src/api/admin/reviews/[id]/route.ts @@ -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(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(PRODUCT_REVIEW_MODULE); + await reviews.deleteProductReviews(req.params.id); + res.json({ id: req.params.id, deleted: true }); +} diff --git a/workloads/commerce-backend/src/api/admin/reviews/route.ts b/workloads/commerce-backend/src/api/admin/reviews/route.ts new file mode 100644 index 0000000..2bc836d --- /dev/null +++ b/workloads/commerce-backend/src/api/admin/reviews/route.ts @@ -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(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 = {}; + 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 })) }); +} diff --git a/workloads/commerce-backend/src/api/middlewares.ts b/workloads/commerce-backend/src/api/middlewares.ts index 099e489..ca95ff3 100644 --- a/workloads/commerce-backend/src/api/middlewares.ts +++ b/workloads/commerce-backend/src/api/middlewares.ts @@ -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` // 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 export default defineMiddlewares({ 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", middlewares: [ diff --git a/workloads/commerce-backend/src/api/store/reviews/me/route.ts b/workloads/commerce-backend/src/api/store/reviews/me/route.ts new file mode 100644 index 0000000..7c3834c --- /dev/null +++ b/workloads/commerce-backend/src/api/store/reviews/me/route.ts @@ -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(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, + })), + }); +} diff --git a/workloads/commerce-backend/src/api/store/reviews/route.ts b/workloads/commerce-backend/src/api/store/reviews/route.ts new file mode 100644 index 0000000..e8193c7 --- /dev/null +++ b/workloads/commerce-backend/src/api/store/reviews/route.ts @@ -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(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, 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(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 } }); +} diff --git a/workloads/commerce-backend/src/api/store/reviews/summary/route.ts b/workloads/commerce-backend/src/api/store/reviews/summary/route.ts new file mode 100644 index 0000000..3bcce1f --- /dev/null +++ b/workloads/commerce-backend/src/api/store/reviews/summary/route.ts @@ -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(PRODUCT_REVIEW_MODULE); + const approved = await reviews.listProductReviews({ status: "approved" }, { select: ["product_id", "rating"], take: 10000 }); + const summary: Record = {}; + 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 }); +} diff --git a/workloads/commerce-backend/src/modules/product-review/index.ts b/workloads/commerce-backend/src/modules/product-review/index.ts new file mode 100644 index 0000000..2d9c59f --- /dev/null +++ b/workloads/commerce-backend/src/modules/product-review/index.ts @@ -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, +}) diff --git a/workloads/commerce-backend/src/modules/product-review/migrations/.snapshot-product-review.json b/workloads/commerce-backend/src/modules/product-review/migrations/.snapshot-product-review.json new file mode 100644 index 0000000..f5b2c50 --- /dev/null +++ b/workloads/commerce-backend/src/modules/product-review/migrations/.snapshot-product-review.json @@ -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": {} +} diff --git a/workloads/commerce-backend/src/modules/product-review/migrations/Migration20260927062845.ts b/workloads/commerce-backend/src/modules/product-review/migrations/Migration20260927062845.ts new file mode 100644 index 0000000..be60457 --- /dev/null +++ b/workloads/commerce-backend/src/modules/product-review/migrations/Migration20260927062845.ts @@ -0,0 +1,18 @@ +import { Migration } from "@medusajs/framework/mikro-orm/migrations"; + +export class Migration20260927062845 extends Migration { + + override async up(): Promise { + 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 { + this.addSql(`drop table if exists "product_review" cascade;`); + } + +} diff --git a/workloads/commerce-backend/src/modules/product-review/models/product-review.ts b/workloads/commerce-backend/src/modules/product-review/models/product-review.ts new file mode 100644 index 0000000..e0cf62f --- /dev/null +++ b/workloads/commerce-backend/src/modules/product-review/models/product-review.ts @@ -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" }, + ]) diff --git a/workloads/commerce-backend/src/modules/product-review/service.ts b/workloads/commerce-backend/src/modules/product-review/service.ts new file mode 100644 index 0000000..aa825b4 --- /dev/null +++ b/workloads/commerce-backend/src/modules/product-review/service.ts @@ -0,0 +1,6 @@ +import { MedusaService } from "@medusajs/framework/utils" +import { ProductReview } from "./models/product-review" + +class ProductReviewModuleService extends MedusaService({ ProductReview }) {} + +export default ProductReviewModuleService diff --git a/workloads/ecommerce/app/api/customer/orders/route.ts b/workloads/ecommerce/app/api/customer/orders/route.ts new file mode 100644 index 0000000..3e48da9 --- /dev/null +++ b/workloads/ecommerce/app/api/customer/orders/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/reviews/me/route.ts b/workloads/ecommerce/app/api/reviews/me/route.ts new file mode 100644 index 0000000..ac23356 --- /dev/null +++ b/workloads/ecommerce/app/api/reviews/me/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/reviews/route.ts b/workloads/ecommerce/app/api/reviews/route.ts new file mode 100644 index 0000000..970c67e --- /dev/null +++ b/workloads/ecommerce/app/api/reviews/route.ts @@ -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 }); + } +} diff --git a/workloads/ecommerce/app/api/reviews/summary/route.ts b/workloads/ecommerce/app/api/reviews/summary/route.ts new file mode 100644 index 0000000..6d89067 --- /dev/null +++ b/workloads/ecommerce/app/api/reviews/summary/route.ts @@ -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: {} }); + } +} diff --git a/workloads/ecommerce/app/cuenta/pedidos/page.tsx b/workloads/ecommerce/app/cuenta/pedidos/page.tsx new file mode 100644 index 0000000..ff64f7a --- /dev/null +++ b/workloads/ecommerce/app/cuenta/pedidos/page.tsx @@ -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 ; +} diff --git a/workloads/ecommerce/app/product/[slug]/page.tsx b/workloads/ecommerce/app/product/[slug]/page.tsx index 2574bb6..1b30c82 100644 --- a/workloads/ecommerce/app/product/[slug]/page.tsx +++ b/workloads/ecommerce/app/product/[slug]/page.tsx @@ -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) {
{product.category} · {product.collection}

{product.name}

+

{product.description}

{/* 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) { Coordinamos envíos nacionales y entrega según ubicación. Los tiempos se confirman durante la cotización. +
{/* Buy box estilo Amazon: precio/variantes/carrito en su propia tarjeta, sticky desde xl mientras el resto de la pagina diff --git a/workloads/ecommerce/components/account/account-dialog.tsx b/workloads/ecommerce/components/account/account-dialog.tsx index efdfd0f..56832e9 100644 --- a/workloads/ecommerce/components/account/account-dialog.tsx +++ b/workloads/ecommerce/components/account/account-dialog.tsx @@ -46,7 +46,7 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha

Hola, {customer.firstName ?? customer.email}

{customer.email}

- +
diff --git a/workloads/ecommerce/components/account/orders-client.tsx b/workloads/ecommerce/components/account/orders-client.tsx new file mode 100644 index 0000000..0220b16 --- /dev/null +++ b/workloads/ecommerce/components/account/orders-client.tsx @@ -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 = { + 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(null); + const [reviews, setReviews] = useState([]); + const [error, setError] = useState(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

Cargando…

; + + if (!customer || !token) { + return ( + +
+

Inicia sesión para ver tus pedidos

+

Desde aquí puedes calificar los productos que compraste.

+ +
+ +
+ ); + } + + const reviewFor = (productId: string) => reviews.find((review) => review.product_id === productId); + + return ( + + {error &&

{error}

} + {orders === null ? ( +

Cargando tus pedidos…

+ ) : orders.length === 0 ? ( +
+

Todavía no tienes pedidos

+ +
+ ) : ( +
    + {orders.map((order) => ( +
  • +
    + Pedido #{order.displayId ?? order.id.slice(-6)} + + {new Date(order.createdAt).toLocaleDateString("es-CO", { year: "numeric", month: "long", day: "numeric" })} + {order.total !== null && <> · {formatMoney(order.total, order.currency)}} + {order.status === "canceled" && Cancelado} + +
    +
      + {order.items.map((item) => { + const review = reviewFor(item.productId); + return ( +
    • +
      + {item.thumbnail && } +
      +
      + {item.handle ? ( + {item.title} + ) : ( + {item.title} + )} + Cantidad: {item.quantity} +
      + {review ? ( + + {review.status === "pending" ? : } + {REVIEW_LABEL[review.status]} + + ) : order.status === "canceled" ? null : ( + + )} +
    • + ); + })} +
    +
  • + ))} +
+ )} + setReviewing(null)} + onSubmitted={() => { + setReviewing(null); + load(); + }} + /> +
+ ); +} + +function Shell({ children }: { children: ReactNode }) { + return ( +
+ Mi cuenta +

Mis pedidos

+
{children}
+
+ ); +} + +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(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 ( + !open && onClose()}> + + Califica tu compra + {target?.item.title} +
+
+ Tu calificación + {/* 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). */} +
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) => ( + + ))} +
+
+ +