Implement the Catalog module

Ask about this Page
Copy for AI
View as Markdown

Read Catalog state from the InStore State module, and render Catalog browsing and search that replaces the one built into the InStore POS.

This page covers the interface that a Catalog replacement module implements. For the build setup that every replacement module needs, see Build a replacement module.

A Catalog replacement module reads everything it needs from the Catalog module context, rather than calling the InStore backend.

Read Catalog state and operations

instoreState/context/CatalogModuleContext provides useCatalogModuleContext, which returns the Catalog state and the operations that act on it.
The POS passes no props to a Catalog replacement module apart from the current URL query parameters, so read Catalog state from the context.
useCatalogModuleContext returns the following members. They are marked @public in the types that the InStore State module publishes, and are supported for use in replacement modules. To resolve those types, see Build a replacement module.
MemberTypeDescription
categoryListCatalogModuleCategory[]Categories from the most recent getCategories call, flat and in the order the backend returned them.
searchResultsCatalogModuleProduct[]Products from the most recent search.
returnReasonListCatalogReturnReason[]Reasons available for an unreceipted return.
productsByCategoryReadonly<Record<string, CatalogModuleProduct[]>>Products cached by Category id, and single Products cached by Product id after a search.
addProductToCartMethodAdds a Product to the active Cart or to a return Cart.
checkCatalogParameterMethodChecks whether an administration parameter is active for this workstation.
checkUserPermissionMethodChecks whether the signed-in user holds a given permission.
clearSearchResultsMethodClears searchResults.
getCategoriesMethodLoads Categories into categoryList.
getCategoryProductsMethodLoads the Products of a Category into productsByCategory.
searchProductsMethodSearches Products, and fills searchResults.

The provider adds the location and workstation to every search, so your module does not pass them.

The context holds no loading or error state. Track both in your own module.

Fetch and read

getCategories and getCategoryProducts resolve Promise<void>. They fill categoryList and productsByCategory respectively, so await the call and then render from the state member rather than from a return value.
searchProducts works both ways. It resolves the matching Products and fills searchResults with the same Products, so you can read either one.
getCategoryProducts returns without calling the backend when productsByCategory already holds Products for that Category. A Category that genuinely holds no Products is refetched on every visit, because an empty cache entry and an unvisited Category look the same.

Handle failures

Every fallible operation in this context rejects, so wrap each one in try and catch. None of them report an outcome through a result field, which is the opposite of searchByEmailAndAttach on the Customer module.

The operations differ in what they leave behind:

  • getCategories rejects and resets categoryList to an empty list.
  • getCategoryProducts rejects and leaves the cache as it was, so a retry is safe.
  • searchProducts rejects and writes nothing, so a failed search is not an empty search. Distinguish the two in your module, because only a failure is worth a retry.
  • addProductToCart rejects with a message that names the cause.
  • checkCatalogParameter and checkUserPermission never throw. They return false for anything they can't confirm.
Two conditions reject a search that might otherwise look like an empty result. An inactive Product Search index on the commercetools Project rejects the search instead of resolving it empty, and a search on a localized field without a language value rejects as a bad request.

Check permissions and parameters

checkUserPermission accepts a value of CatalogModuleUserPermission or the equivalent string:
ValuePermission
ORDER_ITEMSorderitems
SELL_ITEMsellitem
UNRECEIPTED_RETURNSunreceiptedreturns
checkCatalogParameter accepts a value of CatalogModuleParameter or the equivalent string:
ValueParameter
ALLOW_ORDER_ITEMAllow_Order_Item
ALLOW_SELL_ITEMAllow_Sell_Item
ALLOW_UNRECEIPTED_RETURNAllow_Unreceipted_Return

Both checks also accept a permission or parameter name that its enum doesn't list, passed as a string.

A parameter counts as active only when its configured value is the string true. Any other value, and a parameter that isn't configured, returns false. For the way these values reach a workstation, see Administration parameters.

Gate the controls that add a Product on both checks. A missing permission and an inactive parameter each prevent the action, so treat either result as a reason to hide or deactivate the control. Your customers don't see the difference at checkout.

Render a minimal Catalog

