Developer Blog

Custom section components on Sharetribe’s CMS, Pages

Sharetribe marketplaces offer a simple CMS tool that operators can use to create content in their marketplaces without coding. This article discusses two ways to enhance the default display of CMS page components in Sharetribe Web Template.

Apr 11, 2024

A marketplace landing page section showing four images of bicycles with details under each image. Above the images, a heading reads "Recommended listings" and an ingress reads "Check out these amazing bikes!"

Sharetribe marketplaces offer a simple CMS tool, Pages, that operators can use to create content in their marketplaces through Console, without needing to update code. The Sharetribe Web Template has a default way to display the content created in Console. In addition, you can add custom sections for different use cases in your code base.

There are two main ways to use custom CMS sections in your code:

  • You can add custom handling for a specific Console-based section based on the section id
  • You can hard-code a custom section on the page you want

There are advantages and disadvantages to each approach.

If you create custom handling for a Console-based section, operators have more freedom to modify the section content. The caveat is that you need to carefully coordinate that the operator uses a specific sectionId on that section, and only that section. Debugging potential errors also becomes tricky, because you need to cross-reference information between the codebase and Console page sections.

If you hard-code a custom section on a page, changing it always requires code changes. On the other hand, once the feature has been developed, there are no outside influences that might break it, unlike with the Console-based section. This is a good option when any data shown in the section can be fetched directly from the Sharetribe APIs and does not need to rely on Console configurations.

In this article, I will show an example of both cases. You can also read more about passing custom sections to PageBuilder in our documentation.

You can see all code examples related to this blog article in this Gist.

*Note: this post has been updated 2024-05-03 to address an issue that occurred when fetching recommended listings. See sections "Get listing IDs from section data and set them to state" and "Fetch listings and add custom handling for section on LandingPage.js" for more details!

Dynamic custom Console-based section: recommended listings section


A common use case we get questions about is creating a recommended listings section on the landing page, so that the marketplace operator can add a showcase of listings and update them in Console without the need for code changes.

In this part of the guide, we will do the following:

  • Create a SectionRecommendedListings component
  • Create a ‘recommended-listings’ section in Console
  • Get listing IDs from section data and set them to LandingPage state
  • Fetch listings and add custom handling for section on LandingPage.js

Create a SectionRecommendedListings component

The easiest way to create custom sections is to replicate an existing section. For SectionRecommendedListings, you can copy the whole SectionColumns folder and rename it to SectionRecommendedListings. You would then rename the files, as well as replace all references to SectionColumns with SectionRecommendedListings within the files.

└── src
    └── containers
        └── PageBuilder
            └── SectionBuilder
                └── SectionRecommendedListings
                    ├── index.js
                    ├── SectionRecommendedListings.js
                    └── SectionRecommendedListings.module.css

You can see an example of the SectionRecommendedListings.js file here. The changes in SectionRecommendedListings.js compared to SectionColumns.js are the following:

  • Add the necessary imports for useIntl and ListingCard
import { useIntl } from '../../../../util/reactIntl';
import { ListingCard } from '../../../../components';
  • Add listings to props destructuring, and define intl
