Shop (Creator Marketplace) — Technical Reference
← Back to Shop (Creator Marketplace)
Where this lives
Backend
apps/backend/graphql/types/product.type.js—ProductGraphQL schemaapps/backend/graphql/types/product-order.type.js—ProductOrderGraphQL schemaapps/backend/graphql/resolvers/product.resolver.js— resolvesproduct,myProducts,sellerProducts,hasActiveShop,createProduct,updateProduct,setProductActive,deleteProductapps/backend/graphql/resolvers/product-order.resolver.js— resolvesproductOrder,myOrders,mySales,purchaseProduct,markOrderShipped,cancelOrderapps/backend/managers/product-managers/product.manager.js— listing business logic, including digital-product validationapps/backend/managers/product-managers/product-order.manager.js— checkout, coin debit/credit, platform fee split, and fulfillment state machineapps/backend/graphql/types/product-review.type.js/product-review.resolver.js/product-review.manager.js— verified-purchase reviewsapps/backend/graphql/types/product-dispute.type.js/product-dispute.resolver.js/product-dispute.manager.js— buyer disputes + admin arbitration
Frontend
apps/frontend-nextjs/src/page-components/ShopManagePage.tsx— seller's listing management, sales queue, shipping, and the buyer's own order history (including leaving reviews and opening disputes), all in one screenapps/frontend-nextjs/src/app/shop/manage/page.tsx—/shop/managerouteapps/frontend-nextjs/src/components/shop/CreateProductModal.tsx— create/edit a listing, including the physical/digital type picker and delivery-content fieldapps/frontend-nextjs/src/components/shop/PurchaseProductModal.tsx— buyer checkout modal (skips the shipping address field for digital products, shows a rating summary and a preview of recent reviews)apps/frontend-nextjs/src/page-components/PublicProfilePage.tsx— renders the Shop tab (sellerProducts/hasActiveShop) on a creator's public profile and opensPurchaseProductModalapps/frontend-admin/src/app/moderation/disputes/page.tsx— admin arbitration queue (adminOpenProductDisputes/adminResolveProductDispute), gated onMODERATE_CONTENT
Note: this GraphQL layer is recent — the source comments it as "brand new (this session's marketplace build)" and not yet in the shared packages/graphql operations (generated into @closegram/apollo-web), so both page-components above define their GraphQL documents inline rather than importing generated hooks (the established pattern elsewhere in this codebase once a schema stabilizes).
Technical implementation checklist
-
myProducts/createProduct/updateProduct/setProductActive/deleteProduct— wired;ShopManagePage.tsx+CreateProductModal.tsx -
sellerProducts/hasActiveShop— wired;PublicProfilePage.tsxShop tab -
purchaseProduct— wired;PurchaseProductModal.tsx -
mySales/markOrderShipped/cancelOrder— wired;ShopManagePage.tsx -
myOrders— wired;ShopManagePage.tsx(buyer's own purchase history tab) - Digital product delivery —
Product.productType(physical|digital) is set at creation and cannot change afterward;Product.digitalDeliveryContentholds the download link/code/instructions, visible only to the seller or a buyer with a completed order (Product.digitalDeliveryContentfield resolver inproduct.resolver.js).ProductPurchaseInput.shippingAddressis now optional — required for physical purchases, ignored for digital ones. A digitalProductOrderis created withstatus: deliveredimmediately (nopending→shippedstep). - Product reviews / ratings —
ProductReview(rating 1-5, optional comment), one per buyer per product (DB unique index on(buyer_id, product_id)), gated on the buyer having ashipped/deliveredorder for that product.Product.averageRating/Product.reviewCountare computed on read (ProductReviewAccessService#getSummary), not stored columns. - Formal dispute process — a buyer can
openProductDisputeon their ownshipped/deliveredorder (one open dispute per order at a time); an admin withMODERATE_CONTENTpermission resolves it viaadminResolveProductDisputewithresolved_refund(reverses the coin transfer, same buyer-credit/seller-debit pattern ascancelOrder) orresolved_denied(no coin movement). The order's ownstatusis untouched by a dispute — it's theProductDispute.statusthat tracks the outcome.
Listings (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 is the owner-facing list — it includes unpublished (isActive: false) and out-of-stock listings. sellerProducts is the public-facing equivalent shown on a profile's Shop tab — published listings only. hasActiveShop decides whether the Shop tab even renders on a given profile.
ProductCreateInput.productType (physical | digital, defaults to physical) is only settable at creation — updateProduct rejects changing it later (see the code comment in product.manager.js#updateProduct), since flipping it after the fact could leave a digital product without delivery content or a physical one with a stale one. stock is optional for a digital product (defaults to 999999 — effectively unlimited, since a downloadable file isn't inventory-constrained) but still required for a physical one. A digital product must set digitalDeliveryContent (the download link/code/instructions) at creation; it can be updated later via ProductUpdateInput.digitalDeliveryContent (still only on an already-digital product). Product.digitalDeliveryContent is only ever returned to the seller or to a buyer with a completed (shipped/delivered) order for that product — everyone else gets null, even if the underlying row has content (Product.digitalDeliveryContent field resolver in product.resolver.js).
Orders (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 }
}
Coins move immediately at checkout (purchaseProduct) — platformFeeCoins is deducted from totalPriceCoins before crediting sellerEarningsCoins to the seller's balance. ProductOrderStatus is one of pending, shipped, delivered, or canceled — delivered is reached only by a digital order at the moment of purchase (there's no pending→delivered transition for a physical order; those follow pending→shipped, same as before). ProductPurchaseInput.shippingAddress and ProductOrder.shippingAddress are both optional now (String, not String!) — required for a physical purchase, null for a digital one. mySales can be filtered by status to power the seller's fulfillment queue tabs.
Reviews (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 takes an orderId, not a bare productId — the review's orderId foreign key requires one, and anchoring to a specific order lets the resolver verify that exact order actually reached shipped/delivered rather than trusting a client-supplied product ID. A buyer gets exactly one review per product (DB unique index on (buyer_id, product_id)) — calling submitProductReview again, even referencing a different order for the same product, updates the existing review rather than creating a second one.
Disputes (ProductDispute)
query OrderDisputes($orderId: ID!) {
orderDisputes(orderId: $orderId) { id status reason adminNotes createdAt }
}
# Admin only - requires MODERATE_CONTENT permission
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 }
}
# Admin only - requires MODERATE_CONTENT permission
mutation ResolveProductDispute($disputeId: ID!, $resolution: ProductDisputeResolution!, $adminNotes: String) {
adminResolveProductDispute(disputeId: $disputeId, resolution: $resolution, adminNotes: $adminNotes) { id status }
}
A dispute can only be opened on an order that's already shipped/delivered (a still-pending order should use cancelOrder instead) and only one dispute can be open per order at a time (findOpenByOrder guard in product-dispute.manager.js#openDispute). adminResolveProductDispute requires context.admin plus the MODERATE_CONTENT permission (same gate as refundPostPurchase — see exclusive-posts.md). resolved_refund credits the buyer the full order.totalPriceCoins back and reverses the seller's sellerEarningsCoins, using the identical coin-reversal pattern as cancelOrder; it does not restore product stock or change the order's own status, since the order genuinely did ship — the refund is tracked on the ProductDispute row, not by mutating history. resolved_denied just closes the dispute with no coin movement. The seller gets a best-effort notification when a dispute opens against one of their sales, and the buyer gets one when it's resolved (dispute_opened / dispute_resolved — added to the notificationType whitelist in validators/notification.validator.js alongside the other product-marketplace notification types).