From 70484b4cd49c4489233135db63766bbaf5e592c6 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Wed, 27 Sep 2023 20:32:10 +0300 Subject: [PATCH] docs: add Next.js steps to digital product recipe (#5231) * docs: add Next.js steps to digital product recipe * fix eslint errors --- .../docs/content/recipes/digital-products.mdx | 1344 ++++++++++++++++- 1 file changed, 1342 insertions(+), 2 deletions(-) diff --git a/www/apps/docs/content/recipes/digital-products.mdx b/www/apps/docs/content/recipes/digital-products.mdx index 4e31669133..7ae1f221c4 100644 --- a/www/apps/docs/content/recipes/digital-products.mdx +++ b/www/apps/docs/content/recipes/digital-products.mdx @@ -169,6 +169,9 @@ export class ProductMedia extends BaseEntity { @Column({ type: "varchar" }) file_key: string + @Column({ type: "varchar" }) + mime_type: string + @Column({ type: "varchar" }) variant_id: string @@ -187,6 +190,7 @@ The entity has the following attributes: - `type`: an enum value indicating the type of file. If the file’s type is `main`, it means that this is the file that customers download when they purchase the product. If its type is `preview`, it means that the file is only used to preview the product variant to the customer. - `file_key`: a string that indicates the downloadable file’s key. The key is retrieved by the installed file service, which is covered in the next step, and it’s used later if you want to get a downloadable link or delete the file. - `variant_id`: a string indicating the ID of the product variant this file is associated with. +- `mime_type`: a string indicating the MIME type of the product variant. Next, you need to create a migration script that reflects the changes on the database schema. @@ -206,7 +210,7 @@ In the class defined in the file, change the `up` and `down` method to the follo export class ProductMediaCreate1693901604934 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TYPE "public"."product_media_type_enum" AS ENUM('main', 'preview')`) - await queryRunner.query(`CREATE TABLE "product_media" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying NOT NULL, "type" "public"."product_media_type_enum" NOT NULL DEFAULT 'main', "file_key" character varying NOT NULL, "variant_id" character varying NOT NULL, CONSTRAINT "PK_09d4639de8082a32aa27f3ac9a6" PRIMARY KEY ("id"))`) + await queryRunner.query(`CREATE TABLE "product_media" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying NOT NULL, "type" "public"."product_media_type_enum" NOT NULL DEFAULT 'main', "file_key" character varying NOT NULL, "mime_type" character varying NOT NULL, "variant_id" character varying NOT NULL, CONSTRAINT "PK_09d4639de8082a32aa27f3ac9a6" PRIMARY KEY ("id"))`) } public async down(queryRunner: QueryRunner): Promise { @@ -703,6 +707,7 @@ export type CreateProductMediaRequest = { name: string file_key: string type?: MediaType + mime_type: string }; export type CreateProductMediaResponse = { @@ -911,6 +916,7 @@ const ProductMediaCreateForm = ({ name, file_key: uploads[0].key as string, type: type as MediaType, + mime_type: file.type, }, { onSuccess: () => { notify.success( @@ -1029,6 +1035,12 @@ When a customer purchases a digital product, they should receive a link to downl To implement that, you first need to listen to the `order.placed` event using a Subscriber. Then, in the handler method of the subscriber, you check for the digital products in the order and obtain the download URLs using the file service's `getPresignedDownloadUrl` method. +:::note + +Following this approach assumes the file service you're using handles creating secure presigned URLs with an expiration mechanism. Alternatively, you can create a new service that handles creating and validating tokens, and an endpoint that receives that token to allow customers to download the file. + +::: + Finally, you can send a notification, such as an email, to the customer using the Notification Service of your choice. That notification would hold the download links to the products they purchased. +:::note + +An alternative solution is to create a store download endpoint that allows authenticated customers to download products they've purchased, then add the link to the endpoint or a storefront page that calls the endpoint in the email. Learn how to implement the download endpoint [here](#download-product-after-purchase). + +::: + Here’s an example of a subscriber that retrieves the download links and sends them to the customer using the SendGrid plugin: ```ts title=src/subscribers/handle-order.ts @@ -1139,7 +1157,1329 @@ Notice that regardless of what file service you have installed, you can access i The `handleOrderPlaced` method retrieves the order, loops over its items to find digital products and retrieve their download links, then uses SendGrid to send the email, passing it the urls as a data payload. You can customize the sent data based on your SendGrid template and your use case. - + +--- + +## Customize or Build Storefront + +Customers use your storefront to browse your digital products and purchase them. You can also provide other helpful features such as previewing the digital product before purchase. + +Medusa provides a [Next.js storefront](../starters/nextjs-medusa-starter.mdx) with standard commerce features including listing products, placing orders, and managing accounts. You can customize the storefront and cater its functionalities to support digital products. + +Alternatively, you can follow the [Build a Storefront roadmap](../storefront/roadmap.mdx) to build a storefront with your preferred technology stack. + +The rest of this section provides some guidelines into how to customize the Next.js storefront to support digital products. + +### Preview Digital Product + +In the product detail's page, you can add a button that allows customers to download a preview of the digital product. + +To implement this, create a storefront endpoint that allows you to fetch the digital product, then customize the Next.js storefront to show the preview button if a product is digital. + +
+ +Example + + +Before you customize the Next.js storefront, you need to add a store endpoint in your backend. + +Create the file `src/api/routes/store/product-media/list.ts` with the following content: + +```ts title=src/api/routes/store/product-media/list.ts +import { Request, Response } from "express" +import ProductMediaService + from "../../../../services/product-media" +import { MediaType } + from "../../../../models/product-media" + + +// retrieve a list of product medias +export default async (req: Request, res: Response) => { + const productMediaService = req.scope.resolve< + ProductMediaService + >("productMediaService") + // omitting pagination for simplicity + const [ + productMedias, + count, + ] = await productMediaService.listAndCount({ + type: MediaType.PREVIEW, + ...(req.query), + }, { + relations: ["variant"], + }) + + res.json({ + product_medias: productMedias, + count, + }) +} +``` + +This adds a store endpoint that returns a list of product medias of type `preview`. It also allows you to pass query parameters to filter the returned products medias. + +To use this endpoint, you need to export it in a router that’s returned by `src/api/index.ts`. First, create the file `src/api/routes/store/product-media/index.ts` that registers the product media endpoint you created: + +```ts title=src/api/routes/store/product-media/index.ts +import { wrapHandler } from "@medusajs/utils" +import { Router } from "express" +import list from "./list" + +const router = Router() + +export default (storeRouter: Router) => { + storeRouter.use("/product-media", router) + + router.get("/", wrapHandler(list)) +} +``` + +Then, create the file `src/api/routes/store/index.ts` if it doesn’t already exist and add the following in it: + +```ts title=src/api/routes/store/index.ts +import { Router } from "express" +import productMediaRoutes from "./product-media" + +// Initialize a custom router +const router = Router() + +export function attachStoreRoutes(storeRouter: Router) { + productMediaRoutes(storeRouter) + + // existing endpoints... +} +``` + +In this file you import the function in `src/api/routes/store/product-media/index.ts` that registers the necessary routes and pass it the store router, which this file’s function accepts as a parameter. + +If the file `src/api/routes/store/index.ts` wasn’t already created, make sure to import it in `src/api/index.ts` and use it to register the product media endpoints: + +```ts +import { Router } from "express" +import cors from "cors" +import bodyParser from "body-parser" +import { ConfigModule } from "@medusajs/medusa" +import { getConfigFile } from "medusa-core-utils" +import { attachStoreRoutes } from "./routes/store" +import { attachAdminRoutes } from "./routes/admin" + +export default (rootDirectory: string): Router | Router[] => { + // Read currently-loaded medusa config + const { configModule } = getConfigFile( + rootDirectory, + "medusa-config" + ) + const { projectConfig } = configModule + + // Set up our CORS options objects, based on config + const storeCorsOptions = { + origin: projectConfig.store_cors.split(","), + credentials: true, + } + + const adminCorsOptions = { + origin: projectConfig.admin_cors.split(","), + credentials: true, + } + + // Set up express router + const router = Router() + + // Set up root routes for store and admin endpoints, + // with appropriate CORS settings + router.use( + "/store", + cors(storeCorsOptions), + bodyParser.json() + ) + router.use( + "/admin", + cors(adminCorsOptions), + bodyParser.json() + ) + + // Set up routers for store and admin endpoints + const storeRouter = Router() + const adminRouter = Router() + + // Attach these routers to the root routes + router.use("/store", storeRouter) + router.use("/admin", adminRouter) + + // Attach custom routes to these routers + attachStoreRoutes(storeRouter) + attachAdminRoutes(adminRouter) + + return router +} +``` + +Now, you can customize the Next.js storefront to show the preview button. + +First, if you're using TypeScript for your development, create the file `src/types/product-media.ts` with the following content: + +```ts title=src/types/product-media.ts + +import { Product } from "@medusajs/medusa" +import { ProductVariant } from "@medusajs/product" + +export enum ProductMediaVariantType { + PREVIEW = "preview", + MAIN = "main", +} + +export type ProductMedia = { + id: string + variant_id: string + name?: string + file_key?: string + mime_type?: string + created_at?: Date + updated_at?: Date + type?: ProductMediaVariantType + variant_id?: string + variants?: ProductVariant[] + created_at: Date + updated_at: Date +} + +export type DigitalProduct = Omit & { + product_medias?: ProductMedia[] + variants?: DigitalProductVariant[] +} + +export type DigitalProductVariant = ProductVariant & { + product_medias?: ProductMedia +} +``` + +Then, add in `src/lib/data/index.ts` a new function that retrieves the product media of the product variant being viewed: + +```ts title=src/lib/data/index.ts +import { + DigitalProduct, + ProductMedia, +} from "types/product-media" + +// ... rest of the functions + +export async function getProductMediaPreviewByVariant( + variant: Variant +): Promise { + const { + product_medias, + } = await medusaRequest( + "GET", + `/product-media`, + { + query: { + variant_ids: variant.id, + }, + } + ) + .then((res) => res.body) + .catch((err) => { + throw err + }) + + return product_medias[0] +} +``` + +To allow customers to download the file preview without exposing its URL, create a Next.js API route in the file `src/app/api/download/preview/route.ts` with the following content: + +```ts title=src/app/api/download/preview/route.ts +import { NextRequest, NextResponse } from "next/server" + +export async function GET(req: NextRequest) { + // Get the file info from the URL + const { + file_path, + file_name, + mime_type, + } = Object.fromEntries(req.nextUrl.searchParams) + + // Fetch the file + const response = await fetch(file_path) + + // Handle the case where the file could not be fetched + if (!response.ok) { + return new NextResponse("File not found", { status: 404 }) + } + + // Get the file content as a buffer + const fileBuffer = await response.arrayBuffer() + + // Define response headers + const headers = { + "Content-Type": mime_type, + // This sets the file name for the download + "Content-Disposition": `attachment; filename="${ + file_name + }"`, + } + + // Create a NextResponse with the file content and headers + const response = new NextResponse(fileBuffer, { + status: 200, + headers, + }) + + return response +} +``` + +Next, create the preview button in the file `src/modules/products/components/product-media-preview/index.tsx`: + +```tsx title=src/modules/products/components/product-media-preview/index.tsx +import Button from "@modules/common/components/button" +import { ProductMedia } from "types/product-media" + +type Props = { + media: ProductMedia +} + +const ProductMediaPreview: React.FC = ({ media }) => { + const downloadPreview = () => { + window.location.href = `${ + process.env.NEXT_PUBLIC_BASE_URL + }/api/download/preview?file_path=${ + media.file_key + }&file_name=${ + media.name + }&mime_type=${ + media.mime_type + }` + } + + return ( +
+ +
+ ) +} + +export default ProductMediaPreview +``` + +Finally, add the button as one of the product actions defined in `src/modules/products/components/product-actions/index.tsx`. These are the actions shown to the customer in the product details page: + +```tsx title=src/modules/products/components/product-actions/index.tsx +// other imports... +import ProductMediaPreview from "../product-media-preview" +import { getProductMediaPreviewByVariant } from "@lib/data" +import { ProductMedia } from "types/product-media" + + +const ProductActions: React.FC = ({ + product, +}) => { + // other code... + + const [productMedia, setProductMedia] = useState< + ProductMedia + >({}) + + useEffect(() => { + const getProductMedia = async () => { + if (!variant) {return} + await getProductMediaPreviewByVariant(variant) + .then((res) => { + setProductMedia(res) + }) + } + getProductMedia() + }, [variant]) + + return ( +
+ {/* other code... */} + + {productMedia && ( + + )} + + +
+ ) +} + +export default ProductActions +``` + +The + +
+ +### Update Product Tabs + +In the product details page, additional information related to the product and its shipping details are shown at the bottom right side. + +You can change this section to show information relevant to the product. For example, how many pages are in an e-book, or how the e-book will be delivered to the customer. + +
+ +Example + + +In this example, you'll change the content of the Product Information and Shipping & Returns tabs to show information relevant to the digital product. The Product Information tab will include custom information relevant to digital products, and the Shipping & Returns tab will be changed to "E-book delivery" and will hold details about how the e-book will be delivered to the customer. + +One way to store custom information relevant to the digital product is using the `metadata` field. For example, to store the number of pages of an e-book, set the `metadata` field to the following: + +```json +{ + "metadata": { + "Pages": "420" + } +} +``` + +Then, you can customize the product additional details section to loop through the `metadata` field's properties and show their information. + +Next, change the `ProductTabs`, `ProductInfoTab`, and `ShippingInfoTab` components defined in `src/modules/products/components/product-tabs/index.tsx` to the following: + +```tsx title=src/modules/products/components/product-tabs/index.tsx +const ProductTabs = ({ product }: ProductTabsProps) => { + const tabs = useMemo(() => { + return [ + { + label: "Product Information", + component: , + }, + { + label: "E-book delivery", + component: , + }, + ] + }, [product]) + // ... rest of code +} + +const ProductInfoTab = ({ product }: ProductTabsProps) => { + // map the metadata object to an array + const metadata = useMemo(() => { + if (!product.metadata) {return []} + return Object.keys(product.metadata).map((key) => { + return [key, product.metadata?.[key]] + }) + }, [product]) + + return ( + +
+
+ {/* Map the metadata as product information */} + {metadata && + metadata.slice(0, 2).map(([key, value], i) => ( +
+ {key} +

{value}

+
+ ))} +
+
+ {metadata.length > 2 && + metadata.slice(2, 4).map(([key, value], i) => { + return ( +
+ {key} +

{value}

+
+ ) + })} +
+
+ {product.tags?.length ? ( +
+ Tags +
+ ) : null} +
+ ) +} + +const ShippingInfoTab = () => { + return ( + +
+
+ +
+ + Instant delivery + +

+ Your e-book will be delivered instantly via + email. You can also download it from your + account anytime. +

+
+
+
+ +
+ + Free previews + +

+ Get a free preview of the e-book before + you buy it. Just click the + button above to download it. +

+
+
+
+
+ ) +} +``` + +This changes the titles of the tabs and their content. + +
+ +### Change Shipping Form in Checkout + +When a customer purchases a digital product, the shipping form shown during checkout is not relevant. So, you can change its content to instead only ask for the customer's name and email. + +
+ +Example + + +The checkout flow is managed by a checkout context defined in `src/lib/context/checkout-context.tsx`. Change the content of the file to the following: + + + +```tsx title=src/lib/context/checkout-context.tsx +"use client" + +import { medusaClient } from "@lib/config" +import useToggleState, { + StateType, +} from "@lib/hooks/use-toggle-state" +import { + Cart, + Customer, + StorePostCartsCartReq, +} from "@medusajs/medusa" +import Wrapper + from "@modules/checkout/components/payment-wrapper" +import { isEqual } from "lodash" +import { + formatAmount, + useCart, + useCartShippingOptions, + useMeCustomer, + useRegions, + useSetPaymentSession, + useUpdateCart, +} from "medusa-react" +import { useRouter } from "next/navigation" +import React, { + createContext, + useContext, + useEffect, + useMemo, +} from "react" +import { + FormProvider, + useForm, + useFormContext, +} from "react-hook-form" +import { useStore } from "./store-context" + +type AddressValues = { + first_name: string + last_name: string + country_code: string +} + +export type CheckoutFormValues = { + shipping_address: AddressValues + billing_address?: AddressValues + email: string +} + +interface CheckoutContext { + cart?: Omit + shippingMethods: { + label?: string; + value?: string; + price: string + }[] + isLoading: boolean + readyToComplete: boolean + sameAsBilling: StateType + editAddresses: StateType + initPayment: () => Promise + setAddresses: (addresses: CheckoutFormValues) => void + setSavedAddress: (address: AddressValues) => void + setShippingOption: (soId: string) => void + setPaymentSession: (providerId: string) => void + onPaymentCompleted: () => void +} + +const CheckoutContext = createContext< + CheckoutContext | null +>(null) + +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({ + 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 + ) + + /** + * 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]) + + /** + * 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 ( + + + + {children} + + + + ) +} + +export const useCheckout = () => { + const context = useContext(CheckoutContext) + const form = useFormContext() + 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, + cart?: Omit, + 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 || "", + } +} +``` + +This removes all references to shipping fields that you don't need for digital products. + +Next, update the content of `src/modules/checkout/components/addresses/index.tsx` to remove the unnecessary address fields: + + + +```tsx title=src/modules/checkout/components/addresses/index.tsx +import { useCheckout } from "@lib/context/checkout-context" +import Button from "@modules/common/components/button" +import Spinner from "@modules/common/icons/spinner" +import ShippingAddress from "../shipping-address" + +const Addresses = () => { + const { + editAddresses: { state: isEdit, toggle: setEdit }, + setAddresses, + handleSubmit, + cart, + } = useCheckout() + return ( +
+
+
+ 1 +
+

Shipping address

+
+ {isEdit ? ( +
+ + +
+ ) : ( +
+
+ {cart && cart.shipping_address ? ( +
+
+ ✓ +
+
+
+ + {cart.shipping_address.first_name}{" "} + {cart.shipping_address.last_name} + {cart.shipping_address.country} + +
+ {cart.email} +
+
+
+ +
+
+
+ ) : ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +export default Addresses +``` + +Finally, change the shipping details shown in the order confirmation page by replacing the content of `src/modules/order/components/shipping-details/index.tsx` with the following: + +```tsx title=src/modules/order/components/shipping-details/index.tsx +import { Address, ShippingMethod } from "@medusajs/medusa" + +type ShippingDetailsProps = { + address: Address + shippingMethods: ShippingMethod[] + email: string +} + +const ShippingDetails = ({ + address, + shippingMethods, + email, +}: ShippingDetailsProps) => { + return ( +
+

Delivery

+
+

+ Details +

+
+ + {`${address.first_name} ${address.last_name}`} + + {email} +
+
+
+

+ Delivery method +

+
+ {shippingMethods.map((sm) => { + return ( +
+ {sm.shipping_option.name} +
+ ) + })} +
+
+
+ ) +} + +export default ShippingDetails +``` + +
+ +### Download Product After Purchase + +After the customer purchases the digital product you can show a download button to allow them to immediately download the product. + +
+ +Example + + +Before you implement the storefront changes, you need to create a new endpoint in the backend that ensures that the currently logged-in customer has purchased the digital product and, if so, returns a presigned URL to download it. + +Create the file `src/api/routes/store/product-media/download.ts` with the following content: + +```ts title=src/api/routes/store/product-media/download.ts +import { Request, Response } from "express" +import ProductMediaService + from "../../../../services/product-media" +import { + MediaType, +} from "../../../../models/product-media" +import { + AbstractFileService, + CustomerService, + OrderService, + ProductVariant, +} from "@medusajs/medusa" + + +// download a purchased product +export default async (req: Request, res: Response) => { + const variantId = req.params.variant_id + if (!variantId) { + throw new Error("Variant ID is required") + } + const ordersService = req.scope.resolve< + OrderService + >("orderService") + const orders = await ordersService.list({ + customer_id: req.user.customer_id, + }, { + relations: ["items", "items.variant"], + }) + + let variant: ProductVariant + orders.some((order) => ( + order.items.some((item) => { + if (item.variant_id === variantId) { + variant = item.variant + return true + } + + return false + }) + )) + + if (!variant) { + throw new Error("Customer hasn't purchased this product.") + } + + // get the product media and the presigned URL + const productMediaService = req.scope.resolve< + ProductMediaService + >("productMediaService") + const productMedias = await productMediaService.list({ + type: MediaType.MAIN, + variant_id: variant.id, + }) + + const fileService = req.scope.resolve< + AbstractFileService + >("fileService") + + res.json({ + url: await fileService.getPresignedDownloadUrl({ + fileKey: productMedias[0].file_key, + isPrivate: true, + }), + name: productMedias[0].name, + mime_type: productMedias[0].mime_type, + }) +} +``` + +Then, register the new endpoint in `src/api/routes/store/product-media/index.ts`: + +```ts title=src/api/routes/store/product-media/index.ts +import { wrapHandler } from "@medusajs/utils" +import { + requireCustomerAuthentication, +} from "@medusajs/medusa" +import download from "./download" + +// ... + +export default (storeRouter: Router) => { + // ... + + router.get( + "/download/:variant_id", + requireCustomerAuthentication(), + wrapHandler(download) + ) +} +``` + +Note that you use the `requireCustomerAuthentication` middleware to ensure only logged-in customers can access this endpoint. + +You can use this endpoint in your storefront to add a button that allows downloading the purchased digital product. + +To mask the presigned URL, create a Next.js API route at `src/app/api/download/main/[variant_id]/route.ts` with the following content: + +```ts title=src/app/api/download/main/[variant_id]/route.ts +import { NextRequest, NextResponse } from "next/server" + +export async function GET( + req: NextRequest, + { params }: { params: Record } +) { + // Get the variant ID from the URL + const { variant_id } = params + + // Define the API URL + const apiUrl = `${ + process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL + }/store/product-media/download/${variant_id}` + + // Fetch the file data + const { + url, + name, + mime_type, + } = await fetch(apiUrl) + .then((res) => res.json()) + + // Handle the case where the file doesn't exist + // or the customer didn't purchase the product + if (!url) { + return new NextResponse( + "File doesn't exist", + { status: 401 } + ) + } + + // Fetch the file + const response = await fetch(url) + + // Handle the case where the file could not be fetched + if (!response.ok) { + return new NextResponse( + "File not found", + { status: 404 } + ) + } + + // Get the file content as a buffer + const fileBuffer = await response.arrayBuffer() + + // Define response headers + const headers = { + "Content-Type": mime_type, + // This sets the file name for the download + "Content-Disposition": `attachment; filename="${name}"`, + } + + // Create a NextResponse with the PDF content and headers + const response = new NextResponse(fileBuffer, { + status: 200, + headers, + }) + + return response +} +``` + +Finally, add a button in the storefront that uses this route to allow customers to download the digital product after purchase. + +For example, you can change the `src/modules/order/components/items/index.tsx` file that shows the items to the customer in the order confirmation page to include a new download button: + + + +```tsx title=src/modules/order/components/items/index.tsx +import useEnrichedLineItems from "@lib/hooks/use-enrich-line-items" +import { LineItem, Region } from "@medusajs/medusa" +import LineItemOptions from "@modules/common/components/line-item-options" +import LineItemPrice from "@modules/common/components/line-item-price" +import Thumbnail from "@modules/products/components/thumbnail" +import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item" +import Link from "next/link" +import medusaRequest from "../medusa-fetch" + +type ItemsProps = { + items: LineItem[] + region: Region + cartId: string +} + +const Items = ({ items, region, cartId }: ItemsProps) => { + const enrichedItems = useEnrichedLineItems(items, cartId) + + const handleDownload = async (variantId: string) => { + window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/api/download/main/${variant_id}` + } + + return ( +
+ {enrichedItems?.length + ? enrichedItems.map((item) => { + return ( +
+
+ +
+
+
+
+
+

+ + {item.title} + +

+ + Quantity: {item.quantity} +
+
+ + +
+
+
+
+
+ ) + }) + : Array.from(Array(items.length).keys()).map((i) => { + return + })} +
+ ) +} + +export default Items +``` + +
---