Tienda (Marketplace de creadores) — Referencia técnica
← Volver a Tienda (Marketplace de creadores)
Dónde vive esto
Backend
apps/backend/graphql/types/product.type.js— schema de GraphQL deProductapps/backend/graphql/types/product-order.type.js— schema de GraphQL deProductOrderapps/backend/graphql/resolvers/product.resolver.js— resuelveproduct,myProducts,sellerProducts,hasActiveShop,createProduct,updateProduct,setProductActive,deleteProductapps/backend/graphql/resolvers/product-order.resolver.js— resuelveproductOrder,myOrders,mySales,purchaseProduct,markOrderShipped,cancelOrderapps/backend/managers/product-managers/product.manager.js— lógica de negocio de las publicaciones, incluida la validación de productos digitalesapps/backend/managers/product-managers/product-order.manager.js— checkout, débito/crédito de monedas, reparto de la comisión de plataforma, y la máquina de estados de cumplimientoapps/backend/graphql/types/product-review.type.js/product-review.resolver.js/product-review.manager.js— reseñas de compra verificadaapps/backend/graphql/types/product-dispute.type.js/product-dispute.resolver.js/product-dispute.manager.js— disputas del comprador + arbitraje del administrador
Frontend
apps/frontend-nextjs/src/page-components/ShopManagePage.tsx— gestión de publicaciones del vendedor, cola de ventas, envíos, y el historial de pedidos propio del comprador (incluyendo dejar reseñas y abrir disputas), todo en una sola pantallaapps/frontend-nextjs/src/app/shop/manage/page.tsx— ruta/shop/manageapps/frontend-nextjs/src/components/shop/CreateProductModal.tsx— crear/editar una publicación, incluido el selector de tipo físico/digital y el campo de contenido de entregaapps/frontend-nextjs/src/components/shop/PurchaseProductModal.tsx— modal de checkout del comprador (omite el campo de dirección de envío para productos digitales, muestra un resumen de calificación y una vista previa de reseñas recientes)apps/frontend-nextjs/src/page-components/PublicProfilePage.tsx— renderiza la pestaña Tienda (sellerProducts/hasActiveShop) en el perfil público de un creador y abrePurchaseProductModalapps/frontend-admin/src/app/moderation/disputes/page.tsx— cola de arbitraje del administrador (adminOpenProductDisputes/adminResolveProductDispute), protegida porMODERATE_CONTENT
Nota: esta capa de GraphQL es reciente — el código fuente la comenta como "brand new (this session's marketplace build)" y todavía no está en las operaciones compartidas de packages/graphql (generadas en @closegram/apollo-web), por lo que ambos page-components de arriba definen sus documentos de GraphQL en línea en lugar de importar hooks generados (el patrón ya establecido en este código una vez que un schema se estabiliza).
Checklist de implementación técnica
-
myProducts/createProduct/updateProduct/setProductActive/deleteProduct— conectado;ShopManagePage.tsx+CreateProductModal.tsx -
sellerProducts/hasActiveShop— conectado; pestaña Tienda dePublicProfilePage.tsx -
purchaseProduct— conectado;PurchaseProductModal.tsx -
mySales/markOrderShipped/cancelOrder— conectado;ShopManagePage.tsx -
myOrders— conectado;ShopManagePage.tsx(pestaña del historial de compras propias del comprador) - Entrega de productos digitales —
Product.productType(physical|digital) se define al crear y no puede cambiar después;Product.digitalDeliveryContentguarda el enlace de descarga/código/instrucciones, visible solo para el vendedor o para un comprador con un pedido completado (resolver de campoProduct.digitalDeliveryContentenproduct.resolver.js).ProductPurchaseInput.shippingAddressahora es opcional — obligatorio para compras físicas, ignorado para las digitales. UnProductOrderdigital se crea constatus: deliveredde inmediato (sin el pasopending→shipped). - Reseñas / calificaciones de productos —
ProductReview(calificación de 1 a 5, comentario opcional), una por comprador por producto (índice único en la BD sobre(buyer_id, product_id)), condicionada a que el comprador tenga un pedidoshipped/deliveredde ese producto.Product.averageRating/Product.reviewCountse calculan al leer (ProductReviewAccessService#getSummary), no son columnas almacenadas. - Proceso formal de disputa — un comprador puede abrir
openProductDisputesobre su propio pedidoshipped/delivered(una disputa abierta por pedido a la vez); un administrador con permisoMODERATE_CONTENTla resuelve medianteadminResolveProductDisputeconresolved_refund(revierte la transferencia de monedas, mismo patrón de crédito al comprador/débito al vendedor quecancelOrder) oresolved_denied(sin movimiento de monedas). Elstatuspropio del pedido no se toca por una disputa — es elstatusdeProductDisputeel que registra el resultado.
Publicaciones (Product)
query MyProducts($limit: Int, $offset: Int) {
myProducts(limit: $limit, offset: $offset) {
id name description priceCoins stock images isActive salesCount
productType digitalDeliveryContent averageRating reviewCount createdAt
}
}
query SellerProducts($sellerId: ID!, $limit: Int, $offset: Int) {
sellerProducts(sellerId: $sellerId, limit: $limit, offset: $offset) {
id name priceCoins stock images salesCount productType averageRating reviewCount
}
}
query HasActiveShop($sellerId: ID!) { hasActiveShop(sellerId: $sellerId) }
mutation CreateProduct($input: ProductCreateInput!) { createProduct(input: $input) { id } }
mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) { updateProduct(id: $id, input: $input) { id } }
mutation SetProductActive($id: ID!, $isActive: Boolean!) { setProductActive(id: $id, isActive: $isActive) { id isActive } }
mutation DeleteProduct($id: ID!) { deleteProduct(id: $id) }
myProducts es la lista orientada al dueño — incluye publicaciones no publicadas (isActive: false) y sin inventario. sellerProducts es el equivalente público que se muestra en la pestaña Tienda de un perfil — solo publicaciones publicadas. hasActiveShop decide si la pestaña Tienda siquiera se renderiza en un perfil dado.
ProductCreateInput.productType (physical | digital, por defecto physical) solo se puede establecer al crear — updateProduct rechaza cambiarlo después (ver el comentario de código en product.manager.js#updateProduct), ya que cambiarlo después podría dejar un producto digital sin contenido de entrega o uno físico con uno obsoleto. stock es opcional para un producto digital (por defecto 999999 — efectivamente ilimitado, ya que un archivo descargable no está limitado por inventario) pero sigue siendo obligatorio para uno físico. Un producto digital debe establecer digitalDeliveryContent (el enlace de descarga/código/instrucciones) al crearlo; se puede actualizar después mediante ProductUpdateInput.digitalDeliveryContent (solo en un producto ya digital). Product.digitalDeliveryContent solo se devuelve al vendedor o a un comprador con un pedido completado (shipped/delivered) de ese producto — para todos los demás es null, incluso si la fila subyacente tiene contenido (resolver de campo Product.digitalDeliveryContent en product.resolver.js).
Pedidos (ProductOrder)
mutation PurchaseProduct($input: ProductPurchaseInput!) {
purchaseProduct(input: $input) {
id totalPriceCoins platformFeeCoins sellerEarningsCoins status shippingAddress
}
}
query MyOrders($limit: Int, $offset: Int) {
myOrders(limit: $limit, offset: $offset) {
id product { name images productType digitalDeliveryContent } quantity totalPriceCoins status trackingCarrier trackingNumber trackingUrl createdAt
}
}
query MySales($status: String, $limit: Int, $offset: Int) {
mySales(status: $status, limit: $limit, offset: $offset) {
id buyer { username } quantity totalPriceCoins sellerEarningsCoins status shippingAddress buyerNote createdAt
}
}
mutation MarkOrderShipped($id: ID!, $input: MarkOrderShippedInput!) {
markOrderShipped(id: $id, input: $input) { id status trackingCarrier trackingNumber trackingUrl shippedAt }
}
mutation CancelOrder($id: ID!, $reason: String) {
cancelOrder(id: $id, reason: $reason) { id status canceledReason }
}
Las monedas se mueven de inmediato al hacer checkout (purchaseProduct) — platformFeeCoins se descuenta de totalPriceCoins antes de acreditar sellerEarningsCoins al saldo del vendedor. ProductOrderStatus es uno de pending, shipped, delivered o canceled — delivered solo se alcanza en un pedido digital, en el momento de la compra (no hay transición pending→delivered para un pedido físico; esos siguen pending→shipped, igual que antes). ProductPurchaseInput.shippingAddress y ProductOrder.shippingAddress ahora son opcionales (String, no String!) — obligatorio para una compra física, null para una digital. mySales se puede filtrar por estado para alimentar las pestañas de la cola de cumplimiento del vendedor.
Reseñas (ProductReview)
query ProductReviews($productId: ID!, $limit: Int, $offset: Int) {
productReviews(productId: $productId, limit: $limit, offset: $offset) {
id rating comment buyer { username } createdAt
}
}
query ProductReviewSummary($productId: ID!) {
productReviewSummary(productId: $productId) { averageRating reviewCount }
}
query HasReviewedProduct($productId: ID!) { hasReviewedProduct(productId: $productId) }
mutation SubmitProductReview($orderId: ID!, $input: ProductReviewInput!) {
submitProductReview(orderId: $orderId, input: $input) { id rating comment }
}
mutation DeleteProductReview($id: ID!) { deleteProductReview(id: $id) }
submitProductReview recibe un orderId, no un productId simple — la clave foránea orderId de la reseña lo exige, y anclarla a un pedido específico permite que el resolver verifique que ese pedido exacto realmente llegó a shipped/delivered en lugar de confiar en un ID de producto que envía el cliente. Un comprador obtiene exactamente una reseña por producto (índice único en la BD sobre (buyer_id, product_id)) — llamar a submitProductReview de nuevo, incluso referenciando un pedido distinto del mismo producto, actualiza la reseña existente en lugar de crear una segunda.
Disputas (ProductDispute)
query OrderDisputes($orderId: ID!) {
orderDisputes(orderId: $orderId) { id status reason adminNotes createdAt }
}
# Solo administradores - requiere permiso MODERATE_CONTENT
query OpenProductDisputes($limit: Int, $offset: Int) {
adminOpenProductDisputes(limit: $limit, offset: $offset) {
id reason status createdAt
raisedBy { username }
order { id product { name } seller { username } totalPriceCoins }
}
}
mutation OpenProductDispute($orderId: ID!, $reason: String!) {
openProductDispute(orderId: $orderId, reason: $reason) { id status }
}
# Solo administradores - requiere permiso MODERATE_CONTENT
mutation ResolveProductDispute($disputeId: ID!, $resolution: ProductDisputeResolution!, $adminNotes: String) {
adminResolveProductDispute(disputeId: $disputeId, resolution: $resolution, adminNotes: $adminNotes) { id status }
}
Una disputa solo se puede abrir sobre un pedido que ya esté shipped/delivered (un pedido todavía pending debería usar cancelOrder en su lugar), y solo puede haber una disputa abierta por pedido a la vez (protección findOpenByOrder en product-dispute.manager.js#openDispute). adminResolveProductDispute requiere context.admin más el permiso MODERATE_CONTENT (la misma protección que refundPostPurchase — ver exclusive-posts.md). resolved_refund acredita al comprador el order.totalPriceCoins completo de vuelta y revierte el sellerEarningsCoins del vendedor, usando el mismo patrón de reversión de monedas que cancelOrder; no restaura el inventario del producto ni cambia el status propio del pedido, ya que el pedido realmente se envió — el reembolso se registra en la fila de ProductDispute, no alterando el historial. resolved_denied simplemente cierra la disputa sin movimiento de monedas. El vendedor recibe una notificación de mejor esfuerzo cuando se abre una disputa sobre una de sus ventas, y el comprador recibe una cuando se resuelve (dispute_opened / dispute_resolved — agregados a la lista blanca de notificationType en validators/notification.validator.js junto a los demás tipos de notificación del marketplace de productos).