How to Build a Digital Products Store with Medusa and Next.js
[ad_1]
In this tutorial, you will learn how to build an e-book online store using Medusa and Next.js.
Throughout the course of the article, we will:
- Utilize the Medusa Next.js Starter Template along with the Digital Products Recipe build the store.
- Enhance the product pages to suit digital products. This involves adding a button for previewing media content and displaying essential product details.
- Refine the checkout process to make it more efficient for delivering digital products.
- Create Next.js API routes to validate and conceal file paths for product downloads.
Table of Contents
- What is Medusa?
- Prerequisites
- Starting Out
- How to Set Up TypeScript Type Definitions
- How to Incorporate e-Book Previews into the Product Details
- How to Offer e-Book Previews
- How to Adjust the Product and Shipping Details
- How to Simplify the Checkout
- How to Deliver Digital Products
Let’s get started.
What is Medusa?
Medusa is a suite of tools and modules specifically designed for e-Commerce products.
Using Medusa, you can build modularized commerce logic like carts, products, and order management. It also provide tools that help you orchestrate powerful ecommerce websites, POS applications, commerce-enabled products, and everything in between.
Prerequisites
Before you get started with the tutorial, you should have installed:
Starting Out
Using the Next.js starter, you can create a new Medusa app by running the following command:
npx create-medusa-app@latest --with-nextjs-starter
After that, you can opt to create a user account for admin panel access. Then, set up the backend infrastructure following the Medusa Digital Products Recipe.
Once the backend is set, create sample products through your Medusa admin interface. Make sure that these products include digital media files for previews and primary content. Also make sure to incorporate relevant product metadata values using key/value pairs linked to each product.
How to Set Up TypeScript Type Definitions
If you’re using regular JavaScript, you can skip this step.
Before we continue, let’s make sure to add in the necessary TypeScript type definitions for digital products in the Next.js storefront.
This code defines TypeScript types and interfaces for managing digital products and their associated media files in an e-commerce system. It introduces several crucial structures:
ProductMedia
: This interface describes media files related to a product. These files can include images, documents, or any digital assets. It encompasses properties such as anid
(a unique identifier for the media),name
(an optional name for the media),file
(representing the file path or URL),mime_type
(the type of media, e.g., image/jpeg),created_at
andupdated_at
timestamps, andattachment_type
that categorizes the media as “preview” or “main.” Additionally, a media item can have multiple variants, making it adaptable for various use cases.ProductMediaVariant
: This interface represents different variants or versions of a product’s media. Each variant has its uniqueid
,variant_id
(relating it to a specific product variant),product_media_id
(linking it to a particular media item), and timestamps forcreated_at
andupdated_at
.DigitalProduct
: It extends the standardProduct
type by introducing an array calledproduct_medias
. This array enables the association of media files with a digital product, allowing the presentation of images or other media related to the product. Thevariants
property is tailored for digital products, adapting the genericProductVariant
to digital product-specific requirements.DigitalProductVariant
: This type, an extension ofProductVariant
, allows the linking of media files with a specific variant of a digital product. This is particularly valuable for showcasing different digital assets associated with each variant of the product.
How to Incorporate e-Book Previews into the Product Details
Now, let’s move forward by adding e-book previews to our product detail page. To do this, we’ll get the media previews linked to the currently selected product variant.
In the src/lib/data/index.ts
file, we’ll create a function to get these previews based on the chosen variant.
This function is responsible for fetching information related to a specific product variant. It does this by making an HTTP request to the /product-media
endpoint. It takes one argument, variant
, which is expected to be of type Variant
. The request includes query parameters specifying the variant_ids
and requests additional details about related “variants”.
The function awaits the response from the HTTP request and extracts the response body, which is assumed to be an array of product media objects. It then returns the first product media object from this array, presuming there is at least one such object. If an error occurs during the request, it catches the error and rethrows it.
How to Offer e-Book Previews
To give customers a glimpse of the e-book’s content, we’ll provide a preview PDF with the first few pages.
To do this, we’ll set up a Next API route to manage file downloads while keeping the file’s location private. We’ll also create a component for a straightforward “download free preview” button. If a product variant has preview media, it will be shown in the product-actions component.
You can use the newly created DigitalProduct
and DigitalProductVariant
types to fix any TypeScript errors that you may encounter.
The GET
function is designed to handle incoming HTTP GET requests using the Next.js framework. It first extracts information from the request URL, specifically the filepath
and filename
, which are expected to be query parameters. It then attempts to fetch a PDF file from the specified filepath
. If the PDF is successfully retrieved, it proceeds to convert the PDF content into a buffer.
In case the PDF retrieval fails, for instance, if the file is not found, it returns a response with a “PDF not found” message and a 404 status code, indicating a not found error.
If the PDF is successfully fetched, it defines response headers, specifying that the content type is “application/pdf” and setting the “Content-Disposition” header to control the behavior of file downloads. The Content-Disposition
header is set to “attachment,” and the filename
parameter is used to suggest a filename for the downloaded PDF.
The above component displays a preview of a product’s media along with a button to download a free preview of that media. The component receives a prop named media
, which is expected to be of type ProductMedia
.
Inside the component, there’s a downloadPreview
function that’s called when a user clicks the “Download free preview” button. This function constructs a URL for downloading the preview using the window.location.href
property. It combines the base URL from the environment variable NEXT_PUBLIC_BASE_URL
with the “/api/download/preview” route and includes query parameters for the file path and file name, which are extracted from the media
prop.
This component is responsible for displaying product-related actions, such as adding a product to the cart, and showing product media preview if available. It leverages asynchronous operations to fetch the media data based on the provided variant
, making it a dynamic and interactive component.
How to Adjust the Product and Shipping Details
Because product and shipping information differs between digital and physical products, we’ll make changes to these sections on the product page as needed.
How to Add Product Details
I’ve added product details to the e-book using the product’s metadata section in the Medusa admin. Since we’re not using the standard attributes, we’ll enhance the ProductInfoTab
component to display any additional metadata we include.
By default, metadata is structured as an object. To make it simpler to create our list of attributes, we’ll change it into an array.
In this case, we’ll feature four attributes from the metadata, splitting them into two columns. If you want to show a different number of attributes, you can easily adjust the values within the slice()
function as needed.
How to Adjust the Shipping Details
Shipping information isn’t relevant for digital products, so we’ll change the content in this tab. You can make any necessary adjustments to the content within the ShippingInfoTab
component in the same file to better match your store’s requirements.
The ProductTabs
component is used for rendering a set of tabs. The component takes a product
prop, and it uses the useMemo
hook to create an array of tab objects. Each tab object consists of a label and a component to be displayed when that tab is active.
In the above snippet, there are two tabs: “Product Information” and “E-book delivery.” The “Product Information” tab displays information about the product using the ProductInfoTab
component, which we defined earlier.
The “E-book delivery” tab uses the ShippingInfoTab
component to display information related to e-book delivery. Inside the ShippingInfoTab
component, it provides details about the delivery process, mentioning instant delivery via email and the option to download from an account, as well as free e-book previews.
How to Simplify the Checkout
Selling digital products doesn’t require gathering customers’ physical addresses. We only need their first name and email address to deliver the e-book, making the checkout process simpler by removing unnecessary input fields.
In this example, we’ll keep only the first name, last name, country, and email fields, completely removing the billing address section. Keep in mind that your specific requirements may require different input fields.
To start, we’ll adjust the checkout types and context by removing any references to values that are no longer needed.
In the above snippet, you define TypeScript types for address values and the overall form structure. The CheckoutContext
is also created to serve as a context for sharing checkout-related data and functions with other components.
interface CheckoutProviderProps {
children?: React.ReactNode
}
const IDEMPOTENCY_KEY = "create_payment_session_key"
export const CheckoutProvider = ({ children }: CheckoutProviderProps) => {
const {
cart,
setCart,
addShippingMethod: {
mutate: setShippingMethod,
isLoading: addingShippingMethod,
},
completeCheckout: { mutate: complete, isLoading: completingCheckout },
} = useCart()
const { customer } = useMeCustomer()
const { countryCode } = useStore()
const methods = useForm<CheckoutFormValues>({
defaultValues: mapFormValues(customer, cart, countryCode),
reValidateMode: "onChange",
})
The CheckoutProvider
component manages cart data, customer information, form handling, and interactions with payment and shipping methods. It sets up various hooks and functions for these purposes.
You also define an idempotency key which will be used for preventing duplicate requests during payment session creation.
const methods = useForm<CheckoutFormValues>({
defaultValues: mapFormValues(customer, cart, countryCode),
reValidateMode: "onChange",
})
const {
mutate: setPaymentSessionMutation,
isLoading: settingPaymentSession,
} = useSetPaymentSession(cart?.id!)
const { mutate: updateCart, isLoading: updatingCart } = useUpdateCart(
cart?.id!
)
const { shipping_options } = useCartShippingOptions(cart?.id!, {
enabled: !!cart?.id,
})
const { regions } = useRegions()
const { resetCart, setRegion } = useStore()
const { push } = useRouter()
const editAddresses = useToggleState()
const sameAsBilling = useToggleState(
cart?.billing_address && cart?.shipping_address
? isEqual(cart.billing_address, cart.shipping_address)
: true
)
In this section of code, several variables and hooks are initialized to facilitate the management of a checkout process.
We use the methods
variable to manage the checkout form, with initial values populated by the mapFormValues
function. The code also sets up mutation functions for updating the payment session and the cart (setPaymentSessionMutation
and updateCart
) and tracks their loading states. It retrieves available shipping options and regions using hooks, and it also handles cart resets and region selection.
It also employs boolean states (editAddresses
and sameAsBilling
) to manage whether the user is currently editing addresses and whether the billing address matches the shipping address.
These components collectively ensure smooth navigation and data management in the checkout process.
/**
* Boolean that indicates if a part of the checkout is loading.
*/
const isLoading = useMemo(() => {
return (
addingShippingMethod ||
settingPaymentSession ||
updatingCart ||
completingCheckout
)
}, [
addingShippingMethod,
completingCheckout,
settingPaymentSession,
updatingCart,
])
/**
* Boolean that indicates if the checkout is ready to be completed. A checkout is ready to be completed if
* the user has supplied a email, shipping address, billing address, shipping method, and a method of payment.
*/
const readyToComplete = useMemo(() => {
return (
!!cart &&
!!cart.email &&
!!cart.shipping_address &&
!!cart.billing_address &&
!!cart.payment_session &&
cart.shipping_methods?.length > 0
)
}, [cart])
const shippingMethods = useMemo(() => {
if (shipping_options && cart?.region) {
return shipping_options?.map((option) => ({
value: option.id,
label: option.name,
price: formatAmount({
amount: option.amount || 0,
region: cart.region,
}),
}))
}
return []
}, [shipping_options, cart])
In the code above, first the isLoading
boolean is computed using the useMemo
hook. It reflects whether any part of the checkout is in a loading state.
This is determined by observing four loading flags: addingShippingMethod
, settingPaymentSession
, updatingCart
, and completingCheckout
. If any of these flags is true
, the isLoading
flag will also be true
. This indicates that some part of the checkout is currently in progress.
The readyToComplete
boolean, also computed with useMemo
, assesses whether the checkout is prepared for completion.
To be deemed ready, several conditions must be met: there must be a valid cart
object, an email address, a shipping address, a billing address, a payment session, and at least one shipping method selected. If all these conditions are satisfied, readyToComplete
will be true
, signaling that the checkout process is set to be finalized.
Finally, the shippingMethods
variable is computed using useMemo
. It is an array of available shipping methods with associated information. It maps the shipping_options
(if they exist) to an array of objects, each containing a value
, label
, and price
.
These objects represent the shipping options, their names, and prices, formatted using the formatAmount
function. This data is used to display and select shipping methods during the checkout process.
/**
* Resets the form when the cart changed.
*/
useEffect(() => {
if (cart?.id) {
methods.reset(mapFormValues(customer, cart, countryCode))
}
}, [customer, cart, methods, countryCode])
useEffect(() => {
if (!cart) {
editAddresses.open()
return
}
if (cart?.shipping_address && cart?.billing_address) {
editAddresses.close()
return
}
editAddresses.open()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cart])
/**
* Method to set the selected shipping method for the cart. This is called when the user selects a shipping method, such as UPS, FedEx, etc.
*/
const setShippingOption = (soId: string) => {
if (cart) {
setShippingMethod(
{ option_id: soId },
{
onSuccess: ({ cart }) => setCart(cart),
}
)
}
}
/**
* Method to create the payment sessions available for the cart. Uses a idempotency key to prevent duplicate requests.
*/
const createPaymentSession = async (cartId: string) => {
return medusaClient.carts
.createPaymentSessions(cartId, {
"Idempotency-Key": IDEMPOTENCY_KEY,
})
.then(({ cart }) => cart)
.catch(() => null)
}
/**
* Method that calls the createPaymentSession method and updates the cart with the payment session.
*/
const initPayment = async () => {
if (cart?.id && !cart.payment_sessions?.length && cart?.items?.length) {
const paymentSession = await createPaymentSession(cart.id)
if (!paymentSession) {
setTimeout(initPayment, 500)
} else {
setCart(paymentSession)
return
}
}
}
/**
* Method to set the selected payment session for the cart. This is called when the user selects a payment provider, such as Stripe, PayPal, etc.
*/
const setPaymentSession = (providerId: string) => {
if (cart) {
setPaymentSessionMutation(
{
provider_id: providerId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
},
}
)
}
}
const prepareFinalSteps = () => {
initPayment()
if (shippingMethods?.length && shippingMethods?.[0]?.value) {
setShippingOption(shippingMethods[0].value)
}
}
const setSavedAddress = (address: AddressValues) => {
const setValue = methods.setValue
setValue("shipping_address", {
country_code: address.country_code || "",
first_name: address.first_name || "",
last_name: address.last_name || "",
})
}
/**
* Method that validates if the cart's region matches the shipping address's region. If not, it will update the cart region.
*/
const validateRegion = (countryCode: string) => {
if (regions && cart) {
const region = regions.find((r) =>
r.countries.map((c) => c.iso_2).includes(countryCode)
)
if (region && region.id !== cart.region.id) {
setRegion(region.id, countryCode)
}
}
}
/**
* Method that sets the addresses and email on the cart.
*/
const setAddresses = (data: CheckoutFormValues) => {
const { shipping_address, billing_address, email } = data
const payload: StorePostCartsCartReq = {
shipping_address,
email,
}
if (isEqual(shipping_address, billing_address)) {
sameAsBilling.open()
}
if (sameAsBilling.state) {
payload.billing_address = shipping_address
} else {
payload.billing_address = billing_address
}
updateCart(payload, {
onSuccess: ({ cart }) => {
setCart(cart)
prepareFinalSteps()
},
})
}
/**
* Method to complete the checkout process. This is called when the user clicks the "Complete Checkout" button.
*/
const onPaymentCompleted = () => {
complete(undefined, {
onSuccess: ({ data }) => {
resetCart()
push(`/order/confirmed/${data.id}`)
},
})
}
return (
<FormProvider {...methods}>
<CheckoutContext.Provider
value={{
cart,
shippingMethods,
isLoading,
readyToComplete,
sameAsBilling,
editAddresses,
initPayment,
setAddresses,
setSavedAddress,
setShippingOption,
setPaymentSession,
onPaymentCompleted,
}}
>
<Wrapper paymentSession={cart?.payment_session}>{children}</Wrapper>
</CheckoutContext.Provider>
</FormProvider>
)
}
This code section orchestrates various aspects of an e-commerce checkout process. It manages form state, resets the form when the cart changes, and toggles address editing visibility. It handles the selection of shipping methods, the creation and initialization of payment sessions, and the choice of payment providers. And it ensures that shipping addresses, billing addresses, and email information are set appropriately, and validates the cart’s region based on the shipping address.
It also coordinates the completion of the checkout process, including payment processing and order confirmation.
All of these functions and data are encapsulated within the CheckoutProvider
component.
export const useCheckout = () => {
const context = useContext(CheckoutContext)
const form = useFormContext<CheckoutFormValues>()
if (context === null) {
throw new Error(
"useProductActionContext must be used within a ProductActionProvider"
)
}
return { ...context, ...form }
}
/**
* Method to map the fields of a potential customer and the cart to the checkout form values. Information is assigned with the following priority:
* 1. Cart information
* 2. Customer information
* 3. Default values - null
*/
const mapFormValues = (
customer?: Omit<Customer, "password_hash">,
cart?: Omit<Cart, "refundable_amount" | "refunded_total">,
currentCountry?: string
): CheckoutFormValues => {
const customerShippingAddress = customer?.shipping_addresses?.[0]
const customerBillingAddress = customer?.billing_address
return {
shipping_address: {
first_name:
cart?.shipping_address?.first_name ||
customerShippingAddress?.first_name ||
"",
last_name:
cart?.shipping_address?.last_name ||
customerShippingAddress?.last_name ||
"",
country_code:
currentCountry ||
cart?.shipping_address?.country_code ||
customerShippingAddress?.country_code ||
"",
},
billing_address: {
first_name:
cart?.billing_address?.first_name ||
customerBillingAddress?.first_name ||
"",
last_name:
cart?.billing_address?.last_name ||
customerBillingAddress?.last_name ||
"",
country_code:
cart?.shipping_address?.country_code ||
customerBillingAddress?.country_code ||
"",
},
email: cart?.email || customer?.email || "",
}
}
The useCheckout
hook is used to access the checkout context and form context, typically used in React components. It retrieves the CheckoutContext
from the context of the application, and it also gets the form context of the checkout form, allowing components to access and utilize these contexts.
The mapFormValues
function is responsible for mapping and prioritizing information for the checkout form. It takes customer and cart data, along with the current country, and generates values for the checkout form fields.
It prioritizes data in this order: 1) Cart information, 2) Customer information, and 3) Default values set to null if no information is available. This function helps populate the checkout form with the most relevant data, ensuring a smoother user experience during the checkout process.
Now that the context is updated, we’ll remove the redundant input fields from the checkout form.
In the last step, we’ll modify the shipping-details
component to show important information after the order is successfully placed. In this situation, we’ll remove any extra details and add the buyer’s email address for reference.
How to Deliver Digital Products
There are various ways to get digital products to customers, like sending a download link by email, adding a download button on the order confirmation page, or giving access through their account.
In all these situations, our main goal is to confirm that only those who’ve purchased the product can get it.
To do this, I’ve set up the backend to create a special code (token) for each digital item in an order. We can use GET /store/:token
to check the token and give the file to the user. But this method shows the file’s web address to the user, which isn’t great for preventing piracy.
So we are going to make a Next API route at src/app/api/download/main/[token]/route.ts
. This route will handle the token, acting as a middleman to provide the file to the user without revealing where it’s stored.
This code defines a serverless function for handling HTTP GET requests in a Next.js application. It retrieves a PDF file using a token provided in the URL parameters, fetching the file from an external source. The function ensures the token’s validity and the availability of the PDF file. If the token is invalid, it returns a “401 Unauthorized” response. If the PDF is not found, it returns a “404 Not Found” response.
When the PDF is successfully fetched, it constructs response headers, including the content type as “application/pdf” and a suggested filename for download, and returns the PDF file to the client as a downloadable attachment. This code is typically used to serve PDF files in response to specific GET requests.
We can now link to this API route from the delivery email like this: {your_store_url}/api/download/main/{token}
.
You can add your own logic to invalidate tokens after a certain time or X number of downloads.
Mission Accomplished!
Congratulations, you’ve made it! Don’t forget to explore more Recipes for further ways to make the most of Medusa.
[ad_2]
Source link