Product Catalog

Customer prices

A merchant can give some customers their own prices: trade accounts, wholesale buyers, members. The shopper signs in with their Throttle customer account, the storefront sends their token with each request, and the catalog answers their prices.

Customer groups

The merchant creates customer groups in Storefront › Groups and adds Throttle customers to them. A group either has its own fixed prices per variant and currency, or takes a percent off the regular price. Each customer is in at most one group. Shoppers who are signed out, or in no group, get the regular prices.

  • When a group price is below the regular price, the compare-at price carries the regular price (compareAtMinMinor in a list's price range, compareAtPriceMinor on a variant), so you can show it struck through.
  • Price sorts, price filters and price facet ranges use the regular price; only the prices shown change.
  • Answers never name the group.
  • Carts are priced the same way: the price a signed-in shopper sees on a product page is the price their cart charges.

Send the customer token

Shoppers sign in with Throttle's customer accounts, through @usethrottle/auth (see Storefront customer auth). Give the catalog client a customerToken function that returns the shopper's current access token. With @usethrottle/auth 0.10.1 or later, that is auth.getAccessToken(): it refreshes the token first when it is about to expire, and answers null when the shopper is signed out.

A catalog client with customer prices
import { createThrottleAuth } from '@usethrottle/auth';
import { createCatalogClient } from '@usethrottle/catalog';

// Throttle customer login (@usethrottle/auth 0.10.1 or later).
export const auth = createThrottleAuth({
  publishableKey: 'pk_live_your_throttle_key',
});

// The client asks for the token on every request that can use it: the
// current access token, refreshed first when it is about to expire, or null
// when the shopper is signed out.
export const catalog = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
  customerToken: () => auth.getAccessToken(),
});
  • The client calls the function on every request that can use the token, so a refreshed token is used at once. It sends the token only in the X-Customer-Token header, never in a URL, never stores it on the client and never logs it.
  • getAccessToken() can throw when a refresh fails for a moment (a network error, a 5xx). The client treats a throw, or anything that is not a string, as signed out for that request: it answers regular prices instead of failing the page.
  • The token read and the request each get the full timeoutMs (10 seconds by default), so a call that waits on a slow refresh can take up to about twice that. A token function that never answers ends in TimeoutError.
  • Sign shoppers in in the browser. A Throttle pk_ is a browser key: Throttle checks the page's origin against your allowed origins, so a sign-in from server code is refused with origin_not_allowed. On the server, take the shopper's access token from the request (a header or cookie your app sets) and return it from customerToken.
  • The token goes with product lists, product pages, related products, suggestions, carts.check, carts.create, review eligibility and review submission. Categories, collections, tags, filter values, review lists and config answer the same for everyone and never send it.

A verified email is required

Group prices need a verified email address. A signed-in shopper whose email is not verified gets the regular prices, on product pages and in the cart, until they verify it. Knowing a trade customer's email address is not enough to buy at trade prices.

priceContext: which prices an answer carries

An answer to a request that carried a token has priceContext. Use it to tell the shopper why they see the prices they see:

priceContextPricesMeaning and what to show
customerThe shopper’s ownSigned in with a verified email. Their group’s prices, or the regular ones when they are in no group.
email_unverifiedRegularSigned in, email not verified. Show a note such as “Verify your email address to see your prices”, with a way to resend the verification email.
anonymousRegularThe store cannot use customer tokens for prices, most often because its Product Catalog installation needs the update that adds customer prices. Nothing to show the shopper.
anonymous_fallbackRegularThe catalog could not check the token just now. It passes: the next requests usually carry the shopper’s prices again.
absentRegularNo token went with the request: the shopper is signed out, the token function threw or returned null, or the SDK retried without a token the catalog refused (below).
Reading priceContext
import { createThrottleAuth } from '@usethrottle/auth';
import { createCatalogClient } from '@usethrottle/catalog';

const auth = createThrottleAuth({ publishableKey: 'pk_live_your_throttle_key' });
const catalog = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
  customerToken: () => auth.getAccessToken(),
});

const list = await catalog.products.list({ category: 'brakes' });

switch (list.priceContext) {
  case 'customer':
    // The shopper's own prices (their group's, when they are in one).
    break;
  case 'email_unverified':
    // Signed in, email not verified yet: list prices.
    console.log('Verify your email address to see your prices.');
    break;
  case 'anonymous':
  case 'anonymous_fallback':
    // The catalog could not use the token for prices: list prices.
    break;
  case undefined:
    // No token went with the request (signed out, or the SDK's retry
    // without a refused token): list prices.
    break;
}

A refused token

A token the catalog refuses (expired, revoked, or for another store) answers 401 CUSTOMER_TOKEN_INVALID. The client then asks your function for the token once more, and retries with it if it changed. If it did not, a read (or carts.check) is retried once without the token and answers the regular prices, with no priceContext: the page still renders. A call that cannot fall back to regular prices (reviews.eligibility, reviews.submit, and carts.create with a token) throws CustomerTokenInvalidError instead. A call makes at most three requests.

In React

Keep one catalog client for the life of the app and pass customerToken as a function, as above; do not make a new client when the shopper signs in. Give CatalogProvider a customerKey: the signed-in customer's id, or null. The hooks' cache is partitioned by it, so a change empties the cache and every mounted hook fetches again, and one shopper never sees answers fetched for another. Change it in the same update as the token, which useAuth() does when you read the customer from it.

Providers
'use client';
import type { ReactNode } from 'react';
import { createThrottleAuth } from '@usethrottle/auth';
import { ThrottleAuthProvider, useAuth } from '@usethrottle/auth/react';
import { createCatalogClient } from '@usethrottle/catalog';
import { CatalogProvider } from '@usethrottle/catalog/react';

// One auth client and one catalog client for the life of the app.
const auth = createThrottleAuth({ publishableKey: 'pk_live_your_throttle_key' });
const catalog = createCatalogClient({
  publishableKey: 'pc_pk_your_browser_key',
  customerToken: () => auth.getAccessToken(),
});

function Catalog({ children }: { children: ReactNode }) {
  // The signed-in customer's id, or null. It changes in the same render as
  // the session, so cached answers never cross from one shopper to another.
  const { customer } = useAuth();
  return (
    <CatalogProvider client={catalog} customerKey={customer?.id ?? null}>
      {children}
    </CatalogProvider>
  );
}

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThrottleAuthProvider publishableKey="pk_live_your_throttle_key" auth={auth}>
      <Catalog>{children}</Catalog>
    </ThrottleAuthProvider>
  );
}
Caches and personal prices
An answer fetched with a token is personal. Storefront answers vary on X-Customer-Token, so browser caches keep them apart, and the server client sends every request that carries a token with cache: 'no-store'. Do not put a personal answer in a cache of your own that other shoppers read. See React and Next.js SSR and caching.