# Implement the Catalog module 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](/instore/customization/pos-module-development.md). 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](/instore/customization/pos-module-development.md#resolve-types). | Member | Type | Description | | --- | --- | --- | | `categoryList` | `CatalogModuleCategory[]` | Categories from the most recent `getCategories` call, flat and in the order the backend returned them. | | `searchResults` | `CatalogModuleProduct[]` | Products from the most recent search. | | `returnReasonList` | `CatalogReturnReason[]` | Reasons available for an unreceipted return. | | `productsByCategory` | `Readonly>` | Products cached by Category `id`, and single Products cached by Product `id` after a search. | | `addProductToCart` | Method | Adds a Product to the active Cart or to a return Cart. | | `checkCatalogParameter` | Method | Checks whether an administration parameter is active for this workstation. | | `checkUserPermission` | Method | Checks whether the signed-in user holds a given permission. | | `clearSearchResults` | Method | Clears `searchResults`. | | `getCategories` | Method | Loads Categories into `categoryList`. | | `getCategoryProducts` | Method | Loads the Products of a Category into `productsByCategory`. | | `searchProducts` | Method | Searches 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`. 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](/instore/customization/pos-customer-module.md#attach-result-statuses) 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: | Value | Permission | | --- | --- | | `ORDER_ITEMS` | `orderitems` | | `SELL_ITEM` | `sellitem` | | `UNRECEIPTED_RETURNS` | `unreceiptedreturns` | `checkCatalogParameter` accepts a value of `CatalogModuleParameter` or the equivalent string: | Value | Parameter | | --- | --- | | `ALLOW_ORDER_ITEM` | `Allow_Order_Item` | | `ALLOW_SELL_ITEM` | `Allow_Sell_Item` | | `ALLOW_UNRECEIPTED_RETURN` | `Allow_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](/instore/customization/administration-parameters.md). 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: ```tsx title="src/App.tsx" import React from 'react'; import { Route, Routes } from 'react-router-dom'; import { CatalogPage } from './components/CatalogPage'; const App = () => ( } /> ); export default App; ``` Then read the context, search, and render the results: ```tsx title="src/components/CatalogPage.tsx" 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 (
setTerm(event.target.value)} /> {busy ?

Searching

: null} {message ?

{message}

: null} {searchResults.map((product) => { const view = toProductView(product, locale); if (!view) { return null; } return (
{view.name} {view.priceDinero.toFormat()} {canSell ? ( ) : null}
); })}
); }; ``` 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](/instore/customization/list-of-core-strings.md). ## Add a Product to the Cart `addProductToCart` accepts a request with the following fields: | Field | Data type | Description | | --- | --- | --- | | `comment` | String | Return reason, sent as `returnComment`. Applies to a return target only. | | `fulfillment` | String | How the Line Item is fulfilled: `takeWith`, `shipWith`, `pickup`, or `ordered`. Applies to a sale target only, and defaults to `takeWith`. | | `productId` | String | Required. `id` of the Product to add. | | `quantity` | Number | Quantity to add. Defaults to `1`. | | `target` | String | Cart that receives the Product, either `cart` or `returnCart`. Defaults to `cart`. | | `variantId` | Number | `id` 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](/instore/customization/pos-cart-module.md). ## 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: ```tsx title="src/components/CategoryNav.tsx" 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 (

{active.name}

    {rootCategories.map((node) => (
  • {toCategoryView(node, locale).name}
  • ))}
); }; ``` 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](/api/projects/product-search.md#activate-the-product-search-api), and [Search Query Language](/api/search-query-language.md) 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](/instore/implement-instore/modules/instore-catalog.md). ## Next steps Use the following resources to finish and roll out your Catalog module: - [Build a replacement module](/instore/customization/pos-module-development.md) to configure Module Federation, resolve types, and read shared POS state. - [Replace InStore POS UI modules](/instore/customization/pos-ui-modules.md) to register the module and assign it to a location or a workstation. - [Implement the Cart module](/instore/customization/pos-cart-module.md) to read Cart state and render a replacement Cart. - [Implement the Customer module](/instore/customization/pos-customer-module.md) to read Customer state and render a replacement Customer lookup or header control. - [Run InStore POS API requests](/instore/customization/pos-api-access.md) to set the API host, Project, and tenant, and to get an access token. ## Related pages - [Area overview page with navigation](/instore.md) - [Previous page: Implement the Cart module](/instore/customization/pos-cart-module.md) - [Next page: Implement the Customer module](/instore/customization/pos-customer-module.md) - [Search documentation and API specs](/search.md)