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 Luma 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
Category Landing Page configuration

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 person becoase PWA Studio does not supports Category Landing Pages

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

If you read my blog….

Definitely YES.

Happy PWA Studio developer

Let’s do this!

Install PWA Studio

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

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

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

People often ask me:

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

Then I tell them:

Use Mark’s Docker – it’s the best

Thank you so much Mark Shust for awesome Docker for Magento!

Add module base files

Now we will add the base files for our module. If you have never heard of the PWA Studio Extensibility Framework, check one of my recent articles.

Directory for the module and empty index.js file

We will keep our modules in the ‘src’ directory and link it using the Yarn link feature. This is a good way to develop new PWA Studio plugins/modules.

$ mkdir -p @marcinkwiatkowski/category-landing-page
$ touch @marcinkwiatkowski/category-landing-page/index.js

Package.json file

{
  "name": "@marcinkwiatkowski/category-landing-page",
  "version": "1.0.0",
  "description": "PWA Studio Plugin which adding support for Category Landing Pages",
  "author": "Marcin Kwiatkowski <contact@frodigo.com>",
  "license": "MIT",
  "pwa-studio": {
    "targets": {
      "intercept": "intercept.js"
    }
  },
  "peerDependencies": {
    "@magento/pwa-buildpack": "^6.0.0",
    "@magento/venia-ui": "*",
    "react": "~16.9.0",
    "react-router-dom": "^5.1.0"
  }
}

intercept.js file

const componentOverrideMapping = require('./componentOverrideMapping');
const moduleOverridePlugin = require('./moduleOverrideWebpackPlugin');

module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        flags[targets.name] = {esModules: true};
    });

    targets.of('@magento/pwa-buildpack').webpackCompiler.tap(compiler => {
        new moduleOverridePlugin(componentOverrideMapping).apply(compiler);
    })
}

This file lets PWA Studio know that we will use ES Modules here, and which files we will overwrite.

ModuleOverrideWebpackPlugin.js file

const path = require('path');
const glob = require('glob');

module.exports = class NormalModuleOverridePlugin {
    constructor(moduleOverrideMap) {
        this.name = 'NormalModuleOverridePlugin';
        this.moduleOverrideMap = moduleOverrideMap;
    }
    requireResolveIfCan(id, options = undefined) {
        try {
            return require.resolve(id, options);
        } catch (e) {
            return undefined;
        }
    }
    resolveModulePath(context, request) {
        const filePathWithoutExtension = path.resolve(context, request);
        const files = glob.sync(`${filePathWithoutExtension}@(|.*)`);
        if (files.length === 0) {
            throw new Error(`There is no file '${filePathWithoutExtension}'`);
        }
        if (files.length > 1) {
            throw new Error(
                `There is more than one file '${filePathWithoutExtension}'`
            );
        }
        return require.resolve(files[0]);
    }
    resolveModuleOverrideMap(context, map) {
        return Object.keys(map).reduce(
            (result, x) => ({
                ...result,
                [require.resolve(x)]:
                this.requireResolveIfCan(map[x]) ||
                this.resolveModulePath(context, map[x]),
            }),
            {}
        );
    }
    apply(compiler) {
        if (Object.keys(this.moduleOverrideMap).length === 0) {
            return;
        }
        const moduleMap = this.resolveModuleOverrideMap(
            compiler.context,
            this.moduleOverrideMap
        );
        compiler.hooks.normalModuleFactory.tap(this.name, (nmf) => {
            nmf.hooks.beforeResolve.tap(this.name, (resolve) => {
                if (!resolve) {
                    return;
                }
                const moduleToReplace = this.requireResolveIfCan(resolve.request, {
                    paths: [resolve.context],
                });
                if (moduleToReplace &amp;&amp; moduleMap[moduleToReplace]) {
                    resolve.request = moduleMap[moduleToReplace];
                }
                return resolve;
            });
        });
    }
};

ComponentOverrideMappings.js file

module.exports = componentOverride = {
};

Our module is scaffolded, so now we are going to link it as a dependency in our PWA Studio instance. Please add this into your package.json file in PWA Studio root:

"dependencies": {
    "@marcinkwiatkowski/category-landing-page": "link:<absolute_path_of_your_module>"
  },

Overwrite getCategory GraphQL query

We need to extend the getCategory GraphQL query and add two fields there:

  • display_mode – the display mode selected by the Administrator in the admin panel
  • landing_page –  the ID of the static block which is selected for the current category. If no static block is selected, this value will be null.

Create the file src/@marcinkwiatkowski/category-landing-page/lib/queries/getCategory.graphql:

query category(
    $id: Int!
    $pageSize: Int!
    $currentPage: Int!
    $onServer: Boolean!
    $filters: ProductAttributeFilterInput!
    $sort: ProductAttributeSortInput
) {
    category(id: $id) {
        id
        description
        name
        product_count
        meta_title @include(if: $onServer)
        meta_keywords @include(if: $onServer)
        meta_description
        display_mode
        landing_page
    }
    products(
        pageSize: $pageSize
        currentPage: $currentPage
        filter: $filters
        sort: $sort
    ) {
        items {
            # Once graphql-ce/1027 is resolved, use a ProductDetails fragment here instead.
            __typename
            description {
                html
            }
            id
            media_gallery_entries {
                id
                label
                position
                disabled
                file
            }
            meta_title @include(if: $onServer)
            meta_keyword @include(if: $onServer)
            meta_description
            name
            price {
                regularPrice {
                    amount {
                        currency
                        value
                    }
                }
            }
            sku
            small_image {
                url
            }
            url_key
            ... on ConfigurableProduct {
                configurable_options {
                    attribute_code
                    attribute_id
                    id
                    label
                    values {
                        default_label
                        label
                        store_label
                        use_default_value
                        value_index
                        swatch_data {
                            ... on ImageSwatchData {
                                thumbnail
                            }
                            value
                        }
                    }
                }
                variants {
                    attributes {
                        code
                        value_index
                    }
                    product {
                        id
                        media_gallery_entries {
                            id
                            disabled
                            file
                            label
                            position
                        }
                        sku
                        stock_status
                    }
                }
            }
        }
        page_info {
            total_pages
        }
        total_count
    }
}

Add a mapping to src/@marcinkwiatkowski/category-landing-page/componentOverrideMappings.js

module.exports = componentOverride = {
    [`@magento/venia-ui/lib/queries/getCategory.graphql`]: './lib/queries/getCategory.graphql',
};

Our extended query should return the following result:

You can see that display_mode equals “PAGE” which means that ‘static block mode only’ is selected.

Overwrite categoryContent Component

Now it’s time to modify the categoryContent component. We need to do a few things here:

  • check which display mode is selected
  • if display_mode equals PAGE, the filters toolbar will be hidden, and the static block will be displayed

Add file src/@marcinkwiatkowski/category-landing-page/lib/RootComponents/Category/categoryContent.js

import React, { Fragment, Suspense } from 'react';
import { array, shape, string } from 'prop-types';
import RichContent from '@magento/venia-ui/lib/components/RichContent';

import { useCategoryContent } from '@magento/peregrine/lib/talons/RootComponents/Category';
import { useStaticBlockOnly } from './useStaticBlockOnly';

import NoProductsFound from '@magento/venia-ui/lib/RootComponents/Category/NoProductsFound';
import { mergeClasses } from '@magento/venia-ui/lib/classify';
import { Title } from '@magento/venia-ui/lib/components/Head';
import Breadcrumbs from '@magento/venia-ui/lib/components/Breadcrumbs';
import Gallery from '@magento/venia-ui/lib/components/Gallery';
import Pagination from '@magento/venia-ui/lib/components/Pagination';
import RichText from '@magento/venia-ui/lib/components/RichText';
import defaultClasses from '@magento/venia-ui/lib/RootComponents/Category/category.css';

const FilterModal = React.lazy(() => import('@magento/venia-ui/lib/components/FilterModal'));
import GET_PRODUCT_FILTERS_BY_CATEGORY from '@magento/venia-ui/lib/queries/getProductFiltersByCategory.graphql';
import {STATIC_BLOCK_ONLY_DISPLAY_MODE} from "../../constants/category";

const CategoryContent = props => {
    const { categoryId, data, pageControl, sortProps } = props;
    const [currentSort] = sortProps;

    const talonProps = useCategoryContent({
        categoryId,
        data,
        queries: {
            getProductFiltersByCategory: GET_PRODUCT_FILTERS_BY_CATEGORY
        }
    });

    const {
        categoryName,
        categoryDescription,
        filters,
        handleLoadFilters,
        handleOpenFilters,
        items,
        pageTitle,
        totalPagesFromData
    } = talonProps;

    const classes = mergeClasses(defaultClasses, props.classes);
    const isStaticBlockMode = data.category.display_mode === STATIC_BLOCK_ONLY_DISPLAY_MODE;
    const header = !isStaticBlockMode &amp;&amp; filters ? (
        <Fragment>
            <div className={classes.headerButtons}>
                <button
                    className={classes.filterButton}
                    onClick={handleOpenFilters}
                    onFocus={handleLoadFilters}
                    onMouseOver={handleLoadFilters}
                    type="button"
                >
                    {'Filter'}
                </button>
                <ProductSort sortProps={sortProps} />
            </div>
            <div className={classes.sortContainer}>
                {'Items sorted by '}
                <span className={classes.sortText}>{currentSort.sortText}</span>
            </div>
        </Fragment>
    ) : null;

    // If you want to defer the loading of the FilterModal until user interaction
    // (hover, focus, click), simply add the talon's `loadFilters` prop as
    // part of the conditional here.
    const modal = filters ? <FilterModal filters={filters} /> : null;

    const categoryDescriptionElement = categoryDescription ? (
        <RichContent html={categoryDescription} />
    ) : null;

    let content;

    if (isStaticBlockMode) {
        const staticBlockOnly = useStaticBlockOnly({
            blockId: data.category.landing_page
        });
        content = (
            <Fragment>
                <RichText content={staticBlockOnly.content} />
            </Fragment>
        )
    } else {
        content =
            totalPagesFromData === 0 ? (
                <NoProductsFound categoryId={categoryId} />
            ) : (
                <Fragment>
                    <section className={classes.gallery}>
                        <Gallery items={items} />
                    </section>
                    <div className={classes.pagination}>
                        <Pagination pageControl={pageControl} />
                    </div>
                </Fragment>
            );
    }


    return (
        <Fragment>
            <Breadcrumbs categoryId={categoryId} />
            <Title>{pageTitle}</Title>
            <article className={classes.root}>
                <h1 className={classes.title}>
                    <div className={classes.categoryTitle}>{categoryName}</div>
                </h1>
                {categoryDescriptionElement}
                {header}
                {content}
                <Suspense fallback={null}>{modal}</Suspense>
            </article>
        </Fragment>
    );
};

export default CategoryContent;

CategoryContent.propTypes = {
    classes: shape({
        filterContainer: string,
        gallery: string,
        headerButtons: string,
        pagination: string,
        root: string,
        title: string
    }),
    // sortProps contains the following structure:
    // [{sortDirection: string, sortAttribute: string, sortText: string},
    // React.Dispatch<React.SetStateAction<{sortDirection: string, sortAttribute: string, sortText: string}]
    sortProps: array
};

Now I will show you the changes in comparison with the original file. First of all, we need to change import declarations and correct the paths:

Then we will add two new imports:

The first one is a constant, and the second one is a hook that allows us to get the content of the static block for the current category.

Here is the logic for the filters bar:

The last thing is the logic for displaying static block content if the page is in ‘static block only’ display mode.

Add useStatickBlockOnly hook

Create the file src/@marcinkwiatkowski/category-landing-page/lib/RootComponents/Category/useStaticBlockOnly.js

import { useQuery } from '@apollo/react-hooks';
import GET_CMS_BLOCKS from '@magento/venia-ui/lib/queries/getCmsBlocks.graphql';

/**
 * Get static block content for category.
 *
 * @param {object} props
 * @returns {{staticBlockContent: string}}
 */
export const useStaticBlockOnly = props => {
    const {
        blockId
    } = props;

    // TODO: add error handling
    const { data } = useQuery(GET_CMS_BLOCKS, {
        variables: {
            identifiers: [blockId]
        }
    });

    const content = (data &amp;&amp; data.cmsBlocks &amp;&amp; data.cmsBlocks.items) ? data.cmsBlocks.items[0].content : ''

    return {
        content
    }
}

On Line 15 there is some homework for you – adding error handling! 🙂

Now we will add the constant STATIC_BLOCK_ONLY_DISPLAY_MODE. Please create the filesrc/@marcinkwiatkowski/category-landing-page/lib/constants/category with following content:

export const STATIC_BLOCK_ONLY_DISPLAY_MODE = 'PAGE';

The last thing we need to do is to add a new mapping for the categoryContent component:

module.exports = componentOverride = {
    [`@magento/venia-ui/lib/queries/getCategory.graphql`]: './lib/queries/getCategory.graphql',
    ['@magento/venia-ui/lib/RootComponents/Category/categoryContent.js']: './lib/RootComponents/Category/categoryContent.js'
};

See the results of our awesome 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.

WRITTEN BY

Marcin Kwiatkowski

Certified Magento Full Stack Developer who is passionate about Magento. He has 7 years of professional Software engineering experience.  Privately, husband, father, and DIY enthusiast.

 

You may also like