Implement the Customer module

Ask about this Page
Copy for LLM
View as Markdown

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.
MemberDescription
customerCustomer attached to the active Cart, or null when none is attached.
removeCustomerDetaches the Customer from the active Cart.
searchByEmailAndAttachLooks a Customer up by exact email, then attaches it to the active Cart.
searchCustomerSearches 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.

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:
StatusMeaning
attachedThe Customer was found, and is now on the Cart. The result also carries customer.
attach-failedThe Customer was found, but the Cart update failed.
no-cartThere was no Cart to attach to, and a Cart couldn't be created.
not-foundThe search succeeded but there were no matching Customers.
search-failedThe 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:

src/App.tsxtsx
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:

src/components/CustomerPage.tsxtsx
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's true on the Cart route, and false elsewhere.
  • 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:
src/components/CustomerControl.tsx (pseudocode)tsx
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:
FieldTypeDescription
idstringID of the commercetools Customer.
namestringDisplay name, computed from firstName and lastName. Falls back to a placeholder when both are missing.
emailstringEmail address of the Customer.
key, customerNumberstring, optionalPass through from the commercetools Customer. Each falls back to id.
firstName, lastName, title, phone, companyName, vatIdstring, optionalPass through from the commercetools Customer.
dateOfBirthstring | null, optionalDate of birth as an ISO date.
addressesobject[], optionalFlat address objects, with the fields id, key, title, firstName, lastName, streetName, streetNumber, city, state, postalCode, country, phone, and email.
defaultShippingAddressId, defaultBillingAddressIdstring | null, optionalIDs into addresses.
isEmailVerifiedboolean, optionalWhether the email address of the Customer is verified.
customerGroup{ id: string; typeId: string } | null, optionalReference to the Customer Group the Customer belongs to, or null when none is set.
customRecord<string, unknown>, optionalCustom Fields of the Customer, unwrapped from the commercetools custom container.
createdAt, lastModifiedAtstring, optionalCreation and last-modified timestamps as ISO date-times.
versionnumber, optionalCurrent 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 replaces customer, 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.
  • removeCustomer mirrors 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.
  • removeCustomer also clears the wishlists and the cached transaction history of the Customer, and it clears the shared state even when the Cart update fails.
  • searchCustomer resolves 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 searchCustomer accepts, 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: