diff --git a/www/apps/resources/app/storefront-development/cart/context/page.mdx b/www/apps/resources/app/storefront-development/cart/context/page.mdx index b8c7ac2939..aa5e5e4432 100644 --- a/www/apps/resources/app/storefront-development/cart/context/page.mdx +++ b/www/apps/resources/app/storefront-development/cart/context/page.mdx @@ -17,13 +17,15 @@ For example, create the following file that exports a `CartProvider` component a export const highlights = [ ["13", "cart", "Expose cart to children of the context provider."], ["14", "setCart", "Allow the context provider's children to update the cart."], - ["25", "CartProvider", "The provider component to use in your component tree."], - ["31", "useRegion", "Use the `useRegion` hook defined in the Region Context guide."], - ["36", "setItem", "Set the cart's ID in `localStorage` in case it changed."], - ["44", "fetch", "If the customer doesn't have a cart, create a new one."], - ["48", "process.env.NEXT_PUBLIC_PAK", "Pass the Publishable API key to associate the correct sales channel(s)."], - ["62", "fetch", "Retrieve the customer's cart."], - ["82", "useCart", "The hook that child components of the provider use to access the cart."] + ["17", "refreshCart", "Allow the context provider's children to unset and reset the cart."], + ["26", "CartProvider", "The provider component to use in your component tree."], + ["32", "useRegion", "Use the `useRegion` hook defined in the Region Context guide."], + ["37", "setItem", "Set the cart's ID in `localStorage` in case it changed."], + ["45", "fetch", "If the customer doesn't have a cart, create a new one."], + ["49", "process.env.NEXT_PUBLIC_PAK", "Pass the Publishable API key to associate the correct sales channel(s)."], + ["63", "fetch", "Retrieve the customer's cart."], + ["73", "refreshCart", "This function unsets the cart, which triggers the `useEffect` callback to create a cart."], + ["89", "useCart", "The hook that child components of the provider use to access the cart."] ] ```tsx highlights={highlights} @@ -43,6 +45,7 @@ type CartContextType = { setCart: React.Dispatch< React.SetStateAction > + refreshCart: () => void } const CartContext = createContext(null) @@ -93,10 +96,16 @@ export const CartProvider = ({ children }: CartProviderProps) => { } }, [cart, region]) + const refreshCart = () => { + localStorage.removeItem("cart_id") + setCart(undefined) + } + return ( {children} @@ -116,7 +125,7 @@ export const useCart = () => { The `CartProvider` handles retrieving or creating the customer's cart. It uses the `useRegion` hook defined in the [Region Context guide](../../regions/context/page.mdx). -The `useCart` hook returns the value of the `CartContext`. Child components of `CartProvider` use this hook to access `cart` or `setCart`. +The `useCart` hook returns the value of the `CartContext`. Child components of `CartProvider` use this hook to access `cart`, `setCart`, or `refreshCart`. --- diff --git a/www/apps/resources/app/storefront-development/checkout/address/page.mdx b/www/apps/resources/app/storefront-development/checkout/address/page.mdx new file mode 100644 index 0000000000..4174704859 --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/address/page.mdx @@ -0,0 +1,206 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Checkout Step 2: Enter Address`, +} + +# {metadata.title} + +The second step of the checkout flow is to ask the customer for their address. + +{/* TODO add how to list addresses of logged in customer. */} + +A cart has shipping and billing addesses. Use the [Update Cart API route]() to update the cart's addresses. + +For example: + + + + + ```ts + const cartId = localStorage.getItem("cart_id") + + const address = { + first_name, + last_name, + address_1, + company, + postal_code, + city, + country_code, + province, + phone + } + + fetch(`http://localhost:9000/store/carts/${cartId}`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + shipping_address: address, + billing_address: address + }) + }) + .then((res) => res.json()) + .then(({ cart }) => { + // use cart... + console.log(cart) + }) + ``` + + + + +export const highlights = [ + ["4", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["29", "address", "Assemble the address object to be used for both shipping and billing addresses."], + ["41"], ["42"], ["43"], ["44"], ["45"], ["46"], ["47"], ["48"], + ["49"], ["50"], ["51"], ["52"], ["53"], ["54"], ["55"], + ["102", "", "The address's country can only be within the cart's region."] +] + + ```tsx highlights={highlights} + "use client" // include with Next.js 13+ + + import { useState } from "react"; + import { useCart } from "../../../providers/cart"; + + export default function CheckoutAddressStep () { + const { cart, setCart } = useCart() + const [loading, setLoading] = useState(false) + const [firstName, setFirstName] = useState("") + const [lastName, setLastName] = useState("") + const [address1, setAddress1] = useState("") + const [company, setCompany] = useState("") + const [postalCode, setPostalCode] = useState("") + const [city, setCity] = useState("") + const [countryCode, setCountryCode] = useState("") + const [province, setProvince] = useState("") + const [phoneNumber, setPhoneNumber] = useState("") + + const updateAddress = ( + e: React.MouseEvent + ) => { + if (!cart) { + return + } + + e.preventDefault() + setLoading(true) + + const address = { + first_name: firstName, + last_name: lastName, + address_1: address1, + company, + postal_code: postalCode, + city, + country_code: countryCode, + province, + phone: phoneNumber + } + + fetch(`http://localhost:9000/store/carts/${cart.id}`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + shipping_address: address, + billing_address: address + }) + }) + .then((res) => res.json()) + .then(({ cart: updatedCart }) => { + setCart(updatedCart) + }) + .finally(() => setLoading(false)) + } + + return ( +
+ {!cart && Loading...} + setFirstName(e.target.value)} + /> + setLastName(e.target.value)} + /> + setAddress1(e.target.value)} + /> + setCompany(e.target.value)} + /> + setPostalCode(e.target.value)} + /> + setCity(e.target.value)} + /> + + setProvince(e.target.value)} + /> + setPhoneNumber(e.target.value)} + /> + +
+ ) + } + ``` + +
+
+ +In the example above: + +- The same address is used for shipping and billing for simplicity. You can provide the option to enter both addresses instead. +- You send the address to the Update Cart API route under the `shipping_address` and `billing_address` request body parameters. +- The updated cart object is retuned in the response. +- **React example:** in the address, the chosen country must be in the cart's region. So, only the countries part of the cart's region are shown. diff --git a/www/apps/resources/app/storefront-development/checkout/complete-cart/page.mdx b/www/apps/resources/app/storefront-development/checkout/complete-cart/page.mdx new file mode 100644 index 0000000000..90b25b8ef2 --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/complete-cart/page.mdx @@ -0,0 +1,131 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Checkout Step 5: Complete Cart`, +} + +# {metadata.title} + +Once you finish any required actions with the third-party payment provider, you can complete the cart and place the order. + +To complete the cart, send a request to the [Complete Cart API route](!api!/store#carts_postcartsidcomplete). + +For example: + +```ts +fetch( + `http://localhost:9000/store/carts/${cartId}/complete`, + { + credentials: "include", + method: "POST" + } +) +.then((res) => res.json()) +.then(({ type, cart, order, error }) => { + if (type === "cart" && cart) { + // an error occured + console.error(error) + } else if (type === "order" && order) { + // TODO redirect to order success page + alert("Order placed.") + console.log(order) + // unset cart ID from local storage + localStorage.removeItem("cart_id") + } +}) +``` + +In the response of the request, the `type` field determines whether the cart completion was successful: + +- If the `type` is `cart`, it means the cart completion failed. The `error` response field holds the error details. +- If the `type` is `order`, it means the cart was completed and the order was placed successfully. + +When the cart completion is successful, it's important to unset the cart ID from the `localStorage`, as the cart is no longer usable. + +--- + +## React Example with Default System Payment Provider + +For example, to complete the cart when the default system payment provider is used: + +export const highlights = [ + ["4", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["10", "handlePayment", "This function sends the request\nto the Medusa application to complete the cart."], + ["21", "TODO", "If you're integrating a third-party payment provider,\nyou perform the custom logic before completing the cart."], + ["24", "fetch", "Send a request to the Medusa application\nto complete the cart and place the order."], + ["33", `type === "cart"`, "If the `type` returned is `cart`,\nit means an error occurred and the cart wasn't completed."], + ["36", `type === "order"`, "If the `type` returned is `order`,\nit means the cart was completed and the order was placed successfully."], + ["40", "refreshCart", "Unset and reset the cart."], + ["47", "button", "This button triggers the `handlePayment` function when clicked."] +] + +```tsx highlights={highlights} +"use client" // include with Next.js 13+ + +import { useState } from "react" +import { useCart } from "../../providers/cart" + +export default function SystemDefaultPayment () { + const { cart, refreshCart } = useCart() + const [loading, setLoading] = useState(false) + + const handlePayment = ( + e: React.MouseEvent + ) => { + e.preventDefault() + + if (!cart) { + return + } + + setLoading(true) + + // TODO perform any custom payment handling logic + + // complete the cart + fetch( + `http://localhost:9000/store/carts/${cart.id}/complete`, + { + credentials: "include", + method: "POST" + } + ) + .then((res) => res.json()) + .then(({ type, cart, order, error }) => { + if (type === "cart" && cart) { + // an error occured + console.error(error) + } else if (type === "order" && order) { + // TODO redirect to order success page + alert("Order placed.") + console.log(order) + refreshCart() + } + }) + .finally(() => setLoading(false)) + } + + return ( + + ) +} +``` + +In the example above, you create a `handlePayment` function in the payment component. In this function, you: + +- Optionally perform any required actions with the third-party payment provider. For example, authorize the payment. For the default system payment provider, no actions are required. +- Send a request to the Complete Cart API route once all actions with the third-party payment provider are performed. +- In the received response of the request, if the `type` is `cart`, it means that the cart completion failed. The error is set in the `error` response field. +- If the `type` is `order`, it means the card was completed and the order was placed successfully. You can access the order in the `order` response field. +- When the order is placed, you must unset the `cart_id` from the `localStorage`. You can redirect the customer to an order success page at this point. + +--- + +## React Example with Third-Party Provider + +Refer to the [Stripe guide](../payment/stripe/page.mdx) for an example on integrating a third-party provider and implementing card completion. diff --git a/www/apps/resources/app/storefront-development/checkout/email/page.mdx b/www/apps/resources/app/storefront-development/checkout/email/page.mdx new file mode 100644 index 0000000000..011a02c5f2 --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/email/page.mdx @@ -0,0 +1,122 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Checkout Step 1: Enter Email`, +} + +# {metadata.title} + +The first step of the checkout flow is to enter the customer's email. Then, use the [Update Cart API route](!api!/store#carts_postcartsid) to update the cart with the email. + + + +If the customer is logged-in, you can pre-fill the email with the customer's email. + + + +For example: + + + + + ```ts + const cartId = localStorage.getItem("cart_id") + + fetch(`http://localhost:9000/store/carts/${cartId}`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email + }) + }) + .then((res) => res.json()) + .then(({ cart }) => { + // use cart... + console.log(cart) + }) + ``` + + + + +export const highlights = [ + ["4", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["13", "TODO", "Cart must have at least one item. If not, redirect to another page."], + ["27"], ["28"], ["29"], ["30"], ["31"], ["32"], ["33"], ["34"], + ["35"], ["36"], ["37"], ["38"], ["39"], ["40"] +] + + ```tsx highlights={highlights} + "use client" // include with Next.js 13+ + + import { useState } from "react" + import { useCart } from "../../../providers/cart" + + export default function CheckoutEmailStep () { + const { cart, setCart } = useCart() + const [email, setEmail] = useState("") + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (cart && !cart.items?.length) { + // TODO redirect to another path + } + }, [cart]) + + const updateCartEmail = ( + e: React.MouseEvent + ) => { + if (!cart || !email.length) { + return + } + + e.preventDefault() + setLoading(true) + + fetch(`http://localhost:9000/store/carts/${cart.id}`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email + }) + }) + .then((res) => res.json()) + .then(({ cart: updatedCart }) => { + setCart(updatedCart) + }) + .finally(() => setLoading(false)) + } + + return ( +
+ {!cart && Loading...} + setEmail(e.target.value)} + /> + +
+ ) + } + ``` + +
+
+ +After the customer enters and submits their email, you send a request to the Update Cart API route passing it the email in the request body. + +Notice that if the cart doesn't have items, you should redirect to another page as the checkout requires at least one item in the cart. diff --git a/www/apps/resources/app/storefront-development/checkout/page.mdx b/www/apps/resources/app/storefront-development/checkout/page.mdx new file mode 100644 index 0000000000..cca7de9d1d --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/page.mdx @@ -0,0 +1,21 @@ +import { ChildDocs } from "docs-ui" + +export const metadata = { + title: `Checkout in Storefront`, +} + +# {metadata.title} + +Once a customer finishes adding products to cart, they go through the checkout flow to place their order. + +The checkout flow is composed of five steps: + +1. **Email:** Enter customer email. For logged-in customer, you can pre-fill it. +2. **Address:** Enter shipping/billing address details. +3. **Shipping**: Choose a shipping method. +4. **Payment:** Choose a payment provider. +5. **Complete Cart:** Perform any payment action necessary (for example, enter card details), complete the cart, and place the order. + +You can combine steps based on your desired checkout flow. + + \ No newline at end of file diff --git a/www/apps/resources/app/storefront-development/checkout/payment/page.mdx b/www/apps/resources/app/storefront-development/checkout/payment/page.mdx new file mode 100644 index 0000000000..9dd569101b --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/payment/page.mdx @@ -0,0 +1,342 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Checkout Step 4: Choose Payment Provider`, +} + +# {metadata.title} + +The last step before completing the order is choosing the payment provider and performing any necessary actions. + +The actions required after choosing the payment provider are different for each provider. So, this guide doesn't cover that. + +## Payment Step Flow + +The payment step requires implementing the following flow: + +![A diagram illustrating the flow of the payment step](https://res.cloudinary.com/dza7lstvk/image/upload/v1718029777/Medusa%20Resources/storefront-payment_dxry7l.jpg) + +1. Retrieve the payment providers using the [List Payment Providers API route](!api!/store#payment-providers_getpaymentproviders). +2. Customer chooses the payment provider to use. +3. If the cart doesn't have an associated payment collection, create a payment collection for it. +4. Initialize the payment sessions of the cart's payment collection using the [Initialize Payment Sessions API route](!api!/store#payment-collections_postpaymentcollectionsidpaymentsessions). +5. Optionally perform additional actions for payment based on the chosen payment provider. For example, if the customer chooses stripe, you show them the UI to enter their card details. + +--- + +## Code Example + +For example, to implement the payment step flow: + + + + +export const fetchHighlights = [ + ["6", "retrievePaymentProviders", "This function retrieves the payment provider that the customer can choose from."], + ["7", "fetch", "Retrieve available payment providers."], + ["18", "selectPaymentProvider", "This function is executed when the customer submits their chosen payment provider."], + ["25", "fetch", "Create a payment collection for the cart when it doesn't have one."], + ["47", "fetch", "Initialize the payment session in the payment collection for the chosen provider."], + ["65", "fetch", "Retrieve the cart again to update its data."], + ["76", "getPaymentUi", "This function shows the necessary UI based on the selected payment provider."], + ["77", "activePaymentSession", "The active session is the first in the payment collection's sessions."], + ["83", "", "Test which payment provider is chosen based on the prefix of the provider ID."], + ["84", `"pp_stripe_"`, "Check if the chosen provider is Stripe."], + ["88", `"pp_system_default"`, "Check if the chosen provider is the default systen provider."], + ["90", "default", "Handle unrecognized providers."], +] + + ```ts highlights={fetchHighlights} + // assuming the cart is previously fetched + const cart = { + // cart object... + } + + const retrievePaymentProviders = async () => { + const { payment_providers } = await fetch( + `http://localhost:9000/store/payment-providers?region_id=${ + cart.region_id + }`, { + credentials: "include" + }) + .then((res) => res.json()) + + return payment_providers + } + + const selectPaymentProvider = async ( + selectedPaymentProviderId: string + ) => { + let paymentCollectionId = cart.payment_collection?.id + + if (!paymentCollectionId) { + // create payment collection + const { payment_collection } = await fetch( + `http://localhost:9000/store/payment-collections`, + { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + cart_id: cart.id, + region_id: cart.region_id, + currency_code: cart.currency_code, + amount: cart.total + }) + } + ) + .then((res) => res.json()) + + paymentCollectionId = payment_collection.id + } + + // initialize payment session + await fetch(`http://localhost:9000/store/payment-collections/${ + paymentCollectionId + }/payment-sessions`, + { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + provider_id: selectedPaymentProviderId + }) + }) + .then((res) => res.json()) + + // re-fetch cart + const { + cart: updatedCart + } = await fetch( + `http://localhost:9000/store/carts/${cart.id}`, + { + credentials: "include" + } + ) + .then((res) => res.json()) + + return updatedCart + } + + const getPaymentUi = () => { + const activePaymentSession = cart?.payment_collection?. + payment_sessions?.[0] + if (!activePaymentSession) { + return + } + + switch(true) { + case activePaymentSession.provider_id.startsWith("pp_stripe_"): + // TODO handle Stripe UI + return "You chose stripe!" + case activePaymentSession.provider_id + .startsWith("pp_system_default"): + return "You chose manual payment! No additional actions required." + default: + return `You chose ${ + activePaymentSession.provider_id + } which is in development.` + } + } + ``` + + + + +export const highlights = [ + ["4", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["23", "fetch", "Retrieve available payment providers."], + ["31", "setSelectedPaymentProvider", "If a payment provider was selected before, pre-fill it."], + ["37", "handleSelectProvider", "This function is executed when the customer submits their chosen payment provider."], + ["50", "fetch", "Create a payment collection for the cart when it doesn't have one."], + ["72", "fetch", "Initialize the payment session in the payment collection for the chosen provider."], + ["90", "fetch", "Retrieve the cart again to update its data."], + ["103", "getPaymentUi", "This function shows the necessary UI based on the selected payment provider."], + ["104", "activePaymentSession", "The active session is the first in the payment collection's sessions."], + ["110", "", "Test which payment provider is chosen based on the prefix of the provider ID."], + ["111", `"pp_stripe_"`, "Check if the chosen provider is Stripe."], + ["119", `"pp_system_default"`, "Check if the chosen provider is the default systen provider."], + ["125", "default", "Handle unrecognized providers."], + ["160", "getPaymentUi", "If a provider is chosen, render its UI."] +] + + ```tsx highlights={highlights} + "use client" // include with Next.js 13+ + + import { useCallback, useEffect, useState } from "react" + import { useCart } from "../../../providers/cart" + import { HttpTypes } from "@medusajs/types" + + export default function CheckoutPaymentStep () { + const { cart, setCart } = useCart() + const [paymentProviders, setPaymentProviders] = useState< + HttpTypes.StorePaymentProvider[] + >([]) + const [ + selectedPaymentProvider, + setSelectedPaymentProvider + ] = useState() + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!cart) { + return + } + + fetch(`http://localhost:9000/store/payment-providers?region_id=${ + cart.region_id + }`, { + credentials: "include" + }) + .then((res) => res.json()) + .then(({ payment_providers }) => { + setPaymentProviders(payment_providers) + setSelectedPaymentProvider( + cart.payment_collection?.payment_sessions?.[0]?.id + ) + }) + }, [cart]) + + const handleSelectProvider = async ( + e: React.MouseEvent + ) => { + e.preventDefault() + if (!cart || !selectedPaymentProvider) { + return + } + + setLoading(false) + + let paymentCollectionId = cart.payment_collection?.id + + if (!paymentCollectionId) { + // create payment collection + const { payment_collection } = await fetch( + `http://localhost:9000/store/payment-collections`, + { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + cart_id: cart.id, + region_id: cart.region_id, + currency_code: cart.currency_code, + amount: cart.total + }) + } + ) + .then((res) => res.json()) + + paymentCollectionId = payment_collection.id + } + + // initialize payment session + await fetch(`http://localhost:9000/store/payment-collections/${ + paymentCollectionId + }/payment-sessions`, + { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + provider_id: selectedPaymentProvider + }) + }) + .then((res) => res.json()) + + // re-fetch cart + const { + cart: updatedCart + } = await fetch( + `http://localhost:9000/store/carts/${cart.id}`, + { + credentials: "include" + } + ) + .then((res) => res.json()) + + setCart(updatedCart) + setLoading(false) + } + + const getPaymentUi = useCallback(() => { + const activePaymentSession = cart?.payment_collection?. + payment_sessions?.[0] + if (!activePaymentSession) { + return + } + + switch(true) { + case activePaymentSession.provider_id.startsWith("pp_stripe_"): + return ( + + You chose stripe! + {/* TODO add stripe UI */} + + ) + case activePaymentSession.provider_id + .startsWith("pp_system_default"): + return ( + + You chose manual payment! No additional actions required. + + ) + default: + return ( + + You chose {activePaymentSession.provider_id} which is + in development. + + ) + } + } , [cart]) + + return ( +
+
+ + +
+ {getPaymentUi()} +
+ ) + } + ``` + +
+
+ +In the example above, you: + +- Retrieve the payment providers from the Medusa application. You use those to show the customer the available options. +- When the customer chooses a payment provider, you: + 1. Check whether the cart has a payment collection. If not, create one using the [Create Payment Collection API route](!api!/store#payment-collections_postpaymentcollections). + 2. Initialize the payment session for the chosen payment provider using the [Initialize Payment Session API route](!api!/store#payment-collections_postpaymentcollectionsidpaymentsessions). +- Once the cart has a payment session, you optionally render the UI to perform additional actions. For example, if the customer chose stripe, you can show them the card form to enter their credit card. diff --git a/www/apps/resources/app/storefront-development/checkout/payment/stripe/page.mdx b/www/apps/resources/app/storefront-development/checkout/payment/stripe/page.mdx new file mode 100644 index 0000000000..88ad116820 --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/payment/stripe/page.mdx @@ -0,0 +1,233 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Payment with Stripe in React Storefront`, +} + +# {metadata.title} + +In this document, you'll learn how to use Stripe for payment during checkout in a React-based storefront. + + + +For other types of storefronts, the steps are similar. However, refer to [Stripe's documentation](https://docs.stripe.com/) for available tools for your tech stack. + + + + + +- [Stripe Provider Module](../../../../commerce-modules/payment/payment-provider/stripe/page.mdx) installed and configured in your Medusa application. +- [Stripe publishable API key](https://support.stripe.com/questions/locate-api-keys-in-the-dashboard). + + + +## 1. Install Stripe SDK + +In your storefront, use the following command to install Stripe's JS and React SDKs: + +```bash npm2yarn +npm install @stripe/react-stripe-js @stripe/stripe-js +``` + +--- + +## 2. Add Stripe Environment Variables + +Next, add an environment variable holding your Stripe publishable API key. + +For example: + +```bash +NEXT_PUBLIC_STRIPE_PK=pk_test_51Kj... +``` + + + +For Next.js storefronts, the environment variable's name must be prefixed with `NEXT_PUBLIC`. If your storefront's framework requires a different prefix, make sure to change it. + + + +--- + +## 3. Create Stripe Component + +Then, create a file holding the following Stripe component: + +export const highlights = [ + ["10", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["13", "stripePromise", "Initialize stripe using the environment variable added in the previous step."], + ["19", "clientSecret", "After initializing the payment session of Stripe in the Medusa application,\nthe client secret is available in the payment session's `data`."], + ["27", "StripeForm", "The actual form must be a different component nested inside `Elements`."], + ["44", "handlePayment", "This function is used to show Stripe's UI to accept payment,\nthen send the request to the Medusa application to complete the cart."], + ["61", "confirmCardPayment", "This function shows the UI to the customer to accept the card payment."], + ["78", "", "Once the customer enters their card details and submits the form,\nthe Promise resolves and executes this function."], + ["85", "fetch", "Send a request to the Medusa application\nto complete the cart and place the order."], + ["94", `type === "cart"`, "If the `type` returned is `cart`,\nit means an error occurred and the cart wasn't completed."], + ["97", `type === "order"`, "If the `type` returned is `order`,\nit means the cart was completed and the order was placed successfully."], + ["101", "refreshCart", "Unset and reset the cart."], + ["111", "button", "This button triggers the `handlePayment` function when clicked."] +] + +```tsx highlights={highlights} +"use client" // include with Next.js 13+ + +import { + CardElement, + Elements, + useElements, + useStripe +} from "@stripe/react-stripe-js" +import { loadStripe } from "@stripe/stripe-js" +import { useCart } from "../../providers/cart" +import { useState } from "react" + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PK || "temp" +) + +export default function StripePayment() { + const { cart } = useCart() + const clientSecret = cart?.payment_collection?. + payment_sessions?.[0].data.client_secret as string + + return ( +
+ + + +
+ ) +} + +const StripeForm = ({ + clientSecret +}: { + clientSecret: string | undefined +}) => { + const { cart, refreshCart } = useCart() + const [loading, setLoading] = useState(false) + + const stripe = useStripe() + const elements = useElements() + + async function handlePayment( + e: React.MouseEvent + ) { + e.preventDefault() + const card = elements?.getElement(CardElement) + + if ( + !stripe || + !elements || + !card || + !cart || + !clientSecret + ) { + return + } + + setLoading(true) + stripe?.confirmCardPayment(clientSecret, { + payment_method: { + card, + billing_details: { + name: cart.billing_address?.first_name, + email: cart.email, + phone: cart.billing_address?.phone, + address: { + city: cart.billing_address?.city, + country: cart.billing_address?.country_code, + line1: cart.billing_address?.address_1, + line2: cart.billing_address?.address_2, + postal_code: cart.billing_address?.postal_code, + }, + }, + }, + }) + .then(({ error }) => { + if (error) { + // TODO handle errors + console.error(error) + return + } + + fetch( + `http://localhost:9000/store/carts/${cart.id}/complete`, + { + credentials: "include", + method: "POST" + } + ) + .then((res) => res.json()) + .then(({ type, cart, order, error }) => { + if (type === "cart" && cart) { + // an error occured + console.error(error) + } else if (type === "order" && order) { + // TODO redirect to order success page + alert("Order placed.") + console.log(order) + refreshCart() + } + }) + }) + .finally(() => setLoading(false)) + } + + return ( +
+ + + + ) +} +``` + +In the code snippet above, you: + +1. Create a `StripePayment` component that wraps the actual form with Stripe's `Elements` component. + - In the `StripePayment` component, you obtain the client secret from the payment session's `data` field. This is set in the Medusa application. +2. Create a `StripeForm` component that holds the actual form. In this component, you implement a `handlePayment` function that does the following: + - Use Stripe's `confirmCardPayment` method to accept the card details from the customer. + - Once the customer enters their card details and submit their order, the resolution function of the `confirmCardPayment` method is executed. + - In the resolution function, you send a request to the [Complete Cart API route](!api!/store#carts_postcartsidcomplete) to complete the cart and place the order. + - In the received response of the request, if the `type` is `cart`, it means that the cart completion failed. The error is set in the `error` response field. + - If the `type` is `order`, it means the card was completed and the order was placed successfully. You can access the order in the `order` response field. + - When the order is placed, you refresh the cart. You can redirect the customer to an order success page at this point. + +--- + +## 4. Use the Stripe Component + +You can now use the Stripe component in the checkout flow. You should render it after the customer chooses Stripe as a payment provider. + +For example, you can use it in the `getPaymentUi` function defined in the [Payment Checkout Step guide](../page.mdx): + +```tsx highlights={[["10"]]} +const getPaymentUi = useCallback(() => { + const activePaymentSession = cart?.payment_collection?. + payment_sessions?.[0] + if (!activePaymentSession) { + return + } + + switch(true) { + case activePaymentSession.provider_id.startsWith("pp_stripe_"): + return + // ... + } +} , [cart]) +``` + +--- + +## More Resources + +Refer to [Stripe's documentation](https://docs.stripe.com/) for more details on integrating it in your storefront. diff --git a/www/apps/resources/app/storefront-development/checkout/shipping/page.mdx b/www/apps/resources/app/storefront-development/checkout/shipping/page.mdx new file mode 100644 index 0000000000..dd88637f25 --- /dev/null +++ b/www/apps/resources/app/storefront-development/checkout/shipping/page.mdx @@ -0,0 +1,189 @@ +import { CodeTabs, CodeTab } from "docs-ui" + +export const metadata = { + title: `Checkout Step 3: Choose Shipping Method`, +} + +# {metadata.title} + +In the third step of the checkout flow, the customer chooses the shipping method to receive their order's items. + +To do that, you: + +1. Retrieve the available shipping options for the cart using the [List Shipping Options API route](!api!/store#shipping-options_getshippingoptions) and show them to the customer. +2. When the customer chooses a shipping option, you use the [Add Shipping Method to Cart API route](!api!/store#carts_postcartsidshippingmethods) to set the cart's shipping method. + +For example: + + + + +export const fetchHighlights = [ + ["3", "retrieveShippingOptions", "This function retrieves the shipping options of the customer's cart."], + ["16", "setShippingMethod", "This function sets the shipping method of the cart using the selected shipping option."], + ["29", "data", "Pass in this property any data relevant to the fulfillment provider."], +] + + ```ts highlights={fetchHighlights} + const cartId = localStorage.getItem("cart_id") + + const retrieveShippingOptions = () => { + const { shipping_options } = await fetch( + `http://localhost:9000/store/shipping-options?cart_id=${ + cart.id + }`, { + credentials: "include" + } + ) + .then((res) => res.json()) + + return shipping_options + } + + const setShippingMethod = ( + selectedShippingOptionId: string + ) => { + fetch(`http://localhost:9000/store/carts/${ + cart.id + }/shipping-methods`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + option_id: selectedShippingOptionId, + data: { + // TODO add any data necessary for + // fulfillment provider + } + }) + }) + .then((res) => res.json()) + .then(({ cart }) => { + // use cart... + console.log(cart) + }) + } + ``` + + + + +export const highlights = [ + ["4", "useCart", "The `useCart` hook was defined in the Cart React Context documentation."], + ["22", "fetch", "Retrieve available shipping method of the customer's cart."], + ["43", "fetch", "Set the cart's shipping method using the selected shipping option."], + ["53", "data", "Pass in this property any data relevant to the fulfillment provider."] +] + + ```tsx highlights={highlights} + "use client" // include with Next.js 13+ + + import { useEffect, useState } from "react" + import { useCart } from "../../../providers/cart" + import { HttpTypes } from "@medusajs/types" + + export default function CheckoutShippingStep () { + const { cart, setCart } = useCart() + const [loading, setLoading] = useState(false) + const [shippingOptions, setShippingOptions] = useState< + HttpTypes.StoreCartShippingOption[] + >([]) + const [ + selectedShippingOption, + setSelectedShippingOption + ] = useState() + + useEffect(() => { + if (!cart) { + return + } + fetch(`http://localhost:9000/store/shipping-options?cart_id=${ + cart.id + }`, { + credentials: "include" + }) + .then((res) => res.json()) + .then(({ shipping_options }) => { + setShippingOptions(shipping_options) + }) + }, [cart]) + + const setShipping = ( + e: React.MouseEvent + ) => { + if (!cart || !selectedShippingOption) { + return + } + + e.preventDefault() + setLoading(true) + + fetch(`http://localhost:9000/store/carts/${ + cart.id + }/shipping-methods`, { + credentials: "include", + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + option_id: selectedShippingOption, + data: { + // TODO add any data necessary for + // fulfillment provider + } + }) + }) + .then((res) => res.json()) + .then(({ cart: updatedCart }) => { + setCart(updatedCart) + }) + .finally(() => setLoading(false)) + } + + return ( +
+ {loading || !cart && Loading...} +
+ + +
+
+ ) + } + ``` + +
+
+ +In the example above, you: + +- Retrieve the available shipping options of the cart to allow the customer to select from them. +- Once the customer selects a shipping option, you send a request to the Add Shipping Method to Cart API route to update the cart's shipping method using the selected shipping option. + +## data Request Body Parameter + +When setting the shipping method using the Add Shipping Method to Cart API route, you can pass a `data` request body parameter that holds data relevant for the fulfillment provider. + +This isn't implemented here as it's different for each provider. Refer to the provider's documentation on details of expected data, if any. diff --git a/www/apps/resources/generated/files-map.mjs b/www/apps/resources/generated/files-map.mjs index 864acea129..768ba612a8 100644 --- a/www/apps/resources/generated/files-map.mjs +++ b/www/apps/resources/generated/files-map.mjs @@ -895,6 +895,34 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/storefront-development/cart/update/page.mdx", "pathname": "/storefront-development/cart/update" }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/address/page.mdx", + "pathname": "/storefront-development/checkout/address" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/complete-cart/page.mdx", + "pathname": "/storefront-development/checkout/complete-cart" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/email/page.mdx", + "pathname": "/storefront-development/checkout/email" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/page.mdx", + "pathname": "/storefront-development/checkout" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/payment/page.mdx", + "pathname": "/storefront-development/checkout/payment" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/payment/stripe/page.mdx", + "pathname": "/storefront-development/checkout/payment/stripe" + }, + { + "filePath": "/www/apps/resources/app/storefront-development/checkout/shipping/page.mdx", + "pathname": "/storefront-development/checkout/shipping" + }, { "filePath": "/www/apps/resources/app/storefront-development/page.mdx", "pathname": "/storefront-development" diff --git a/www/apps/resources/generated/sidebar.mjs b/www/apps/resources/generated/sidebar.mjs index 879f96daf9..dfc0e624cf 100644 --- a/www/apps/resources/generated/sidebar.mjs +++ b/www/apps/resources/generated/sidebar.mjs @@ -7283,6 +7283,57 @@ export const generatedSidebar = [ ] } ] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout", + "title": "Checkout", + "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/email", + "title": "1. Enter Email", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/address", + "title": "2. Enter Address", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/shipping", + "title": "3. Choose Shipping Method", + "children": [] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/payment", + "title": "4. Choose Payment Provider", + "children": [ + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/payment/stripe", + "title": "Example: Stripe", + "children": [] + } + ] + }, + { + "loaded": true, + "isPathHref": true, + "path": "/storefront-development/checkout/complete-cart", + "title": "5. Complete Cart", + "children": [] + } + ] } ] }, diff --git a/www/apps/resources/sidebar.mjs b/www/apps/resources/sidebar.mjs index 2743e812a3..8b864c0fbb 100644 --- a/www/apps/resources/sidebar.mjs +++ b/www/apps/resources/sidebar.mjs @@ -1897,6 +1897,38 @@ export const sidebar = sidebarAttachHrefCommonOptions([ }, ], }, + { + path: "/storefront-development/checkout", + title: "Checkout", + children: [ + { + path: "/storefront-development/checkout/email", + title: "1. Enter Email", + }, + { + path: "/storefront-development/checkout/address", + title: "2. Enter Address", + }, + { + path: "/storefront-development/checkout/shipping", + title: "3. Choose Shipping Method", + }, + { + path: "/storefront-development/checkout/payment", + title: "4. Choose Payment Provider", + children: [ + { + path: "/storefront-development/checkout/payment/stripe", + title: "Example: Stripe", + }, + ], + }, + { + path: "/storefront-development/checkout/complete-cart", + title: "5. Complete Cart", + }, + ], + }, ], }, { diff --git a/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx index 7a29e37718..1f276e31d6 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx @@ -268,7 +268,7 @@ export const CodeBlockLine = ({ tooltipClassName="font-base" render={({ content }) => ( {content || ""} diff --git a/www/packages/docs-ui/src/components/Tooltip/index.tsx b/www/packages/docs-ui/src/components/Tooltip/index.tsx index fd33bac340..e94a641176 100644 --- a/www/packages/docs-ui/src/components/Tooltip/index.tsx +++ b/www/packages/docs-ui/src/components/Tooltip/index.tsx @@ -43,7 +43,7 @@ export const Tooltip = ({ "!text-compact-x-small !shadow-elevation-tooltip dark:!shadow-elevation-tooltip-dark !rounded-docs_DEFAULT", "!py-docs_0.25 !z-[399] hidden !px-docs_0.5 lg:block", "!bg-medusa-bg-component", - "!text-medusa-fg-base", + "!text-medusa-fg-base text-center", tooltipClassName )} wrapper="span"