Expose a root component that defines your routes:

src/App.tsxtsx
import React from 'react';
import { Route, Routes } from 'react-router-dom';

import { CatalogPage } from './components/CatalogPage';

const App = () => (
  <Routes>
    <Route index element={<CatalogPage />} />
  </Routes>
);

export default App;

Then read the context, search, and render the results:

src/components/CatalogPage.tsxtsx
import {
  CatalogModuleParameter,
  CatalogModuleUserPermission,
  toProductView,
  useCatalogModuleContext,
} from 'instoreState/context/CatalogModuleContext';
import { useLocalizationContext } from 'instoreState/locale';
import React, { useCallback, useState } from 'react';

export const CatalogPage = () => {
  const { locale, t } = useLocalizationContext();
  const {
    searchResults,
    searchProducts,
    addProductToCart,
    checkCatalogParameter,
    checkUserPermission,
  } = useCatalogModuleContext();
  const [term, setTerm] = useState('');
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState('');

  const canSell =
    checkUserPermission(CatalogModuleUserPermission.SELL_ITEM) &&
    checkCatalogParameter(CatalogModuleParameter.ALLOW_SELL_ITEM);

  const search = useCallback(async () => {
    setBusy(true);
    setMessage('');

    try {
      await searchProducts({
        query: { fullText: { field: 'name', language: locale, value: term } },
        limit: 24,
        offset: 0,
      });
    } catch (error) {
      setMessage(t('Catalog.search_failed'));
    } finally {
      setBusy(false);
    }
  }, [locale, searchProducts, t, term]);

  const add = useCallback(
    async (productId: string) => {
      try {
        await addProductToCart({ productId, quantity: 1 });
      } catch (error) {
        setMessage(t('Catalog.product_detail.labels.error_add'));
      }
    },
    [addProductToCart, t],
  );

  return (
    <div>
      <input
        aria-label={t('Common.search')}
        value={term}
        onChange={(event) => setTerm(event.target.value)}
      />
      <button onClick={search}>{t('Common.search')}</button>
      {busy ? <p role="status">Searching</p> : null}
      {message ? <p role="alert">{message}</p> : null}
      {searchResults.map((product) => {
        const view = toProductView(product, locale);

        if (!view) {
          return null;
        }

        return (
          <article key={view.id}>
            <span>{view.name}</span>
            <span>{view.priceDinero.toFormat()}</span>
            {canSell ? (
              <button onClick={() => add(view.id)}>
                {t('Catalog.add_to_sale')}
              </button>
            ) : null}
          </article>
        );
      })}
    </div>
  );
};

This example serves a single index route. Treat it as a starting point for the interface rather than as a complete replacement.

The translation function t from instoreState/locale returns the key itself when the key is missing, so a key that the POS doesn't ship renders as literal text, such as Catalog.browse.example. Prefer the keys that the POS already ships, and supply your own text for anything else, as the example does for its loading state. For the available keys, see List of core strings.

Add a Product to the Cart

addProductToCart accepts a request with the following fields:
FieldData typeDescription
commentStringReturn reason, sent as returnComment. Applies to a return target only.
fulfillmentStringHow the Line Item is fulfilled: takeWith, shipWith, pickup, or ordered. Applies to a sale target only, and defaults to takeWith.
productIdStringRequired. id of the Product to add.
quantityNumberQuantity to add. Defaults to 1.
targetStringCart that receives the Product, either cart or returnCart. Defaults to cart.
variantIdNumberid of the Product Variant to add. Defaults to the Master Variant.
The Product must already be in productsByCategory or in searchResults. A productId that the module hasn't loaded rejects, so add a Product from a rendered result rather than from an identifier you hold elsewhere.
A request with an empty productId resolves without adding anything.
The context decides which Cart a sale lands on, and creates a return Cart when one is needed. For the Cart itself and its update actions, see Implement the Cart module.

Build the Category tree

