Read Catalog state from the InStore State module, and render Catalog browsing and search that replaces the one built into the InStore POS.
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.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.| 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<Record<string, CatalogModuleProduct[]>> | 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<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
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:
getCategoriesrejects and resetscategoryListto an empty list.getCategoryProductsrejects and leaves the cache as it was, so a retry is safe.searchProductsrejects 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.addProductToCartrejects with a message that names the cause.checkCatalogParameterandcheckUserPermissionnever throw. They returnfalsefor anything they can't confirm.
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.
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:
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:
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.
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:| 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. |
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.productId resolves without adding anything.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:buildCategoryTreeturnscategoryListinto a list of root nodes, each with aparentIdandchildren. A Category whose parent is absent from the list becomes a root rather than disappearing.selectRootCategoriestakes those roots, drops the ones that only became roots because their parent was absent, and sorts the remainder byorderHint.findCategoryNodefinds a node in a tree by Categoryid, and returnsnullwhen the tree holds no such node.toCategoryViewflattens a node for display. Anullnode yields empty values rather thannull, so the result destructures without a guard.toProductViewflattens a Product for display, and returnsnullfor a Product that is absent.
categoryList stays empty until getCategories resolves, so load the Categories first, then memoize the tree so it isn't rebuilt on every render: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>
);
};
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.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.
toProductViewandtoCategoryViewresolve them by exact match on the active locale, with no fallback chain, and fall back to an empty string. toProductViewselects the Price by the country subtag of the active locale, and returnspriceDinero. Format that value with.toFormat()instead of readingcentAmount.productsByCategoryholds both shapes at runtime, a list per Category and a single Product per Productid, although its declared type covers only the lists. Check withArray.isArraybefore you treat a value from this map as a list.getCategoryProductsrequests 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
offsetin your own state, and treat a full page as inconclusive rather than as proof that more Products exist.searchProductsreturns 10 Products when you omitlimit. - 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.
searchProductsresolves 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
searchProductsaccepts. - 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.Next steps
Use the following resources to finish and roll out your Catalog module:
- Build a replacement module to configure Module Federation, resolve types, and read shared POS state.
- Replace InStore POS UI modules to register the module and assign it to a location or a workstation.
- Implement the Cart module to read Cart state and render a replacement Cart.
- Implement the Customer module to read Customer state and render a replacement Customer lookup or header control.
- Run InStore POS API requests to set the API host, Project, and tenant, and to get an access token.