From 8958760d5b0c3351ae2889fcf16ed9911cc76a3e Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Mon, 15 Apr 2024 10:32:07 +0300 Subject: [PATCH] docs: fixes to Digital Product recipe (#7025) --- .../docs/content/recipes/digital-products.mdx | 1108 +++++++---------- 1 file changed, 483 insertions(+), 625 deletions(-) diff --git a/www/apps/docs/content/recipes/digital-products.mdx b/www/apps/docs/content/recipes/digital-products.mdx index 3ca73f2a3f..340dfaf355 100644 --- a/www/apps/docs/content/recipes/digital-products.mdx +++ b/www/apps/docs/content/recipes/digital-products.mdx @@ -225,8 +225,6 @@ For example, if you're selling the Harry Potter movies, you would have a `Produc npx medusa migrations run ``` - To avoid TypeScript errors while using the `ProductMedia` - @@ -332,8 +330,6 @@ Creating an API Route also requires creating a service, which is a class that ty config.relations = relations - console.log(selector, config.relations) - const query = buildQuery(selector, config) const [ @@ -1070,6 +1066,12 @@ Alternatively, you can follow the [Build a Storefront roadmap](../storefront/roa The rest of this section provides some guidelines into how to customize the Next.js storefront to support digital products. +:::note + +While our team ensures to maintain this section with the changes in the Next.js storefront, some changes may cause the code in this section to be outdated. If you encounter outdated code snippets, please submit [an issue](https://github.com/medusajs/medusa/issues/new?assignees=&labels=type:+docs&projects=&template=docs.yml). + +::: + ### 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. @@ -1155,31 +1157,27 @@ To implement this, create a storefront API Route that allows you to fetch the di } ``` - Then, add in `src/lib/data/index.ts` a new function that retrieves the product media of the product variant being viewed: + Then, create the file `src/modules/products/actions.ts` having a function that retrieves the product media of the product variant being viewed: - ```ts title="src/lib/data/index.ts" badgeLabel="Storefront" badgeColor="blue" + ```ts title="src/modules/products/actions.ts" badgeLabel="Storefront" badgeColor="blue" import { ProductMedia, } from "types/product-media" import { Variant } from "../../types/medusa" - // ... 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) + } = await fetch(`${ + process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || + "http://localhost:9000" + }/store/product-media?variant_id=${variant.id}`, { + headers: { + "Content-Type": "application/json", + }, + }).then((res) => res.json()) .catch((err) => { throw err }) @@ -1234,14 +1232,14 @@ To implement this, create a storefront API Route that allows you to fetch the di 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" badgeLabel="Storefront" badgeColor="blue" - import Button from "@modules/common/components/button" + import { Button } from "@medusajs/ui" import { ProductMedia } from "types/product-media" type Props = { media: ProductMedia } - const ProductMediaPreview: React.FC = ({ media }) => { + const ProductMediaPreview = ({ media }: Props) => { const downloadPreview = () => { window.location.href = `${ process.env.NEXT_PUBLIC_BASE_URL @@ -1270,10 +1268,10 @@ To implement this, create a storefront API Route that allows you to fetch the di ```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" // other imports... - import { useState, useEffect } from "react" import ProductMediaPreview from "../product-media-preview" - import { getProductMediaPreviewByVariant } from "@lib/data" import { ProductMedia } from "types/product-media" + import { Variant } from "../../../../types/medusa" + import { getProductMediaPreviewByVariant } from "../../actions" const ProductActions: React.FC = ({ @@ -1288,7 +1286,7 @@ To implement this, create a storefront API Route that allows you to fetch the di useEffect(() => { const getProductMedia = async () => { if (!variant) {return} - await getProductMediaPreviewByVariant(variant) + await getProductMediaPreviewByVariant(variant as Variant) .then((res) => { setProductMedia(res) }) @@ -1304,9 +1302,12 @@ To implement this, create a storefront API Route that allows you to fetch the di )} - + + {/* other code ... */} ) } @@ -1341,7 +1342,12 @@ You can change this section to show information relevant to the product. For exa 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" badgeLabel="Storefront" badgeColor="blue" + // other imports... + import { useMemo } from "react" + const ProductTabs = ({ product }: ProductTabsProps) => { const tabs = useMemo(() => { return [ @@ -1363,15 +1369,15 @@ You can change this section to show information relevant to the product. For exa const metadata = useMemo(() => { if (!product.metadata) {return []} return Object.keys(product.metadata).map((key) => { - return [key, product.metadata?.[key]] + return [key as string, product.metadata?.[key] as string] }) }, [product]) - return ( - +
- {/* Map the metadata as product information */} + {/* Map the metadata as product information */} + {/* Show first 2 info */} {metadata && metadata.slice(0, 2).map(([key, value], i) => (
@@ -1381,6 +1387,7 @@ You can change this section to show information relevant to the product. For exa ))}
+ {/* Show second 2 info */} {metadata.length > 2 && metadata.slice(2, 4).map(([key, value], i) => { return ( @@ -1397,20 +1404,18 @@ You can change this section to show information relevant to the product. For exa Tags
) : null} - +
) } const ShippingInfoTab = () => { return ( - +
- - Instant delivery - + Instant delivery

Your e-book will be delivered instantly via email. You can also download it from your @@ -1421,9 +1426,7 @@ You can change this section to show information relevant to the product. For exa

- - Free previews - + Free previews

Get a free preview of the e-book before you buy it. Just click the @@ -1432,7 +1435,7 @@ You can change this section to show information relevant to the product. For exa

- +
) } ``` @@ -1448,609 +1451,434 @@ When a customer purchases a digital product, the shipping form shown during chec
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: + To remove irrelevant fields during the checkout flow, start by changing the content of `setAddresses` method in the file `src/modules/checkout/actions.ts`: - + - ```tsx title="src/lib/context/checkout-context.tsx" badgeLabel="Storefront" badgeColor="blue" - "use client" + ```ts title="src/modules/checkout/actions.ts" badgeLabel="Storefront" badgeColor="blue" + // ... + export async function setAddresses(currentState: unknown, formData: FormData) { + if (!formData) {return "No form data received"} - import { medusaClient } from "@lib/config" - import useToggleState, { StateType } from "@lib/hooks/use-toggle-state" - import { - Address, - 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" - import Spinner from "@modules/common/icons/spinner" + const cartId = cookies().get("_medusa_cart_id")?.value - type AddressValues = { - first_name: string - last_name: string - country_code: string - } + if (!cartId) {return { message: "No cartId cookie found" }} - export type CheckoutFormValues = { - shipping_address: AddressValues - billing_address: AddressValues - email: string - } - - interface CheckoutContext { - cart?: Omit - shippingMethods: { label?: string; value?: string; price: string }[] - isLoading: boolean - addressReady: boolean - shippingReady: boolean - paymentReady: boolean - readyToComplete: boolean - sameAsBilling: StateType - editAddresses: StateType - editShipping: StateType - editPayment: StateType - isCompleting: StateType - initPayment: () => Promise - setAddresses: (addresses: CheckoutFormValues) => void - setSavedAddress: (address: Address) => void - setShippingOption: (soId: string) => void - setPaymentSession: (providerId: string) => void - onPaymentCompleted: () => void - } - - const CheckoutContext = createContext(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 }, - } = 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 - ) - - const editShipping = useToggleState() - const editPayment = useToggleState() - - /** - * Boolean that indicates if a part of the checkout is loading. - */ - const isLoading = useMemo(() => { - return addingShippingMethod || settingPaymentSession || updatingCart - }, [addingShippingMethod, 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 { addressReady, shippingReady, paymentReady, readyToComplete } = - useMemo(() => { - const addressReady = - !!cart?.shipping_address && !!cart?.billing_address && !!cart?.email - - const shippingReady = - addressReady && - !!( - cart?.shipping_methods && - cart.shipping_methods.length > 0 && - cart.shipping_methods[0].shipping_option - ) - - const paymentReady = shippingReady && !!cart?.payment_session - - const readyToComplete = addressReady && shippingReady && paymentReady - - return { - addressReady, - shippingReady, - paymentReady, - readyToComplete, - } - }, [cart]) - - useEffect(() => { - if (addressReady && !shippingReady) { - editShipping.open() - } - }, [addressReady, shippingReady, editShipping]) - - 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 = async (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 initPayment = async () => { - if (cart?.id && !cart.payment_sessions?.length && cart?.items?.length) { - return medusaClient.carts - .createPaymentSessions(cart.id, { - "Idempotency-Key": IDEMPOTENCY_KEY, - }) - .then(({ cart }) => cart && setCart(cart)) - .catch((err) => err) - } - } - - useEffect(() => { - // initialize payment session - const start = async () => { - await initPayment() - } - start() - }, [cart?.region, cart?.id, cart?.items]) - - /** - * 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 setSavedAddress = (address: Address) => { - 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 - - validateRegion(shipping_address.country_code) - - 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), - }) - } - - const isCompleting = useToggleState() - - /** - * Method to complete the checkout process. This is called when the user clicks the "Complete Checkout" button. - */ - const onPaymentCompleted = () => { - isCompleting.open() - complete(undefined, { - onSuccess: ({ data }) => { - push(`/order/confirmed/${data.id}`) - resetCart() - }, - }) - isCompleting.close() - } - - return ( - - - {isLoading && cart?.id === "" ? ( -
-
- -
-
- ) : ( - {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 { + const data = { 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 || - "", + first_name: formData.get("shipping_address.first_name"), + last_name: formData.get("shipping_address.last_name"), + country_code: formData.get("shipping_address.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 || "", + email: formData.get("email"), + } as StorePostCartsCartReq + + const sameAsBilling = formData.get("same_as_billing") + + if (sameAsBilling === "on") {data.billing_address = data.shipping_address} + + if (sameAsBilling !== "on") + {data.billing_address = { + first_name: formData.get("billing_address.first_name"), + last_name: formData.get("billing_address.last_name"), + country_code: formData.get("billing_address.country_code"), + } as StorePostCartsCartReq} + + try { + await updateCart(cartId, data) + revalidateTag("cart") + } catch (error: any) { + return error.toString() } + + redirect( + `/${formData.get("shipping_address.country_code")}/checkout?step=delivery` + ) } ``` - This removes all references to shipping fields that you don't need for digital products. + This keeps the `first_name`, `last_name`, and `country_code` of the shipping and billing addresses. Next, change the content of `src/modules/checkout/components/shipping-address/index.tsx` to remove the unnecessary address fields: - + ```tsx title="src/modules/checkout/components/shipping-address/index.tsx" badgeLabel="Storefront" badgeColor="blue" - import { CheckoutFormValues } from "@lib/context/checkout-context" - import { emailRegex } from "@lib/util/regex" - import ConnectForm from "@modules/common/components/connect-form" + import React, { useState, useEffect, useMemo } from "react" + import { Address, Cart, Customer } from "@medusajs/medusa" + import Checkbox from "@modules/common/components/checkbox" import Input from "@modules/common/components/input" - import { useMeCustomer } from "medusa-react" import AddressSelect from "../address-select" import CountrySelect from "../country-select" - import Checkbox from "@modules/common/components/checkbox" import { Container } from "@medusajs/ui" const ShippingAddress = ({ + customer, + cart, checked, onChange, + countryCode, }: { + customer: Omit | null + cart: Omit | null checked: boolean onChange: () => void + countryCode: string }) => { - const { customer } = useMeCustomer() + const [formData, setFormData] = useState({ + "shipping_address.first_name": cart?.shipping_address?.first_name || "", + "shipping_address.last_name": cart?.shipping_address?.last_name || "", + "shipping_address.country_code": + cart?.shipping_address?.country_code || countryCode || "", + email: cart?.email || "", + }) + + const countriesInRegion = useMemo( + () => cart?.region.countries.map((c) => c.iso_2), + [cart?.region] + ) + + // check if customer has saved addresses that are in the current region + const addressesInRegion = useMemo( + () => + customer?.shipping_addresses.filter( + (a) => a.country_code && countriesInRegion?.includes(a.country_code) + ), + [customer?.shipping_addresses, countriesInRegion] + ) + + useEffect(() => { + setFormData({ + "shipping_address.first_name": cart?.shipping_address?.first_name || "", + "shipping_address.last_name": cart?.shipping_address?.last_name || "", + email: cart?.email || "", + "shipping_address.country_code": + cart?.shipping_address?.country_code || "", + }) + }, [cart?.shipping_address, cart?.email]) + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLInputElement | HTMLSelectElement + > + ) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }) + } return ( -
- {customer && (customer.shipping_addresses?.length || 0) > 0 && ( + <> + {customer && (addressesInRegion?.length || 0) > 0 && (

{`Hi ${customer.first_name}, do you want to use one of your saved addresses?`}

- +
)} - > - {({ register, formState: { errors, touchedFields } }) => ( - <> -
- - - -
-
- -
-
- -
- - )} - -
+
+ + + +
+
+ +
+
+ +
+ ) } export default ShippingAddress ``` + Also, change the content of the file `src/modules/checkout/components/billing_address/index.tsx` for the same reason: + + + + ```tsx title="src/modules/checkout/components/billing_address/index.tsx" badgeLabel="Storefront" badgeColor="blue" + import React, { useState, useEffect } from "react" + import Input from "@modules/common/components/input" + import CountrySelect from "../country-select" + import { Cart } from "@medusajs/medusa" + + const BillingAddress = ({ + cart, + countryCode, + }: { + cart: Omit | null + countryCode: string + }) => { + const [formData, setFormData] = useState({ + "billing_address.first_name": cart?.billing_address?.first_name || "", + "billing_address.last_name": cart?.billing_address?.last_name || "", + "billing_address.country_code": + cart?.billing_address?.country_code || countryCode || "", + }) + + useEffect(() => { + setFormData({ + "billing_address.first_name": cart?.billing_address?.first_name || "", + "billing_address.last_name": cart?.billing_address?.last_name || "", + "billing_address.country_code": cart?.billing_address?.country_code || "", + }) + }, [cart?.billing_address]) + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLInputElement | HTMLSelectElement + > + ) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }) + } + + return ( + <> +
+ + + +
+ + ) + } + + export default BillingAddress + ``` + And update the content of `src/modules/checkout/components/addresses/index.tsx` to only show the necessary address fields: ```tsx title="src/modules/checkout/components/addresses/index.tsx" badgeLabel="Storefront" badgeColor="blue" - import { useCheckout } from "@lib/context/checkout-context" - import { Button } from "@medusajs/ui" - import Spinner from "@modules/common/icons/spinner" - import ShippingAddress from "../shipping-address" + "use client" - const Addresses = () => { - const { - sameAsBilling: { state: checked, toggle: onChange }, - editAddresses: { state: isOpen, open }, - editShipping: { close: closeShipping }, - editPayment: { close: closePayment }, - setAddresses, - handleSubmit, - cart, - } = useCheckout() + import { + useSearchParams, + useRouter, + usePathname, + useParams, + } from "next/navigation" + import { Cart, Customer } from "@medusajs/medusa" + import { CheckCircleSolid } from "@medusajs/icons" + import { Heading, Text, useToggleState } from "@medusajs/ui" + + import Divider from "@modules/common/components/divider" + import Spinner from "@modules/common/icons/spinner" + + import BillingAddress from "../billing_address" + import ShippingAddress from "../shipping-address" + import { setAddresses } from "../../actions" + import { SubmitButton } from "../submit-button" + import { useFormState } from "react-dom" + import ErrorMessage from "../error-message" + import compareAddresses from "@lib/util/compare-addresses" + + const Addresses = ({ + cart, + customer, + }: { + cart: Omit | null + customer: Omit | null + }) => { + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + const params = useParams() + + const countryCode = params.countryCode as string + + const isOpen = searchParams.get("step") === "address" + + const { state: sameAsSBilling, toggle: toggleSameAsBilling } = useToggleState( + cart?.shipping_address && cart?.billing_address + ? compareAddresses(cart?.shipping_address, cart?.billing_address) + : true + ) const handleEdit = () => { - open() - closeShipping() - closePayment() + router.push(pathname + "?step=address") } + const [message, formAction] = useFormState(setAddresses, null) + return (
-
-
- 1 -
-

Shipping address

+
+ + Shipping Address + {!isOpen && } + + {!isOpen && cart?.shipping_address && ( + + + + )}
{isOpen ? ( -
- - -
+
+
+ + + {!sameAsSBilling && ( +
+ + Billing address + + + +
+ )} + Continue to delivery + +
+
) : (
-
+
{cart && cart.shipping_address ? (
-
- ✓ -
-
-
- +
+
+ + Shipping Address + + {cart.shipping_address.first_name}{" "} {cart.shipping_address.last_name} - {cart.shipping_address.country} - -
- {cart.email} -
+
+ + {cart.shipping_address.country_code?.toUpperCase()} +
-
- + +
+ + {cart.email} + +
+ +
+ + Billing Address + + + {sameAsSBilling ? ( + + Billing- and delivery address are the same. + + ) : ( + <> + + {cart.billing_address.first_name}{" "} + {cart.billing_address.last_name} + + + {cart.billing_address.country_code?.toUpperCase()} + + + )}
) : ( -
+
)}
)} +
) } @@ -2060,13 +1888,14 @@ When a customer purchases a digital product, the shipping form shown during chec 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" badgeLabel="Storefront" badgeColor="blue" import { Order } from "@medusajs/medusa" import { Heading, Text } from "@medusajs/ui" + import { formatAmount } from "@lib/util/prices" + import Divider from "@modules/common/components/divider" - import { formatAmount } from "medusa-react" type ShippingDetailsProps = { order: Order @@ -2079,7 +1908,7 @@ When a customer purchases a digital product, the shipping form shown during chec Delivery
-
+
Shipping Address @@ -2092,18 +1921,19 @@ When a customer purchases a digital product, the shipping form shown during chec
-
+
Contact {order.email}
-
+
Method - {order.shipping_methods[0].shipping_option.name} ( + {order.shipping_methods[0].shipping_option?.name} ( {formatAmount({ amount: order.shipping_methods[0].price, region: order.region, + includeTaxes: false, }) .replace(/,/g, "") .replace(/\./g, ",")} @@ -2130,9 +1960,9 @@ After the customer purchases the digital product you can show a download button Before you implement the storefront changes, you need to create a new API Route 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/store/product-media/download/[variant_id]/route.ts` with the following content: + Create the file `src/api/store/product-media/download/[variant_id]/route.ts` in the Medusa backend with the following content: - ```ts title="src/api/store/product-media/download/route.ts" badgeLabel="Backend" + ```ts title="src/api/store/product-media/download/[variant_id]/route.ts" badgeLabel="Backend" import type { AbstractFileService, MedusaRequest, @@ -2223,10 +2053,19 @@ After the customer purchases the digital product you can show a download button You can use this API Route 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: + Before creating the API Route in the storefront that sends the request, change the `getMedusaHeaders` function in `src/lib/data/index.ts` to export it: + + ```ts title="src/lib/data/index.ts" badgeLabel="Storefront" badgeColor="blue" + export const getMedusaHeaders = (tags: string[] = []) => { + // ... + } + ``` + + Then, 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" badgeLabel="Storefront" badgeColor="blue" import { NextRequest, NextResponse } from "next/server" + import { getMedusaHeaders } from "../../../../../lib/data" export async function GET( req: NextRequest, @@ -2245,7 +2084,9 @@ After the customer purchases the digital product you can show a download button url, name, mime_type, - } = await fetch(apiUrl) + } = await fetch(apiUrl, { + headers: getMedusaHeaders(["customer"]), + }) .then((res) => res.json()) // Handle the case where the file doesn't exist @@ -2288,66 +2129,83 @@ After the customer purchases the digital product you can show a download button } ``` - Finally, add a button in the storefront that uses this route to allow customers to download the digital product after purchase. + Finally, add a button in the storefront that uses this route to allow logged-in customers to download the digital product after purchase. - For example, you can change the `src/modules/order/components/item/index.tsx` file that handles showing each of the order's items in the order confirmation page to include a new download button if the customer is logged-in: + To do that, create the file `src/modules/order/components/item-download-button/index.tsx` with the following content: + + ```tsx title="src/modules/order/components/item-download-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" + "use client" + + import { LineItem } from "@medusajs/medusa" + import { Button } from "@medusajs/ui" + import { useEffect, useState } from "react" + + type ItemProps = { + item: Omit + } + + const ItemDownloadButton = async ({ item }: ItemProps) => { + const [fileUrl, setFileUrl] = useState("") + + useEffect(() => { + fetch(`${ + process.env.NEXT_PUBLIC_BASE_URL + }/api/download/main/${item.variant_id}`, { + credentials: "include", + }) + .then((res) => res.blob()) + .then((file) => setFileUrl( + typeof window !== "undefined" ? + window.URL.createObjectURL(file) + : "")) + }, []) + + return ( + + ) + } + + export default ItemDownloadButton + ``` + + This component retrieves the file from the API route you created and shows a button to download it. + + Then, change the `src/modules/order/components/item/index.tsx` file to show the new component if the customer is logged-in: ```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" - import { LineItem, Region } from "@medusajs/medusa" - import { Button, Table, Text, clx } from "@medusajs/ui" - import LineItemOptions from "@modules/common/components/line-item-options" - import LineItemPrice from "@modules/common/components/line-item-price" - import LineItemUnitPrice from "@modules/common/components/line-item-unit-price" - import Thumbnail from "@modules/products/components/thumbnail" - import { useAccount } from "../../../../lib/context/account-context" + // other imports ... + import { getCustomer } from "@lib/data" + import ItemDownloadButton from "../item-download-button" - type ItemProps = { - item: Omit - region: Region - } + const Item = async ({ item, region }: ItemProps) => { + const customer = await getCustomer() - const Item = ({ item, region }: ItemProps) => { - const { customer } = useAccount() - const handleDownload = async () => { - window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/api/download/main/${item.variant_id}` - } return ( - - -
- -
-
+ - - {item.title} - - + {/* ... */} - - - - {item.quantity}x - - + + + {/* ... */} - - {customer && ( - - )} - - - + {/* existing price component */} + + {/* new download component */} + {customer && ( + + )} + + +
) } - - export default Item ```