Medusa
Medusa Digital Product
Digital Products Recipe Example
Reference URL: https://docs.medusajs.com/resources/recipes/digital-products/examples/standard
In this guide, you'll learn how to support digital products in Medusa.
When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. Medusa provides all features related to products and managing them, and the Medusa Framework allows you to extend those features and implement your custom use case.
You can extend Medusa's product features to support selling, storing, and fulfilling digital products. In this guide, you'll customize Medusa to add the following features:
- Support digital products with multiple media items.
- Manage digital products from the admin dashboard.
- Handle and fulfill digital product orders.
- Allow customers to download their digital product purchases from the storefront.
- All other commerce features that Medusa provides.
This guide provides an example of an approach to implement digital products. You're free to choose a different approach using the Medusa Framework.
- Digital Products Example Repository: Find the full code for this recipe example in this repository.
- OpenApi Specs for Postman: Imported this OpenApi Specs file into tools like Postman.
Step 1: Install a Medusa Application
Prerequisites
Start by installing the Medusa application on your machine with the following command:
npx create-medusa-app@latest
You'll first be asked for the project's name. Then, when asked whether you want to install the Next.js Starter Storefront, choose Yes.
Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed in a separate directory with the {project-name}-storefront name.
The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more about Medusa's architecture in this documentation.
Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard.
Check out the troubleshooting guides for help.
Step 2: Create the Digital Product Module
Medusa creates commerce features in modules. For example, product features and data models are created in the Product Module.
You also create custom commerce data models and features in custom modules. They're integrated into the Medusa application similar to Medusa's modules without side effects.
So, you'll create a digital product module that holds the data models related to a digital product and allows you to manage them.
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:
import { model } from "@medusajs/framework/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 will create next.
Create the file src/modules/digital-product/models/digital-product-media.ts with the following content:
import { model } from "@medusajs/framework/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:
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:
import { model } from "@medusajs/framework/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",
pivotTable: "digitalproduct_digitalproductorders",
}),
})
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:
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:
import { MedusaService } from "@medusajs/framework/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, 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:
import DigitalProductModuleService from "./service"
import { Module } from "@medusajs/framework/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.ts:
module.exports = defineConfig({
// ...
modules: [
{
resolve: "./src/modules/digital-product",
},
],
})
Further Reads
Step 3: Define Links
Modules are isolated in Medusa, making them reusable, replaceable, and integrable in your application without side effects.
So, you can't have relations between data models in modules. Instead, you define a link between them.
Links are relations between data models of different modules that maintain the isolation between the modules.
In this step, you will define links between your module’s data models and data models from Medusa’s Commerce Modules:
- Link between the
DigitalProductmodel and the Product Module'sProductVariantmodel. - Link between the
DigitalProductOrdermodel and the Order Module'sOrdermodel.
Start by creating the file src/links/digital-product-variant.ts with the following content:
import DigitalProductModule from "../modules/digital-product"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
{
linkable: DigitalProductModule.linkable.digitalProduct,
deleteCascade: true,
},
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.
deleteCascades is enabled on the digitalProduct so that when a product variant is deleted, its linked digital product is also deleted.
Next, create the file src/links/digital-product-order.ts with the following content:
import DigitalProductModule from "../modules/digital-product"
import OrderModule from "@medusajs/medusa/order"
import { defineLink } from "@medusajs/framework/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
Step 4: 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:
npx medusa db:generate digitalProductModuleService
This generates a migration in the src/modules/digital-product/migrations directory.
Then, reflect the migrations and links in the database with the following command:
npx medusa db:migrate
Step 5: List Digital Products Admin API Route
To expose custom commerce features to frontend applications, such as the Medusa Admin dashboard or a storefront, you expose an endpoint by creating an API route.
In this step, you will create the admin API route to list digital products.
Create the file src/api/admin/digital-products/route.ts with the following content:
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const {
fields,
limit = 20,
offset = 0,
} = req.validatedQuery || {}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: digitalProducts,
metadata: { count, take, skip } = {},
} = await query.graph({
entity: "digital_product",
fields: [
"*",
"medias.*",
"product_variant.*",
...(fields || []),
],
pagination: {
skip: offset,
take: limit,
},
})
res.json({
digital_products: digitalProducts,
count,
limit: take,
offset: skip,
})
}
This adds a GET API route at /admin/digital-products.
In the route handler, you use 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:
npm run dev
Then, obtain a JWT token as an admin user with the following request:
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:
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
Step 6: Create Digital Product Workflow
To implement and expose a feature that manipulates data, you create a workflow that uses services to implement the functionality, then create an API route that executes that workflow.
In this step, you will create a workflow that creates a digital product. You will use this workflow in an API route in the next section.
This workflow has the following steps:
createProductsWorkflow: Create the Medusa product that the digital product is associated with its variant. Medusa provides this workflow through the@medusajs/medusa/core-flowspackage, which you can use as a step.createDigitalProductStep: Create the digital product.createDigitalProductMediasStep: Create the medias associated with the digital product.createRemoteLinkStep: Create the link between the digital product and the product variant. Medusa provides this step through the@medusajs/medusa/core-flowspackage.
You will 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:
import { createStep, StepResponse } from "@medusajs/framework/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 (data, { container }) => {
if (!data) {
return
}
const digitalProductModuleService: DigitalProductModuleService =
container.resolve(DIGITAL_PRODUCT_MODULE)
await digitalProductModuleService.deleteDigitalProducts(
data.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:
import {
createStep,
StepResponse,
} from "@medusajs/framework/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 (data, { container }) => {
if (!data) {
return
}
const digitalProductModuleService: DigitalProductModuleService =
container.resolve(DIGITAL_PRODUCT_MODULE)
await digitalProductModuleService.deleteDigitalProductMedias(
data.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:
import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { CreateProductWorkflowInputDTO } from "@medusajs/framework/types"
import { createProductsWorkflow, createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import { Modules } from "@medusajs/framework/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<CreateDigitalProductMediaInput, "digital_product_id">[]
}
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:
createProductsWorkflowas a step to create a Medusa product.createDigitalProductStepto create the digital product.createDigitalProductMediasStepto create the digital product’s medias.createRemoteLinkStepto link the digital product to the product variant.
You will test out the workflow in the next section.
Further Reads
Step 7: Create Digital Product API Route
In this step, you will add the API route to create a digital product using the createDigitalProductWorkflow.
In the file src/api/admin/digital-products/route.ts add a new route handler:
// 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<CreateRequestBody>,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: [shippingProfile] } = await query.graph({
entity: "shipping_profile",
fields: ["id"],
})
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<CreateDigitalProductMediaInput, "digital_product_id">[],
},
product: {
...req.validatedBody.product,
shipping_profile_id: shippingProfile.id,
},
},
})
res.json({
digital_product: result.digital_product,
})
}
This adds a POST API route at /admin/digital-products. In the route handler, you first retrieve a shipping profile to associate it with the product, which is required.
Then, you execute the createDigitalProductWorkflow created in the previous step, passing data from the request body as input, along with the retrieved shipping profile.
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:
import { AdminCreateProduct } from "@medusajs/medusa/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:
import { defineMiddlewares, validateAndTransformBody } from "@medusajs/framework/http"
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
Step 8: Upload Digital Product Media API Route
To upload the digital product media files, use Medusa’s File Module.
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.
In this step, you will create an API route for uploading preview and main digital product media files.
Before creating the API route, install the multer express middleware to support file uploads:
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:
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { uploadFilesWorkflow } from "@medusajs/medusa/core-flows"
import { MedusaError } from "@medusajs/framework/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 uploadFilesWorkflow from Medusa's core workflows 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:
// 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 will test out this API route in the next step as you use these API routes in the admin customizations.
Step 9: Add Digital Products UI Route in Admin
The Medusa Admin is customizable, allowing you to inject widgets into existing pages or add UI routes to create new pages.
In this step, you will 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:
import { ProductVariantDTO } from "@medusajs/framework/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:
import { defineRouteConfig } from "@medusajs/admin-sdk"
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 (
<Container>
<div className="flex justify-between items-center mb-4">
<Heading level="h2">Digital Products</Heading>
{/* TODO add create button */}
</div>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Action</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{digitalProducts.map((digitalProduct) => (
<Table.Row key={digitalProduct.id}>
<Table.Cell>
{digitalProduct.name}
</Table.Cell>
<Table.Cell>
<Link to={`/products/${digitalProduct.product_variant?.product_id}`}>
View Product
</Link>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
{/* TODO add pagination component */}
</Container>
)
}
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:
// 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:
currentPage: The number of the current page.pageLimit: The number of digital products to show per page.count: The total count of digital products.pagesCount: A memoized variable that holds the number of pages based oncountandpageLimit.canNextPage: A memoized variable that indicates whether there’s a next page based on whether the current page is less thanpagesCount - 1.canPreviousPage: A memoized variable that indicates whether there’s a previous pages based on whether the current page is greater than0.nextPage: A function that increments thecurrentPage.previousPage: A function that decrements thecurrentPage.
Then, replace the new TODO fetch digital products with the following:
// 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:
return (
<Container>
{/* ... */}
<Table.Pagination
count={count}
pageSize={pageLimit}
pageIndex={currentPage}
pageCount={pagesCount}
canPreviousPage={canPreviousPage}
canNextPage={canNextPage}
previousPage={previousPage}
nextPage={nextPage}
/>
</Container>
)
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 will find a new sidebar item, “Digital Products.†If you click on it, you will see the UI route you created with a table of digital products.
Further Reads
Step 10: Add Create Digital Product Form in Admin
In this step, you will 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:
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<CreateMedia[]>([])
const [productTitle, setProductTitle] = useState("")
const [loading, setLoading] = useState(false)
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
// TODO handle submit
}
return (
<form onSubmit={onSubmit}>
{/* TODO show form inputs */}
<Button
type="submit"
isLoading={loading}
>
Create
</Button>
</form>
)
}
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:
return (
<form onSubmit={onSubmit}>
<Input
name="name"
placeholder="Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<fieldset className="my-4">
<legend className="mb-2">Media</legend>
<Button type="button" onClick={onAddMedia}>Add Media</Button>
{medias.map((media, index) => (
<fieldset className="my-2 p-2 border-solid border rounded">
<legend>Media {index + 1}</legend>
<Select
value={media.type}
onValueChange={(value) => changeFiles(
index,
{
type: value as MediaType,
}
)}
>
<Select.Trigger>
<Select.Value placeholder="Media Type" />
</Select.Trigger>
<Select.Content>
<Select.Item value={MediaType.PREVIEW}>
Preview
</Select.Item>
<Select.Item value={MediaType.MAIN}>
Main
</Select.Item>
</Select.Content>
</Select>
<Input
name={`file-${index}`}
type="file"
onChange={(e) => changeFiles(
index,
{
file: e.target.files?.[0],
}
)}
className="mt-2"
/>
</fieldset>
))}
</fieldset>
<fieldset className="my-4">
<legend className="mb-2">Product</legend>
<Input
name="product_title"
placeholder="Product Title"
type="text"
value={productTitle}
onChange={(e) => setProductTitle(e.target.value)}
/>
</fieldset>
<Button
type="submit"
isLoading={loading}
>
Create
</Button>
</form>
)
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:
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:
const changeFiles = (
index: number,
data: Partial<CreateMedia>
) => {
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:
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) => {
if (!media.file) {
return
}
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 will implement the onSubmit function. Replace it with the following:
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
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: {
type: MediaType
file_id: string
mime_type: string
}[] = []
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:
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",
},
manage_inventory: false,
// delegate setting the prices to the
// product's page.
prices: [],
shipping_profile_id: "",
}],
},
}),
})
.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 will make changes now to src/admin/routes/digital-products/page.tsx to show the form.
First, add a new open state variable:
const DigitalProductsPage = () => {
const [open, setOpen] = useState(false)
// ...
}
Then, replace the TODO add create button in the return statement to show the CreateDigitalProductForm component:
// other imports...
import { Drawer } from "@medusajs/ui"
import CreateDigitalProductForm from "../../components/create-digital-product-form"
const DigitalProductsPage = () => {
// ...
return (
<Container>
{/* Replace the TODO with the following */}
<Drawer open={open} onOpenChange={(openChanged) => setOpen(openChanged)}>
<Drawer.Trigger
onClick={() => {
setOpen(true)
}}
asChild
>
<Button>Create</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Create Product</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<CreateDigitalProductForm onSuccess={() => {
setOpen(false)
if (currentPage === 0) {
fetchProducts()
} else {
setCurrentPage(0)
}
}} />
</Drawer.Body>
</Drawer.Content>
</Drawer>
</Container>
)
}
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 will 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 will 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:
- Change the status to published.
- Add it to the default sales channel.
- Add prices to the variant.
Step 11: Handle Product Deletion
When a product is deleted, its product variants are also deleted, meaning that their associated digital products should also be deleted.
In this step, you'll build a flow that deletes the digital products associated with a deleted product's variants. Then, you'll execute this workflow whenever a product is deleted.
The workflow has the following steps:
retrieveDigitalProductsToDeleteStep: Retrieve the digital products associated with a deleted product's variants.deleteDigitalProductsStep: Delete the digital products.
retrieveDigitalProductsToDeleteStep
The first step of the workflow receives the ID of the deleted product as an input and retrieves the digital products associated with its variants.
Create the file src/workflows/delete-product-digital-products/steps/retrieve-digital-products-to-delete.ts with the following content:
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import DigitalProductVariantLink from "../../../links/digital-product-variant"
type RetrieveDigitalProductsToDeleteStepInput = {
product_id: string
}
export const retrieveDigitalProductsToDeleteStep = createStep(
"retrieve-digital-products-to-delete",
async ({ product_id }: RetrieveDigitalProductsToDeleteStepInput, { container }) => {
const productService = container.resolve("product")
const query = container.resolve("query")
const productVariants = await productService.listProductVariants({
product_id: product_id,
}, {
withDeleted: true,
})
const { data } = await query.graph({
entity: DigitalProductVariantLink.entryPoint,
fields: ["digital_product.*"],
filters: {
product_variant_id: productVariants.map((v) => v.id),
},
})
const digitalProductIds = data.map((d) => d.digital_product.id)
return new StepResponse(digitalProductIds)
}
)
You create a retrieveDigitalProductsToDeleteStep step that retrieves the product variants of the deleted product. Notice that you pass in the second object parameter of listProductVariants a withDeleted property that ensures deleted variants are included in the result.
Then, you use Query to retrieve the digital products associated with the product variants. Links created with defineLink have an entryPoint property that you can use with Query to retrieve data from the pivot table of the link between the data models.
Finally, you return the IDs of the digital products to delete.
deleteDigitalProductsSteps
Next, you'll implement the step that deletes those digital products.
Create the file src/workflows/delete-product-digital-products/steps/delete-digital-products.ts with the following content:
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product"
import DigitalProductModuleService from "../../../modules/digital-product/service"
type DeleteDigitalProductsStep = {
ids: string[]
}
export const deleteDigitalProductsSteps = createStep(
"delete-digital-products",
async ({ ids }: DeleteDigitalProductsStep, { container }) => {
const digitalProductService: DigitalProductModuleService =
container.resolve(DIGITAL_PRODUCT_MODULE)
await digitalProductService.softDeleteDigitalProducts(ids)
return new StepResponse({}, ids)
},
async (ids, { container }) => {
if (!ids) {
return
}
const digitalProductService: DigitalProductModuleService =
container.resolve(DIGITAL_PRODUCT_MODULE)
await digitalProductService.restoreDigitalProducts(ids)
}
)
In the deleteDigitalProductsSteps, you soft delete the digital products by the ID passed as a parameter. In the compensation function, you restore the digital products if an error occurs.
Create deleteProductDigitalProductsWorkflow
You can now create the workflow that executes those steps.
Create the file src/workflows/delete-product-digital-products/index.ts with the following content:
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { deleteDigitalProductsSteps } from "./steps/delete-digital-products"
import { retrieveDigitalProductsToDeleteStep } from "./steps/retrieve-digital-products-to-delete"
type DeleteProductDigitalProductsInput = {
id: string
}
export const deleteProductDigitalProductsWorkflow = createWorkflow(
"delete-product-digital-products",
(input: DeleteProductDigitalProductsInput) => {
const digitalProductsToDelete = retrieveDigitalProductsToDeleteStep({
product_id: input.id,
})
deleteDigitalProductsSteps({
ids: digitalProductsToDelete,
})
return new WorkflowResponse({})
}
)
The deleteProductDigitalProductsWorkflow receives the ID of the deleted product as an input. In the workflow, you:
- Run the
retrieveDigitalProductsToDeleteStepto retrieve the digital products associated with the deleted product. - Run the
deleteDigitalProductsStepsto delete the digital products.
Execute Workflow on Product Deletion
When a product is deleted, Medusa emits a product.deleted event. You can handle this event with a subscriber. A subscriber is an asynchronous function that, when an event is emitted, is executed. You can implement in subscribers features that aren't essential to the original flow that emitted the event.
Learn more about subscribers in this documentation.
So, you'll listen to the product.deleted event in a subscriber, and execute the workflow whenever the product is deleted.
Create the file src/subscribers/handle-product-deleted.ts with the following content:
import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import {
deleteProductDigitalProductsWorkflow,
} from "../workflows/delete-product-digital-products"
export default async function handleProductDeleted({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
await deleteProductDigitalProductsWorkflow(container)
.run({
input: data,
})
}
export const config: SubscriberConfig = {
event: "product.deleted",
}
A subscriber file must export:
- An asynchronous function that's executed whenever the specified event is emitted.
- A configuration object that specifies the event the subscriber listens to, which is in this case
product.deleted.
The subscriber function receives as a parameter an object having the following properties:
event: An object containing the data payload of the emitted event.container: Instance of the Medusa Container.
In the subscriber, you execute the workflow by invoking it, passing the Medusa container as an input, then executing its run method. You pass the product's ID, which is received through the event's data payload, as an input to the workflow.
Test it Out
To test this out, start the Medusa application and, from the Medusa Admin dashboard, delete a product that has digital products. You can confirm that the digital product was deleted by checking the Digital Products page.
Step 12: 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:
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"
import {
CreateFulfillmentResult,
FulfillmentDTO,
FulfillmentItemDTO,
FulfillmentOption,
FulfillmentOrderDTO,
} from "@medusajs/framework/types"
class DigitalProductFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "digital"
constructor() {
super()
}
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
return [
{
id: "digital-fulfillment",
},
]
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>
): Promise<any> {
return data
}
async validateOption(data: Record<string, any>): Promise<boolean> {
return true
}
async createFulfillment(
data: Record<string, unknown>,
items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
order: Partial<FulfillmentOrderDTO> | undefined,
fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>
): Promise<CreateFulfillmentResult> {
// No data is being sent anywhere
return {
data,
labels: [],
}
}
async cancelFulfillment(): Promise<any> {
return {}
}
async createReturnFulfillment(): Promise<any> {
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:
import { ModuleProviderExports } from "@medusajs/framework/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.ts:
// other imports...
import { Modules } from "@medusajs/framework/utils"
module.exports = defineConfig({
modules: [
// ...
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "@medusajs/medusa/fulfillment-manual",
id: "manual",
},
{
resolve: "./src/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 13: Create Cart Completion Flow for Digital Products
In this step, you will create a new cart completion flow that not only creates a Medusa order, but also create a digital product order.
To create the cart completion flow, you will create a workflow and then use that workflow in an API route defined at src/api/store/carts/[id]/complete-digital/route.ts.
graph TD
completeCartWorkflow["completeCartWorkflow (Medusa)"] --> useQueryGraphStep["useQueryGraphStep (Medusa)"]
useQueryGraphStep --> 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:
completeCartWorkflowto create a Medusa order from the cart. Medusa provides this workflow through the@medusajs/medusa/core-flowspackage and you can use it as a step.useQueryGraphStepto retrieve the order’s items with the digital products associated with the purchased product variants. Medusa provides this step through the@medusajs/medusa/core-flowspackage.- If the order has digital products, you:
- create the digital product order.
- link the digital product order with the Medusa order. Medusa provides a
createRemoteLinkStepin the@medusajs/medusa/core-flowspackage that can be used here. - Create a fulfillment for the digital products in the order. Medusa provides a
createOrderFulfillmentWorkflowin the@medusajs/medusa/core-flowspackage that you can use as a step here. - Emit the
digital_product_order.createdcustom event to handle it later in a subscriber and send the customer an email. Medusa provides aemitEventStepin the@medusajs/medusa/core-flowsthat you can use as a step here.
You will 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:
import {
createStep,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
import {
OrderLineItemDTO,
ProductVariantDTO,
InferTypeOf,
} from "@medusajs/framework/types"
import { OrderStatus } from "../../../modules/digital-product/types"
import DigitalProductModuleService from "../../../modules/digital-product/service"
import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product"
import DigitalProduct from "../../../modules/digital-product/models/digital-product"
export type CreateDigitalProductOrderStepInput = {
items: (OrderLineItemDTO & {
variant: ProductVariantDTO & {
digital_product: InferTypeOf<typeof DigitalProduct>
}
})[]
}
const createDigitalProductOrderStep = createStep(
"create-digital-product-order",
async ({ items }: CreateDigitalProductOrderStepInput, { 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 (data, { container }) => {
if (!data) {
return
}
const digitalProductModuleService: DigitalProductModuleService =
container.resolve(DIGITAL_PRODUCT_MODULE)
await digitalProductModuleService.deleteDigitalProductOrders(
data.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:
import {
createWorkflow,
transform,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
completeCartWorkflow,
useQueryGraphStep,
createRemoteLinkStep,
createOrderFulfillmentWorkflow,
emitEventStep,
} from "@medusajs/medusa/core-flows"
import {
Modules,
} from "@medusajs/framework/utils"
import createDigitalProductOrderStep, {
CreateDigitalProductOrderStepInput,
} 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 { id } = completeCartWorkflow.runAsStep({
input: {
id: input.cart_id,
},
})
const { data: orders } = useQueryGraphStep({
entity: "order",
fields: [
"*",
"items.*",
"items.variant.*",
"items.variant.digital_product.*",
"shipping_address.*",
],
filters: {
id,
},
options: {
throwIfKeyNotFound: true,
},
})
const itemsWithDigitalProducts = transform({
orders,
},
(data) => {
return data.orders[0].items?.filter((item) => item?.variant?.digital_product !== undefined)
}
)
const digital_product_order = when(
"create-digital-product-order-condition",
itemsWithDigitalProducts,
(itemsWithDigitalProducts) => {
return !!itemsWithDigitalProducts?.length
}
).then(() => {
const {
digital_product_order,
} = createDigitalProductOrderStep({
items: orders[0].items,
} as unknown as CreateDigitalProductOrderStepInput)
createRemoteLinkStep([{
[DIGITAL_PRODUCT_MODULE]: {
digital_product_order_id: digital_product_order.id,
},
[Modules.ORDER]: {
order_id: id,
},
}])
createOrderFulfillmentWorkflow.runAsStep({
input: {
order_id: 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: orders[0],
digital_product_order,
})
}
)
export default createDigitalProductOrderWorkflow
This creates the workflow createDigitalProductOrderWorkflow. It runs the following steps:
completeCartWorkflowas a step to create the Medusa order.useQueryGraphStepto retrieve the order’s items with their associated variants and linked digital products.- Use
whento check whether the order has digital products. If so:- Use the
createDigitalProductOrderStepto create the digital product order. - Use the
createRemoteLinkStepto link the digital product order to the Medusa order. - Use the
createOrderFulfillmentWorkflowto create a fulfillment for the digital products in the order. - Use the
emitEventStepto emit a custom event.
- Use the
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-digital/route.ts with the following content:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
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,
})
}
Since you export a POST function, you expose a POST API route at /store/carts/[id]/complete-digital.
In the route handler, you execute the createDigitalProductOrderWorkflow and return the created order in the response.
Test Cart Completion: Customize Next.js Starter Storefront
To test out the cart completion, you'll customize the Next.js Starter Storefront that you installed in the first step to use the new cart completion route to place an order.
The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is {your-project}-storefront.
So, if your Medusa application's directory is medusa-digital-product, you can find the storefront by going back to the parent directory and changing to the medusa-digital-product-storefront directory:
cd ../medusa-digital-product-storefront # change based on your project name
In the Next.js Starter Storefront, open the file src/lib/data/cart.ts and find the following lines in the placeOrder function:
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
Replace these lines with the following:
const cartRes = await sdk.client.fetch<HttpTypes.StoreCompleteCartResponse>(
`/store/carts/${id}/complete-digital`,
{
method: "POST",
headers,
}
)
This will send a POST request to the new cart completion route you created to complete the cart.
Then, run the following command in the Medusa application directory to start the Medusa application:
npm run start
And run the following command in the Next.js Starter Storefront directory to start the Next.js application:
npm run dev
Open the storefront in your browser at http://localhost:8000 and add a digital product to the cart.
Then, go through the checkout process. Make sure to choose the shipping option you created in the previous step for shipping.
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 will add an API route to allow customers to view and download their purchased digital products.
Further Read
Step 14: Fulfill Digital Order Workflow
In this step, you'll create a workflow that fulfills a digital order by sending a notification to the customer. Later, you'll execute this workflow in a subscriber that listens to the digital_product_order.created event.
The workflow has the following steps:
- Retrieve the digital product order's details. For this, you'll use
useQueryGraphStepfrom Medusa's core workflows. - Send a notification to the customer with the digital products to download.
- Mark the Medusa order's fulfillment as delivered. For this, you'll use
markOrderFulfillmentAsDeliveredWorkflowfrom Medusa's core workflows.
So, you only need to implement the second step.
Add Types
Before creating the step, add to src/modules/digital-product/types/index.ts the following:
import { OrderDTO, InferTypeOf } from "@medusajs/framework/types"
import DigitalProductOrder from "../models/digital-product-order"
// ...
export type DigitalProductOrder =
InferTypeOf<typeof DigitalProductOrder> & {
order?: OrderDTO
}
This adds a type for a digital product order, which you'll use next.
You use InferTypeOf to infer the type of the DigitalProductOrder data model, and add to it the optional order property, which is the linked order.
Create sendDigitalOrderNotificationStep
To create the step, create the file src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts with the following content:
import {
createStep,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
import {
INotificationModuleService,
IFileModuleService,
} from "@medusajs/framework/types"
import {
MedusaError,
ModuleRegistrationName,
promiseAll,
} from "@medusajs/framework/utils"
import { DigitalProductOrder, MediaType } from "../../../modules/digital-product/types"
export type SendDigitalOrderNotificationStepInput = {
digital_product_order: DigitalProductOrder
}
export const sendDigitalOrderNotificationStep = createStep(
"send-digital-order-notification",
async ({
digital_product_order: digitalProductOrder,
}: SendDigitalOrderNotificationStepInput,
{ container }) => {
const notificationModuleService: INotificationModuleService = container
.resolve(ModuleRegistrationName.NOTIFICATION)
const fileModuleService: IFileModuleService = container.resolve(
ModuleRegistrationName.FILE
)
if (!digitalProductOrder.order) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Digital product order is missing associated order."
)
}
if (!digitalProductOrder.order.email) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order is missing email."
)
}
// TODO assemble notification
}
)
This creates the sendDigitalOrderNotificationStep step that receives a digital product order as an input.
In the step, so far you resolve the main services of the Notification and File Modules. You also check that the digital product order has an associated order and that the order has an email.
Next, you'll prepare the data to pass in the notification. Replace the TODO with the following:
const notificationData = await promiseAll(
digitalProductOrder.products.map(async (product) => {
const medias: string[] = []
await promiseAll(
product.medias
.filter((media) => media.type === MediaType.MAIN)
.map(async (media) => {
medias.push(
(await fileModuleService.retrieveFile(media.fileId)).url
)
})
)
return {
name: product.name,
medias,
}
})
)
// TODO send notification
In this snippet, you put together the data to send in the notification. You loop over the digital products in the order and retrieve the URL of their main files using the File Module.
Finally, replace the new TODO with the following:
const notification = await notificationModuleService.createNotifications({
to: digitalProductOrder.order.email,
template: "digital-order-template",
channel: "email",
data: {
products: notificationData,
},
})
return new StepResponse(notification)
You use the createNotifications method of the Notification Module's main service to send an email using the installed provider.
Create Workflow
Create the workflow in the file src/workflows/fulfill-digital-order/index.ts:
import {
createWorkflow,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
markOrderFulfillmentAsDeliveredWorkflow,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
import {
sendDigitalOrderNotificationStep,
SendDigitalOrderNotificationStepInput,
} from "./steps/send-digital-order-notification"
type FulfillDigitalOrderWorkflowInput = {
id: string
}
export const fulfillDigitalOrderWorkflow = createWorkflow(
"fulfill-digital-order",
({ id }: FulfillDigitalOrderWorkflowInput) => {
const { data: digitalProductOrders } = useQueryGraphStep({
entity: "digital_product_order",
fields: [
"*",
"products.*",
"products.medias.*",
"order.*",
"order.fulfillments.*",
],
filters: {
id,
},
options: {
throwIfKeyNotFound: true,
},
})
sendDigitalOrderNotificationStep({
digital_product_order: digitalProductOrders[0],
} as unknown as SendDigitalOrderNotificationStepInput)
when({ digitalProductOrders }, (data) => !!data.digitalProductOrders[0].order?.fulfillments?.length)
.then(() => {
markOrderFulfillmentAsDeliveredWorkflow.runAsStep({
input: {
orderId: digitalProductOrders[0].order!.id,
fulfillmentId: digitalProductOrders[0].order!.fulfillments![0]!.id,
},
})
})
return new WorkflowResponse(
digitalProductOrders[0]
)
}
)
In the workflow, you:
- Retrieve the digital product order's details using
useQueryGraphStepfrom Medusa's core workflows. - Send a notification to the customer with the digital product download links using the
sendDigitalOrderNotificationStep. - Check whether the Medusa order has fulfillments. If so, mark the order's fulfillment as delivered using
markOrderFulfillmentAsDeliveredWorkflowfrom Medusa's core workflows.
when allows you to perform steps based on a condition during execution. Learn more in the Conditions in Workflows documentation.
Configure Notification Module Provider
In the sendDigitalOrderNotificationStep, you use a notification provider configured for the email channel to send the notification.
Check out the Integrations page to find Notification Module Providers.
For testing purposes, add to medusa-config.ts the following to use the Local Notification Module Provider:
module.exports = defineConfig({
// ...
modules: [
// ...
{
resolve: "@medusajs/medusa/notification",
options: {
providers: [
{
resolve: "@medusajs/medusa/notification-local",
id: "local",
options: {
name: "Local Notification Provider",
channels: ["email"],
},
},
],
},
},
],
})
Step 15: Handle the Digital Product Order Event
In this step, you'll create a subscriber that listens to the digital_product_order.created event and executes the workflow from the above step.
Create the file src/subscribers/handle-digital-order.ts with the following content:
import type {
SubscriberArgs,
SubscriberConfig,
} from "@medusajs/framework"
import {
fulfillDigitalOrderWorkflow,
} from "../workflows/fulfill-digital-order"
async function digitalProductOrderCreatedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
await fulfillDigitalOrderWorkflow(container).run({
input: {
id: data.id,
},
})
}
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. It executes the fulfillDigitalOrderWorkflow to send the customer an email and mark the order's fulfillment as fulfilled.
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.
Step 16: Create Store API Routes
In this step, you will create three store API routes:
- Retrieve the preview files of a digital product. This is useful when the customer is browsing the products before purchase.
- List the digital products that the customer has purchased.
- 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:
import {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
Modules,
} from "@medusajs/framework/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(
Modules.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:
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
ContainerRegistrationKeys,
} from "@medusajs/framework/utils"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: [customer] } = await query.graph({
entity: "customer",
fields: [
"orders.digital_product_order.products.*",
"orders.digital_product_order.products.medias.*",
],
filters: {
id: req.auth_context.actor_id,
},
})
const digitalProducts = {}
customer.orders?.forEach((order) => {
order?.digital_product_order?.products.forEach((product) => {
if (!product) {
return
}
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 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:
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
Modules,
ContainerRegistrationKeys,
MedusaError,
} from "@medusajs/framework/utils"
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const fileModuleService = req.scope.resolve(
Modules.FILE
)
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: [customer] } = await query.graph({
entity: "customer",
fields: [
"orders.digital_product_order.*",
],
filters: {
id: req.auth_context.actor_id,
},
})
const customerDigitalOrderIds = customer.orders?.filter(
(order) => order?.digital_product_order !== undefined
).map((order) => order!.digital_product_order!.id)
const { data: dpoResult } = await query.graph({
entity: "digital_product_order",
fields: [
"products.medias.*",
],
filters: {
id: customerDigitalOrderIds,
},
})
if (!dpoResult.length) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Customer didn't purchase digital product."
)
}
let foundMedia: any | undefined = 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 will test out these API routes in the next step.
Further Reads
Step 17: Customize Next.js Starter
In this section, you will customize the Next.js Starter storefront to:
- Show a preview button on a digital product’s page to view the preview files.
- Add a new tab in the customer’s dashboard to view their purchased digital products.
- Allow customers to download the digital products through the new page in the dashboard.
The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is {your-project}-storefront.
So, if your Medusa application's directory is medusa-digital-product, you can find the storefront by going back to the parent directory and changing to the medusa-digital-product-storefront directory:
cd ../medusa-digital-product-storefront # change based on your project name
Add Types
In src/types/global.ts, add the following types that you will use in your customizations:
import {
// other imports...
StoreProductVariant,
} from "@medusajs/types"
// ...
export type DigitalProduct = {
id: string
name: string
medias?: DigitalProductMedia[]
}
export type DigitalProductMedia = {
id: string
fileId: string
type: "preview" | "main"
mimeType: string
digitalProduct?: DigitalProduct[]
}
export type DigitalProductPreview = DigitalProductMedia & {
url: string
}
export type VariantWithDigitalProduct = StoreProductVariant & {
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 listProducts function to pass the digital products in the fields property passed to the sdk.store.product.list method:
export const listProducts = async ({
pageParam = 1,
queryParams,
countryCode,
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
countryCode?: string
regionId?: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
// ...
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
`/store/products`,
{
// ...
query: {
// ...
fields: "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,*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, first, add the following import at the top of src/lib/data/products.ts:
import { DigitalProductPreview } from "../../types/global"
Then, add the following function at the end of the file:
export const getDigitalProductPreview = async function ({
id,
}: {
id: string
}) {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
const { previews } = await sdk.client.fetch<{
previews: DigitalProductPreview[]
}>(
`/store/digital-products/${id}/preview`,
{
headers,
next,
cache: "force-cache",
}
)
// 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:
// 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:
// 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:
return (
<div>
{/* Before add to cart */}
{selectedVariant?.digital_product && (
<Button
onClick={handleDownloadPreview}
variant="secondary"
className="w-full h-10"
>
Download Preview
</Button>
)}
</div>
)
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 will 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:
"use server"
import { DigitalProduct } from "../../types/global"
import { sdk } from "../config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const getCustomerDigitalProducts = async () => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
const { digital_products } = await sdk.client.fetch<{
digital_products: DigitalProduct[]
}>(`/store/customers/me/digital-products`, {
headers,
next,
cache: "no-cache",
})
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:
"use client"
import { Table } from "@medusajs/ui"
import { DigitalProduct } from "../../../../types/global"
type Props = {
digitalProducts: DigitalProduct[]
}
export const DigitalProductsList = ({
digitalProducts,
}: Props) => {
return (
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Action</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{digitalProducts.map((digitalProduct) => {
const medias = digitalProduct.medias?.filter((media) => media.type === "main")
const showMediaCount = (medias?.length || 0) > 1
return (
<Table.Row key={digitalProduct.id}>
<Table.Cell>
{digitalProduct.name}
</Table.Cell>
<Table.Cell>
<ul>
{medias?.map((media, index) => (
<li key={media.id}>
<a href="#">
Download{showMediaCount ? ` ${index + 1}` : ``}
</a>
</li>
))}
</ul>
</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table>
)
}
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 will implement its functionality afterwards.
Next, create the file src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx with the following content:
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 (
<div className="w-full" data-testid="orders-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Digital Products</h1>
<p className="text-base-regular">
View the digital products you've purchased and download them.
</p>
</div>
<div>
<DigitalProductsList digitalProducts={digitalProducts} />
</div>
</div>
)
}
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:
// other imports...
import { Photo } from "@medusajs/icons"
const AccountNav = ({
customer,
}: {
customer: HttpTypes.StoreCustomer | null
}) => {
// ...
return (
<div>
<div className="small:hidden">
{/* ... */}
{/* Add before log out */}
<li>
<LocalizedClientLink
href="/account/digital-products"
className="flex items-center justify-between py-4 border-b border-gray-200 px-8"
data-testid="digital-products-link"
>
<div className="flex items-center gap-x-2">
<Photo />
<span>Digital Products</span>
</div>
<ChevronDown className="transform -rotate-90" />
</LocalizedClientLink>
</li>
{/* ... */}
</div>
<div className="hidden small:block">
{/* ... */}
{/* Add before log out */}
<li>
<AccountNavLink
href="/account/digital-products"
route={route!}
data-testid="digital-products-link"
>
Digital Products
</AccountNavLink>
</li>
{/* ... */}
</div>
</div>
)
}
You add a link to the new route before the log out tab both for small and large devices.
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 will 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:
export const getDigitalMediaDownloadLink = async (mediaId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
const { url } = await sdk.client.fetch<{
url: string
}>(`/store/customers/me/digital-products/${mediaId}/download`, {
method: "POST",
headers,
next,
cache: "no-cache",
})
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, import the getDigitalMediaDownloadLink at the top of the file:
import { getDigitalMediaDownloadLink } from "../../../../lib/data/digital-products"
And add a handleDownload function in the DigitalProductsList component:
const handleDownload = async (
e: React.MouseEvent<HTMLAnchorElement, 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:
<a href="#" onClick={(e) => handleDownload(e, media.id)}>
Download{showMediaCount ? ` ${index + 1}` : ``}
</a>
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.
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 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.