categoryList arrives flat, so a replacement that renders nested navigation derives the tree itself. The context exports pure helpers for this:
  • buildCategoryTree turns categoryList into a list of root nodes, each with a parentId and children. A Category whose parent is absent from the list becomes a root rather than disappearing.
  • selectRootCategories takes those roots, drops the ones that only became roots because their parent was absent, and sorts the remainder by orderHint.
  • findCategoryNode finds a node in a tree by Category id, and returns null when the tree holds no such node.
  • toCategoryView flattens a node for display. A null node yields empty values rather than null, so the result destructures without a guard.
  • toProductView flattens a Product for display, and returns null for a Product that is absent.
Import the helpers alongside the hook. categoryList stays empty until getCategories resolves, so load the Categories first, then memoize the tree so it isn't rebuilt on every render:
src/components/CategoryNav.tsxtsx
import {
  buildCategoryTree,
  findCategoryNode,
  selectRootCategories,
  toCategoryView,
  useCatalogModuleContext,
} from 'instoreState/context/CatalogModuleContext';
import { useLocalizationContext } from 'instoreState/locale';
import React, { useEffect, useMemo } from 'react';

type Props = { activeCategoryId: string };

export const CategoryNav = ({ activeCategoryId }: Props) => {
  const { locale } = useLocalizationContext();
  const { categoryList, getCategories } = useCatalogModuleContext();

  useEffect(() => {
    getCategories().catch(() => {
      // `categoryList` is reset to an empty list on failure, so offer a retry.
    });
  }, [getCategories]);

  const categories = useMemo(
    () => buildCategoryTree(categoryList),
    [categoryList],
  );

  const rootCategories = useMemo(
    () => selectRootCategories(categories),
    [categories],
  );

  const active = toCategoryView(
    findCategoryNode(categories, activeCategoryId),
    locale,
  );

  return (
    <div>
      <h2>{active.name}</h2>
      <ul>
        {rootCategories.map((node) => (
          <li key={node.id}>{toCategoryView(node, locale).name}</li>
        ))}
      </ul>
    </div>
  );
};
Call both buildCategoryTree and selectRootCategories. The first sorts the children of each node by orderHint and leaves the roots unsorted, and the second sorts the roots and drops the promoted orphans, so skipping it gives you unsorted roots mixed with orphans.
Derive the tree from categoryList on each change rather than storing a second copy of it, which can drift.

Catalog behavior to account for

Products and Categories arrive as commercetools representations rather than as display models, so account for the following behavior:

  • Localized fields stay localized. toProductView and toCategoryView resolve them by exact match on the active locale, with no fallback chain, and fall back to an empty string.
  • toProductView selects the Price by the country subtag of the active locale, and returns priceDinero. Format that value with .toFormat() instead of reading centAmount.
  • productsByCategory holds both shapes at runtime, a list per Category and a single Product per Product id, although its declared type covers only the lists. Check with Array.isArray before you treat a value from this map as a list.
  • getCategoryProducts requests one page of 100 Products. A larger Category is truncated without an error.
  • The context keeps only the results of each response and discards the surrounding page, so no total or count is available. Track offset in your own state, and treat a full page as inconclusive rather than as proof that more Products exist. searchProducts returns 10 Products when you omit limit.
  • The cache lives in the shared POS store, outlives your component, and isn't invalidated. Prices and availability stay as first loaded until the POS reloads.
  • searchProducts resolves an empty result without calling the backend when the workstation isn't configured yet. An empty result is therefore not proof that no Product matches.
  • Product Search must be active on the commercetools Project. See Activate the Product Search API, and Search Query Language for the query syntax that searchProducts accepts.
  • The POS shell mounts the Catalog module provider above your routes. Consume the context, and don't mount a provider of your own.
useCatalogModuleContext does not throw when the Catalog context is out of scope. It returns empty Categories and search results, resolves every fetch without loading anything, and denies every permission and parameter. A wiring mistake presents as a Catalog that stays empty rather than as an error, so check this first when nothing renders.
The Catalog module context covers Categories, Product search, and adding a Product to a Cart. The built-in module also displays breadcrumbs and scan modes, which the context does not expose. A replacement module implements those surfaces itself. The search control in the POS header is a separate module that a Catalog replacement does not provide. For the full set of built-in capabilities, see InStore_Catalog.

Next steps

Use the following resources to finish and roll out your Catalog module: