diff --git a/www/apps/resources/app/recipes/digital-products/examples/standard/page.mdx b/www/apps/resources/app/recipes/digital-products/examples/standard/page.mdx new file mode 100644 index 0000000000..76b000eb87 --- /dev/null +++ b/www/apps/resources/app/recipes/digital-products/examples/standard/page.mdx @@ -0,0 +1,2619 @@ +import { Github, PlaySolid } from "@medusajs/icons" + +export const metadata = { + title: `Digital Products Recipe Example`, +} + +# {metadata.title} + +This document provides an example of implementing the digital product recipe. + + + +You can implement digital products as you see fit for your use case. This is only an example of one way to implement it. + + + +## Features + +By following this example, you’ll have a commerce application with the following features: + +1. Digital products with multiple media items. +2. Manage digital products from the admin dashboard. +3. Handle and fulfill digital product orders. +4. Allow customers to download their digital product purchases from the storefront. +5. All other commerce features that Medusa provides. + +, + showLinkIcon: false + }, + { + href: "https://res.cloudinary.com/dza7lstvk/raw/upload/v1721654620/OpenApi/Digital_Products_Postman_vjr3jg.yml", + title: "OpenApi Specs for Postman", + text: "Imported this OpenApi Specs file into tools like Postman.", + startIcon: , + showLinkIcon: false + }, +]} /> + +--- + + + +- [A new Medusa application installed.](!docs!#get-started) + + + +## Step 1: Create the Digital Product Module + +The first step is to create a digital product module that holds the data models related to a digital product. + +Create the directory `src/modules/digital-product`. + +### Create Data Models + +Create the file `src/modules/digital-product/models/digital-product.ts` with the following content: + +```ts title="src/modules/digital-product/models/digital-product.ts" +import { model } from "@medusajs/utils" +import DigitalProductMedia from "./digital-product-media" +import DigitalProductOrder from "./digital-product-order" + +const DigitalProduct = model.define("digital_product", { + id: model.id().primaryKey(), + name: model.text(), + medias: model.hasMany(() => DigitalProductMedia, { + mappedBy: "digitalProduct", + }), + orders: model.manyToMany(() => DigitalProductOrder, { + mappedBy: "products", + }), +}) +.cascades({ + delete: ["medias"], +}) + +export default DigitalProduct +``` + +This creates a `DigitalProduct` data model. It has many medias and orders, which you’ll create next. + +Create the file `src/modules/digital-product/models/digital-product-media.ts` with the following content: + +export const dpmModelHighlights = [ + ["8", "fileId", "The file ID is retrieved from the File Module when the file is uploaded."] +] + +```ts title="src/modules/digital-product/models/digital-product-media.ts" highlights={dpmModelHighlights} +import { model } from "@medusajs/utils" +import { MediaType } from "../types" +import DigitalProduct from "./digital-product" + +const DigitalProductMedia = model.define("digital_product_media", { + id: model.id().primaryKey(), + type: model.enum(MediaType), + fileId: model.text(), + mimeType: model.text(), + digitalProduct: model.belongsTo(() => DigitalProduct, { + mappedBy: "medias", + }), +}) + +export default DigitalProductMedia +``` + +This creates a `DigitalProductMedia` data model, which represents a media file that belongs to the digital product. The `fileId` property holds the ID of the uploaded file as returned by the File Module, which is explained in later sections. + +Notice that the above data model uses an enum from a `types` file. So, create the file `src/modules/digital-product/types/index.ts` with the following content: + +```ts title="src/modules/digital-product/types/index.ts" +export enum MediaType { + MAIN = "main", + PREVIEW = "preview" +} +``` + +This enum indicates that a digital product media can either be used to preview the digital product, or is the main file available on purchase. + +Next, create the file `src/modules/digital-product/models/digital-product-order.ts` with the following content: + +```ts title="src/modules/digital-product/models/digital-product-order.ts" +import { model } from "@medusajs/utils" +import { OrderStatus } from "../types" +import DigitalProduct from "./digital-product" + +const DigitalProductOrder = model.define("digital_product_order", { + id: model.id().primaryKey(), + status: model.enum(OrderStatus), + products: model.manyToMany(() => DigitalProduct, { + mappedBy: "orders", + }), +}) + +export default DigitalProductOrder +``` + +This creates a `DigitalProductOrder` data model, which represents an order of digital products. + +This data model also uses an enum from the `types` file. So, add the following to the `src/modules/digital-product/types/index.ts` file: + +```ts title="src/modules/digital-product/types/index.ts" +export enum OrderStatus { + PENDING = "pending", + SENT = "sent" +} +``` + +### Create Main Module Service + +Next, create the main service of the module at `src/modules/digital-product/service.ts` with the following content: + +```ts title="src/modules/digital-product/service.ts" +import { MedusaService } from "@medusajs/utils" +import DigitalProduct from "./models/digital-product" +import DigitalProductOrder from "./models/digital-product-order" +import DigitalProductMedia from "./models/digital-product-media" + +class DigitalProductModuleService extends MedusaService({ + DigitalProduct, + DigitalProductMedia, + DigitalProductOrder, +}) { + +} + +export default DigitalProductModuleService +``` + +The service extends the [service factory](https://docs.medusajs.com/v2/advanced-development/modules/service-factory), which provides basic data-management features. + +### Create Module Definition + +After that, create the module definition at `src/modules/digital-product/index.ts` with the following content: + +```ts title="src/modules/digital-product/index.ts" +import DigitalProductModuleService from "./service" +import { Module } from "@medusajs/utils" + +export const DIGITAL_PRODUCT_MODULE = "digitalProductModuleService" + +export default Module(DIGITAL_PRODUCT_MODULE, { + service: DigitalProductModuleService, +}) +``` + +### Add Module to Medusa Configuration + +Finally, add the module to the list of modules in `medusa-config.js`: + +```tsx title="medusa-config.js" +module.exports = defineConfig({ + // ... + modules: { + digitalProductModuleService: { + resolve: "./modules/digital-product", + }, + }, +}) +``` + +### Further Reads + +- [How to Create a Module](!docs!/basics/modules-and-services) +- [How to Create a Data Model](!docs!/basics/data-models) + +--- + +## Step 2: Define Links + +In this step, you’ll define links between your module’s data models and data models from Medusa’s commerce modules. + +Start by creating the file `src/links/digital-product-variant.ts` with the following content: + +```ts title="src/links/digital-product-variant.ts" +import DigitalProductModule from "../modules/digital-product" +import ProductModule from "@medusajs/product" +import { defineLink } from "@medusajs/utils" + +export default defineLink( + DigitalProductModule.linkable.digitalProduct, + ProductModule.linkable.productVariant +) +``` + +This defines a link between `DigitalProduct` and the Product Module’s `ProductVariant`. This allows product variants that customers purchase to be digital products. + +Next, create the file `src/links/digital-product-order.ts` with the following content: + +export const orderLinkHighlights = [ + ["8", "deleteCascade", "Delete the digital product order when its linked Medusa order is deleted."] +] + +```ts title="src/links/digital-product-order.ts" +import DigitalProductModule from "../modules/digital-product" +import OrderModule from "@medusajs/order" +import { defineLink } from "@medusajs/utils" + +export default defineLink( + { + linkable: DigitalProductModule.linkable.digitalProductOrder, + deleteCascade: true, + }, + OrderModule.linkable.order +) + +``` + +This defines a link between `DigitalProductOrder` and the Order Module’s `Order`. This keeps track of orders that include purchases of digital products. + +`deleteCascades` is enabled on the `digitalProductOrder` so that when a Medusa order is deleted, its linked digital product order is also deleted. + +### Further Read + +- [How to Define Module Links](!docs!/advanced-development/modules/module-links) + +--- + +## Step 3: Run Migrations and Sync Links + +To create tables for the digital product data models in the database, start by generating the migrations for the Digital Product Module with the following command: + +```bash +npx medusa migrations generate digitalProductModuleService +``` + +This generates a migration in the `src/modules/digital-product/migrations` directory. + +Then, reflect the migrations in the database with the following command: + +```bash +npx medusa migrations run +``` + +Next, to reflect your links in the database, run the `links sync` command: + +```bash +npx medusa links sync +``` + +--- + +## Step 4: List Digital Products Admin API Route + +In this step, you’ll create the admin API route to list digital products. + +Create the file `src/api/admin/digital-products/route.ts` with the following content: + +```ts title="src/api/admin/digital-products/route.ts" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/medusa" +import { remoteQueryObjectFromString } from "@medusajs/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { + fields, + limit = 20, + offset = 0, + } = req.validatedQuery || {} + const remoteQuery = req.scope.resolve("remoteQuery") + + const query = remoteQueryObjectFromString({ + entryPoint: "digital_product", + fields: [ + "*", + "medias.*", + "product_variant.*", + ...(fields || []), + ], + variables: { + skip: offset, + take: limit, + }, + }) + + const { + rows, + metadata: { count, take, skip }, + } = await remoteQuery(query) + + res.json({ + digital_products: rows, + count, + limit: take, + offset: skip, + }) +} +``` + +This adds a `GET` API route at `/admin/digital-products`. + +In the route handler, you use the remote query to retrieve the list of digital products and their relations. The route handler also supports pagination. + +### Test API Route + +To test out the API route, start the Medusa application: + +```bash npm2yarn +npm run dev +``` + +Then, obtain a JWT token as an admin user with the following request: + +```bash +curl -X POST 'http://localhost:9000/auth/user/emailpass' \ +-H 'Content-Type: application/json' \ +--data-raw '{ + "email": "admin@medusajs.com", + "password": "supersecret" +}' +``` + +Finally, send the following request to retrieve the list of digital products: + +```bash +curl -L 'http://localhost:9000/admin/digital-products' \ +-H 'Authorization: Bearer {token}' +``` + +Make sure to replace `{token}` with the JWT token you retrieved. + +### Further Reads + +- [How to Create an API Route](!docs!/basics/api-routes) +- [Learn more about the remote query](!docs!/advanced-development/modules/remote-query) + +--- + +## Step 5: Create Digital Product Workflow + +In this step, you’ll create a workflow that creates a digital product. You’ll use this workflow in the next section. + +This workflow has the following steps: + +```mermaid +graph TD + createProductsWorkflow["createProductsWorkflow (Medusa)"] --> createDigitalProductStep + createDigitalProductStep --> createDigitalProductMediasStep + createDigitalProductMediasStep --> createRemoteLinkStep["createRemoteLinkStep (Medusa)"] +``` + +1. `createProductsWorkflow`: Create the Medusa product that the digital product is associated with its variant. Medusa provides this workflow through the `@medusajs/core-flows` package, which you can use as a step. +2. `createDigitalProductStep`: Create the digital product. +3. `createDigitalProductMediasStep`: Create the medias associated with the digital product. +4. `createRemoteLinkStep`: Create the link between the digital product and the product variant. Medusa provides this step through the `@medusajs/core-flows` package. + +You’ll implement the second and third steps. + +### createDigitalProductStep (Second Step) + +Create the file `src/workflows/create-digital-product/steps/create-digital-product.ts` with the following content: + +export const createDpHighlights = [ + ["19", "createDigitalProducts", "Create the digital product."], + ["24", "digital_product", "Pass the digital product to the compensation function."], + ["31", "deleteDigitalProducts", "Delete the digital product if an error occurs in the workflow."] +] + +```ts title="src/workflows/create-digital-product/steps/create-digital-product.ts" highlights={createDpHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" + +export type CreateDigitalProductStepInput = { + name: string +} + +const createDigitalProductStep = createStep( + "create-digital-product-step", + async (data: CreateDigitalProductStepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProduct = await digitalProductModuleService + .createDigitalProducts(data) + + return new StepResponse({ + digital_product: digitalProduct, + }, { + digital_product: digitalProduct, + }) + }, + async ({ digital_product }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProducts( + digital_product.id + ) + } +) + +export default createDigitalProductStep +``` + +This creates the `createDigitalProductStep`. In this step, you create a digital product. + +In the compensation function, which is executed if an error occurs in the workflow, you delete the digital products. + +### createDigitalProductMediasStep (Third Step) + +Create the file `src/workflows/create-digital-product/steps/create-digital-product-medias.ts` with the following content: + +export const createDigitalProductMediaHighlights = [ + ["29", "createDigitalProductMedias", "Create the digital product medias."], + ["34", "digital_product_medias", "Pass the digital product medias to the compensation function."], + ["41", "deleteDigitalProductMedias", "Delete the digital product medias if an error occurs in the workflow."] +] + +```ts title="src/workflows/create-digital-product/steps/create-digital-product-medias.ts" highlights={createDigitalProductMediaHighlights} collapsibleLines="1-8" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" +import { MediaType } from "../../../modules/digital-product/types" + +export type CreateDigitalProductMediaInput = { + type: MediaType + fileId: string + mimeType: string + digital_product_id: string +} + +type CreateDigitalProductMediasStepInput = { + medias: CreateDigitalProductMediaInput[] +} + +const createDigitalProductMediasStep = createStep( + "create-digital-product-medias", + async ({ + medias, + }: CreateDigitalProductMediasStepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProductMedias = await digitalProductModuleService + .createDigitalProductMedias(medias) + + return new StepResponse({ + digital_product_medias: digitalProductMedias, + }, { + digital_product_medias: digitalProductMedias, + }) + }, + async ({ digital_product_medias }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProductMedias( + digital_product_medias.map((media) => media.id) + ) + } +) + +export default createDigitalProductMediasStep +``` + +This creates the `createDigitalProductMediasStep`. In this step, you create medias of the digital product. + +In the compensation function, you delete the digital product medias. + +### Create createDigitalProductWorkflow + +Finally, create the file `src/workflows/create-digital-product/index.ts` with the following content: + +export const createDpWorkflowHighlights = [ + ["36", "createProductsWorkflow", "Create the Medusa product."], + ["42", "createDigitalProductStep", "Create the digital product."], + ["46", "createDigitalProductMediasStep", "Create the digital product's medias."], + ["60", "createRemoteLinkStep", "Link the digital product to the first variant of the product."], +] + +```ts title="src/workflows/create-digital-product/index.ts" highlights={createDpWorkflowHighlights} collapsibleLines="1-23" expandMoreLabel="Show Imports" +import { + createWorkflow, + transform, + WorkflowResponse +} from "@medusajs/workflows-sdk" +import { + CreateProductWorkflowInputDTO, +} from "@medusajs/types" +import { + createProductsWorkflow, + createRemoteLinkStep, +} from "@medusajs/core-flows" +import { + Modules, +} from "@medusajs/utils" +import createDigitalProductStep, { + CreateDigitalProductStepInput, +} from "./steps/create-digital-product" +import createDigitalProductMediasStep, { + CreateDigitalProductMediaInput, +} from "./steps/create-digital-product-medias" +import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product" + +type CreateDigitalProductWorkflowInput = { + digital_product: CreateDigitalProductStepInput & { + medias: Omit[] + } + product: CreateProductWorkflowInputDTO +} + +const createDigitalProductWorkflow = createWorkflow( + "create-digital-product", + (input: CreateDigitalProductWorkflowInput) => { + const { medias, ...digitalProductData } = input.digital_product + + const product = createProductsWorkflow.runAsStep({ + input: { + products: [input.product], + }, + }) + + const { digital_product } = createDigitalProductStep( + digitalProductData + ) + + const { digital_product_medias } = createDigitalProductMediasStep( + transform({ + digital_product, + medias, + }, + (data) => ({ + medias: data.medias.map((media) => ({ + ...media, + digital_product_id: data.digital_product.id, + })), + }) + ) + ) + + createRemoteLinkStep([{ + [DIGITAL_PRODUCT_MODULE]: { + digital_product_id: digital_product.id, + }, + [Modules.PRODUCT]: { + product_variant_id: product[0].variants[0].id, + }, + }]) + + return new WorkflowResponse({ + digital_product: { + ...digital_product, + medias: digital_product_medias, + }, + }) + } +) + +export default createDigitalProductWorkflow +``` + +This creates the `createDigitalProductWorkflow`. The workflow accepts as a parameter the digital product and the Medusa product to create. + +In the workflow, you run the following steps: + +1. `createProductsWorkflow` as a step to create a Medusa product. +2. `createDigitalProductStep` to create the digital product. +3. `createDigitalProductMediasStep` to create the digital product’s medias. +4. `createRemoteLinkStep` to link the digital product to the product variant. + +You’ll test out the workflow in the next section. + +### Further Reads + +- [How to Create a Workflow](!docs!/basics/workflows) +- [What is the Compensation Function](!docs!/advanced-development/workflows/compensation-function) +- [Learn more about the remote link function](!docs!/advanced-development/modules/remote-link) + +--- + +## Step 6: Create Digital Product API Route + +In this step, you’ll add the API route to create a digital product. + +In the file `src/api/admin/digital-products/route.ts` add a new route handler: + +```ts title="src/api/admin/digital-products/route.ts" +// other imports... +import { z } from "zod" +import createDigitalProductWorkflow from "../../../workflows/create-digital-product" +import { CreateDigitalProductMediaInput } from "../../../workflows/create-digital-product/steps/create-digital-product-medias" +import { createDigitalProductsSchema } from "../../validation-schemas" + +// ... + +type CreateRequestBody = z.infer< + typeof createDigitalProductsSchema +> + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await createDigitalProductWorkflow( + req.scope + ).run({ + input: { + digital_product: { + name: req.validatedBody.name, + medias: req.validatedBody.medias.map((media) => ({ + fileId: media.file_id, + mimeType: media.mime_type, + ...media, + })) as Omit[], + }, + product: req.validatedBody.product, + }, + }) + + res.json({ + digital_product: result.digital_product, + }) +} +``` + +This adds a `POST` API route at `/admin/digital-products`. In the route handler, you execute the `createDigitalProductWorkflow` created in the previous step, passing data from the request body as input. + +The route handler imports a validation schema from a `validation-schema` file. So, create the file `src/api/validation-schemas.ts` with the following content: + +```ts title="src/api/validation-schemas.ts" +import { + AdminCreateProduct, +} from "@medusajs/medusa/dist/api/admin/products/validators" +import { z } from "zod" +import { MediaType } from "../modules/digital-product/types" + +export const createDigitalProductsSchema = z.object({ + name: z.string(), + medias: z.array(z.object({ + type: z.nativeEnum(MediaType), + file_id: z.string(), + mime_type: z.string(), + })), + product: AdminCreateProduct, +}) +``` + +This defines the expected request body schema. + +Finally, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" +import { defineMiddlewares } from "@medusajs/medusa" +import { + validateAndTransformBody, +} from "@medusajs/medusa/dist/api/utils/validate-body" +import { createDigitalProductsSchema } from "./validation-schemas" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/digital-products", + method: "POST", + middlewares: [ + validateAndTransformBody(createDigitalProductsSchema), + ], + }, + ], +}) +``` + +This adds a validation middleware to ensure that the body of `POST` requests sent to `/admin/digital-products` match the `createDigitalProductsSchema`. + +### Further Read + +- [How to Create a Middleware](!docs!/advanced-development/api-routes/middlewares) + +--- + +## Step 7: Upload Digital Product Media API Route + +To upload the digital product media files, use Medusa’s file module. + +In this step, you’ll create an API route for uploading preview and main digital product media files. + + + +Your Medusa application uses the local file module provider by default, which uploads files to a local directory. However, you can use other file module providers, such as the [S3 module provider](../../../../architectural-modules/file/s3/page.mdx). + + + +Before creating the API route, install the [multer express middleware](https://expressjs.com/en/resources/middleware/multer.html) to support file uploads: + +```bash npm2yarn +npm install multer +npm install --save-dev @types/multer +``` + +Then, create the file `src/api/admin/digital-products/upload/[type]/route.ts` with the following content: + +export const uploadHighlights = [ + ["12", "access", "If the media type is `main`, upload the file with `private` access. Otherwise, upload it publically."], +] + +```ts title="src/api/admin/digital-products/upload/[type]/route.ts" highlights={uploadHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/medusa" +import { uploadFilesWorkflow } from "@medusajs/core-flows" +import { MedusaError } from "@medusajs/utils" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const access = req.params.type === "main" ? "private" : "public" + const input = req.files as Express.Multer.File[] + + if (!input?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "No files were uploaded" + ) + } + + const { result } = await uploadFilesWorkflow(req.scope).run({ + input: { + files: input?.map((f) => ({ + filename: f.originalname, + mimeType: f.mimetype, + content: f.buffer.toString("binary"), + access, + })), + }, + }) + + res.status(200).json({ files: result }) +} + +``` + +This adds a `POST` API route at `/admin/digital-products/upload/[type]` where `[type]` is either `preview` or `main`. + +In the route handler, you use the `uploadFilesWorkflow` imported from `@medusajs/core-flows` to upload the file. If the file type is `main`, it’s uploaded with private access, as only customers who purchased it can download it. Otherwise, it’s uploaded with `public` access. + +Next, add to the file `src/api/middlewares.ts` the `multer` middleware on this API route: + +```ts title="src/api/middlewares.ts" +// other imports... +import multer from "multer" + +const upload = multer({ storage: multer.memoryStorage() }) + +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/digital-products/upload**", + method: "POST", + middlewares: [ + upload.array("files"), + ], + }, + ], +}) +``` + +You’ll test out this API route in the next step as you use these API routes in the admin customizations. + +--- + +## Step 8: Add Digital Products UI Route in Admin + +In this step, you’ll add a UI route to the Medusa Admin that displays a list of digital products. + +Before you create the UI route, create the file `src/admin/types/index.ts` that holds the following types: + +```ts title="src/admin/types/index.ts" +import { ProductVariantDTO } from "@medusajs/types" + +export enum MediaType { + MAIN = "main", + PREVIEW = "preview" +} + +export type DigitalProductMedia = { + id: string + type: MediaType + fileId: string + mimeType: string + digitalProducts?: DigitalProduct +} + +export type DigitalProduct = { + id: string + name: string + medias?: DigitalProductMedia[] + product_variant?:ProductVariantDTO +} + +``` + +These types will be used by the UI route. + +Next, create the file `src/admin/routes/digital-products/page.tsx` with the following content: + +export const digitalProductPageHighlights = [ + ["48", `"Digital Products"`, "Show a sidebar item pointing to this page."] +] + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={digitalProductPageHighlights} collapsibleLines="1-7" expandMoreLabel="Show Imports" +import { defineRouteConfig } from "@medusajs/admin-shared" +import { PhotoSolid } from "@medusajs/icons" +import { Container, Heading, Table } from "@medusajs/ui" +import { useState } from "react" +import { Link } from "react-router-dom" +import { DigitalProduct } from "../../types" + +const DigitalProductsPage = () => { + const [digitalProducts, setDigitalProducts] = useState< + DigitalProduct[] + >([]) + // TODO fetch digital products... + + return ( + +
+ Digital Products + {/* TODO add create button */} +
+ + + + Name + Action + + + + {digitalProducts.map((digitalProduct) => ( + + + {digitalProduct.name} + + + + View Product + + + + ))} + +
+ {/* TODO add pagination component */} +
+ ) +} + +export const config = defineRouteConfig({ + label: "Digital Products", + icon: PhotoSolid, +}) + +export default DigitalProductsPage + +``` + +This creates a UI route that's displayed at the `/digital-products` path in the Medusa Admin. The UI route also adds a sidebar item with the label “Digital Products" pointing to the UI route. + +In the React component of the UI route, you just display the table of digital products. + +Next, replace the first `TODO` with the following: + +export const paginationHighlights = [ + ["7", "currentPage", "The number of the current page."], + ["8", "pageLimit", "The number of digital products to show per page."], + ["9", "count", "The total count of digital products."], + ["10", "pagesCount", "The number of pages based on `count` and `pageLimit`."], + ["13", "canNextPage", "Whether there’s a next page based on whether the current page is less than `pagesCount - 1`."], + ["17", "canPreviousPage", "Whether there’s a previous pages based on whether the current page is greater than `0`."], + ["22", "nextPage", "A function used to increment the `currentPage`."], + ["28", "previousPage", "A function used to decrement the `currentPage`."] +] + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={paginationHighlights} +// other imports... +import { useMemo } from "react" + +const DigitalProductsPage = () => { + // ... + + const [currentPage, setCurrentPage] = useState(0) + const pageLimit = 20 + const [count, setCount] = useState(0) + const pagesCount = useMemo(() => { + return count / pageLimit + }, [count]) + const canNextPage = useMemo( + () => currentPage < pagesCount - 1, + [currentPage, pagesCount] + ) + const canPreviousPage = useMemo( + () => currentPage > 0, + [currentPage] + ) + + const nextPage = () => { + if (canNextPage) { + setCurrentPage((prev) => prev + 1) + } + } + + const previousPage = () => { + if (canPreviousPage) { + setCurrentPage((prev) => prev - 1) + } + } + + // TODO fetch digital products + + // ... +} +``` + +This defines the following pagination variables: + +1. `currentPage`: The number of the current page. +2. `pageLimit`: The number of digital products to show per page. +3. `count`: The total count of digital products. +4. `pagesCount`: A memoized variable that holds the number of pages based on `count` and `pageLimit`. +5. `canNextPage`: A memoized variable that indicates whether there’s a next page based on whether the current page is less than `pagesCount - 1`. +6. `canPreviousPage`: A memoized variable that indicates whether there’s a previous pages based on whether the current page is greater than `0`. +7. `nextPage`: A function that increments the `currentPage`. +8. `previousPage`: A function that decrements the `currentPage`. + +Then, replace the new `TODO fetch digital products` with the following: + +export const fetchDigitalProductsHighlights = [ + ["7", "fetchProducts", "A function that fetches the digital products from the Medusa application."], + ["27", "fetchProducts", "Fetch the digital products whenever the `currentPage` changes."] +] + +```tsx title="src/admin/routes/digital-products/page.tsx" highlights={fetchDigitalProductsHighlights} +// other imports +import { useEffect } from "react" + +const DigitalProductsPage = () => { + // ... + + const fetchProducts = () => { + const query = new URLSearchParams({ + limit: `${pageLimit}`, + offset: `${pageLimit * currentPage}`, + }) + + fetch(`/admin/digital-products?${query.toString()}`, { + credentials: "include", + }) + .then((res) => res.json()) + .then(({ + digital_products: data, + count, + }) => { + setDigitalProducts(data) + setCount(count) + }) + } + + useEffect(() => { + fetchProducts() + }, [currentPage]) + + // ... +} +``` + +This defines a `fetchProducts` function that fetches the digital products using the API route you created in step 4. You also call that function within a `useEffect` callback which is executed whenever the `currentPage` changes. + +Finally, replace the `TODO add pagination component` in the return statement with `Table.Pagination` component: + +```tsx title="src/admin/routes/digital-products/page.tsx" +return ( + + {/* ... */} + + + ) +``` + +The `Table.Pagination` component accepts as props the pagination variables you defined earlier. + +### Test UI Route + +To test the UI route out, start the Medusa application, go to `localhost:9000/app`, and log in as an admin user. + +Once you log in, you’ll find a new sidebar item, “Digital Products.” If you click on it, you’ll see the UI route you created with a table of digital products. + +### Further Reads + +- [How to Create UI Routes](!docs!/advanced-development/admin/ui-routes) +- [How to Create Admin Widgets](!docs!/advanced-development/admin/widgets) + +--- + +## Step 9: Add Create Digital Product Form in Admin + +In this step, you’ll add a form for admins to create digital products. The form opens in a drawer or side window from within the Digital Products UI route you created in the previous section. + +Create the file `src/admin/components/create-digital-product-form/index.tsx` with the following content: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +import { useState } from "react" +import { Input, Button, Select, toast } from "@medusajs/ui" +import { MediaType } from "../../types" + +type CreateMedia = { + type: MediaType + file?: File +} + +type Props = { + onSuccess?: () => void +} + +const CreateDigitalProductForm = ({ + onSuccess, +}: Props) => { + const [name, setName] = useState("") + const [medias, setMedias] = useState([]) + const [productTitle, setProductTitle] = useState("") + const [loading, setLoading] = useState(false) + + const onSubmit = async (e: React.FormEvent) => { + // TODO handle submit + } + + return ( +
+ {/* TODO show form inputs */} + +
+ ) +} + +export default CreateDigitalProductForm +``` + +This creates a React component that shows a form and handles creating a digital product on form submission. + +You currently don’t display the form. Replace the return statement with the following: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +return ( +
+ setName(e.target.value)} + /> +
+ Media + + {medias.map((media, index) => ( +
+ Media {index + 1} + + changeFiles( + index, + { + file: e.target.files[0], + } + )} + className="mt-2" + /> +
+ ))} +
+
+ Product + setProductTitle(e.target.value)} + /> +
+ +
+) +``` + +This shows input fields for the digital product and product’s names. It also shows a fieldset of media files, with the ability to add more media files on a button click. + +Add in the component the `onAddMedia` function that is triggered by a button click to add a new media: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const onAddMedia = () => { + setMedias((prev) => [ + ...prev, + { + type: MediaType.PREVIEW, + }, + ]) +} +``` + +And add in the component a `changeFiles` function that saves changes related to a media in the `medias` state variable: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const changeFiles = ( + index: number, + data: Partial +) => { + setMedias((prev) => [ + ...(prev.slice(0, index)), + { + ...prev[index], + ...data, + }, + ...(prev.slice(index + 1)), + ]) +} +``` + +On submission, the media files should first be uploaded before the digital product is created. + +So, add before the `onSubmit` function the following new function: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +const uploadMediaFiles = async ( + type: MediaType +) => { + const formData = new FormData() + const mediaWithFiles = medias.filter( + (media) => media.file !== undefined && + media.type === type + ) + + if (!mediaWithFiles.length) { + return + } + + mediaWithFiles.forEach((media) => { + formData.append("files", media.file) + }) + + const { files } = await fetch(`/admin/digital-products/upload/${type}`, { + method: "POST", + credentials: "include", + body: formData, + }).then((res) => res.json()) + + return { + mediaWithFiles, + files, + } +} +``` + +This function accepts a type of media to upload (`preview` or `main`). In the function, you upload the files of the specified type using the API route you created in step 7. You return the uploaded files and their associated media. + +Next, you’ll implement the `onSubmit` function. Replace it with the following: + +export const uploadMediaHighlights = [ + ["9", "uploadMediaFiles", "Upload preview media."], + ["13", "uploadMediaFiles", "Upload the main media."] +] + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" highlights={uploadMediaHighlights} +const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + + try { + const { + mediaWithFiles: previewMedias, + files: previewFiles, + } = await uploadMediaFiles(MediaType.PREVIEW) || {} + const { + mediaWithFiles: mainMedias, + files: mainFiles, + } = await uploadMediaFiles(MediaType.MAIN) || {} + + const mediaData = [] + + previewMedias?.forEach((media, index) => { + mediaData.push({ + type: media.type, + file_id: previewFiles[index].id, + mime_type: media.file!.type, + }) + }) + + mainMedias?.forEach((media, index) => { + mediaData.push({ + type: media.type, + file_id: mainFiles[index].id, + mime_type: media.file!.type, + }) + }) + + // TODO create digital product + } catch (e) { + console.error(e) + setLoading(false) + } +} +``` + +In this function, you use the `uploadMediaFiles` function to upload `preview` and `main` media files. Then, you prepare the media data that’ll be used when creating the digital product in a `mediaData` variable. + + + +Notice that you use the `id` of uploaded files, as returned in the response of `/admin/digital-products/upload/[type]` as the `file_id` value of the media to be created. + + + +Finally, replace the new `TODO` in `onSubmit` with the following: + +```tsx title="src/admin/components/create-digital-product-form/index.tsx" +fetch(`/admin/digital-products`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + medias: mediaData, + product: { + title: productTitle, + options: [{ + title: "Default", + values: ["default"], + }], + variants: [{ + title: productTitle, + options: { + Default: "default", + }, + // delegate setting the prices to the + // product's page. + prices: [], + }], + }, + }), +}) +.then((res) => res.json()) +.then(({ message }) => { + if (message) { + throw message + } + onSuccess?.() +}) +.catch((e) => { + console.error(e) + toast.error("Error", { + description: `An error occurred while creating the digital product: ${e}`, + }) +}) +.finally(() => setLoading(false)) +``` + +In this snippet, you send a `POST` request to `/admin/digital-products` to create a digital product. + +You’ll make changes now to `src/admin/routes/digital-products/page.tsx` to show the form. + +First, add a new `open` state variable: + +```tsx title="src/admin/routes/digital-products/page.tsx" +const DigitalProductsPage = () => { + const [open, setOpen] = useState(false) + // ... +} +``` + +Then, replace the `TODO add create button` in the return statement to show the `CreateDigitalProductForm` component: + +```tsx title="src/admin/routes/digital-products/page.tsx" +// other imports... +import { Drawer } from "@medusajs/ui" +import CreateDigitalProductForm from "../../components/create-digital-product-form" + +const DigitalProductsPage = () => { + // ... + + return ( + + {/* Replace the TODO with the following */} + setOpen(openChanged)}> + { + setOpen(true) + }} + asChild + > + + + + + Create Product + + + { + setOpen(false) + if (currentPage === 0) { + fetchProducts() + } else { + setCurrentPage(0) + } + }} /> + + + + + ) +} +``` + +This adds a Create button in the Digital Products UI route and, when it’s clicked, shows the form in a drawer or side window. + +You pass to the `CreateDigitalProductForm` component an `onSuccess` prop that, when the digital product is created successfully, re-fetches the digital products. + +### Test Create Form Out + +To test the form, open the Digital Products page in the Medusa Admin. There, you’ll find a new Create button. + +If you click on the button, a form will open in a drawer. Fill in the details of the digital product to create one. + +After you create the digital product, you’ll find it in the table. You can also click on View Product to edit the product’s details, such as the variant’s price. + + + +To use this digital product in later steps (such as to create an order), you must make the following changes to its associated product details: + +1. Change the status to published. +2. Add it to the default sales channel. +3. Disable manage inventory of the variant. +4. Add prices to the variant. + + + +--- + +## Step 10: Create Digital Product Fulfillment Module Provider + +In this step, you'll create a fulfillment module provider for digital products. It doesn't have any real fulfillment functionality as digital products aren't physically fulfilled. + +### Create Module Provider Service + +Start by creating the `src/modules/digital-product-fulfillment` directory. + +Then, create the file `src/modules/digital-product-fulfillment/service.ts` with the following content: + +```ts title="src/modules/digital-product-fulfillment/service.ts" +import { AbstractFulfillmentProviderService } from "@medusajs/utils" + +class DigitalProductFulfillmentService extends AbstractFulfillmentProviderService { + static identifier = "digital" + + constructor() { + super() + } + + async getFulfillmentOptions(): Promise[]> { + return [ + { + id: "digital-fulfillment", + }, + ] + } + + async validateFulfillmentData( + optionData: Record, + data: Record, + context: Record + ): Promise { + return data + } + + async validateOption(data: Record): Promise { + return true + } + + async createFulfillment(): Promise> { + // No data is being sent anywhere + return {} + } + + async cancelFulfillment(): Promise { + return {} + } + + async createReturnFulfillment(): Promise { + return {} + } +} + +export default DigitalProductFulfillmentService +``` + +The fulfillment provider registers one fulfillment option, and doesn't perform actual fulfillment. + +### Create Module Provider Definition + +Then, create the module provider's definition in the file `src/modules/digital-product-fulfillment/index.ts`: + +```ts title="src/modules/digital-product-fulfillment/index.ts" +import { ModuleProviderExports } from "@medusajs/types" +import DigitalProductFulfillmentService from "./service" + +const services = [DigitalProductFulfillmentService] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport +``` + +### Register Module Provider in Medusa's Configurations + +Finally, register the module provider in `medusa-config.js`: + +```js title="medusa-config.js" +// other imports... +import { + Modules +} from '@medusajs/utils' + +module.exports = defineConfig({ + modules: { + // ... + [Modules.FULFILLMENT]: { + resolve: "@medusajs/fulfillment", + options: { + providers: [ + { + resolve: "@medusajs/fulfillment-manual", + id: "manual", + }, + { + resolve: "./modules/digital-product-fulfillment", + id: "digital" + } + ], + }, + }, + } +}) +``` + +This registers the digital product fulfillment as a module provider of the Fulfillment Module. + +### Add Fulfillment Provider to Location + +In the Medusa Admin, go to Settings -> Location & Shipping, and add the fulfillment provider and a shipping option for it in a location. + +This is necessary to use the fulfillment provider's shipping option during checkout. + +--- + +## Step 11: Customize Cart Completion + +In this step, you’ll customize the cart completion flow to not only create a Medusa order, but also create a digital product order. + +To customize the cart completion flow, you’ll create a workflow and then use that workflow in an API route defined at `src/api/store/carts/[id]/complete/route.ts`. + +```mermaid +graph TD + completeCartWorkflow["completeCartWorkflow (Medusa)"] --> useRemoteQueryStep["useRemoteQueryStep (Medusa)"] + useRemoteQueryStep --> when{order has digital products?} + when -->|Yes| createDigitalProductOrderStep + createDigitalProductOrderStep --> createRemoteLinkStep["createRemoteLinkStep (Medusa)"] + createRemoteLinkStep --> createOrderFulfillmentWorkflow["createOrderFulfillmentWorkflow (Medusa)"] + createOrderFulfillmentWorkflow --> emitEventStep["emitEventStep (Medusa)"] + emitEventStep --> End + when -->|No| End +``` + +The workflow has the following steps: + +1. `completeCartWorkflow` to create a Medusa order from the cart. Medusa provides this workflow through the `@medusajs/core-flows` package and you can use it as a step. +2. `useRemoteQueryStep` to retrieve the order’s items with the digital products associated with the purchased product variants. Medusa provides this step through the `@medusajs/core-flows` package. +3. If the order has digital products, you: + 1. create the digital product order. + 2. link the digital product order with the Medusa order. Medusa provides a `createRemoteLinkStep` in the `@medusajs/core-flows` package that can be used here. + 3. Create a fulfillment for the digital products in the order. Medusa provides a `createOrderFulfillmentWorkflow` in the `@medusajs/core-flows` package that you can use as a step here. + 4. Emit the `digital_product_order.created` custom event to handle it later in a subscriber and send the customer an email. Medusa provides a `emitEventStep` in the `@medusajs/core-flows` that you can use as a step here. + +You’ll only implement the `3.a` step of the workflow. + +### createDigitalProductOrderStep (Step 3.a) + +Create the file `src/workflows/create-digital-product-order/steps/create-digital-product-order.ts` with the following content: + +export const createDpoHighlights = [ + ["33", "createDigitalProductOrders", "Create the digital product order."], + ["41", "digital_product_order", "Pass the created digital product order to the compensation function."], + ["48", "deleteDigitalProductOrders", "Delete the digital product order if an error occurs in the workflow."] +] + +```ts title="src/workflows/create-digital-product-order/steps/create-digital-product-order.ts" highlights={createDpoHighlights} collapsibleLines="1-15" expandMoreLabel="Show Imports" +import { + createStep, + StepResponse, +} from "@medusajs/workflows-sdk" +import { + OrderLineItemDTO, + ProductVariantDTO, +} from "@medusajs/types" +import { + DigitalProductData, + OrderStatus, +} from "../../../modules/digital-product/types" +import DigitalProductModuleService from "../../../modules/digital-product/service" +import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product" + +type StepInput = { + items: (OrderLineItemDTO & { + variant: ProductVariantDTO & { + digital_product: DigitalProductData + } + })[] +} + +const createDigitalProductOrderStep = createStep( + "create-digital-product-order", + async ({ items }: StepInput, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + const digitalProductIds = items.map((item) => item.variant.digital_product.id) + + const digitalProductOrder = await digitalProductModuleService + .createDigitalProductOrders({ + status: OrderStatus.PENDING, + products: digitalProductIds, + }) + + return new StepResponse({ + digital_product_order: digitalProductOrder, + }, { + digital_product_order: digitalProductOrder, + }) + }, + async ({ digital_product_order }, { container }) => { + const digitalProductModuleService: DigitalProductModuleService = + container.resolve(DIGITAL_PRODUCT_MODULE) + + await digitalProductModuleService.deleteDigitalProductOrders( + digital_product_order.id + ) + } +) + +export default createDigitalProductOrderStep +``` + +This creates the `createDigitalProductOrderStep`. In this step, you create a digital product order. + +In the compensation function, you delete the digital product order. + +### Create createDigitalProductOrderWorkflow + +Create the file `src/workflows/create-digital-product-order/index.ts` with the following content: + +export const createDpoWorkflowHighlights = [ + ["25", "completeCartWorkflow", "Create an order for the cart."], + ["31", "useRemoteQueryStep", "Retrieve the order's items and their associated variants and linked digital products."], + ["56", "when", "Check whether the order has any digital products."], + ["61", "then", "Perform the callback function if an order has digital products."], + ["64", "createDigitalProductOrderStep", "Create the digital product order."], + ["66", "createRemoteLinkStep", "Link the digital product order to the Medusa order."], + ["75", "createOrderFulfillmentWorkflow", "Create a fulfillment for the digital products in the order."], + ["89", "emitEventStep", "Emit the `digital_product_order.created` event."] +] + +```ts title="src/workflows/create-digital-product-order/index.ts" highlights={createDpoWorkflowHighlights} collapsibleLines="1-17" expandMoreLabel="Show Imports" +import { + createWorkflow, + transform, + when, + WorkflowResponse +} from "@medusajs/workflows-sdk" +import { + completeCartWorkflow, + useRemoteQueryStep, + createRemoteLinkStep, + createOrderFulfillmentWorkflow, + emitEventStep +} from "@medusajs/core-flows" +import { Modules } from "@medusajs/utils" +import createDigitalProductOrderStep from "./steps/create-digital-product-order" +import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product" + +type WorkflowInput = { + cart_id: string +} + +const createDigitalProductOrderWorkflow = createWorkflow( + "create-digital-product-order", + (input: WorkflowInput) => { + const order = completeCartWorkflow.runAsStep({ + input: { + id: input.cart_id + } + }) + + const { items } = useRemoteQueryStep({ + entry_point: "order", + fields: [ + "*", + "items.*", + "items.variant.*", + "items.variant.digital_product.*" + ], + variables: { + filters: { + id: order.id + } + }, + throw_if_key_not_found: true, + list: false + }) + + const itemsWithDigitalProducts = transform({ + items + }, + (data) => { + return data.items.filter((item) => item.variant.digital_product !== undefined) + } + ) + + const digital_product_order = when(itemsWithDigitalProducts, (itemsWithDigitalProducts) => { + return itemsWithDigitalProducts.length + }) + .then(() => { + const { + digital_product_order, + } = createDigitalProductOrderStep({ items }) + + createRemoteLinkStep([{ + [DIGITAL_PRODUCT_MODULE]: { + digital_product_order_id: digital_product_order.id + }, + [Modules.ORDER]: { + order_id: order.id + } + }]) + + createOrderFulfillmentWorkflow.runAsStep({ + input: { + order_id: order.id, + items: transform({ + itemsWithDigitalProducts + }, (data) => { + return data.itemsWithDigitalProducts.map((item) => ({ + id: item.id, + quantity: item.quantity + })) + }) + } + }) + + emitEventStep({ + eventName: "digital_product_order.created", + data: { + id: digital_product_order.id + } + }) + + return digital_product_order + }) + + return new WorkflowResponse({ + order, + digital_product_order + }) + } +) + +export default createDigitalProductOrderWorkflow +``` + +This creates the workflow `createDigitalProductOrderWorkflow`. It runs the following steps: + +1. `completeCartWorkflow` as a step to create the Medusa order. +2. `useRemoteQueryStep` to retrieve the order’s items with their associated variants and linked digital products. +3. Use `when` to check whether the order has digital products. If so: + 1. Use the `createDigitalProductOrderStep` to create the digital product order. + 2. Use the `createRemoteLinkStep` to link the digital product order to the Medusa order. + 3. Use the `createOrderFulfillmentWorkflow` to create a fulfillment for the digital products in the order. + 4. Use the `emitEventStep` to emit a custom event. + +The workflow returns the Medusa order and the digital product order, if created. + +### Cart Completion API Route + +Next, create the file `src/api/store/carts/[id]/complete/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/complete/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/medusa" +import createDigitalProductOrderWorkflow from "../../../../../workflows/create-digital-product-order" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { result } = await createDigitalProductOrderWorkflow(req.scope) + .run({ + input: { + cart_id: req.params.id, + }, + }) + + res.json({ + type: "order", + ...result, + }) +} +``` + +This overrides the Cart Completion API route. In the route handler, you execute the `createDigitalProductOrderWorkflow` and return the created order in the response. + +### Test Cart Completion + +To test out the cart completion, it’s recommended to use the [Next.js Starter storefront](../../../../nextjs-starter/page.mdx) to place an order. + +Once you place the order, the cart completion route you added above will run, creating the order and digital product order, if the order has digital products. + +In a later step, you’ll add an API route to allow customers to view and download their purchased digital products. + +### Further Read + +- [Conditions in Workflows with When-Then](!docs!/advanced-development/workflows/conditions) + +--- + +## Step 12: Handle the Digital Product Order Event + +In this step, you'll create a subscriber that listens to the `digital_product_order.created` event and sends a customer an email with the digital products they purchased. + +Create the file `digital-product/src/subscribers/handle-digital-order.ts` with the following content: + +export const subscriberHighlight = [ + ["20", "notificationModuleService", "Resolve the Notification Module's service to use it later to send a notification."], + ["22", "fileModuleService", "Resolve the File Module's service to use it later to retrieve a media's URL."], + ["26", "query", "Assemble the query to retrieve the digital product order."] +] + +```ts title="digital-product/src/subscribers/handle-digital-order.ts" highlights={subscriberHighlight} collapsibleLines="1-14" expandMoreLabel="Show Imports" +import type { + SubscriberArgs, + SubscriberConfig, +} from "@medusajs/medusa" +import { + INotificationModuleService, + IFileModuleService +} from "@medusajs/types" +import { + ModuleRegistrationName, + remoteQueryObjectFromString +} from "@medusajs/utils" +import { MediaType } from "../modules/digital-product/types" + +async function digitalProductOrderCreatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const remoteQuery = container.resolve("remoteQuery") + const notificationModuleService: INotificationModuleService = container + .resolve(ModuleRegistrationName.NOTIFICATION) + const fileModuleService: IFileModuleService = container.resolve( + ModuleRegistrationName.FILE + ) + + const query = remoteQueryObjectFromString({ + entryPoint: "digital_product_order", + fields: [ + "*", + "products.*", + "products.medias.*", + "order.*" + ], + variables: { + filters: { + id: data.id + } + } + }) + + const digitalProductOrder = (await remoteQuery(query))[0] + + // TODO format and send notification +} + +export default digitalProductOrderCreatedHandler + +export const config: SubscriberConfig = { + event: "digital_product_order.created", +} +``` + +This adds a subscriber that listens to the `digital_product_order.created` event. For now, it just resolves dependencies and retrieves the digital product order. + +Next, replace the `TODO` with the following: + +export const subscriber2Highlights = [ + ["1", "notificationData", "Format the data to be sent as a notification payload."], + ["10", "retrieveFile", "Retrieve the media's URL using the File Module's service."], + ["22", "createNotifications", "Send the notification to the customer."], + ["24", `"digital-order-template"`, "Replace with a real template ID."] +] + +```ts highlights={subscriber2Highlights} +const notificationData = await Promise.all( + digitalProductOrder.products.map(async (product) => { + const medias = [] + + await Promise.all( + product.medias + .filter((media) => media.type === MediaType.MAIN) + .map(async (media) => { + medias.push( + (await fileModuleService.retrieveFile(media.fileId)).url + ) + }) + ) + + return { + name: product.name, + medias + } + }) +) + +await notificationModuleService.createNotifications({ + to: digitalProductOrder.order.email, + template: "digital-order-template", + channel: "email", + data: { + products: notificationData + } +}) +``` + +First, you format the data payload to send in the notification by retrieving the URLs of the purchased products' main medias. You use the File Module's service to retrieve the media URLs. + +Then, you use the Notification Module's service to send the notification as an email. + + + +Replace the `digital-order-template` with a real template ID from your third-party notification service. + + + +### Test Subscriber Out + +To test out the subscriber, place an order with digital products. This triggers the `digital_product_order.created` event which executes the subscriber. + + + +Check out the [integrations page](../../../../integrations/page.mdx) to find notification and file modules. + + + +--- + +## Step 13: Create Store API Routes + +In this step, you’ll create three store API routes: + +1. Retrieve the preview files of a digital product. This is useful when the customer is browsing the products before purchase. +2. List the digital products that the customer has purchased. +3. Get the download link to a media of the digital product that the customer purchased. + +### Retrieve Digital Product Previews API Route + +Create the file `src/api/store/digital-products/[id]/preview/route.ts` with the following content: + +export const previewRouteHighlights = [ + ["29", "listDigitalProductMedias", "Get the digital product's preview medias."], + ["37", "retrieveFile", "Get the file's details using the File Module's main service."] +] + +```ts title="src/api/store/digital-products/[id]/preview/route.ts" highlights={previewRouteHighlights} collapsibleLines="1-15" expandMoreLabel="Show Imports" +import { + MedusaRequest, + MedusaResponse, +} from "@medusajs/medusa" +import { + ModuleRegistrationName, +} from "@medusajs/utils" +import { + DIGITAL_PRODUCT_MODULE, +} from "../../../../../modules/digital-product" +import DigitalProductModuleService from "../../../../../modules/digital-product/service" +import { + MediaType, +} from "../../../../../modules/digital-product/types" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const fileModuleService = req.scope.resolve( + ModuleRegistrationName.FILE + ) + + const digitalProductModuleService: DigitalProductModuleService = + req.scope.resolve( + DIGITAL_PRODUCT_MODULE + ) + + const medias = await digitalProductModuleService.listDigitalProductMedias({ + digital_product_id: req.params.id, + type: MediaType.PREVIEW, + }) + + const normalizedMedias = await Promise.all( + medias.map(async (media) => { + const { fileId, ...mediaData } = media + const fileData = await fileModuleService.retrieveFile(fileId) + + return { + ...mediaData, + url: fileData.url, + } + }) + ) + + res.json({ + previews: normalizedMedias, + }) +} +``` + +This adds a `GET` API route at `/store/digital-products/[id]/preview`, where `[id]` is the ID of the digital product to retrieve its preview media. + +In the route handler, you retrieve the preview media of the digital product and then use the File Module’s service to get the URL of the preview file. + +You return in the response the preview files. + +### List Digital Product Purchases API Route + +Create the file `src/api/store/customers/me/digital-products/route.ts` with the following content: + +export const purchasedDpHighlights = [ + ["15", "remoteQueryObjectFromString", "Retrieve the customer's purchased digital products."] +] + +```ts title="src/api/store/customers/me/digital-products/route.ts" highlights={purchasedDpHighlights} collapsibleLines="1-8" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/medusa" +import { + remoteQueryObjectFromString, +} from "@medusajs/utils" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve("remoteQuery") + + const query = remoteQueryObjectFromString({ + entryPoint: "customer", + fields: [ + "orders.digital_product_order.products.*", + "orders.digital_product_order.products.medias.*", + ], + variables: { + filters: { + id: req.auth_context.actor_id, + }, + }, + }) + + const result = await remoteQuery(query) + + const digitalProducts = {} + + result[0].orders.forEach((order) => { + order.digital_product_order.products.forEach((product) => { + digitalProducts[product.id] = product + }) + }) + + res.json({ + digital_products: Object.values(digitalProducts), + }) +} + +``` + +This adds a `GET` API route at `/store/customers/me/digital-products`. All API routes under `/store/customers/me` require customer authentication. + +In the route handler, you use remote query to retrieve the customer’s orders and linked digital product orders, and return the purchased digital products in the response. + +### Get Digital Product Media Download URL API Route + +Create the file `src/api/store/customers/me/digital-products/[mediaId]/download/route.ts` with the following content: + +export const downloadUrlHighlights = [ + ["20", "remoteQueryObjectFromString", "Get the customer's orders and linked digital orders."], + ["37", "remoteQueryObjectFromString", "Get the digital product orders of the customer and associated products and media."], + ["62", "foundMedia", "Set `foundMedia` if the media's ID is equal to the ID passed as a route parameter."], + ["68", "!foundMedia", "If `foundMedia` isn't set, throw an error."], + ["75", "retrieveFile", "Retrieve the details of the media's file."] +] + +```ts title="src/api/store/customers/me/digital-products/[mediaId]/download/route.ts" highlights={downloadUrlHighlights} collapsibleLines="1-10" expandMoreLabel="Show Imports" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/medusa" +import { + ModuleRegistrationName, + remoteQueryObjectFromString, + MedusaError, +} from "@medusajs/utils" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const fileModuleService = req.scope.resolve( + ModuleRegistrationName.FILE + ) + const remoteQuery = req.scope.resolve("remoteQuery") + + const customerQuery = remoteQueryObjectFromString({ + entryPoint: "customer", + fields: [ + "orders.digital_product_order.*", + ], + variables: { + filters: { + id: req.auth_context.actor_id, + }, + }, + }) + + const customerResult = await remoteQuery(customerQuery) + const customerDigitalOrderIds = customerResult[0].orders + .filter((order) => order.digital_product_order !== undefined) + .map((order) => order.digital_product_order.id) + + const dpoQuery = remoteQueryObjectFromString({ + entryPoint: "digital_product_order", + fields: [ + "products.medias.*", + ], + variables: { + filters: { + id: customerDigitalOrderIds, + }, + }, + }) + + const dpoResult = await remoteQuery(dpoQuery) + + if (!dpoResult.length) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Customer didn't purchase digital product." + ) + } + + let foundMedia = undefined + + dpoResult[0].products.some((product) => { + return product.medias.some((media) => { + foundMedia = media.id === req.params.mediaId ? media : undefined + + return foundMedia !== undefined + }) + }) + + if (!foundMedia) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Customer didn't purchase digital product." + ) + } + + const fileData = await fileModuleService.retrieveFile(foundMedia.fileId) + + res.json({ + url: fileData.url, + }) +} +``` + +This adds a `POST` API route at `/store/customers/me/digital-products/[mediaId]`, where `[mediaId]` is the ID of the digital product media to download. + +In the route handler, you retrieve the customer’s orders and linked digital orders, then check if the digital orders have the required media file. If not, an error is thrown. + +If the media is found in th customer's previous purchases, you use the File Module’s service to retrieve the download URL of the media and return it in the response. + +You’ll test out these API routes in the next step. + +### Further Reads + +- [What are protected API routes](!docs!/advanced-development/api-routes/protected-routes) + +--- + +## Step 14: Customize Next.js Starter + +In this section, you’ll customize the [Next.js Starter storefront](../../../../nextjs-starter/page.mdx) to: + +1. Show a preview button on a digital product’s page to view the preview files. +2. Add a new tab in the customer’s dashboard to view their purchased digital products. +3. Allow customers to download the digital products through the new page in the dashboard. + +### Add Types + +In `src/types/global.ts`, add the following types that you’ll use in your customizations: + +```ts title="src/types/global.ts" +import { BaseProductVariant } from "@medusajs/types/dist/http/product/common" + +// ... + +export type DigitalProduct = { + id: string + name: string + medias?: DigitalProductMedia[] +} + +export type DigitalProductMedia = { + id: string + fileId: string + type: "preview" | "main" + mimeType: string +} + +export type VariantWithDigitalProduct = BaseProductVariant & { + digital_product?: DigitalProduct +} + +``` + +### Retrieve Digital Products with Variants + +To retrieve the digital products details when retrieving a product and its variants, in the `src/lib/data/products.ts` file, change the `getProductsById` and `getProductByHandle` functions to pass the digital products in the `fields` property passed to the `sdk.store.product.list` method: + +export const fieldHighlights = [ + ["12"], ["27"] +] + +```ts title="src/lib/data/products.ts" highlights={fieldHighlights} +export const getProductsById = cache(async function ({ + ids, + regionId, +}: { + ids: string[] + regionId: string +}) { + return sdk.store.product + .list( + { + // ... + fields: "*variants.calculated_price,*variants.digital_product", + } + // ... + ) + // ... +}) + +export const getProductByHandle = cache(async function ( + handle: string, + regionId: string +) { + return sdk.store.product + .list( + { + // ... + fields: "*variants.calculated_price,*variants.digital_product", + } + // ... + ) + // ... +}) +``` + +When a customer views a product’s details page, digital products linked to variants are also retrieved. + +### Get Digital Product Preview Links + +To retrieve the links of a digital product’s preview media, add in `src/lib/data/products.ts` the following function: + +```ts title="src/lib/data/products.ts" +export const getDigitalProductPreview = cache(async function ({ + id, +}: { + id: string +}) { + const { previews } = await fetch( + `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/digital-products/${id}/preview`, { + credentials: "include", + }).then((res) => res.json()) + + // for simplicity, return only the first preview url + // instead you can show all the preview media to the customer + return previews.length ? previews[0].url : "" +}) +``` + +This function uses the API route you created in the previous section to get the preview links and return the first preview link. + +### Add Preview Button + +To add a button that shows the customer the preview media of a digital product, first, in `src/modules/products/components/product-actions/index.tsx`, cast the `selectedVariant` variable in the component to the `VariantWithDigitalProduct` type you created earlier: + +```tsx title="src/modules/products/components/product-actions/index.tsx" +// other imports... +import { VariantWithDigitalProduct } from "../../../../types/global" + +export default function ProductActions({ + product, + region, + disabled, +}: ProductActionsProps) { + + // ... + + const selectedVariant = useMemo(() => { + // ... + }, [product.variants, options]) as VariantWithDigitalProduct + + // ... +} +``` + +Then, add the following function in the component: + +```tsx title="src/modules/products/components/product-actions/index.tsx" +// other imports... +import { getDigitalProductPreview } from "../../../../lib/data/products" + +export default function ProductActions({ + product, + region, + disabled, +}: ProductActionsProps) { + // ... + + const handleDownloadPreview = async () => { + if (!selectedVariant?.digital_product) { + return + } + + const downloadUrl = await getDigitalProductPreview({ + id: selectedVariant?.digital_product.id, + }) + + if (downloadUrl.length) { + window.open(downloadUrl) + } + } + + // ... +} +``` + +This function uses the `getDigitalProductPreview` function you created earlier to retrieve the preview URL of the selected variant’s digital product. + +Finally, in the `return` statement, add a new button above the add-to-cart button: + +```tsx title="src/modules/products/components/product-actions/index.tsx" +return ( +
+ {/* Before add to cart */} + {selectedVariant?.digital_product && ( + + )} +
+) +``` + +This button is only shown if the selected variant has a digital product. When it’s clicked, the preview URL is retrieved to show the preview media to the customer. + +### Test Preview Out + +To test it out, run the Next.js starter with the Medusa application, then open the details page of a product that’s digital. You should see a “Download Preview” button to download the preview media of the product. + +### Add Digital Purchases Page + +You’ll now create the page customers can view their purchased digital product in. + +Start by creating the file `src/lib/data/digital-products.ts` with the following content: + +```ts title="src/lib/data/digital-products.ts" +"use server" + +import { DigitalProduct } from "../../types/global" +import { getAuthHeaders } from "./cookies" + +export const getCustomerDigitalProducts = async () => { + const { digital_products } = await fetch( + `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/customers/me/digital-products`, { + credentials: "include", + headers: { + ...getAuthHeaders(), + }, + }).then((res) => res.json()) + + return digital_products as DigitalProduct[] +} +``` + +The `getCustomerDigitalProducts` retrieves the logged-in customer’s purchased digital products by sending a request to the API route you created earlier. + +Then, create the file `src/modules/account/components/digital-products-list/index.tsx` with the following content: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" +"use client" + +import { Table } from "@medusajs/ui" +import { DigitalProduct } from "../../../../types/global" +import { getDigitalMediaDownloadLink } from "../../../../lib/data/digital-products" + +type Props = { + digitalProducts: DigitalProduct[] +} + +export const DigitalProductsList = ({ + digitalProducts, +}: Props) => { + return ( + + + + Name + Action + + + + {digitalProducts.map((digitalProduct) => { + const medias = digitalProduct.medias?.filter((media) => media.type === "main") + const showMediaCount = (medias?.length || 0) > 1 + return ( + + + {digitalProduct.name} + + + + + + ) + })} + +
+ ) +} +``` + +This adds a `DigitalProductsList` component that receives a list of digital products and shows them in a table. + +Each digital product’s media has a download link. You’ll implement its functionality afterwards. + +Next, create the file `src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx` with the following content: + +```tsx title="src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx" +import { Metadata } from "next" + +import { getCustomerDigitalProducts } from "../../../../../../lib/data/digital-products" +import { DigitalProductsList } from "../../../../../../modules/account/components/digital-products-list" + +export const metadata: Metadata = { + title: "Digital Products", + description: "Overview of your purchased digital products.", +} + +export default async function DigitalProducts() { + const digitalProducts = await getCustomerDigitalProducts() + + return ( +
+
+

Digital Products

+

+ View the digital products you've purchased and download them. +

+
+
+ +
+
+ ) +} +``` + +This adds a new route in your Next.js application to show the customer’s purchased digital products. + +In the route, you retrieve the digital’s products using the `getCustomerDigitalProducts` function and pass them as the prop of the `DigitalProductsList` component. + +Finally, to add a tab in the customer’s account dashboard that links to this page, add it in the `src/modules/account/components/account-nav/index.tsx` file: + +```tsx title="src/modules/account/components/account-nav/index.tsx" +// other imports... +import { Photo } from "@medusajs/icons" + +const AccountNav = ({ + customer, +}: { + customer: HttpTypes.StoreCustomer | null +}) => { + // ... + + return ( +
+ {/* Add before log out */} +
  • + +
    + + Digital Products +
    + +
    +
  • +
    + ) +} +``` + +You add a link to the new route before the log out tab. + +### Test Purchased Digital Products Page + +To test out this page, first, log-in as a customer and place an order with a digital product. + +Then, go to the customer’s account page and click on the new Digital Products tab. You’ll see a table of digital products to download. + +### Add Download Link + +To add a download link for the purchased digital products’ medias, first, add a new function to `src/lib/data/digital-products.ts`: + +```ts title="src/lib/data/digital-products.ts" +export const getDigitalMediaDownloadLink = async (mediaId: string) => { + const { url } = await fetch( + `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/customers/me/digital-products/${ + mediaId + }/download`, { + credentials: "include", + method: "POST", + headers: { + ...getAuthHeaders(), + }, + }).then((res) => res.json()) + + return url +} +``` + +In this function, you send a request to the download API route you created earlier to retrieve the download URL of a purchased digital product media. + +Then, in `src/modules/account/components/digital-products-list/index.tsx`, add a `handleDownload` function in the `DigitalProductsList` component: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" +const handleDownload = async ( + e: React.MouseEvent, + mediaId: string +) => { + e.preventDefault() + + const url = await getDigitalMediaDownloadLink(mediaId) + + window.open(url) +} +``` + +This function uses the `getDigitalMediaDownloadLink` function to get the download link and opens it in a new window. + +Finally, add an `onClick` handler to the digital product medias’ link in the return statement: + +```tsx title="src/modules/account/components/digital-products-list/index.tsx" + handleDownload(e, media.id)}> + Download{showMediaCount ? ` ${index + 1}` : ``} + +``` + +### Test Download Purchased Digital Product Media + +To test the latest changes out, open the purchased digital products page and click on the Download link of any media in the table. The media’s download link will open in a new page. + + + +The local file module provider doesn't support private uploads, so the download link won't actually be useful. Instead, use the [S3 module provider](../../../../architectural-modules/file/s3/page.mdx) in production. + + + +--- + +## Next Steps + +The next steps of this example depend on your use case. This section provides some insight into implementing them. + +### Storefront Development + +Aside from customizing the Next.js Starter storefront, you can also create a custom storefront. Check out the [Storefront Development](../../../../storefront-development/page.mdx) section to learn how to create a storefront. + +### Admin Development + +In this recipe, you learned how to customize the admin with UI routes. You can also do further customization using widgets. Learn more in [this documentation](!docs!/advanced-development/admin). \ No newline at end of file diff --git a/www/apps/resources/app/recipes/digital-products/page.mdx b/www/apps/resources/app/recipes/digital-products/page.mdx index 926c000e1a..5b529ae498 100644 --- a/www/apps/resources/app/recipes/digital-products/page.mdx +++ b/www/apps/resources/app/recipes/digital-products/page.mdx @@ -9,19 +9,11 @@ export const metadata = { This recipe provides the general steps to implement digital products in your Medusa application. - - -This recipe is a work in progress, as some features are not ready yet in Medusa V2. - - - ## Overview Digital products are stored privately using a storage service like S3. When the customer buys this type of product, an email is sent to them where they can download the product. -Medusa doesn't have a built-in concept of a digital product since our focus is standardizing features and implementations, then offering the building blocks that enable you to build your use case. - -You can create a Digital Product Module that introduces the concept of a digital product and links it to existing product concepts in the Product Module. +To implement digital products in Medusa, you create a Digital Product Module that introduces the concept of a digital product and link it to existing product concepts in the Product Module. --- @@ -52,35 +44,6 @@ During development, you can use the Local File Module Provider, which is install --- -## Install a Notification Module Provider - -A notification module provider handles sending notifications to users and customers. - -For digital products, a notification module provider allows you to send the customer an email or another form of notification with a link to download the file they purchased. - -You can use one of Medusa’s notification module providers or create your own. - -{/* TODO add links */} - -, - showLinkIcon: false - }, - { - href: "/references/notification-provider-module", - title: "Create a Notification Module Provider", - text: "Learn how to create a custom notification service.", - startIcon: , - showLinkIcon: false, - }, -]} /> - ---- - ## Create Digital Product Module Your custom features and functionalities are implemented inside modules. The module is integrated into the Medusa application without any implications on existing functionalities. @@ -95,149 +58,34 @@ The module will hold your custom data models and the service implementing digita showLinkIcon={false} /> -
    +### Create Custom Data Model - In this section, you’ll create the skeleton of the Digital Product Module. In later sections, you’ll add more resources to it. - - Start by creating the directory `src/modules/digital-product`. - - Then, create the file `src/modules/digital-product/service.ts` with the following content: - - ```ts title="src/modules/digital-product/service.ts" - class DigitalProductModuleService { - // TODO - } - - export default DigitalProductModuleService - ``` - - A module must export a service. So, you implement a dummy service for now. - - Next, create the file `src/modules/digital-product/index.ts` with the following content: - - ```ts title="src/modules/digital-product/index.ts" - import DigitalProductModuleService from "./service" - import { Module } from "@medusajs/utils" +A data model represents a table in the database. You can define in your module data models to store data related to your custom features, such as a digital product. - export default Module("digital-product", { - service: DigitalProductModuleService, - }) - ``` - - This file holds the definition of the module. - - Finally, add the module to `medusa-config.js` into the `modules` object: - - ```js title="medusa-config.js" - module.exports = defineConfig({ - // ... - modules: { - digitalProductModuleService: { - resolve: "./modules/digital-product", - }, - }, - }) - ``` +Then, you can link your custom data model to data models from other modules. For example, you can link the digital product model to the Product Module's `ProductVariant` data model. -
    +, + showLinkIcon: false + }, + { + href: "!docs!/advanced-development/modules/module-links", + title: "Module Links", + text: "Learn how to link data models of different modules.", + startIcon: , + showLinkIcon: false + }, +]} /> ---- +### Implement Data Management Features -## Create Custom Data Model +Your module’s main service holds data-management and other related features. Then, in other resources, such as an API route, you can resolve the service from the Medusa container and use its functionalities. -A data model represents a table in the database. You can define in your module data models to store data related to your custom features. - -To represent a digital product, it's recommended to create a data model that has a `variant_id` property. In a later section, you’ll learn how to add a relationship to the Product Module’s `ProductVariant` data model. - - - -Module Relationships is coming soon. - - - -} - showLinkIcon={false} -/> - -{/*
    - - In this section, you’ll create a `ProductMedia` data model that represents your digital products. - - Before creating the data model, create the file `src/types/digital-product/product-media.ts` that holds common types: - - ```ts title="src/types/digital-product/product-media.ts" - export enum MediaType { - MAIN = "main", - PREVIEW = "preview" - } - ``` - - Then, create the file `src/modules/digital-product/models/product-media.ts` with the following content: - - ```ts title="src/modules/digital-product/models/product-media.ts" - import { model } from "@medusajs/utils" - import { MediaType } from "../../../types/digital-product/product-media" - - const ProductMedia = model.define("product_media", { - id: model.id().primaryKey(), - name: model.text(), - type: model.enum(Object.values(MediaType)), - fileKey: model.text(), - mimeType: model.text(), - variant_id: model.text().index("IDX_product_media_variant_id"), - }) - - export default ProductMedia - ``` - - The `ProductMedia` data model has properties relevant to digital products. Most importantly, it has a `variant_id` property that will later be used for its relationship with the Product Module. - - To reflect the data model in the database, you must create a migration. - - - - Learn how to generate a migration in [this guide](!docs!/basics/data-models#create-a-migration). - - - - Create the file `src/modules/digital-product/migrations/Migration20240509093233.ts` with the following content: - - ```ts title="src/modules/digital-product/migrations/Migration20240509093233.ts" - import { Migration } from "@mikro-orm/migrations" - - export class Migration20240509093233 extends Migration { - - async up(): Promise { - this.addSql("create table if not exists \"product_media\" (\"id\" text not null, \"name\" text not null, \"type\" text check (\"type\" in ('main', 'preview')) not null, \"file_key\" text not null, \"mime_type\" text not null, \"variant_id\" text not null, constraint \"product_media_pkey\" primary key (\"id\"));") - this.addSql("CREATE INDEX IF NOT EXISTS \"IDX_product_media_variant_id\" ON \"product_media\" (variant_id);") - } - - async down(): Promise { - this.addSql("drop table if exists \"product_media\" cascade;") - } - - } - ``` - - To run the migrations, run the `migrations run` command: - - ```bash npm2yarn - npx medusa migrations run - ``` - -
    */} - ---- - -## Implement Data Management Features - -Your module’s main service holds the management and other related features. Then, in other resources, such as an API route, you can resolve the service from the Medusa container and use its functionalities. - -Medusa facilitates implementing data-management features by providing a service factory. This service factory implements basic data-management features, so you only need to implement features specific to your module. +Medusa facilitates implementing data-management features using the service factory. Your module's main service can extend this service factory, and it generates data-management methods for your data models. -{/*
    - - In this section, you’ll modify the `DigitalProductModuleService` you created earlier to provide data-management functionalities of the `ProductMedia` data model. - - Change the content of `src/modules/digital-product/service.ts` to the following: - - ```ts title="src/modules/digital-product/service.ts" - import { MedusaService } from "@medusajs/utils" - import ProductMedia from "./models/product-media" - - class DigitalProductModuleService extends MedusaService({ - ProductMedia, - }){ - // TODO add custom methods - } - - export default DigitalProductModuleService - ``` - - The `DigitalProductModuleService` now extends the service factory which generates data-management methods for the `ProductMedia` data model. - -
    */} - -{/* --- */} - -{/* ## Add Relationship to Product Variants - -As mentioned in a previous section, the product media has a `variant_id` that points to the saleable product variant. - -The Product Module implements product variants. However, modules are isolated. So, to reference data models and records from other modules, you can use module relationships. - -The Medusa application resolves module relationships without creating an actual dependency between the modules. This allows you to associate more properties with existing modules while maintaining module isolation. - -} - showLinkIcon={false} -/> - -
    - - In this section, you’ll create a relationship from the Digital Product Module to the Product Module. - - To do that, add the following `__joinerConfig` method to the `DigitalProductModuleService`: - - ```ts title="src/modules/digital-product/service.ts" collapsibleLines="1-6" expandButtonLabel="Show Imports" - // other imports... - import { ModuleJoinerConfig } from "@medusajs/types" - import { Modules } from "@medusajs/modules-sdk" - - // ... - - class DigitalProductModuleService extends ModulesSdkUtils - .abstractModuleServiceFactory< - // ... - >( - // ... - ) { - // ... - - __joinerConfig(): ModuleJoinerConfig { - return { - serviceName: "digitalProductModuleService", - primaryKeys: ["id"], - alias: [ - { - name: "product_media", - args: { - entity: ProductMedia.name, - }, - }, - ], - relationships: [ - { - serviceName: Modules.PRODUCT, - alias: "variant", - primaryKey: "id", - foreignKey: "variant_id", - args: { - methodSuffix: "Variants", - }, - }, - ], - } - } - } - ``` - - This informs the Medusa application that, whenever there’s a `variant_id` property in your module’s data models, look for the record it’s referencing in the `variant` alias (which is the `ProductVariant` data model) of the Product Module. - - - - Learn more about the data returned in the `__joinerConfig` method here. - - - - Next, change the module’s entry in the `modules` object in `medusa-config.js` to the following: - - ```ts title="medusa-config.js" - module.exports = defineConfig({ - // ... - modules: { - digitalProductModuleService: { - resolve: "./modules/digital-product", - definition: { - isQueryable: true, - }, - }, - }, - }) - ``` - -
    */} - --- ## Add Custom API Routes @@ -371,369 +103,55 @@ API routes expose your features to external applications, such as the admin dash You can create custom API routes that allow merchants to list and create digital products. In these API routes, you resolve the Digital Product Module’s main service to use its data-management features. -To utilize the relationship to the Product Module, you use the remote query to fetch data across modules. +} + showLinkIcon={false} +/> - +--- -Fetching data across modules using the remote query is coming soon. +## Implement Workflows - +Your use case most likely has flows, such as creating digital products, that require multiple steps. + +Create workflows to implement these flows, then utilize these workflows in other resources, such as an API route. + +} + showLinkIcon={false} +/> + +--- + +## Manage Linked Records + +If you've defined links between data models of two modules, you can manage them through two functions: remote link and remote query. + +Use the remote link to create a link between two records, and use the remote query to fetch data across linked data models. , showLinkIcon: false }, { href: "!docs!/advanced-development/modules/remote-query", - title: "Remote Query", - text: "Learn about what the remote query is and how to use it.", + title: "How to Use the Remote Query", + text: "Learn how to fetch data across modules with remote query.", startIcon: , showLinkIcon: false }, ]} /> -{/*
    - - In this section, you’ll create a List and Create API routes to retrieve and create digital products. - - ### Create API Route - - In the Create API route, you want to create not only the product media but also the associated product variant if no ID is specified. - - You’ll implement this logic in a workflow, then use the workflow in the API route. - - Start by changing the content of `src/types/digital-product/product-media.ts` to include more types: - - ```ts title="src/types/digital-product/product-media.ts" - import { - ProductVariantDTO, - CreateProductWorkflowInputDTO, - } from "@medusajs/types" - - export enum MediaType { - MAIN = "main", - PREVIEW = "preview" - } - - export type ProductMediaDTO = { - id: string - name: string - type: MediaType - file_key: string - mime_type: string - variant_id: string - variant?: ProductVariantDTO - } - - export type CreateProductMediaDTO = { - name: string - file_key: string - type: MediaType - mime_type: string - variant_id?: string - } - - export type CreateProductMediaWorkflowInput = - CreateProductMediaDTO & { - product?: CreateProductWorkflowInputDTO - } - - ``` - - Then, create the file `src/workflows/digital-product/create.ts` with the following content: - - ```ts title="src/workflows/digital-product/create.ts" collapsibleLines="1-19" expandButtonLabel="Show Imports" - import { - createWorkflow, - WorkflowData, - createStep, - StepResponse, - } from "@medusajs/workflows-sdk" - import { createProductsWorkflow } from "@medusajs/core-flows" - import { - CreateProductMediaDTO, - CreateProductMediaWorkflowInput, - ProductMediaDTO, - } from "../../types/digital-product/product-media" - import DigitalProductModuleService from - "../../modules/digital-product/service" - import { RemoteQueryFunction } from "@medusajs/modules-sdk" - import { - ContainerRegistrationKeys, - remoteQueryObjectFromString, - } from "@medusajs/utils" - - const tryToCreateProductVariantStep = createStep( - "try-to-create-product-variant-step", - async (input: CreateProductMediaWorkflowInput, { container }) => { - if (input.product && !input.variant_id) { - const { result, errors } = await createProductsWorkflow(container) - .run({ - input: { - products: [input.product], - }, - throwOnError: false, - }) - - if (errors.length) { - throw errors[0].error - } - - input.variant_id = result[0].variants[0].id - - delete input.product - } - - return new StepResponse(input) - } - ) - - const createProductMediaStep = createStep( - "create-product-media-step", - async (input: CreateProductMediaDTO, { container }) => { - const digitalProductModuleService: - DigitalProductModuleService = container.resolve( - "digitalProductModuleService" - ) - - const productMedia = await digitalProductModuleService - .createProductMedias( - input - ) - - return new StepResponse(productMedia) - } - ) - - const retrieveProductMediaWithVariant = createStep( - "retrieve-product-media-with-variant-step", - async (input: ProductMediaDTO, { container }) => { - const remoteQuery: RemoteQueryFunction = container.resolve( - ContainerRegistrationKeys.REMOTE_QUERY - ) - - const query = remoteQueryObjectFromString({ - entryPoint: "product_media", - fields: [ - "id", - "name", - "type", - "file_key", - "mime_type", - "variant.*", - ], - variables: { - filters: { - id: input.id, - }, - }, - }) - - const result = await remoteQuery(query) - - return new StepResponse(result[0]) - } - ) - - type WorkflowInput = { - data: CreateProductMediaWorkflowInput - } - - export const createProductMediaWorkflow = createWorkflow( - "create-product-media-workflow", - function (input: WorkflowData) { - // create the product variant before creating the media - // if variant_id isn't passed - const normalizedInput = tryToCreateProductVariantStep(input.data) - - const productMedia = createProductMediaStep(normalizedInput) - - return retrieveProductMediaWithVariant(productMedia) - } - ) - ``` - - This workflow has three steps: - - 1. If a `variant_id` field isn’t passed and a `product` field is passed, create the product using Medusa’s `createProductsWorkflow` and set the ID of the variant in the `variant_id` property. - 2. Use the `DigitalProductModuleService` to create the product media. - 3. Use the remote query to retrieve the product media along with the variant it references. - - Finally, create the `src/api/admin/digital-products/route.ts` file with the following content: - - ```ts title="src/api/admin/digital-products/route.ts" collapsibleLines="1-12" expandButtonLabel="Show Imports" - import { - MedusaRequest, - MedusaResponse, - } from "@medusajs/medusa" - import { MedusaError } from "@medusajs/utils" - import { - CreateProductMediaWorkflowInput, - } from "../../../types/digital-product/product-media" - import { - createProductMediaWorkflow, - } from "../../../workflows/digital-product/create" - - type CreateProductMediaReq = CreateProductMediaWorkflowInput - - export async function POST( - req: MedusaRequest, - res: MedusaResponse - ) { - // validation omitted for simplicity - const { - result, - errors, - } = await createProductMediaWorkflow(req.scope) - .run({ - input: { - data: { - ...req.body, - }, - }, - throwOnError: false, - }) - - if (errors.length) { - throw new MedusaError( - MedusaError.Types.DB_ERROR, - errors[0].error - ) - } - - res.json({ - product_media: result, - }) - } - ``` - - This adds a `POST` API route at `/admin/digital-products` that executes the `createProductMediaWorkflow` workflow. - - To test it out, start the Medusa application: - - ```bash npm2yarn - npm run dev - ``` - - Next, authenticate as an admin user as explained in the [API Reference] - - Then, upload a file using the Upload API route: - - ```bash - curl -X POST 'http://localhost:9000/admin/uploads' \ - -H 'Authorization: Bearer {bearer_token}' \ - --form 'files=@"/path/to/file"' - ``` - - Make sure to replace `/path/to/file` with the path to the file to upload. Copy the `id` field’s value as you’ll use it as the `file_key`'s value when creating the digital product. - - Finally, send a request to the API route you created: - - ```bash - curl -X POST 'localhost:9000/admin/digital-products' \ - -H 'Content-Type: application/json' \ - -H 'Authorization: Bearer {bearer_token}' \ - --data '{ - "name": "Harry Potter", - "file_key": "file.png", - "type": "main", - "mime_type": "image/png", - "product": { - "title": "Harry Potter Books", - "variants": [ - { - "title": "Harry Potter 1" - } - ] - } - }' - ``` - - This creates a product and a variant, and a product media that references the created variant. - - You’ll receive a response similar to the following: - - ```json - { - "product_media": { - "id": "promed_01HXEFRMS79293ASYVN8YY9Y0J", - "name": "Harry Potter", - "type": "main", - "file_key": "file.png", - "mime_type": "image/png", - "variant_id": "variant_01HXEFRMR3B09EZJX23P1DMYFQ", - "variant": { - "id": "variant_01HXEFRMR3B09EZJX23P1DMYFQ", - "title": "Harry Potter 1", - // ... - } - } - } - ``` - - ### List API Route - - Next, you’ll create the List API route that returns a list of digital products. - - To do that, add the following to `src/api/admin/digital-products/route.ts`: - - ```ts title="src/api/admin/digital-products/route.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" - // other imports... - import { RemoteQueryFunction } from "@medusajs/modules-sdk" - import { - ContainerRegistrationKeys, - remoteQueryObjectFromString, - } from "@medusajs/utils" - - // ... - - export async function GET( - req: MedusaRequest, - res: MedusaResponse - ) { - const remoteQuery: RemoteQueryFunction = req.scope.resolve( - ContainerRegistrationKeys.REMOTE_QUERY - ) - - const query = remoteQueryObjectFromString({ - entryPoint: "product_media", - fields: [ - "id", - "name", - "type", - "file_key", - "mime_type", - "variant.*", - "variant.product.*", - ], - }) - - const result = await remoteQuery(query) - - res.json({ - product_medias: result, - }) - } - ``` - - This creates a new `GET` API route at `/admin/digital-products` that retrieves the list of digital products and their associated variants. - - To test it out, send a request to the API route while your Medusa application is running: - - ```bash apiTesting testApiUrl="http://localhost:9000/admin/digital-products" testApiMethod="GET" - curl 'localhost:9000/admin/digital-products' \ - -H 'Authorization: Bearer {bearer_token}' \ - ``` - - You’ll receive a list of digital products. - -
    */} - - --- ## Customize Admin Dashboard @@ -759,420 +177,28 @@ In your customizations, you send requests to the API routes you created to creat }, ]} /> -{/*
    - - In this example, you’ll add a single page that lists the digital products and allows you to create a new one. The implementation will be minimal for the purpose of simplicity, so you can elaborate on it based on your use case. - - To create the UI route, create the file `src/admin/routes/product-media/page.tsx` with the following content: - - ```tsx title="src/admin/routes/product-media/page.tsx" badgeLabel="Medusa Application" collapsibleLines="1-13" expandButtonLabel="Show Imports" - import { defineRouteConfig } from "@medusajs/admin-shared" - import { useEffect, useState } from "react" - import { - Container, - Heading, - Table, - } from "@medusajs/ui" - import { PhotoSolid } from "@medusajs/icons" - import { ProductMediaDTO } from "../../../types/digital-product/product-media" - import { Link } from "react-router-dom" - import ProductMediaCreateForm - from "../../components/product-media/CreateForm" - - const ProductMediaListPage = () => { - const [loading, setLoading] = useState(true) - const [productMedias, setProductMedias] = useState([]) - - useEffect(() => { - if (!loading) { - return - } - - fetch(`/admin/digital-products`, { - credentials: "include", - }) - .then((response) => response.json()) - .then(({ product_medias }) => { - setProductMedias(product_medias) - setLoading(false) - }) - }, [loading]) - - return ( - -
    - Digital Products - setLoading(true)} /> -
    - {loading &&
    Loading...
    } - {!loading && !productMedias.length && ( -
    No Digital Products
    - )} - {!loading && productMedias.length > 0 && ( - - - - Product - - Product Variant - - File Key - Action - - - - {productMedias.map((product_media) => ( - - - {product_media.variant?.product.title} - - - {product_media.variant?.title} - - - {product_media.file_key} - - - {product_media.variant && - View Product - } - - - ))} - -
    - )} -
    - ) - } - - export const config = defineRouteConfig({ - label: "Digital Products", - icon: PhotoSolid, - }) - - export default ProductMediaListPage - ``` - - This UI route will show under the sidebar with the label “Digital Products”. In the page, you use the `/admin/digital-products` API route to retrieve and display the product medias. - - You also render a `ProductMediaCreateForm` component that implements the Create Digital Product form. - - To create this form, create the file `src/admin/components/product-media/CreateForm/index.tsx` with the following content: - - ```tsx title="src/admin/components/product-media/CreateForm/index.tsx" badgeLabel="Medusa Application" collapsibleLines="1-11" expandButtonLabel="Show Imports" - import { useState } from "react" - import { redirect } from "react-router-dom" - import { - Button, - Container, - Input, - Label, - Select, - Drawer, - } from "@medusajs/ui" - - type Props = { - onCreate: () => void - } - - const ProductMediaCreateForm = ({ onCreate }: Props) => { - const [open, setOpen] = useState(false) - const [productName, setProductName] = useState("") - const [ - productVariantName, - setProductVariantName, - ] = useState("") - const [name, setName] = useState("") - const [type, setType] = useState("main") - const [file, setFile] = useState() - const [loading, setLoading] = useState(false) - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - setLoading(true) - - const formData = new FormData() - formData.append("files", file) - - // upload file - fetch(`/admin/uploads`, { - method: "POST", - credentials: "include", - body: formData, - }) - .then((res) => res.json()) - .then(({ files }) => { - // create digital product - fetch(`/admin/digital-products`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - file_key: files[0].id, - type, - mime_type: file.type, - product: { - title: productName, - variants: [ - { - title: name, - }, - ], - }, - }), - }) - .then((res) => res.json()) - .then(() => { - setOpen(false) - setLoading(false) - onCreate() - }) - .catch((e) => { - console.error(e) - setLoading(false) - }) - }) - .catch((e) => { - console.error(e) - setLoading(false) - }) - } - - return ( - - - - - - - - Create Digital Product - - - - -
    -
    - - setProductName(e.target.value)} - /> -
    -
    - - - setProductVariantName(e.target.value) - } - /> -
    -
    - - setName(e.target.value)} - /> -
    -
    - - -
    -
    - - setFile(e.target.files[0])} - /> -
    - -
    -
    -
    -
    -
    - ) - } - - export default ProductMediaCreateForm - ``` - - In this component, you create a form that accepts basic information needed to create the digital product. This form only accepts one file for one variant for simplicity purposes. You can expand on this based on your use case. - - - - An alternative approach would be to inject a widget to the Product Details page and allow users to upload the files from there. It depends on whether you’re only supporting Digital Products or you want the distinction between them, as done here. - - - - When the user submits the form, you first upload the file using the [Upload Protected File API Route](!api!/admin#uploads_postuploads). Then, you create the digital product using the custom API Route you created. - - The product’s details can still be edited from the product's page, similar to regular products. You can edit its price, add more variants, and more. - - To test it out, run the `dev` command: - - ```bash npm2yarn - npm run dev - ``` - - If you open the admin now, you’ll find a new Digital Products item in the sidebar. You can try adding Digital Products and viewing them. - -
    */} - --- ## Deliver Digital Products to the Customer When a customer purchases a digital product, they should receive a link to download it. -You can create a subscriber that listens to the `order.placed` event. In the subscriber, you check for the digital products in the order and obtain the download URLs using the file module provider’s `getPresignedDownloadUrl` method. +You can create or install a fulfillment module provider that handles the logic of fulfilling the digital product. -The `order.placed` event isn't currently emitted. +Fulfillment providers are under development. - - -Following this approach assumes the file module provider you're using handles creating secure pre-signed URLs with an expiration mechanism. Alternatively, create a token on purchase using the API Key Module and create an API route that validates that token. - - - -In the subscriber, you can send a notification, such as an email, to the customer using the notification module provider of your choice. That notification would hold the download links to the products the customer purchased. - } showLinkIcon={false} /> -{/*
    - - - - An alternative solution is to create a store download API Route that allows authenticated customers to download products they've purchased, then add the link to the API Route or a storefront page that calls the API Route in the email. Learn how to implement the download API Route [here](#download-product-after-purchase). - - - - Here’s an example of a subscriber that retrieves the download links and sends them to the customer using the installed notification module provider: - - ```ts title="src/subscribers/handle-order.ts" badgeLabel="Medusa Application" collapsibleLines="1-15" expandButtonLabel="Show Imports" - import { - type SubscriberConfig, - type SubscriberArgs, - } from "@medusajs/medusa" - import { - IOrderModuleService, - IFileModuleService, - INotificationModuleService, - } from "@medusajs/types" - import { - ModuleRegistrationName, - } from "@medusajs/modules-sdk" - import DigitalProductModuleService - from "../modules/digital-product/service" - - export default async function handleOrderPlaced({ - data, container, - }: SubscriberArgs<{ id: string }>) { - const orderModuleService: IOrderModuleService = - container.resolve( - ModuleRegistrationName.ORDER - ) - const fileModuleService: IFileModuleService = - container.resolve( - ModuleRegistrationName.FILE - ) - const notificationModuleService: INotificationModuleService = - container.resolve(ModuleRegistrationName.NOTIFICATION) - const digitalProductModuleService: - DigitalProductModuleService = container.resolve( - "digitalProductModuleService" - ) - - const orderId = data.data.id - - const order = await orderModuleService.retrieveOrder(orderId, { - relations: ["items"], - }) - - // find product medias in the order - const urls = [] - for (const item of order.items) { - const productMedias = await digitalProductModuleService - .listProductMedias({ - variant_id: [item.variant_id], - }) - - const downloadUrls = await Promise.all( - productMedias.map(async (productMedia) => { - - // get the download URL from the file service - return (await fileModuleService.retrieveFile( - productMedia.file_key - )).url - }) - ) - - urls.push(...downloadUrls) - } - - notificationModuleService.createNotifications({ - to: order.email, - template: "digital-download", - channel: "email", - data: { - // any data necessary for your template... - digital_download_urls: urls, - }, - }) - } - - export const config: SubscriberConfig = { - event: "order.placed", - } - ``` - - The `handleOrderPlaced` subscriber function retrieves the order, loops over its items to find digital products and retrieve their download links, then uses the installed notification module provider to send the email, passing the URLs as a data payload. You can customize the sent data based on your template and your use case. - -
    */} - --- ## Customize or Build Storefront @@ -1183,10 +209,19 @@ Medusa provides a Next.js storefront with standard commerce features including l Alternatively, you can build the storefront with your preferred tech stack. -} - showLinkIcon={false} -/> \ No newline at end of file +, + showLinkIcon: false + }, + { + href: "/storefront-development", + title: "Storefront Guides", + text: "Learn how to build a storefront for your Medusa application.", + startIcon: , + showLinkIcon: false + }, +]} /> diff --git a/www/apps/resources/generated/files-map.mjs b/www/apps/resources/generated/files-map.mjs index 7ab39b3084..e3d57a38b3 100644 --- a/www/apps/resources/generated/files-map.mjs +++ b/www/apps/resources/generated/files-map.mjs @@ -699,6 +699,10 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/recipes/commerce-automation/page.mdx", "pathname": "/recipes/commerce-automation" }, + { + "filePath": "/www/apps/resources/app/recipes/digital-products/examples/standard/page.mdx", + "pathname": "/recipes/digital-products/examples/standard" + }, { "filePath": "/www/apps/resources/app/recipes/digital-products/page.mdx", "pathname": "/recipes/digital-products" diff --git a/www/apps/resources/generated/sidebar.mjs b/www/apps/resources/generated/sidebar.mjs index c792207d62..d67f51c6dd 100644 --- a/www/apps/resources/generated/sidebar.mjs +++ b/www/apps/resources/generated/sidebar.mjs @@ -6405,6 +6405,21 @@ export const generatedSidebar = [ } ] }, + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/subscriptions", + "title": "Subscriptions", + "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/recipes/subscriptions/examples/standard", + "title": "Example", + "children": [] + } + ] + }, { "loaded": true, "isPathHref": true, @@ -6419,13 +6434,6 @@ export const generatedSidebar = [ "title": "Commerce Automation", "children": [] }, - { - "loaded": true, - "isPathHref": true, - "path": "/recipes/digital-products", - "title": "Digital Products", - "children": [] - }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/sidebar.mjs b/www/apps/resources/sidebar.mjs index 953fd5e6fd..c95369d2ca 100644 --- a/www/apps/resources/sidebar.mjs +++ b/www/apps/resources/sidebar.mjs @@ -1313,6 +1313,16 @@ export const sidebar = sidebarAttachHrefCommonOptions([ }, ], }, + { + path: "/recipes/subscriptions", + title: "Subscriptions", + children: [ + { + path: "/recipes/subscriptions/examples/standard", + title: "Example", + }, + ], + }, { path: "/recipes/b2b", title: "B2B", @@ -1321,10 +1331,6 @@ export const sidebar = sidebarAttachHrefCommonOptions([ path: "/recipes/commerce-automation", title: "Commerce Automation", }, - { - path: "/recipes/digital-products", - title: "Digital Products", - }, { path: "/recipes/ecommerce", title: "Ecommerce",