Read Customer state from the InStore State module, and render a Customer lookup that replaces the one built into the InStore POS.
This page covers the interface that a Customer replacement module implements. For the build setup that every replacement module needs, see Build a replacement module.
A Customer replacement module reads everything it needs from the Customer module context instead of calling the InStore backend.
This interface covers two external module types. Register
Customer for the full-page lookup and LookupCustomer for the Customer control in the POS header. Both types read the same context. For the way one replacement module provides several components, see Build a replacement module.Read Customer state and operations
instoreState/context/CustomerModuleContext provides useCustomerModuleContext, which returns the attached Customer and the operations that act on it.useCustomerModuleContext returns a context object with 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 | Description |
|---|---|
customer | Customer attached to the active Cart, or null when none is attached. |
removeCustomer | Detaches the Customer from the active Cart. |
searchByEmailAndAttach | Looks a Customer up by exact email, then attaches it to the active Cart. |
searchCustomer | Searches Customers by offset and limit. |
The provider adds the location and workstation to every search, so your module does not pass them.
Both
searchByEmailAndAttach and searchCustomer require Customer Search to be active and its index to be ready on the commercetools Project. Neither has a fallback while Customer Search is inactive or still indexing: searchByEmailAndAttach resolves to search-failed, and searchCustomer rejects, the same as any other backend failure.Paginate a search
searchCustomer takes a request of the following shape, omitting locationKey and workstationKey, which the provider fills in for you:{
query: SearchQuery;
sort?: SearchSorting[];
limit?: number;
offset?: number;
}
It resolves to a page of the following shape:
{
limit: number;
offset: number;
count: number;
total: number;
results: Customer[];
}
To fetch the next page, call
searchCustomer again with offset set to the previous offset plus the previous limit. Keep requesting pages until offset + count reaches total.Attach result statuses
searchByEmailAndAttach never rejects. It reports every outcome through the status field of its result, so branch on status instead of catching an error:| Status | Meaning |
|---|---|
attached | The Customer was found, and is now on the Cart. The result also carries customer. |
attach-failed | The Customer was found, but the Cart update failed. |
no-cart | There was no Cart to attach to, and a Cart couldn't be created. |
not-found | The search succeeded but there were no matching Customers. |
search-failed | The lookup itself failed. |
Distinguish
not-found from the three failure statuses in your module. Only a failure is worth a retry.searchCustomer reports failure the opposite way. It rejects on a transport or backend failure, so wrap it in try and catch.Render a minimal Customer lookup
Expose a root component that defines your routes:
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { CustomerPage } from './components/CustomerPage';
const App = () => (
<Routes>
<Route index element={<CustomerPage />} />
</Routes>
);
export default App;
Then read the context, attach by email, and render the attached Customer:
import { useCustomerModuleContext } from 'instoreState/context/CustomerModuleContext';
import { useLocalizationContext } from 'instoreState/locale';
import React, { useCallback, useState } from 'react';
export const CustomerPage = () => {
const { t } = useLocalizationContext();
const { customer, searchByEmailAndAttach, removeCustomer } =
useCustomerModuleContext();
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const attach = useCallback(async () => {
const result = await searchByEmailAndAttach(email);
switch (result.status) {
case 'attached':
return setMessage('');
case 'not-found':
return setMessage(t('Customer.lookup.not_found'));
case 'no-cart':
case 'search-failed':
case 'attach-failed':
return setMessage('Something went wrong. Try again.');
}
}, [email, searchByEmailAndAttach, t]);
if (customer) {
return (
<div>
<span>{customer.name}</span>
<span>{customer.email}</span>
<button onClick={removeCustomer}>{t('Customer.lookup.remove')}</button>
</div>
);
}
return (
<div>
<input
aria-label={t('Customer.lookup.subtitle')}
placeholder={t('Customer.lookup.placeholder')}
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<button onClick={attach}>{t('Customer.lookup.button')}</button>
<p>{message}</p>
</div>
);
};
The POS passes no props to a
Customer replacement module, so read Customer state from the context.The translation function
t returns the key itself when the key is missing. A key that the POS doesn't ship therefore renders as literal text, such as Customer.lookup.example. Prefer the keys that the POS already ships, and supply your own fallback text for anything else. For the available keys, see List of core strings.Provide the header Customer control
Register the
LookupCustomer type to replace the Customer control in the POS header. The context is the same, with three differences:- The control renders in the header on every route, rather than under a route of its own. Don't define routes in it, and keep the surface compact.
- The POS passes one prop,
enableLookupButton. It'strueon the Cart route, andfalseelsewhere. - A dialog that the control opens renders inside the header. Position the dialog so that the surrounding layout doesn't clip it.
To provide several surfaces from one replacement module, expose a module path for each surface, then register an external module for each type. Those records use the same
scope and url, and differ in key, module, and type.Render the control from the same context, branching on whether a Customer is attached and on
enableLookupButton:import { useCustomerModuleContext } from 'instoreState/context/CustomerModuleContext';
import { useLocalizationContext } from 'instoreState/locale';
import React, { useState } from 'react';
type CustomerControlProps = {
enableLookupButton: boolean;
};
export const CustomerControl = ({
enableLookupButton,
}: CustomerControlProps) => {
const { t } = useLocalizationContext();
const { customer, removeCustomer } = useCustomerModuleContext();
const [isDialogOpen, setDialogOpen] = useState(false);
// Pseudocode: implement CustomerLookupDialog using the attach logic from
// "Render a minimal Customer lookup".
if (customer) {
return (
<div>
<span>{customer.name}</span>
<button onClick={removeCustomer}>{t('Customer.lookup.remove')}</button>
</div>
);
}
if (!enableLookupButton) {
return null;
}
return (
<div>
<button onClick={() => setDialogOpen(true)}>
{t('Customer.lookup.button')}
</button>
{isDialogOpen && (
<CustomerLookupDialog onClose={() => setDialogOpen(false)} />
)}
</div>
);
};
The attached Customer chip renders regardless of
enableLookupButton; only the lookup button itself respects the prop.Customer behavior to account for
A Customer arrives normalized, reshaped by the InStore backend into a flat form, so your module never handles a raw commercetools payload. Localized values like names become plain strings rather than localized objects, and addresses become flat objects. The normalized
customer, typed as CustomerModuleCustomer, has the following shape:| Field | Type | Description |
|---|---|---|
id | string | ID of the commercetools Customer. |
name | string | Display name, computed from firstName and lastName. Falls back to a placeholder when both are missing. |
email | string | Email address of the Customer. |
key, customerNumber | string, optional | Pass through from the commercetools Customer. Each falls back to id. |
firstName, lastName, title, phone, companyName, vatId | string, optional | Pass through from the commercetools Customer. |
dateOfBirth | string | null, optional | Date of birth as an ISO date. |
addresses | object[], optional | Flat address objects, with the fields id, key, title, firstName, lastName, streetName, streetNumber, city, state, postalCode, country, phone, and email. |
defaultShippingAddressId, defaultBillingAddressId | string | null, optional | IDs into addresses. |
isEmailVerified | boolean, optional | Whether the email address of the Customer is verified. |
customerGroup | { id: string; typeId: string } | null, optional | Reference to the Customer Group the Customer belongs to, or null when none is set. |
custom | Record<string, unknown>, optional | Custom Fields of the Customer, unwrapped from the commercetools custom container. |
createdAt, lastModifiedAt | string, optional | Creation and last-modified timestamps as ISO date-times. |
version | number, optional | Current version of the commercetools Customer. |
attachStrategy | { type: 'CartAction' | 'External'; action: CartUpdateAction[] } | How the Customer was attached to the Cart. removeCustomer replays action in reverse. |
Only
id, name, email, and attachStrategy are required. The InStore backend populates the optional fields too, defaulting them where the commercetools Customer has no value, but a Customer set through the legacy in-POS lookup can lack any of them, including attachStrategy. Treat fields beyond id, email, and name as best-effort until that path is retired.Account for the following behavior:
- The Cart is the authority for
customer, and the provider keeps the two in sync. A resumed or swapped Cart that already carries a Customer populates or replacescustomer, and a Cart with no Customer clears it. Render from the context value instead of from the result of your last lookup. - A return Cart does not clear a Customer while it carries one. The return flow attaches a Customer to the return Cart and leaves the sale Cart alone.
- The provider applies the attach strategy of the Customer on your behalf and records the Cart update actions it applied.
removeCustomermirrors the attach. It replays the recorded actions with their values removed, which is how commercetools unsets those fields. A Customer attached by email therefore has that email cleared. A Business Unit set during the attach stays on the Cart because that action has no valueless form.removeCustomeralso clears the wishlists and the cached transaction history of the Customer, and it clears the shared state even when the Cart update fails.searchCustomerresolves an empty page without calling the backend when the workstation is not configured yet. An empty page is therefore not proof that no Customer matches.- For the query syntax that
searchCustomeraccepts, see Customer Search.
useCustomerModuleContext does not throw when the Customer context is out of scope. It returns customer as null, resolves searchByEmailAndAttach to not-found, and resolves searchCustomer to an empty page. A wiring issue appears as a lookup that never finds a Customer instead of as a thrown error. Check this first when an attach never succeeds.For the provider and import setup, see Read shared POS state.
The Customer module context covers lookup, attach, and detach. The built-in module also displays wishlists, Orders, shipping options, and pay-on-account details, which the context does not expose. A replacement module implements those surfaces itself. For the full set of built-in capabilities, see InStore_Customer.
Next steps
Use the following resources to finish and roll out your Customer 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.
- Run InStore POS API requests to set the API host, Project, and tenant, and to get an access token.