How to add support for Category Landing Pages to PWA Studio

How to add support for Category Landing Pages to PWA Studio

A Category Landing Page is a special type of Category Page. In Magento, a Merchant is able to set special content for a specific category. Take a look at this sample Magento Luma Category Landing Page:

Magento Category landing page

How does it work

In the Magento category page configuration, there is a config field called Display Mode and there are three available options:

  • Products only

  • Static block only

  • Static block and products

how to enable category landing page in Magento 2

If you select the ‘Static block only’ option and set up a static block for the category, you will see your static block on the frontend for the Category, and this is exactly what Category Landing Page means.

Does PWA Studio supports Category Landing Pages?

No.

No.

No.

Shocked man

Is it possible to add support for CLP in PWA Studio?

If you read my blog….

Definitely YES.

Happy man

Let’s do this!

Install PWA Studio

The very first thing that we need to do is create a new instance of PWA Studio.

1$ yarn create @magento/pwa
2$ cd <directory where PWA Studio has been installed
3$ yarn buildpack create-custom-origin ./

Also, we need to have our own Magento 2 instance. This time I am using Magento 2.4.2 with sample data installed. If you don’t have your own local Magento 2 installation, I recommend using Mark Shust’s Magento Docker.

People often ask me:

What is the best way to install Magento in the local environment?

Anonymous Magento developer

Then I tell them:

Use Mark’s Docker – it’s the best

thank you

The Idea

To achieve our goal, we are going to use the PWA Studio Extensibility framework. We will create an improvedCategoryContent component which will be a wrapper for CategoryContent PWA Studio’s component. We will add additional logic there, and display Category Landing page content instead of an empty page.

Create theme

Before we start coding, let’s create a directory to keep all modifications related to the Category Landing Page.

First, create @theme/category folder in pwa_studio_root/src directory.

Second, let’s link this folder as a package in the package.json

1dependencies": {
2    "@magento/pwa-buildpack": "~9.0.0",
3    "@theme": "link:src/@theme"
4  },

Add improvedCategoryContent component

Create file src / @theme / category / components / ImprovedCategoryContent / ImprovedCategoryContent.js with the following content:

1import React from 'react';
2import PropTypes from 'prop-types';
3
4import CategoryContent from '@magento/venia-ui/lib/RootComponents/Category/categoryContent';
5import LoadingIndicator from '@magento/venia-ui/lib/components/LoadingIndicator/indicator'
6
7import { useImprovedCategoryContent } from '../../talons/useImprovedCategoryContent';
8import CategoryLandingPage from '../CategoryLandingPage';
9
10/**
11 * The ImprovedCategoryContent wraps CategoryContent @components and allows to display Category Landing Pages
12 * @param {object} props
13 * @param {number} props.categoryId - Category's ID
14 * @param {object} props.classes - additional CSS classes that will be applied to the component
15 * @param {object} props.data - Category data
16 * @param {object} props.pageControl - Pagination data
17 * @param {number} props.pageSize - Page size
18 * @param {object} props.sortProps - Sort props
19 */
20const ImprovedCategoryContent = (props) => {
21    const {
22        categoryId,
23        classes,
24        data,
25        pageControl,
26        sortProps,
27        pageSize,
28        ...rest
29    } = props;
30
31    const {
32        isLandingPage,
33        isLoading,
34        staticBlockId
35    } = useImprovedCategoryContent({categoryId});
36
37    const categoryContent = isLandingPage ? <div>
38        <CategoryLandingPage staticBlockId={staticBlockId}/>
39    </div> : <CategoryContent
40        categoryId={categoryId}
41        classes={classes}
42        data={data}
43        pageControl={pageControl}
44        sortProps={sortProps}
45        pageSize={pageSize}
46    />;
47
48    const shouldDisplayContent = !isLoading ? categoryContent : <LoadingIndicator/>;
49
50    return (
51        <div {...rest}>
52            {shouldDisplayContent}
53        </div>
54    );
55};
56
57ImprovedCategoryContent.propTypes = {
58    categoryId: PropTypes.number.isRequired,
59    classes: PropTypes.object,
60    data: PropTypes.object,
61    pageControl: PropTypes.object,
62    pageSize: PropTypes.number,
63    sortProps: PropTypes.array
64};
65
66export default ImprovedCategoryContent;

The purpose of this component is to check if the category page is a category landing page. All logic related to checking that is in useImprovedCategoryContent hook that we will create in the next step.

