Developer Blog

Checking out a marketplace shopping cart

From a customer’s point of view, checking out on a Sharetribe marketplace is fairly simple. When checking out a cart, we need to add a few more actions in between the steps of the default flow under the hood. In this blog series, we are diving into the ins and outs of building a multi-vendor shopping cart with single-vendor checkout using the Sharetribe Developer Platform.

Mar 18, 2024

Store checkout machine with a person holding the screen with one hand and a receipt with the other.

From a customer’s point of view, checking out on a Sharetribe marketplace is fairly simple. The customer enters their payment information on the checkout page and clicks a button, and soon enough they are shown the order page.

Under the hood, things are a bit more complicated. The default checkout flow makes three to five API calls as a part of the journey from clicking “Buy now” to showing the order page:

  • Initiate a transaction with Sharetribe API
  • Confirm the payment intent with Stripe API
  • Transition the transaction with Sharetribe API
  • Optionally send a message with Sharetribe API, if the customer included one in the transaction
  • Optionally save the customer’s payment method with Sharetribe API, if they selected to save it

When checking out a cart, we need to add a few more actions in between the steps of the default flow:

  • After the main transaction has been initiated, initiate the child stock transactions and update their transaction ids in the main transaction’s protected data
  • After the payment has been confirmed, confirm stock for the child stock transactions
  • After the whole process is complete, clear all items from the cart that was just purchased

To make all of this happen, we need to make the following changes:

  • Update CheckoutPage.duck.js to handle cart checkout
  • Update the server endpoint for transaction initiation to handle cart transactions
  • Add the necessary mapped props and initial data handling to CheckoutPage.js
  • Add and update helper functions on CheckoutPageTransactionHelpers.js
  • Initiate the correct checkout flow on CheckoutPageWithPayment.js when the customer submits the payment form

All the code examples used in this guide can be found in this Gist.

In this blog series, we are diving into the ins and outs of building a multi-vendor shopping cart with single-vendor checkout using the Sharetribe Developer Platform. The previous articles in this guide dived into

If you didn't already, read those first!

Update CheckoutPage.duck.js to handle cart checkout


Checking out a shopping cart actually begins on the cart page, as the customer clicks the cart page “Buy now” button. When that happens, the cart page calls the CheckoutPage.duck setInitialValues Redux action, which sets the payload to CheckoutPage state.

The initial values sent from CartPage look like this:

    const initialValues = {
      listing,
      cartListings: cartListingDetails,
      cartAuthorId: currentAuthor.id.uuid,
      orderData: {
        cart: authorCart,
        deliveryMethod: delivery,
      },
      confirmPaymentError: null,
    };

By default, CheckoutPage state already contains attributes for listing, orderData, and confirmPaymentError. In other words, we only need to add two attributes to CheckoutPage state: cartListings, and cartAuthorId

   src
    ├── containers
            ├── CheckoutPage
                    ├── CheckoutPage.duck.js

Add these attributes into CheckoutPage.duck.js initialState:

const initialState = {
  ...,
  cartListings: null,
  cartAuthorId: null,
};

After calling setInitialValues, CartPage then navigates the user to CheckoutPage. At that point, the page immediately calls the speculateTransaction thunk to get the upcoming transaction’s details without actually initiating the transaction yet. 

We will include cart in the order parameters later in this guide, but let’s make the necessary changes to speculateTransaction at this point:

  • remove references to quantity, as the main transaction process for purchases no longer handles stock, and
  • add cart to orderData
export const speculateTransaction = (
...
  const { deliveryMethod, bookingDates, cart, ...otherOrderParams } = orderParams;
  const bookingParamsMaybe = bookingDates || {};
  // Parameters only for client app's server
  const orderData = deliveryMethod ? { cart, deliveryMethod } : { cart };
  // Parameters for Marketplace API
  const transitionParams = {
    ...bookingParamsMaybe,
    ...otherOrderParams,
    cardToken: 'CheckoutPage_speculative_card_token',
  };

Once the customer clicks “Buy now” on the checkout page, the first step in processing the checkout is the initiateOrder thunk. Let’s make corresponding changes there, too

  • remove references to quantity, and
  • add cart to orderData
export const initiateOrder = (
...
  const { deliveryMethod, bookingDates, cart, ...otherOrderParams } = orderParams;
  const bookingParamsMaybe = bookingDates || {};
  // Parameters only for client app's server
  const orderData = deliveryMethod ? { cart, deliveryMethod } : { cart };
...

We still need to add a few thunks for managing the child transactions. From this file, add the thunks createStockReservationTransactions and updateStockReservationTransactions to the end of CheckoutPage.duck.js.

The createStockReservationTransactions thunk does the following things:

  • It gets the cart listing ids from the main transaction’s protected data
  • It maps the cart listing ids to an array of SDK calls, one for each listing id, with the correct stock reservation counts
  • Then, it promises all the SDK calls, and reduces the response into an object with listing ids as keys and their associated stock transaction ids as values:
childTransactions: {
	[childListingId1]: childTransactionId1,
	[childListingId2]: childTransactionId2,
	...
}
  • Then, it calls the SDK to update the parent transaction’s protectedData with the childTransactions attribute
  • Finally, it returns the updated main transaction

The updateStockReservationTransactions thunk does the following things:

  • It gets the childTransactions object from the main transaction’s protected data
  • It maps the child transactions to an array of SDK calls to transition each one with the specified transition
  • It promises all the SDK calls and then returns the main transaction

Update the server endpoint for transaction initiation to handle cart transactions


Next, we need to update the template server endpoint that gets used for initiating transactions with a privileged transition. The server also has an endpoint for transitioning transactions with a privileged transition, which is used in cases where the customer has first started an inquiry with the provider, and then proceeds to start an order on that same listing. However, in this implementation we have not allowed users to first start an inquiry and then continue the same transaction with a cart purchase, so we do not need to update that endpoint to handle a cart.

In the initiate-privileged endpoint, we want to allow both regular and cart transactions, because in this marketplace, bookings are not handled with a cart logic. This means that we need to determine whether the incoming request contains a cart parameter, and then distinguish the behavior accordingly.

   server
      ├── api
            ├── initiate-privileged.js

Let’s start with importing the same cart line item calculation we already created for the cart-transaction-line-items endpoint. We will alias it with cartLineItems so that we know which calculation to use in which context. We will also import the listing id helper we created earlier.

const { transactionLineItems: cartLineItems } = require('../api-util/cartLineItems');
const { getListingIdsFromCart } = require('../api-util/lineItemHelpers');

We will need the value of cart in this endpoint. In addition, we’ll define a boolean for whether or not the cart exists, so that we can conditionally pick the correct options later on.

module.exports = (req, res) => {
  const { isSpeculative, orderData, bodyParams, queryParams } = req.body;
  const { cart } = orderData;
  const isCart = !!cart;
...

If the transaction has a cart, we need to fetch all its listings, so we can redefine listingPromise. Note that since listingPromise is now an array, we also need to wrap the non-cart listing promise in an array as well. That way, we can spread it as the attributes to Promise.all

We’ll also change the ordering of the Promise.all attributes array – because we do not know how many listing promises we have, we move fetchCommission(sdk) as the first Promise. That way, when handling the responses, we can assign the first item in the response array to fetchAssetsResponse and the rest to showListingResponse. We also set the listing response to listingData instead of listing for better naming accuracy.

 const listingPromise = isCart
    ? () => getListingIdsFromCart(cart).map(id => sdk.listings.show({ id }))
    : () => [sdk.listings.show({ id: bodyParams?.params?.listingId })];
  Promise.all([fetchCommission(sdk), ...listingPromise()])
    .then(([fetchAssetsResponse, ...showListingResponse]) => {
      const listingData = isCart
        ? showListingResponse.map(resp => resp.data.data)
        : showListingResponse[0].data.data;
...

Finally, we determine which line item function to use to calculate line items, and pass listingData instead of listing to the function.

      const lineItemFunction = isCart ? cartLineItems : transactionLineItems;
      lineItems = lineItemFunction(
        listingData,
        { ...orderData, ...bodyParams.params },
        providerCommission,
        customerCommission
);

Once line items have been calculated, the rest of the endpoint proceeds the same whether there is a cart or not.

You can see all changes to the initiate-privileged endpoint in this Gist file.

Add the necessary mapped props and initial data handling to CheckoutPage.js


The template uses CheckoutPage.js as a wrapper container to show one of two alternatives:

  • CheckoutPageWithPayment.js is used when the transaction involves payments
  • CheckoutPageWithInquiryProcess.js is used for free messaging transactions.

This means that for mapping state and dispatch to props, we need to use CheckoutPage.js, and then pass the necessary props onwards to CheckoutPageWithPayment.js.

   src
    ├── containers
            ├── CheckoutPage
                    ├── CheckoutPage.js

First, add the necessary imports to CheckoutPage.js:

import {
  ...
  createStockReservationTransactions,
  updateStockReservationTransactions,
} from './CheckoutPage.duck';
import { clearCart } from '../CartPage/CartPage.duck';
...

In the very beginning of the component (titled EnhancedCheckoutPage), we have a useEffect hook that sends initialData to be set to pageData in certain navigation situations, such as navigating through a link. Let’s add cartListings to initialData here. We will update handlePageData a bit later in this guide.

const EnhancedCheckoutPage = props => {
...
  useEffect(() => {
    const {
      ...
      cartListings,
    } = props;
    const initialData = { orderData, listing, cartListings, transaction };
    const data = handlePageData(initialData, STORAGE_KEY, history);

Then, we can add the necessary attributes to mapStateToProps and mapDispatchToProps. By default, CheckoutPage.js passes all its props to the child components, so we don’t need to make changes to props handling itself.

const mapStateToProps = state => {
  const {
    ...
    cartListings,
    cartAuthorId,
  } = state.CheckoutPage;
  const { currentUser } = state.user;
  const { confirmCardPaymentError, paymentIntent, retrievePaymentIntentError } = state.stripe;
  return {
    ...
    cartListings,
    cartAuthorId,
  };
};

const mapDispatchToProps = dispatch => ({
...
  onReserveCartItemStock: (transaction, processAlias, transitionName, parentTransitionName) =>
    dispatch(
      createStockReservationTransactions(
        transaction,
        processAlias,
        transitionName,
        parentTransitionName
      )
    ),
  onUpdateCartItemStock: (transaction, transitionName) =>
    dispatch(updateStockReservationTransactions(transaction, transitionName)),
  onClearCart: authorId => dispatch(clearCart(authorId)),
});
...

Then, update the setInitialValues function to also save cartListings into session storage if the user is signed in:

CheckoutPage.setInitialValues = (initialValues, saveToSessionStorage = false) => {
  if (saveToSessionStorage) {
    const { listing, cartListings, orderData } = initialValues;
    storeData(orderData, listing, cartListings, null, STORAGE_KEY);
  }

  return setInitialValues(initialValues);
};

Update data storing helpers in CheckoutPageSessionHelpers.js

Let's now update the necessary session helpers in CheckoutPageSessionHelpers.js as well, so that the data we just set up gets saved correctly.

   src
    ├── containers
            ├── CheckoutPage
                    ├── CheckoutPageSessionHelpers.js

The storeData helper adds data to session storage behind a specified key. Add cartListings to the function parameters and the data object.

// Stores given bookinData, listing, cartListings, and transaction to sessionStorage
export const storeData = (orderData, listing, cartListings, transaction, storageKey) => {
  if (window && window.sessionStorage && listing && orderData) {
    const data = {
      orderData,
      listing,
      cartListings,
      transaction,
      storedAt: new Date(),
    };
    ...
  }
};

The storedData function retrieves data from session storage with the specified storage key. Here, add cartListings to the checkoutPageData destructuring, as well as the return statement.

// Get stored data
export const storedData = storageKey => {
  if (window && window.sessionStorage) {
    const checkoutPageData = window.sessionStorage.getItem(storageKey);
    const reviver = (k, v) => {...};
    // Note: orderData may contain bookingDates if booking process is used.
    const { orderData, listing, cartListings, transaction, storedAt } = checkoutPageData
      ? JSON.parse(checkoutPageData, reviver)
      : {};

    ...

    if (isStoredDataValid) {
      return { orderData, listing, cartListings, transaction };
    }
  }
  return {};
};

The final session helper we will update is handlePageData, which was called in the useEffect hook when CheckoutPage first loads.

/**
 * Save page data to sessionstorage if the data is passed through navigation
 *
 * @param {Object} pageData an object containing orderData, listing and transaction entities, and optionally cartListings.
 * @param {String} storageKey key for the sessionStorage
 * @param {Object} history navigation related object with pushState action
 * @returns pageData
 */
export const handlePageData = ({ orderData, listing, cartListings, transaction }, storageKey, history) => {
  // Browser's back navigation should not rewrite data in session store.
  // Action is 'POP' on both history.back() and page refresh cases.
  // Action is 'PUSH' when user has directed through a link
  // Action is 'REPLACE' when user has directed through login/signup process
  const hasNavigatedThroughLink = history.action === 'PUSH' || history.action === 'REPLACE';
  const hasDataInProps = !!(orderData && (listing || cartListings) && hasNavigatedThroughLink);
  if (hasDataInProps) {
    // Store data only if data is passed through props and user has navigated through a link.
    storeData(orderData, listing, cartListings, transaction, storageKey);
  }
  // NOTE: stored data can be empty if user has already successfully completed transaction.
  const pageData = hasDataInProps ? { orderData, listing, cartListings, transaction } : storedData(storageKey);
  return pageData;
};

Add and update helper functions on CheckoutPageTransactionHelpers.js


Remember the multi-step default checkout flow we talked about in the beginning of this article? That flow is constructed in the CheckoutPageTransactionHelpers.js file. 

   src
    ├── containers
            ├── CheckoutPage
                    ├── CheckoutPageTransactionHelpers.js

The file exports a function called processCheckoutWithPayment, which first constructs five individual sub-functions corresponding to the functionalities mentioned earlier:

  • Initiate a transaction with Sharetribe API
  • Confirm the payment intent with Stripe API
  • Transition the transaction with Sharetribe API
  • Optionally send a message with Sharetribe API, if the customer included one in the transaction
  • Optionally save the customer’s payment method with Sharetribe API, if they selected to save it

The functions are built so that each function expects to receive a set of parameters that corresponds to the return value of the previous function. This way, they can eventually be composed into an async chain of promise calls.

When we add cart functionality here, we need to do the following things:

  • create the necessary sub-functions for managing the cart stock transactions, paying close attention to the return values of each function, because they become the parameters to the next function in the chain
  • pass the sub-functions to the processCheckoutWithPayment function
  • compose the async chain of promise calls in the correct order when handling a cart transaction, and using the default chain when not

A part of this work has already been done – when we created the createStockReservationTransactions and updateStockReservationTransactions thunks earlier in this guide, we set them to return the main transaction. This is because steps 2 and 4 in the default chain expect to get the transaction as their fnParams, so now we can inject functions calling those thunks into the chain.

We will handle the distinction between cart transactions and regular ones by creating a new helper function, processCartCheckoutWithPayment. In this function, we will construct the necessary sub-functions, and then call the main processCheckoutWithPayment with the original parameters as well as the new functions.

/**
 * Create additional functions for processing cart checkout
 * @param {*} orderParams
 * @param {*} extraPaymentParams
 */
export const processCartCheckoutWithPayment = (
  orderParams,
  extraPaymentParams,
  cartItemProcessAlias,
  cartTransitions,
  onReserveCartItemStock,
  onUpdateCartItemStock,
  clearAuthorCart
) => {
/**
   * - create fn for triggering createStockReservationTransactions => this comes after initiateOrder
   * - create fn for triggering updateStockReservationTransactions with confirm => this comes after confirming Stripe but before confirming the backend
   * - create fn for clearing the cart author's cart
   */

  const { process } = extraPaymentParams;
  const fnReserveCartStock = fnParams => {
    return onReserveCartItemStock(
      fnParams,
      cartItemProcessAlias,
      cartTransitions.CART_TRANSITION_RESERVE_STOCK,
      process.transitions.UPDATE_CHILD_TRANSACTIONS
    );
  };

  const fnConfirmCartStock = fnParams => {
    return onUpdateCartItemStock(fnParams, cartTransitions.CART_TRANSITION_CONFIRM_STOCK);
  };

  const fnClearCart = fnParams => {
    clearAuthorCart();
    return fnParams;
  };

  const cartFns = {
    fnReserveCartStock,
    fnConfirmCartStock,
    fnClearCart,
  };

  return processCheckoutWithPayment(orderParams, extraPaymentParams, cartFns);
};

Now, we need to update processCheckoutWithPayment to accept a new cartFns parameter and then, towards the very end of the function, conditionally compose the handlePaymentIntentCreation function either with or without the cartFns functions.

export const processCheckoutWithPayment = (orderParams, extraPaymentParams, cartFns = null) => {
  ...
  const isCart = !!cartFns;
  let handlePaymentIntentCreation;
  if (isCart) {
    const { fnReserveCartStock, fnConfirmCartStock, fnClearCart } = cartFns;
    handlePaymentIntentCreation = composeAsync(
      fnRequestPayment,
      fnReserveCartStock,
      fnConfirmCardPayment,
      fnConfirmPayment,
      fnConfirmCartStock,
      fnSendMessage,
      fnSavePaymentMethod,
      fnClearCart
    );
  } else {
    handlePaymentIntentCreation = composeAsync(
      fnRequestPayment,
      fnConfirmCardPayment,
      fnConfirmPayment,
      fnSendMessage,
      fnSavePaymentMethod
    );
  }
  return handlePaymentIntentCreation(orderParams);
};

Initiate the correct checkout flow on CheckoutPageWithPayment.js


We’re approaching the final step of the process! Well, at least the final file of this guide – CheckoutPageWithPayment.js. After this change, your customer will be able to check out their cart from a single vendor.

   src
    ├── containers
            ├── CheckoutPage
                    ├── CheckoutPageWithPayment.js

Again, let’s start with some imports. We’ll import the processCartCheckoutWithPayment function we just created. In addition, we’ll import some details from the cart stock transaction process we created in an earlier blog post.

import {
  ...  processCartCheckoutWithPayment,
} from './CheckoutPageTransactionHelpers.js';
import {
  transitions as cartTransitions,
  graph as cartProcess,
} from '../../transactions/transactionProcessCartStock.js';
const { id: cartItemProcessAlias } = cartProcess;

Next, we’ll update the getOrderParams function to pass the cart information to the backend endpoint correctly. Since we want to both use the cart in the line item calculation and save it in the transaction’s protected data, we need to include it in both places when creating order params.

Note, too, that we can remove quantity handling from orderParams – in the CheckoutPage.duck.js step, we removed any quantity handling from the relevant endpoints, so we do not need to pass it here, either.

const getOrderParams = (pageData, shippingDetails, optionalPaymentParams, config) => {
  const cart = pageData.orderData?.cart;
  const cartMaybe = cart ? { cart } : {};
  const deliveryMethod = pageData.orderData?.deliveryMethod;
  const deliveryMethodMaybe = deliveryMethod ? { deliveryMethod } : {};
  const { listingType, unitType } = pageData?.listing?.attributes?.publicData || {};
  const protectedDataMaybe = {
    protectedData: {
      ...getTransactionTypeData(listingType, unitType, config),
      ...deliveryMethodMaybe,
      ...cartMaybe,
      ...shippingDetails,
    },
  };
  // These are the order parameters for the first payment-related transition
  // which is either initiate-transition or initiate-transition-after-enquiry
  const orderParams = {
    listingId: pageData?.listing?.id,
    ...deliveryMethodMaybe,
    ...cartMaybe,
    ...bookingDatesMaybe(pageData.orderData?.bookingDates),
    ...protectedDataMaybe,
    ...optionalPaymentParams,
  };
  return orderParams;
};

Next, we will need to update the handleSubmit function that gets called when the customer clicks “Buy now”. First, let’s add the props we included on CheckoutPage.js to the function props restructuring:

const handleSubmit = (values, process, props, stripe, submitting, setSubmitting) => {
  if (submitting) {
    return;
  }
  setSubmitting(true);
  const {
    ...
    onReserveCartItemStock,
    onUpdateCartItemStock,
    onClearCart,
    cartAuthorId,
  } = props;

By default, the function calls processCheckoutWithPayment. We will update this logic so that the function first checks whether this is a cart transaction, and then uses either the cart checkout function or the default one. Then, we will prepare some additional parameters for the cart checkout function, and then call checkoutFn with all the necessary parameters.

  ...
  // These are the order parameters for the first payment-related transition
  // which is either initiate-transition or initiate-transition-after-enquiry
  const orderParams = getOrderParams(pageData, shippingDetails, optionalPaymentParams, config);
  const isCart = !!orderParams.cart;
  const checkoutFn = isCart ? processCartCheckoutWithPayment : processCheckoutWithPayment;
  const clearAuthorCart = () => onClearCart(cartAuthorId);
  const cartParamsMaybe = isCart
    ? [
        cartItemProcessAlias,
        cartTransitions,
        onReserveCartItemStock,
        onUpdateCartItemStock,
        clearAuthorCart,
      ]
    : [];
  // There are multiple XHR calls that needs to be made against Stripe API and Sharetribe Marketplace API on checkout with payments
  checkoutFn(orderParams, requestPaymentParams, ...cartParamsMaybe)
    .then(response => {
    ...

After these changes, you should be able to click “Buy now” on CartPage and initiate the transaction!

Summary


In this guide, we updated the marketplace to allow checking out a vendor’s shopping cart.

  • We updated CheckoutPage.duck.js to handle cart checkout
  • We updated the server endpoint for transaction initiation to handle cart transactions
  • We added the necessary mapped props and initial data handling to CheckoutPage.js
  • We added and updated helper functions on CheckoutPageTransactionHelpers.js
  • We updated CheckoutPageWithPayment.js to initiate the correct checkout flow when the customer submits the payment form

The next article is the final instalment in this shopping cart series. In it, we will display all cart listings, instead of just the main listing, during and after checking out!