const SectionRecommendedListings = props => {
  const {
    ...
    listings,
  } = props;

  const intl = useIntl();
  • Replace hasBlocks check with a hasListings check, and replace the BlockBuilder component with a mapping of listings into ListingCard components
  const hasListings = listings.length > 0;

  return (
    <SectionContainer
     ...
      {hasListings ? (
        <div
          className={classNames(defaultClasses.blockContainer, getColumnCSS(numColumns), {
            [css.noSidePaddings]: isInsideContainer,
          })}
        >
          {listings.map(l => (
            <ListingCard key={l.id.uuid} listing={l} intl={intl} />
          ))}
        </div>
      ) : null}
    </SectionContainer>

In essence, we want to modify this component to not render its blocks directly, but instead render the listings that have been fetched based on the block information.

The final step for adding this custom component is importing and exporting it in SectionBuilderindex.js. This will help us avoid circular imports.

└── src
    └── containers
        └── PageBuilder
            └── SectionBuilder
                └── index.js
...
// Section components
...
import SectionRecommendedListings from './SectionRecommendedListings';

...

export {
  ...
  SectionRecommendedListings,
};

Next, let’s create the necessary section and block information in the Sharetribe Console!

Create a ‘recommended-listings’ section in Console

Visit your marketplace Console > Build > Content > Pages > landing-page. Click the “Add a new section” link below the existing sections, and add the following configurations to the section:

  • Section template: Columns
  • Number of columns: 4, although you can change this later
  • Section title and Section description: add the content you want to show above the recommended listings
  • Section call to action: No call to action - the listing cards are clickable, and will lead to the listing in question
  • Section appearance: Default appearance
  • Anchor link ID: recommended-listings – this is the section id that we want to target in code.
  • Content blocks: select four listings from your marketplace, and create four blocks in this section. For each block, add a listing UUID as the block name. Leave other settings as the default options.

Save your changes, and refresh your local marketplace. You should see a section with the title and description you added! However, no block information is rendered, because the blocks we added only have names and no content.

Black text on a white background. A title says "Recommended listings" and an ingress says "Check out these amazing bikes!"

Now, we can get the listing ids from the section’s blocks and fetch their information from the API.

Get listing IDs from section data and set them to state

The examples in this post both focus on the landing page, which has its own container, LandingPage

Most content pages created with Pages are displayed with the CMSPage container. This means that to make similar changes in regular CMS pages, you will need to make corresponding changes in the CMSPage.duck.js and CMSPage.js files instead of LandingPage.duck.js and LandingPage.js files.

└── src
    └── containers
        └── LandingPage
            └── LandingPage.duck.js
        └── reducers.js

In this example, we are using the SearchPage.duck searchListings thunk to fetch listings and set them to SearchPage state. On LandingPage, we will dispatch the listing fetch function, map the listings to props from SearchPage state, and use them in the custom section. When a user navigates to the search page, the loadData function does a new fetch with the default search page configurations, so this does not affect the functionality of the search page itself. However, you could also build the listing fetching logic directly to LandingPage.duck.js.

Let’s start by defining and exporting the recommended listings section ID as a constant, so that we can use it both in this file and in LandingPage.js.

export const recommendedSectionId = 'recommended-listings';

Next, we want to add state capabilities to the LandingPage container – we will store the listing ids fetched from assets in state. This way, we can store the Console-based listing ids in state, and then use them to dispatch the searchListings thunk with those ids.

// ================ Action types ================ //

export const FETCH_ASSETS_SUCCESS = 'app/LandingPage/FETCH_ASSETS_SUCCESS';

// ================ Reducer ================ //

const initialState = {
  recommendedListingIds: [],
};

export default function reducer(state = initialState, action = {}) {
  const { type, payload } = action;
  switch (type) {
    case FETCH_ASSETS_SUCCESS:
      return {
        ...state,
        recommendedListingIds: payload.ids,
      };

    default:
      return state;
  }
}

// ================ Action creators ================ //

export const fetchAssetsSuccess = ids => ({
  type: FETCH_ASSETS_SUCCESS,
  payload: { ids },
});

Then, we’ll create a helper function to get params for the listing fetch call. These params are similar to the ones used in SearchPage.duck.js loadData, and additionally we pass an array of listingIds to the function to only return the listings specified in Console.

const getListingParams = (config, listingIds) => {
  const {
    aspectWidth = 1,
    aspectHeight = 1,
    variantPrefix = 'listing-card',
  } = config.layout.listingImage;

  const aspectRatio = aspectHeight / aspectWidth;

  return {
    ids: listingIds,
    include: ['author', 'images'],
    'fields.listing': [
      'title',
      'price',
      'deleted',
      'state',
      'publicData.transactionProcessAlias',
    ],
    'fields.user': ['profile.displayName', 'profile.abbreviatedName'],
    'fields.image': [
      'variants.scaled-small',
      'variants.scaled-medium',
      `variants.${variantPrefix}`,
      `variants.${variantPrefix}-2x`,
    ],
    ...createImageVariantConfig(`${variantPrefix}`, 400, aspectRatio),
    ...createImageVariantConfig(`${variantPrefix}-2x`, 800, aspectRatio),
    'limit.images': 1,
  };
};

Now, we can update the loadData function to parse the listing IDs from the page asset, and then save the ids in state.

Technically, you could also try dispatching the searchListings thunk in loadData as well, but that triggers errors for example when hosting your app in Render. By triggering the listing fetch in LandingPage.js, we avoid that issue.

export const loadData = (params, search) => dispatch => {
  const pageAsset = { landingPage: `content/pages/${ASSET_NAME}.json` };

  return dispatch(fetchPageAssets(pageAsset, true)).then(assetResp => {
    // Get listing ids from custom recommended listings section
    const customSection = assetResp.landingPage?.data?.sections.find(
      s => s.sectionId === recommendedSectionId
    );

    if (customSection) {
      const recommendedListingIds = customSection?.blocks.map(b => b.blockName);
      dispatch(fetchAssetsSuccess(recommendedListingIds));
    }
  });
};

Finally, we need to connect the new LandingPage state in the application state. We do this by importing and exporting the new reducer in src/containers/reducers.js:

...
import LandingPage from './LandingPage/LandingPage.duck';
...

export {
  ...
  LandingPage,
  ...
}

After this, we can start putting all this together in LandingPage.js!

Fetch listings and add custom handling for section on LandingPage.js

In the actual LandingPage.js file, we need to do four main things:

  • Map listings from state to props and add dispatch mapping
  • Add a useEffect hook to fetch our listings
  • Define custom page data, complete with the custom section
  • Pass the custom page data and custom component to PageBuilder
└── src
    └── containers
        └── LandingPage
            └── LandingPage.js

First, let’s add some imports and a new constant towards the top of the file.

import React, { useEffect } from 'react';
...
import { ASSET_NAME, getRecommendedListingParams, recommendedSectionId } from './LandingPage.duck';
import { getListingsById } from '../../ducks/marketplaceData.duck';

import { searchListings } from '../SearchPage/SearchPage.duck';
import { useConfiguration } from '../../context/configurationContext';

const PageBuilder = loadable(() =>
  import(/* webpackChunkName: "PageBuilder" */ '../PageBuilder/PageBuilder')
);

import { SectionRecommendedListings} from '../PageBuilder/SectionBuilder';

const recommendedSectionType = 'recommended';

Toward the very bottom of the file, we have a mapStateToProps function already defined. Let’s add listings and recommendedListingIds to that handling.

const mapStateToProps = state => {
  const { pageAssetsData, inProgress, error } = state.hostedAssets || {};
  // These are the ids from the Console section
  const { recommendedListingIds } = state.LandingPage;
  // These are the ids for the listings returned from the API –
  // they may be different than the Console ones: if for example
  // one of the listings was closed, it does not get returned from the API
  const { currentPageResultIds } = state.SearchPage;
  const listings = getListingsById(state, currentPageResultIds);
  return { pageAssetsData, listings, inProgress, error, recommendedListingIds };
};

Then, we will need to add mapDispatchToProps to the component as well, so that we can add a prop to fetch our listings.

const mapDispatchToProps = dispatch => ({
  onFetchRecommendedListings: (params, config) => dispatch(searchListings(params, config)),
});

// Note: it is important that the withRouter HOC is **outside** the
// connect HOC, otherwise React Router won't rerender any Route
// components since connect implements a shouldComponentUpdate
// lifecycle hook.
//
// See: https://github.com/ReactTraining/react-router/issues/4671
const LandingPage = compose(
  connect(
    mapStateToProps,
    mapDispatchToProps
  )
)(LandingPageComponent);

Now, we can add our new props – listings, recommendedListingIds, and onFetchRecommendedListings – to the props destructuring in the beginning of the component:

  const {
    pageAssetsData,
    listings,
    inProgress,
    error,
    recommendedListingIds,
    onFetchRecommendedListings,
  } = props;

Next, we will add the useEffect hook that fetches listings once the ids have been fetched to state. Since the dispatch requires a config parameter, we can use the useConfiguration() hook that is built into the template by default.

  const config = useConfiguration();

  useEffect(() => {
    const params = getRecommendedListingParams(config, recommendedListingIds);
    onFetchRecommendedListings(params, config);
  }, [recommendedListingIds]);

When this hook gets called, it triggers the listing fetch, and the fetched listings get then mapped to LandingPage props through the state mapping we did before.

For the next step, instead of passing the page asset data to PageBuilder directly, we need to define custom page data that includes our custom section.

  // Construct custom page data
  const pageData = pageAssetsData?.[camelize(ASSET_NAME)]?.data;

  // Find the correct custom section based on section id
  const recommendedSectionIdx = pageData?.sections.findIndex(s => s.sectionId === recommendedSectionId);
  const recommendedSection = pageData?.sections[recommendedSectionIdx];

  // Define the necessary props for the custom section
  const customRecommendedSection = {
    ...recommendedSection,
    sectionId: recommendedSectionId,
    sectionType: recommendedSectionType,
    listings: listings,
  };

  // Replace the original section with the custom section object
  // in custom page data
  const customPageData = pageData
    ? {
        ...pageData,
        sections: pageData.sections.map((s, idx) =>
          idx === recommendedSectionIdx ? customRecommendedSection : s
        ),
      }
    : pageData;

Finally, let’s pass customPageData and the custom component to PageBuilder.

  return (
    <PageBuilder
      pageAssetsData={customPageData}
      options={{
        sectionComponents: {
          [recommendedSectionType]: { component: SectionRecommendedListings },
        },
      }}
      inProgress={inProgress}
      error={error}
      fallbackPage={<FallbackPage error={error} />}
    />
  );

Now, when you save the file, you can see your recommended listings section on the landing page!

A marketplace landing page section showing four images of bicycles with details under each image. Above the images, a heading reads "Recommended listings" and an ingress reads "Check out these amazing bikes!"

You can test how the Console section settings work together with this section by

  • Changing the listing ids in the block names or adding new blocks
  • Changing the column count of the section

When you save your changes in Console and then refresh your site to fetch the new assets, you will see those changes reflected on your landing page.

Hard-coded dynamic section: Current user welcome bar


Sometimes, you may want to create a custom section that shows dynamic data that has been fetched from the Sharetribe APIs, but you don’t need to modify the section through Console in any way. An example would be a custom section that is only shown for signed-in users with the information fetched from the API.

Since we don’t need to involve the Console in this process, the steps for creating a hard-coded section are simpler:

  • Create a SectionCurrentUser component
  • Add custom handling for the section in the LandingPage component

Our use case is to show this section above the landing page hero component, and show a “Welcome {first name}!” title and a link to their public profile page. To do this, we need to use the current user’s details.

The simplest version of this would be to use the default SectionArticle component, create the title and callToAction props in LandingPage.js, and pass them as props on to the custom section. However, we also want to visually modify this custom section, so we will create a fully new component.

Create a SectionCurrentUser component

In the previous use case, we copied an existing section as the basis of our custom section. We can do that now, too. Duplicate the whole SectionArticle folder, rename it and the files to SectionCurrentUser, and replace references to SectionArticle with SectionCurrentUser in the folder’s files.

└── src
    └── containers
        └── PageBuilder
            └── SectionBuilder
                └── SectionCurrentUser
                    ├── index.js
                    ├── SectionCurrentUser.js
                    └── SectionCurrentUser.module.css

In this example, we will simplify the SectionCurrentUser.js component. You can see a full version of the SectionCurrentUser.js file here

The main changes we will make compared to our copied SectionArticle.js starting point are the following:

  • Add currentUser to the props destructuring, and remove other props besides defaultClasses and options. Return null from the component if currentUser is empty.
const SectionCurrentUser = props => {
  const { defaultClasses, options, currentUser } = props;

  if (!currentUser) {
    return null;
  }
  • Define title and callToAction within the component using attributes from currentUser. To use Console-editable values for the content strings, you can create new Marketplace text strings and add them to the relevant src/translations/ file and your Console's Marketplace texts editor. You'd need to import and use the useIntl() function in the section similarly to SectionRecommendedListings, use the intl.formatMessage(...) version for the texts, and add the key-value pairs to the necessary marketplace text files.
  const title = {
    fieldType: 'heading2',
    content: `Welcome, ${currentUser.attributes.profile.firstName}!`,
    // content: intl.formatMessage(
    //   { id: 'LandingPageSectionCurrentUser.title' },
    //   { firstName: currentUser.attributes.profile.firstName }
    // ),
  };

  const callToAction = {
    content: 'View your public user profile',
    // content: intl.formatMessage({
    //   id: 'LandingPageSectionCurrentUser.callToAction',
    // }),
    fieldType: 'internalButtonLink',
    href: `/u/${currentUser.id.uuid}`,
  };
  • Remove description from hasHeaderFields check
    const hasHeaderFields = hasDataInFields([title, callToAction], fieldOptions);
  • Replace SectionContainer with a div that has a custom class (we will define the custom class next). Remove the Field row that renders description, as well as the whole hasBlocks section.
  return (
    <div className={css.userContainer}>
      {hasHeaderFields ? (
        <header className={defaultClasses.sectionDetails}>
          <Field data={title} className={defaultClasses.title} options={fieldOptions} />
          <Field data={callToAction} className={defaultClasses.ctaButton} options={fieldOptions} />
        </header>
      ) : null}
    </div>

Next, you can add this custom CSS class to SectionCurrentUser.module.css.

.userContainer {
  padding: 12px 0;
}

Finally, we can import and export this file in SectionBuilder index.js.

└── src
    └── containers
        └── PageBuilder
            └── SectionBuilder
                └── index.js
...
// Section components
...
import SectionCurrentUser from './SectionCurrentUser;

...

export {
  ...
  SectionCurrentUser,
};

Now the component is ready to be used on the landing page.

Add custom handling for the section in the LandingPage component

On LandingPage.js, taking the new section to use has very similar steps to the previous use case:

  • Map currentUser from state to props
  • Define custom page data, complete with the custom section
  • Pass the custom page data and custom component to PageBuilder
└── src
    └── containers
        └── LandingPage
            └── LandingPage.js

First, let’s import the new section and add a new constant for section type.

const PageBuilder = loadable(() =>
  import(/* webpackChunkName: "PageBuilder" */ '../PageBuilder/PageBuilder')
);

import { SectionRecommendedListings, SectionCurrentUser } from '../PageBuilder/SectionBuilder';

const recommendedSectionType = 'recommended';
const userSectionType = 'user';

Then, we can add currentUser to the mapStateToProps function towards the bottom of the file.

const mapStateToProps = state => {
  const { pageAssetsData, inProgress, error } = state.hostedAssets || {};
  const { recommendedListingIds } = state.LandingPage;
  const { currentPageResultIds } = state.SearchPage;
  const { currentUser } = state.user;
  const listings = getListingsById(state, currentPageResultIds);
  return { pageAssetsData, listings, inProgress, error, currentUser, recommendedListingIds };
};

Now, we can add currentUser to the props destructuring in the beginning of the component:

export const LandingPageComponent = props => {
  const {
    pageAssetsData,
    listings,
    inProgress,
    error,
    currentUser,
    recommendedListingIds,
    onFetchRecommendedListings,
  } = props;

Next, let’s create a props object for the new custom section, and add it to the beginning of the custom page data sections array. Since the section definition is getting more complex, we’ll also set our custom sections to a constant first for better readability.

const customCurrentUserSection = {
    sectionType: userSectionType,
    currentUser,
  };

  const customSections = pageData
    ? [
        customCurrentUserSection,
        ...pageData?.sections?.map((s, idx) =>
          idx === recommendedSectionIdx ? customRecommendedSection : s
        ),
      ]
    : null;

  const customPageData = pageData
    ? {
        ...pageData,
        sections: customSections,
      }
    : pageData;

Finally, we can include the new custom component in the options prop in PageBuilder.

  return (
    <PageBuilder
      pageAssetsData={customPageData}
      options={{
        sectionComponents: {
          [recommendedSectionType]: { component: SectionRecommendedListings },
          [userSectionType]: { component: SectionCurrentUser },
        },
      }}
      inProgress={inProgress}
      error={error}
      fallbackPage={<FallbackPage error={error} />}
    />

Now you can test the component! When you are logged out, the landing page looks the same as it always does. When you log in, you will see a custom section on top of the Hero image!

A fictional bike marketplace landing page with a section where on white background, there is a title "Welcome, Emma"! and a button with text "View your public user profile". Under this section, there is a large image of a person doing a bicycle trick and a text "Rent a bike from a local".

Depending on what data you have saved in your user profiles, you can also modify this section to show

…or something completely different!

Summary


In this blog post, we reviewed two ways to add custom sections for your Pages content pages:

  • We created a dynamic custom section for showing Console-defined data
  • And we created a dynamic hard-coded custom section for showing user data

If you come up with other cool ideas on using custom sections, or if you have any questions, reach out to us at the Sharetribe Developer Advocate team through the chat widget in your Sharetribe marketplace Console!

Liked this? Get email updates on new Sharetribe Developer Blog posts.

Subscribe