If the category is a landing page, the CategoryLandingPage component is rendered. Otherwise, native PWA Studio’s CategoryContent will be rendered.

Next, create the src / @theme / category / components / ImprovedCategoryContent / index.js file with following content:

1export { default } from './ImprovedCategoryContent';

Add GET_CATEGORY_LANDING_PAGE query Before implementing the hook, let’s create a GraphQL query that will get information about category display mode and a Static Block ID used for a category’s content.

Create a src/@theme/category/queries.gql.js file:

1import gql from 'graphql-tag'
2
3export const GET_CATEGORY_LANDING_PAGE = gql`
4    query category(
5        $id: Int!
6    ) {
7        category(id: $id) {
8            id
9            display_mode
10            landing_page
11        }
12    }
13`;

The query is really straightforward. One important thing here is the ID field in results. Thanks to that ID, Apollo Client can merge these query results with other queries by ID.

Records are merged by ID, and if you have three queries for a category with the same ID, results will be stored in Apollo cache and available without querying to the server. How powerful is that?

Add useImprovedCategoryContent hook

Create a src / @theme / category / talons / useImprovedCategoryContent.js file:

1import { useQuery } from '@apollo/client';
2import { GET_CATEGORY_LANDING_PAGE } from '../queries.gql';
3
4/**
5 * Returns props necessary to render the ImprovedCategoryContent @component.
6 *
7 * @param {object} props
8 * @param {number} props.categoryID
9 *
10 * @returns {string} result.error - error message returns if something went wrong
11 * @returns {bool} result.isLandingPage - flag determinates is a Category is in Statick Blocks only mode.
12 *                                        This is true when display mode eauals 'PAGE'
13 * @returns {bool} result.isLoading - flag determinates is data loading
14 * @returns {number|null} result.staticBlockId - Static block ID set up for the Category Page or null.
15 */
16export const useImprovedCategoryContent = props => {
17    const {
18        categoryId
19    } = props;
20
21    const { data, loading } = useQuery(GET_CATEGORY_LANDING_PAGE, {
22        fetchPolicy: 'cache-and-network',
23        nextFetchPolicy: 'cache-first',
24        variables: {
25            id: categoryId
26        }
27    });
28
29    return {
30        isLandingPage: data && data.category.display_mode === 'PAGE',
31        isLoading: loading,
32        staticBlockId: data ? data.category.landing_page : null,
33    }
34}

That hook receives one parameter - categoryId, and it gets data from the Magento backend using the already declared GET_CATEGORY_LANDING_PAGE query.

Hook returns three fields:

  • isLandingPage - this flag determines category is a landing page or not. Each category that has set up Display Mode equals Page is a landing page.

  • isLoading - determines state of data loading

  • staticBlockId - ID of static block set up for Category landing page.

Update local-intercept.js

It’s time to inject our component to Storefront. Take a look at the code below:

File src/local-intercept.js:

1const { Targetables } = require('@magento/pwa-buildpack')
2
3module.exports = targets => {
4
5    const targetables = Targetables.using(targets);
6
7    const CategoryRootComponent = targetables.reactComponent(
8        '@magento/venia-ui/lib/RootComponents/Category/category'
9    );
10
11    const ImprovedCategoryContent = CategoryRootComponent.addImport(
12        "ImprovedCategoryContent from '@theme/category/components/ImprovedCategoryContent'"
13    );
14
15    CategoryRootComponent.replaceJSX('<CategoryContent />', `<${ImprovedCategoryContent} />`)
16        .setJSXProps(`ImprovedCategoryContent`, {
17            'categoryId': '{id}',
18            'classes': '{classes}',
19            'data': '{categoryData}',
20            'pageControl': '{pageControl}',
21            'sortProps': '{sortProps}',
22            'pageSize': '{pageSize}',
23        });
24}
25

To insert the ImprovedCategoryContent component, we added import to the Category root component. We used the replaceJSX method to insert the component to the JSX (replace the native component with our new one). We passed all props from the native component to ours.

Add CategoryLandingPage component

First, create a file src / @theme / category / components / CategoryLandingPage / CategoryLandingPage.js with the following content:

1import React from 'react';
2import PropTypes from 'prop-types';
3import PlainHtmlRenderer from '@magento/venia-ui/lib/components/RichContent';
4import LoadingIndicator from '@magento/venia-ui/lib/components/LoadingIndicator/indicator';
5
6import { useCategoryLandingPage } from '../../talons/useCategoryLandingPage';
7import classes from './CategoryLandingPage.module.css';
8
9/**
10 * The CategoryLandingPage @component displays CMS content for categories that have set up Display mode to Static block only.
11 *
12 * @param {object} props
13 * @param {string} props.staticBlockId - Static block's ID that provides content for the Page
14 */
15const CategoryLandingPage = (props) => {
16    const {
17        staticBlockId,
18        ...rest
19    } = props;
20
21    const {
22        content,
23        errorMessage,
24        isLoading
25    } = useCategoryLandingPage({staticBlockId});
26
27    const shouldDisplayContent = !isLoading ? <PlainHtmlRenderer html={content}/> : <LoadingIndicator/>;
28    const shouldDisplayError = errorMessage ? <p>{errorMessage}</p> : null;
29
30    return (
31        <div className={classes.categoryLandingPage} {...rest}>
32            {shouldDisplayContent}
33            {shouldDisplayError}
34        </div>
35    );
36};
37
38CategoryLandingPage.propTypes = {
39    staticBlockId: PropTypes.string.isRequired
40};
41
42export default CategoryLandingPage;
43

Keep in mind the component is rendered if a category has display mode equals Page set up. The component receives staticBlockId prop and uses it to get content for a specific category.

Content is rendered using PlainHtmlRenderer. If something went wrong, an error message is rendered.

Second, create a file src / @theme / category / components / CategoryLandingPage / index.js:

1export { default } from './CategoryLandingPage';

Lastly, create a CSS module src /@theme / category / components / CategoryLandingPage / CategoryLandingPage.module.css

1.categoryLandingPage {
2    padding: 20px;
3}
4

Add useCategoryLandingPage hook

The last thing needed to display the Category landing page’s content is the hook that collects content from Magento.

Create a file src / @theme / category / talons / useCategoryLandingPage.js:

1import { useState, useEffect } from 'react';
2import { useQuery } from '@apollo/client';
3import { GET_CMS_BLOCKS } from '@magento/venia-ui/lib/components/CmsBlock/cmsBlock.js';
4
5/**
6 * Returns props necessary to render the CategoryLandingPage @component.
7 *
8 * @param {object} props
9 * @param {number} props.staticBlockId - ID of a Static Block connected with the Category Landing Page
10 *
11 * @returns {string} result.errorMessage - error message returns if something went wrong
12 * @returns {bool} result.isLoading - flag determinates is data loading
13 * @returns {string} result.content - HTML content of Static Block connected to the Category Landing Page
14 */
15export const useCategoryLandingPage = props => {
16    const {
17        staticBlockId
18    } = props;
19
20    const [ content, setContent ] = useState(null);
21    const [ errorMessage, setErrorMessage ] = useState(null);
22
23    const { data, error, loading } = useQuery(GET_CMS_BLOCKS, {
24        fetchPolicy: 'cache-and-network',
25        nextFetchPolicy: 'cache-first',
26        skip: !staticBlockId,
27        variables: {
28            identifiers: [ staticBlockId ]
29        }
30    });
31
32    useEffect(() => {
33        if (data && data.cmsBlocks && data.cmsBlocks.items) {
34            setContent(data.cmsBlocks.items[0].content);
35        }
36
37        if (!staticBlockId || error || data && data.cmsBlocks && data.cmsBlocks.items.length === 0) {
38            setErrorMessage('Unable to get category page content. Please try again later.')
39        }
40
41    }, [data, staticBlockId, error])
42
43    return {
44        errorMessage,
45        isLoading: loading,
46        content
47    }
48}

The component returns three fields, and the most important for us is content, which contains the Category Landing page’s content!

Take a look at the results of our fantastic work!

After the modifications, I can see the working category landing page in PWA Studio!

As you can see, the Page works but there are missing styles. A new content renderer is probably needed here, and I think this is quite a good subject for the next article.

Summary

This time we added support for Category Landing Pages to PWA Studio Storefront. As you can see, the PWA Studio Extensibility framework is really powerful, and thanks to this we can easily extend PWA Studio with new features.

Source code

The source code for this tutorial is available on my Github.

About the author

Marcin Kwiatkowski

Certified Magento Full Stack Developer, based in Poland. He has 7 years of professional Software engineering experience. Privately, husband, father, and DIY enthusiast.

Read more