Multi-step guest checkout
Multi-step checkout is a common pattern used in e-commerce websites to guide users through the checkout process.
Some of the benefits of using a multi-step checkout process include:
Enhanced user experience: Breaking down the checkout process into multiple steps can make it feel less overwhelming for customers. Each step focuses on a specific task, such as entering shipping information or payment details, which can simplify the process and reduce errors1.
Increased conversion rates: A streamlined, step-by-step process can help reduce cart abandonment rates. Customers are more likely to complete their purchase if the process is clear and straightforward.
Customization and flexibility: Adobe Commerce allows for customization of each step in the checkout process. This means you can tailor the experience to meet the specific needs of your customers, such as offering different payment or shipping options.
The Commerce boilerplate template does not include a multi-step checkout flow by default, but you can easily implement one using Adobe’s drop-in components.
Step-by-step
The following steps describe how to modify the commerce-checkout.js and commerce-checkout.css block files in the boilerplate template to implement a multi-step checkout flow.
This tutorial covers the guest checkout experience. If you want to implement a custom payment method, customer checkout, virtual cart, or any other feature, you’ll need to adapt the code accordingly. The following table shows the features covered in this tutorial:
Feature | Covered |
---|---|
Custom payment method | ❌ |
Customer checkout | ❌ |
Empty cart | ✅ |
Guest checkout | ✅ |
Virtual cart | ❌ |
Order confirmation | ✅ |
Define layout
Define the layout for the checkout using a contextual fragment and reference the DOM elements where the containers need to be rendered. Replace the block content with this fragment.
export default async function decorate(block) { ... // Define the Layout for the Checkout const fragment = document.createRange().createContextualFragment(` <div class="checkout__wrapper"> <div class="checkout__heading"></div> <div class="checkout__empty-cart"></div> <div class="checkout__content"> <div class="checkout__main"> <div class="checkout__shipping"> <div class="checkout__shipping-title"></div> <div class="checkout__login"></div> <div class="checkout__shipping-form"></div> <div class="checkout__continue-to-delivery"></div> </div> <div class="checkout__delivery"> <div class="checkout__delivery-title"></div> <div class="checkout__delivery-methods"></div> <div class="checkout__continue-to-payment"></div> </div> <div class="checkout__payment"> <div class="checkout__payment-title"></div> <div class="checkout__bill-to-shipping"></div> <div class="checkout__billing-form"></div> <div class="checkout__payment-methods"></div> <div class="checkout__place-order"></div> </div> </div> <div class="checkout__aside"> <div class="checkout__order-summary"></div> <div class="checkout__cart-summary"></div> </div> </div> </div> `);
const $heading = fragment.querySelector('.checkout__heading'); const $emptyCart = fragment.querySelector('.checkout__empty-cart'); const $content = fragment.querySelector('.checkout__content');
const $orderSummary = fragment.querySelector('.checkout__order-summary'); const $cartSummary = fragment.querySelector('.checkout__cart-summary');
const $shippingTitle = fragment.querySelector('.checkout__shipping-title'); const $login = fragment.querySelector('.checkout__login'); const $shippingForm = fragment.querySelector('.checkout__shipping-form'); const $continueToDeliveryBtn = fragment.querySelector( '.checkout__continue-to-delivery', );
const $deliveryTitle = fragment.querySelector('.checkout__delivery-title'); const $deliveryMethods = fragment.querySelector( '.checkout__delivery-methods', ); const $continueToPaymentBtn = fragment.querySelector( '.checkout__continue-to-payment', );
const $paymentTitle = fragment.querySelector('.checkout__payment-title'); const $billToShipping = fragment.querySelector('.checkout__bill-to-shipping'); const $billingForm = fragment.querySelector('.checkout__billing-form'); const $paymentMethods = fragment.querySelector('.checkout__payment-methods'); const $placeOrder = fragment.querySelector('.checkout__place-order');
block.replaceChildren(fragment);};
Render initial containers
Render the initial containers in the corresponding DOM elements that you created in the previous step.
export default async function decorate(block) { ... const [ heading, _shippingInfoHeading, __shippingMethodHeading, _paymentHeading, _loginForm, shippingFormSkeleton, continueToDeliveryBtn, orderSummary, _cartSummary, ] = await Promise.all([ UI.render(Header, { title: 'Guest Checkout', size: 'large', divider: false, })($heading),
UI.render(Header, { title: '1. SHIPPING INFORMATION', size: 'medium', divider: false, })($shippingTitle),
UI.render(Header, { title: '2. SHIPPING METHOD', size: 'medium', divider: false, })($deliveryTitle),
UI.render(Header, { title: '3. PAYMENT INFORMATION', size: 'medium', divider: false, })($paymentTitle),
// render the initial containers CheckoutProvider.render(LoginForm, { name: LOGIN_FORM_NAME, })($login),
AccountProvider.render(AddressForm, { isOpen: true, showFormLoader: true, })($shippingForm),
UI.render(Button, { children: 'CONTINUE TO SHIPPING METHOD', disabled: true, onClick: async () => { await continueToDelivery(); }, })($continueToDeliveryBtn),
CartProvider.render(OrderSummary)($orderSummary),
CartProvider.render(CartSummaryList, { variant: 'secondary', slots: { Heading: (headingCtx) => { const title = 'Your Cart ({count})';
const cartSummaryListHeading = document.createElement('div'); cartSummaryListHeading.classList.add('cart-summary-list__heading');
const cartSummaryListHeadingText = document.createElement('div'); cartSummaryListHeadingText.classList.add( 'cart-summary-list__heading-text', );
cartSummaryListHeadingText.innerText = title.replace( '({count})', headingCtx.count ? `(${headingCtx.count})` : '', ); const editCartLink = document.createElement('a'); editCartLink.classList.add('cart-summary-list__edit'); editCartLink.href = '/cart'; editCartLink.rel = 'noreferrer'; editCartLink.innerText = 'Edit';
cartSummaryListHeading.appendChild(cartSummaryListHeadingText); cartSummaryListHeading.appendChild(editCartLink); headingCtx.appendChild(cartSummaryListHeading);
headingCtx.onChange((nextHeadingCtx) => { cartSummaryListHeadingText.innerText = title.replace( '({count})', nextHeadingCtx.count ? `(${nextHeadingCtx.count})` : '', ); }); }, }, })($cartSummary), ]);};
At this point, if you access the page where you are using the block, you should see that all containers are being rendered. The layout still needs some work, so let’s move on to the next step.
Add styles
Add some styles to the layout and containers to make them look good. Open the commerce-checkout.css
file and add the following styles:
/* stylelint-disable selector-class-pattern */
.checkout__wrapper { padding-left: 3rem; padding-right: 3rem;}
.checkout__banners { padding-top: 1.5rem;}
.checkout__content { display: grid; align-items: start; grid-template-columns: repeat(var(--grid-4-columns), 1fr); gap: var(--spacing-big); padding-top: 1.5rem;}
.checkout__content--empty { display: none;}
.checkout__empty-cart { padding-top: 1.5rem;}
.checkout__main { display: grid; grid-column: 1 / span 7; row-gap: var(--spacing-xbig);}
.checkout__aside { display: grid; grid-column: 9 / span 4; row-gap: var(--spacing-xbig);}
.checkout__shipping { display: flex; flex-direction: column; gap: var(--spacing-big); padding: 0;}
.checkout__shipping:has(> div:empty:not(.checkout__continue-to-delivery)) { gap: 0; padding-top: var(--spacing-small); padding-bottom: var(--spacing-small); border-top: var(--shape-border-width-3) solid var(--color-neutral-400); border-bottom: var(--shape-border-width-3) solid var(--color-neutral-400);}
.checkout__delivery { display: flex; flex-direction: column; gap: var(--spacing-big); padding: 0;}
.checkout__delivery:has(> div:empty:not(.checkout__continue-to-payment)) { gap: 0; padding-top: var(--spacing-small); padding-bottom: var(--spacing-small); border-top: var(--shape-border-width-3) solid var(--color-neutral-400); border-bottom: var(--shape-border-width-3) solid var(--color-neutral-400);}
.checkout__payment { display: flex; flex-direction: column; gap: var(--spacing-big); padding: 0;}
.checkout__payment:has(> div:empty) { gap: 0; padding-top: var(--spacing-small); padding-bottom: var(--spacing-small); border-top: var(--shape-border-width-3) solid var(--color-neutral-400); border-bottom: var(--shape-border-width-3) solid var(--color-neutral-400);}
/* temporary fix to hide the default cart heading */[data-testid='default-cart-heading'] { display: none;}
/* Responsive adjustments */@media only screen and (width <= 768px) { .checkout__wrapper { padding-left: 1.5rem; padding-right: 1.5rem; }
.checkout__content { grid-template-columns: 1fr; gap: var(--spacing-big) 0; }
.checkout__main, .checkout__aside { grid-column: auto; }
.checkout__aside { order: -1; }}
Display/remove empty cart
Check if the cart is empty and display a message to the user. To do this, create a new container called EmptyCart
.
Create two utility functions to display or remove the
EmptyCart
container when needed:let emptyCart;const displayEmptyCart = async () => {if (emptyCart) return;heading.setProps((prev) => ({...prev,title: 'Empty Cart',}));emptyCart = await CartProvider.render(EmptyCart, {routeCTA: () => '/',})($emptyCart);$content.classList.add('checkout__content--empty');};const removeEmptyCart = () => {if (!emptyCart) return;emptyCart.remove();emptyCart = null;$emptyCart.innerHTML = '';heading.setProps((prev) => ({...prev,title: 'Guest Checkout',}));$content.classList.remove('checkout__content--empty');};Add the following declarations at the end of the decorate function to start listening for the
checkout/initialized
andcheckout/updated
events to determine when to display the empty cart.events.on('checkout/initialized', handleCheckoutInitialized, { eager: true });events.on('checkout/updated', handleCheckoutUpdated);Add the missing handlers for the previous events.
async function handleCheckoutInitialized(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}}async function handleCheckoutUpdated(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}removeEmptyCart();}At this point, if you access the checkout page without a cart, you should see the
EmptyCart
container displayed.Empty cart
Handle shipping method form
You’ve probably noticed that even after initializing the checkout the shipping address form still loads. To resolve this, create a new utility method called
continueToShipping
to remove the skeleton and render the shipping method form when the checkout is initialized.let shippingForm;const continueToShipping = async (initialData = null) => {if (shippingForm) return;// cleanupshippingFormSkeleton.remove();$shippingForm.innerHTML = '';shippingForm = await AccountProvider.render(AddressForm, {addressesFormTitle: 'Shipping address',className: 'checkout-shipping-form__address-form',formName: SHIPPING_FORM_NAME,hideActionFormButtons: true,inputsDefaultValueSet: initialData ?? {countryCode: storeConfig.defaultCountry,},isOpen: true,onChange: debounce((values) => {setAddressOnCart(values, checkoutApi.setShippingAddress);}, DEBOUNCE_TIME),showBillingCheckBox: false,showShippingCheckBox: false,})($shippingForm);};Update the handlers to use the new method.
async function handleCheckoutInitialized(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);}async function handleCheckoutUpdated(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}removeEmptyCart();// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);}
Enable shipping
In the render initial containers step, we added a call to a non-existing continueToDelivery
method and disabled the CONTINUE TO SHIPPING METHOD button by default. Now we are going to enable the button when the user selects a shipping address and implement the missing method.
Update the
handleCheckoutUpdated
handler to enable the button when the received data contains a selected shipping address.async function handleCheckoutUpdated(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}removeEmptyCart();// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);if (!cartShippingAddress) return;continueToDeliveryBtn.setProps((prev) => ({...prev,disabled: false,}));}Update the logic inside the
handleCheckoutInitialized
handler to automatically call thecontinueToDelivery
method if the cart already contains the necessary data.async function handleCheckoutInitialized(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);// continue to deliveryif (!cartShippingAddress) return;await continueToDelivery();}Create the
continueToDelivery
function to render all the containers that are relevant to the delivery step.let deliveryMethods;let continueToPaymentBtn;const continueToDelivery = async () => {if (deliveryMethods) return;deliveryMethods = await CheckoutProvider.render(ShippingMethods, {hideOnVirtualCart: true,onCheckoutDataUpdate: () => {cartApi.refreshCart().catch(console.error);},})($deliveryMethods);orderSummary.setProps((prev) => ({...prev,slots: {EstimateShipping: (esCtx) => {const estimateShippingForm = document.createElement('div');CheckoutProvider.render(EstimateShipping)(estimateShippingForm);esCtx.appendChild(estimateShippingForm);},},}));continueToDeliveryBtn.remove();$continueToDeliveryBtn.remove();continueToPaymentBtn = await UI.render(Button, {children: 'CONTINUE TO PAYMENT INFORMATION',disabled: true,onClick: async () => {await continueToPayment();},})($continueToPaymentBtn);};Continue to shipping
Enable payment
Similar to the enable shipping step, you now need to add the logic to enable the CONTINUE TO PAYMENT INFORMATION button and implement the missing continueToPayment
method.
Update the logic inside the
handleCheckoutInitialized
handler to automatically call thecontinueToPayment
method if the cart already contains the necessary data.async function handleCheckoutInitialized(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);// continue to deliveryif (!cartShippingAddress) return;await continueToDelivery();// continue to paymentconst deliveryMethod = getCartDeliveryMethod(data);if (!deliveryMethod) return;await continueToPayment();}async function handleCheckoutUpdated(data) {if (isEmptyCart(data)) {await displayEmptyCart();return;}removeEmptyCart();// continue to shippingconst cartShippingAddress = getCartAddress(data, 'shipping');await continueToShipping(cartShippingAddress);if (!cartShippingAddress) return;continueToDeliveryBtn.setProps((prev) => ({...prev,disabled: false,}));const deliveryMethod = getCartDeliveryMethod(data);if (!deliveryMethod) return;continueToPaymentBtn.setProps((prev) => ({...prev,disabled: false,}));}Create the
continueToPayment
function to render all the containers that are relevant to the payment step.let billToShipping;let billingForm;let paymentMethods;let placeOrder;const continueToPayment = async () => {if (!billToShipping) {billToShipping = await CheckoutProvider.render(BillToShippingAddress, {hideOnVirtualCart: true,onChange: (checked) => {$billingForm.style.display = checked ? 'none' : 'block';},})($billToShipping);}if (!billingForm) {billingForm = await AccountProvider.render(AddressForm, {addressesFormTitle: 'Billing address',className: 'checkout-billing-form__address-form',formName: BILLING_FORM_NAME,hideActionFormButtons: true,isOpen: true,onChange: debounce((values) => {setAddressOnCart(values, checkoutApi.setBillingAddress);}, DEBOUNCE_TIME),showBillingCheckBox: false,showShippingCheckBox: false,})($billingForm);}if (!paymentMethods) {paymentMethods = await CheckoutProvider.render(PaymentMethods)($paymentMethods);}if (!placeOrder) {placeOrder = await CheckoutProvider.render(PlaceOrder, {handlePlaceOrder: async ({ cartId }) => {orderApi.placeOrder(cartId).catch(console.error);},})($placeOrder);}continueToPaymentBtn.remove();$continueToPaymentBtn.remove();};Continue to payment
At this point, you should have a fully functional multi-step checkout, but there’s still some work to do. Let’s move on to the last step.
Create order confirmation
The last step to complete the multi-step checkout process is to create an order confirmation page. To make things easy, you can reuse the code from the commerce-checkout.js
block in the boilerplate template.
Copy the
displayOrderConfirmation
function.Copy the
handleCheckoutOrder
handler.Register the
checkout/order
event listener.events.on('checkout/initialized', handleCheckoutInitialized, { eager: true });events.on('checkout/order', handleCheckoutOrder);events.on('checkout/updated', handleCheckoutUpdated);Order confirmation
Example
See blocks/commerce-checkout-multi-step
in the demos
branch of the boilerplate repository for complete JS and CSS code for the multi-step checkout flow.