Showing purchased cart listings
The default behavior of Sharetribe marketplace transactions is to only involve a single listing. Therefore both the checkout page and the order page show only the main listing’s information, even if you are checking out a cart full of items. We want to modify this behavior so that cart listing information is displayed instead of the transaction’s default listing. 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
The default behavior of Sharetribe marketplace transactions is to only involve a single listing. Therefore both the checkout page and the order page show only the main listing’s information, even if you are checking out a cart full of items. We want to modify this behavior so that in relevant places, cart listing information is displayed instead of the transaction’s default listing.
To make this happen, we will need to make the following changes:
- Add a new component, CartDetailsSideCard.js, to CheckoutPage and use it when checking out a cart instead of a single listing
- Update InboxPage.js to indicate multiple listings within a transaction
- Update TransactionPage to show cart listings
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. This is the final part of the series, so after implementing these steps, you will have a functioning first version of a multi-vendor shopping cart with single-vendor checkout. Still, it is a good idea to keep testing your implementation to find where you would like to continue making further improvements before you release the feature to your live marketplace.
The previous articles in this guide have dived into
- adding listings to cart,
- viewing cart items,
- calculating shopping cart price,
- designing a shopping cart transaction flow, and
- checking out the cart.
If you didn't already, read those first!
The default display for listing information on CheckoutPage is a component called DetailsSideCard. It accepts details on a single listing and displays them alongside the order breakdown for the order being checked out.
For a cart, we need something similar but with the ability to show details on multiple listings. Add a new file to src/containers/CheckoutPage called CartDetailsSideCard.js.
src ├── containers ├── CheckoutPage ├── CartDetailsSideCard.js
Then, copy the contents of this file in that new file.
The CartDetailsSideCard.js component is modified from the default DetailsSideCard.js component, and it does the following things:
- It defines helper functions to get image configurations for each listing
- It shows the order breakdown if one has been passed to it – the default DetailsSideCard shows the breakdown after the image, but since we can’t know how many listings the cart has, we will show the breakdown first
- It shows an error message if there is an issue with speculating the transaction – again, the default DetailsSideCard shows this after the image, but we want the user to always see this without scrolling.
- Finally, it maps the listings prop to an array of listing image and detail cards similar to what the DetailsSideCard uses.
Next, use the CartDetailsSideCard.js component on CheckoutPageWithPayment.js.
src ├── containers ├── CheckoutPage ├── CheckoutPageWithPayment.js
First, let’s import the new component.
import CartDetailsSideCard from './CartDetailsSideCard';
Then, we will add cartListings to the destructuring statement from pageData at the beginning of the CheckoutPageWithPayment component.
export const CheckoutPageWithPayment = props => { ... const { listing, transaction, orderData, cartListings } = pageData;
We can then conditionally use either the new CartDetailsSideCard component or the default DetailsSideCard one depending on whether cartListings exists.
const detailsSideCard = cartListings ? ( <CartDetailsSideCard listings={cartListings} author={listing?.author} layoutListingImageConfig={config.layout.listingImage} speculateTransactionErrorMessage={errorMessages.speculateTransactionErrorMessage} isInquiryProcess={false} processName={processName} breakdown={breakdown} intl={intl} /> ) : ( <DetailsSideCard listing={listing} listingTitle={listingTitle} author={listing?.author} firstImage={firstImage} layoutListingImageConfig={config.layout.listingImage} speculateTransactionErrorMessage={errorMessages.speculateTransactionErrorMessage} isInquiryProcess={false} processName={processName} breakdown={breakdown} intl={intl} /> );
Finally, replace the original DetailsSideCard usage with the variable we just defined.
return ( <Page title={title} scrollingDisabled={scrollingDisabled}> ... </div> {detailsSideCard} </div> </Page>
Now, you should be able to see all listings represented on the right side of the checkout page.
After the transaction has been successfully initiated, it will show up on the inbox page. Here, too, the default setup only handles transactions with a single listing. Let’s modify the inbox item display to distinguish cart transactions from regular ones.
To start with, we need to determine whether the transaction has a cart in the first place, so we need to access the transaction’s protected data. By default, however, transactions on the inbox page are fetched using sparse attributes, i.e. only the specified fields are included in the response. We need to add protected data to that list of specified fields.
src ├── containers ├── InboxPage ├── InboxPage.duck.js
On InboxPage.duck.js loadData thunk, the apiQueryParams constant defines what gets included in the response. In the fields.transaction array, add protectedData.
const apiQueryParams = { ..., 'fields.transaction': [ ... 'protectedData', ],
Now, you can access transaction.protectedData for each transaction on InboxPage. Next, we can update the inbox page to use this information.
src ├── containers ├── InboxPage ├── InboxPage.js
First, we will import a helper function for getting listing item ids from the transaction’s cart.
import { getCartListingIds } from '../CartPage/CartPage.duck';
The reason why each inbox item only shows details related to one of the listings is that there is a function getUnitLineItem, which finds the line item where the code corresponds to a listing unit type. However, for cart transactions, we have multiple such line items, so instead of finding one, we will return an array of one or more unit line items, however many are included in the line item array.
const getUnitLineItem = lineItems => { const unitLineItems = lineItems?.filter( item => LISTING_UNIT_TYPES.includes(item.code) && !item.reversal ); return unitLineItems; };
Next, we will modify the InboxItem component in the same file. We will start with determining the number of listings in the transaction’s cart, and set itemTitle to either a generic message or the listing’s title depending on how many listings the transaction has.
const listingIds = getCartListingIds(tx.attributes.protectedData.cart || {}); const hasMultipleListings = listingIds.length > 1; const listingCount = listingIds.length; const itemTitle = hasMultipleListings ? intl.formatMessage({ id: 'InboxPage.cartTitle' }, { listingCount }) : listing?.attributes?.title;
For stock items, the InboxItem component also shows the number of items in the transaction, which is determined from the unit line items. Let’s update the quantity calculation next. Instead of using the unitLineItem quantity directly, we need to calculate the total quantity based on all unit line items.
const unitLineItems = getUnitLineItem(lineItems); const unitLineItemsQuantity = unitLineItems.reduce((sum, item) => { return sum + Number(item.quantity); }, 0); const quantity = hasPricingData && !isBooking ? unitLineItemsQuantity.toString() : null; const showStock = stockType === STOCK_MULTIPLE_ITEMS || (quantity && unitLineItemsQuantity > 1);
Then, we can replace the listing title with our new itemTitle constant in the returned InboxItem component.
<div className={css.itemTitle}>{itemTitle}</div>
Finally, you will need to add the new marketplace text key and value in your translations.
src ├── translations ├── en.json
Include this in your en.json file and your Marketplace texts editor in Console. That way, you can modify the actual text later, but the template has a fallback value in case the Console value is later removed.
"InboxPage.cartTitle": "{listingCount} listings",
Now, we can see cart transactions indicated differently on the Inbox page.
If you want to show listing names or other details on InboxPage as well, you would need to make, for example, these additional changes:
- After fetching the transaction information in InboxPage.duck.js, determine the transactions that have a cart
- Parse the cart listing ids from each transaction’s cart
- Save an object with the transaction ids as keys, and an array of their associated cart listing ids mapped to UUIDs as values, in Inbox state
- Fetch all cart listings with the sdk, using the { ids: [...] } query parameter
- Create a selector function in InboxPage.duck.js that accepts a transaction id as parameter, gets the transaction’s cart listing ids from state, and returns the listings using the getListingsById helper
- Map the selector function to InboxPage props with mapDispatchToProps
- Pass the selector function to InboxItem and use it to fetch the listing details for each transaction separately
Finally, we want to show the transaction’s cart listings on the order page as well. To do this, we will need to make the following changes:
- On TransactionPage.duck.js, save the cart listing ids in TransactionPage state
- Fetch all cart listings, instead of only the transaction’s primary listing, in the fetchTransaction thunk
- On TransactionPage, use the getListingsById helper in mapStateToProps to fetch cart listings and set them to props
- Pass the cart listings in the ActivityFeed and TransactionPanel components
- In ActivityFeed, set listingTitle based on cart listing names
- In TransactionPanel, show multiple listing detail cards when the transaction has cart listings.
Save cart listing ids in state and fetch cart listings instead of the primary listing
src ├── containers ├── TransactionPage ├── TransactionPage.duck.js
First, let’s import a helper from CartPage.duck.js for handling cart ids.
import { getCartListingIds } from '../CartPage/CartPage.duck';
We’ll need to prepare the TransactionPage state for a new attribute, cartListingIds. Whenever we add a new state attribute, we need to make the following changes:
- Include a new action type (grouped under the Action types comment heading)
export const SET_CART_LISTING_IDS = 'app/TransactionPage/SET_CART_LISTING_IDS';
- Add the new attribute to initialState
const initialState = { ... cartListingIds: [], };
- Add a new case to the switch statement inside the transactionPageReducer function
export default function transactionPageReducer(state = initialState, action = {}) { const { type, payload } = action; switch (type) { ... case SET_CART_LISTING_IDS: return { ...state, cartListingIds: payload }; default: return state; } }
- Add a new action creator for the action type we created (grouped under the Action creators comment heading)
export const setCartListingIds = ids => ({ type: SET_CART_LISTING_IDS, payload: ids, });
After adding all these elements, we can set an array as cartListingIds to TransactionPage state by dispatching setCartListingIds(array). We will use this action in the fetchTransaction thunk.
After the transaction has been fetched in the fetchTransaction thunk, the default behavior of the next block is to fetch the related listing. We want to update this so that if the transaction has a cart, we instead fetch all the cart listings.
To start with, we need to get the cart information from the transaction’s protected data. We will then use the cart information to determine whether to fetch the related listing or the whole cart.
.then(response => { ... const { cart } = transaction.attributes.protectedData; // No cart, a single non-deleted listing const canFetchListing = listing && listing.attributes && !listing.attributes.deleted && !cart; if (canFetchListing) { return sdk.listings.show({ id: listingId, include: ['author', 'author.profileImage', 'images'], ...getImageVariants(config.layout.listingImage), }); // a cart of 1-n listings that may or may not be deleted } else if (!!cart) { const listingIds = getCartListingIds(cart); dispatch(setCartListingIds(listingIds.map(id => new UUID(id)))); return sdk.listings.query({ ids: listingIds, include: ['author', 'author.profileImage', 'images'], ...getImageVariants(config.layout.listingImage), }); } else { return response; }
It’s good to note that in the previous snippet, we saved cart listing ids to state as UUIDs. In the next .then block, the response gets set to state.marketplaceData when addMarketplaceEntities is dispatched. Later, we will use a default helper to get that listing data to our component, and the default helper uses UUIDs instead of string ids, so we can already save them to state in the correct format.
Get cart listings from state and pass them to components
On TransactionPage, we can now fetch the information from state and pass it to components.
src ├── containers ├── TransactionPage ├── TransactionPage.js
First, add the necessary helper to our import from marketplaceData.duck.
import { getMarketplaceEntities, getListingsById } from '../../ducks/marketplaceData.duck';
Then, all the way towards the bottom of the file in mapStateToProps, we will fetch our cart listings using the ids from state and the helper we just imported.
const mapStateToProps = state => { const { ... cartListingIds, } = state.TransactionPage; ... const cartListings = getListingsById(state, cartListingIds); return { ... cartListings, }; };
Now we can destructure cartListings from props in the component, and then pass it to the necessary subcomponents, TransactionPanel and ActivityFeed.
export const TransactionPageComponent = props => { ... const { ... cartListings, } = props; ... const panel = isDataAvailable ? ( <TransactionPanel ... cartListings={cartListings} activityFeed={ <ActivityFeed ... cartListings={cartListings} /> } ... /> ) : ( loadingOrFailedFetching );
Set listingTitle based on cart listing names in ActivityFeed
The ActivityFeed component renders the different events and messages within the transaction as a coherent timeline. Here, we want to show the names of all the listings whenever the component needs a listing title.
src ├── containers ├── TransactionPage ├── ActivityFeed ├── ActivityFeed.js
First, let’s add the cartListings prop to the destructuring statement, and set a boolean constant for whether or not we are dealing with a cart transaction.
export const ActivityFeedComponent = props => { const { ... cartListings, } = props; const isCart = cartListings?.length > 0;
By default, the listing title in const transitionListItem is set depending on whether the listing has been deleted. We will update this logic to a function that can take an individual listing as a parameter. This way, we can construct the actual listingTitle using the same logic with either the cart listings or the main listing.
const transitionListItem = transition => { ... if (currentUser?.id && customer?.id && provider?.id && listing?.id) { ... const getListingTitle = listing => listing.attributes.deleted ? intl.formatMessage({ id: 'TransactionPage.ActivityFeed.deletedListing' }) : listing.attributes.title; const listingTitle = isCart ? cartListings.map(l => getListingTitle(l)).join(', ') : getListingTitle(listing);
Now, you can see the cart listing names in the activity feed titles.
Show cart listing detail cards in TransactionPanel
Finally, let’s modify the TransactionPanel to show listing details for all cart listings.
src ├── containers ├── TransactionPage ├── TransactionPanel ├── TransactionPanel.js
Unlike CheckoutPage, the TransactionPanel does not have a specified component for showing listing details. However, the logic we will use here is very similar to what we did on CheckoutPage: if the transaction has a cart, we will show the order breakdown and possible action buttons first, and then map the array of cart listings to the same listing detail display that a single listing uses by default.
First, let’s again destructure cartListings from props at the beginning of the render() function, and set a boolean to determine whether we are dealing with a cart transaction.
render() { const { ... cartListings, } = this.props; const isCart = cartListings?.length > 0;
Then, instead of defining a constant for firstImage, let’s convert the logic to a function that we can use to get the first image of either the main listing or each cart listing.
const getFirstImage = listing => (listing?.images?.length > 0 ? listing?.images[0] : null); const firstImage = getFirstImage(listing);
Now, we can add a listingDetails constant that either shows the cart version of listing details, or the default one, depending on whether we are working with a cart transaction or not.
const listingDetails = isCart ? ( {/* Show breakdown and action buttons followed by all cart listings */} <div className={css.detailCard}> {stateData.showOrderPanel ? orderPanel : null} <BreakdownMaybe className={css.breakdownContainer} orderBreakdown={orderBreakdown} processName={stateData.processName} /> {stateData.showActionButtons ? ( <div className={css.desktopActionButtons}>{actionButtons}</div> ) : null} {cartListings.map(l => ( <div key={l.id?.uuid}> <DetailCardImage avatarWrapperClassName={css.avatarWrapperDesktop} listingTitle={l.attributes.title} image={getFirstImage(l)} provider={provider} isCustomer={isCustomer} listingImageConfig={config.layout.listingImage} /> <DetailCardHeadingsMaybe showDetailCardHeadings={stateData.showDetailCardHeadings} listingTitle={ l.attributes.deleted ? ( l.attributes.title ) : ( <NamedLink name="ListingPage" params={{ id: l.id?.uuid, slug: createSlug(l.attributes.title) }} > {l.attributes.title} </NamedLink> ) } showPrice price={l?.attributes?.price} intl={intl} /> </div> ))} </div> ) : ( // Show listing image followed by breakdown and action buttons <div className={css.detailCard}> <DetailCardImage avatarWrapperClassName={css.avatarWrapperDesktop} listingTitle={listingTitle} image={firstImage} provider={provider} isCustomer={isCustomer} listingImageConfig={config.layout.listingImage} /> <DetailCardHeadingsMaybe showDetailCardHeadings={stateData.showDetailCardHeadings} listingTitle={ listingDeleted ? ( listingTitle ) : ( <NamedLink name="ListingPage" params={{ id: listing.id?.uuid, slug: createSlug(listingTitle) }} > {listingTitle} </NamedLink> ) } showPrice={showPrice} price={listing?.attributes?.price} intl={intl} /> {stateData.showOrderPanel ? orderPanel : null} <BreakdownMaybe className={css.breakdownContainer} orderBreakdown={orderBreakdown} processName={stateData.processName} /> {stateData.showActionButtons ? ( <div className={css.desktopActionButtons}>{actionButtons}</div> ) : null} </div> );
Finally, we can replace the original listing detail element, wrapped in a div with class css.detailCard, with the listingDetails variable.
<div className={css.stickySection}> {listingDetails} <DiminishedActionButtonMaybe showDispute={stateData.showDispute} onOpenDisputeModal={onOpenDisputeModal} /> </div>
Now, you can see the breakdown and action buttons, followed by all listing images, on the transaction page.
In this version, we still keep the dispute button i.e. the DiminishedActionButtonMaybe component below all listing images, but you could absolutely include it in the listingDetails constant as well. That way, you could show the dispute button above the cart listing images similarly to the order breakdown and other action buttons.
In this guide, we made the following changes:
- We added a new component, CartDetailsSideCard.js, to CheckoutPage and used it when checking out a cart instead of a single listing
- We updated InboxPage.js to indicate multiple listings within a transaction
- We updated TransactionPage to show cart listings
If you followed the whole series and built your own first draft implementation of a single-vendor shopping cart in your marketplace, we would love to hear from you! You can reach out to our Developer Advocate team through the chat widget in your Sharetribe Console and let us know if you learned something new or discovered something unexpected while following this series. We're happy to help you keep building your marketplace!