Skip to content

feature-product

@evolve-storefront/feature-product · v1.0.0 · Storybook ↗

The logic and parts of a product: the listing page — a category, a search result — with the filter state its URL describes, its facets, its paging and its data; and the parts that render one product: a card, its availability, its variant selector and its specifications.

Other features never import this package. A feature that shows a product takes a render prop and the site plugs these parts in, so the card lives once, next to the data it reads.

Merge the package's Product, Facets, VariantSelector and ProductSpecifications messages into your next-intl messages, and add its stylesheet.

Get started

  1. Install the package with the peer dependencies it needs. It comes from the Evolve registry, which needs a one-time sign-in.

    pnpm add @evolve-storefront/feature-product @labdigital/graphql-fetcher graphql next-intl react
    Requirements4
    @labdigital/graphql-fetcher>=3
    graphql>=16
    next-intl>=4
    react>=19
  2. Import its styles in the stylesheet where you load Tailwind. Without them the components render unstyled.

    @import "@evolve-storefront/feature-product/styles.css";
  3. Wrap your app in ProductListingProvider. Setup lists its props.

  4. Add its translations to your message files with evolve messages.

Styling — required

Add this to your app's global stylesheet, or this package's classes will not exist:

@import "@evolve-storefront/feature-product/styles.css";

This package ships raw .tsx and no compiled CSS. styles.css registers the package's source files with Tailwind (@source "./src") so it generates the utilities the components use. Skipping it fails silently: the components render, but with classes that were never generated. See the root README for a stylesheet listing every package.

The filter state in the URL

readFilterState(params, defaults) reads what a listing URL says. writeFilterState(state, defaults) writes it back, leaving out whatever equals the defaults. Both are on the server-safe . path, so a page and a client control read the URL the same way.

const defaults = { sort: config.defaultSort, pageSize: 24, maxPageSize: 96 };
const state = readFilterState(new URLSearchParams(searchParams), defaults);

const { data } = await client(ProductSearch, {
  page: state.page,
  pageSize: state.pageSize,
  sort: state.sort,
  searchTerm: state.searchTerm,
  filters: toFacetFilters(state.filters),
});
  • page, page_size, sort and q are reserved. Tracking parameters are skipped: utm_*, gclid, gbraid, wbraid, dclid, fbclid and msclkid. Every other parameter is a facet: a range when it ends in _min/_max, a toggle when it is true/false, and otherwise a set of options. A site needs no stripping of its own.
  • A sort that is not a ProductSortOrderType value falls back to the default sort, so a stale or hand-typed link still renders the listing instead of failing the query.
  • A page or page size that is not a positive whole number falls back to the default. A page size above maxPageSize is clamped, and a bound that is not a number is dropped, never NaN. A minimum of 0 counts as a value.

applyParamsToFacets(facets, params) returns the facets as they will look once a URL loads, so a click can show before the server answers. A range moves selectedMin and selectedMax and leaves the facet's own min and max alone. A facet the URL did not change keeps its identity.

Search suggestions

readSearchSuggestions(client, { term, limit }) from ./queries is what the header search suggests for a partly typed term:

  • terms: completions;
  • categories: the categories holding most of the result, each with its path, its ancestors and a productCount;
  • products: the best matching products. Each is the variant that matched, so a typed SKU links to its own variant.

It asks nothing for a term under two characters. Plug it into @evolve-storefront/layout's HeaderSearch, which is the keyboard-accessible combobox. The site maps the result, since it owns the routes and the price display:

<HeaderSearch
  loadSuggestions={async (query) => {
    const found = await readSearchSuggestions(browserClient, { term: query });
    return {
      terms: found.terms,
      categories: found.categories.map((category) => ({
        id: category.id,
        label: category.name,
        href: category.path,
        path: category.ancestors,
        count: category.productCount,
      })),
      products: found.products.map((variant) => ({
        id: variant.sku ?? "",
        name: variant.name ?? "",
        href: productHref(variant),
        imageUrl: variant.primaryImage?.url,
        price: <Price money={variant.price?.gross} locale={locale} />,
      })),
    };
  }}
/>

The store's assortment and price context apply on the server, as for the listing.

The listing's data

./queries holds the listing's two documents and the server-safe reads around them. The products and the facets are two searches, read side by side:

const config = catalogPage.productListingConfig; // absent on a search page
const state = readFilterState(params, {
  sort: config?.defaultOrder ?? "RELEVANCE", // or the CMS's order never applies
  pageSize: 24,
});
const listingPromise = readProductListing(serverClient, { state, config });
const facetsPromise = readListingFacets(serverClient, { state, config });

productListingVariables({ state, config }) scopes a category listing to its category and its prefilters, read once from the config. Select prefilters { key values }: a page's source may select several values of a facet. A config that still selects the deprecated value is sent as one value. The page size is capped at MAX_PAGE_SIZE whatever the URL says. A URL's sort wins over the CMS's default order, so the default has to reach readFilterState, which is where the sites lost it.

The sort orders

ProductListingProducts also reads productSortOrders, the orders the deployment applies, and readProductListing returns them as the listing's sortOrders. The sort menu offers those, in that order, and leaves out any this release has no label for. The labelled orders are RELEVANCE ("Relevance", the engine's own ranking and the default), POPULARITY ("Best rated", by review rating), NEWEST, PRICE_ASCENDING and PRICE_DESCENDING. There is no name sort. A URL asking for an order outside that set falls back to the default.

Facets apart from the products

Facets depend on the category, the prefilters, the search term and the filters, not on the page or the sort. So ProductListingFacets fixes page, page size and sort in the document itself, and listingFacetsVariables carries only what narrows the result: every page and order of one filter selection sends the same request. A bot paging through a listing then hits one cached facets entry instead of recomputing them per page. ProductListingProducts selects no facets, so the framework skips the facet work for it.

  • The cache is the site's. Both reads take any ClientFetcher, so a site sets its cache options by binding them, and can tag the facets apart from the products:

    const facetsPromise = readListingFacets(
      (document, variables) =>
        serverClient(document, variables, {
          cache: isSignedIn ? "no-cache" : "force-cache",
          next: { revalidate: 300, tags: ["product_listing_facets"] },
        }),
      { state, config },
    );
    
  • Pass the facets as their own promise. The sidebar and the mobile filters take facetsPromise and the product grid takes listingPromise, so each region suspends on its own search. useFacetNavigation and activeFilters already take the facets as their own input.

  • A signed-in or draft request that skips the cache pays for two searches per page instead of one. Each is cheaper than the combined search was, and they run in parallel.

Changing the facets

useFacetNavigation (./hooks) holds the facets a listing shows and the shopper's changes to them:

const router = useRouter();
const { facets, change, isPending, isSearchable } = useFacetNavigation({
  facets: listing.facets,
  search: useSearchParams(),
  navigate: (params) => router.push(`?${params}`),
  searchable: ["brand"], // or (facet) => facet.options.length > 12; long facets by default
});

change({ type: "option", key: "brand", value: "acme", selected: true });
  • A change shows at once, through applyParamsToFacets, while its URL loads. All facets share one transition, so isPending can hold them still while one loads.
  • Every change goes back to the first page. Page 4 of the old result is not a page of the new one.
  • Which facets get a search box is a site's setting. By default it is every options or category facet with more than six options to pick (isLongFacet). It is never a hardcoded key.
  • facetChangeParams(params, change) on the . path is the same step without React, covering option, range, toggle and clear.

The filters in force

activeFilters(facets, { formatRange }) on the . path gives one chip per selected option or toggle, and one per range with a bound set. Each chip has an id, a label and the remove change that takes it away:

{activeFilters(facets, { formatRange: (facet, value) => formatPrice(value) }).map((chip) => (
  <button key={chip.id} onClick={() => change(chip.remove)}>{chip.label}</button>
))}

A range with one bound reads as open on the other side, never against the facet's own limit: labelRange({ min, max }) words it, with the absent bound left out. The default is ≥ €50 or ≤ €200, and ProductListingActiveFilters reads "From €50" and "Up to €200" from ProductListing.filters.range-from/range-to. Removing a chip goes through the same change, so it also returns to the first page. FacetRange keeps a bound from the URL inside the facet, so a stale price_min above its max cannot draw the bar off the track.

Paging

pageHref(search, page, pathname) on the . path is the listing URL for a page, with its filters, sort and search kept. Use it for ui's Pagination renderLink:

<Pagination
  page={listing.pagination.page}
  pageCount={listing.pagination.pageCount ?? undefined}
  hasNextPage={listing.pagination.hasNextPage}
  renderLink={({ page, children }) => (
    <Link href={pageHref(searchParams, page, pathname)}>{children}</Link>
  )}
/>

Page one is written as the bare URL, so a shared link to it carries no page number that could go stale.

Structured data

listingJsonLd({ listing, name, url, locale, productUrl }) on the . path describes a listing page as a schema.org CollectionPage. Its main entity is the ItemList of the page's products. jsonLdScript serializes it for a <script type="application/ld+json">:

const jsonLd = listingJsonLd({
  listing, name: page.name, url: `${origin}${path}`, locale,
  productUrl: (product) => product.product && `${origin}${productHref(product.product.slug)}`,
});
{jsonLd ? (
  <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd) }} />
) : null}

Positions are counted across the whole result, from the listing's own pagination, so page two starts where page one ended. numberOfItems is the engine's total where it can count one exactly. jsonLdScript escapes <, so a product name cannot close the script.

Beside the products: spelling correction and redirects

readSearchAssist on ./queries reads what the search engine answers beside the products, in one light request that asks for no products:

  • The corrected term. The engine corrects the term only when the term as typed finds nothing and its top suggestion finds something. ProductListingSearchCorrection says so, and links to exactSearchHref. That URL sets q_exact=1, so readFilterState reads exactSearchTerm and the engine searches the term as typed.
  • A keyword redirect from the engine's own rules, such as "returns" leading to the returns page. Send the shopper there before rendering the listing.

Content hits and the CMS's own keyword list come from the CMS, through readContentSearch in @evolve-storefront/feature-content-blocks. The engine's redirect goes first:

const state = readFilterState(params, defaults);
const [assist, content] = await Promise.all([
  readSearchAssist(gqlClient, { state, config }),
  readContentSearch(gqlClient, { searchTerm: state.searchTerm }),
]);
if (assist?.redirect) redirect(searchRedirectHref(assist.redirect));
if (content?.redirect) redirect(contentSearchRedirectHref(content.redirect));

{assist?.correctedSearchTerm && state.searchTerm ? (
  <ProductListingSearchCorrection
    searchTerm={state.searchTerm}
    correctedSearchTerm={assist.correctedSearchTerm}
    search={params}
    pathname="/search"
  />
) : null}
<ContentSearchHits hits={content?.hits ?? []} renderLink={(props) => <Link {...props} />} />

Paths are from the site root, without a locale prefix: a localized site adds it in renderLink and before it redirects.

The listing's products query does not pass exactSearchTerm yet: only the assist reads it.

Indexing and the canonical URL

A filtered listing is a near-copy of its category, and every combination of facets is another URL, so a search engine indexes only the combinations an editor curated. A curated page is a CMS catalog page with a facet filter. readCuratedListingPage on ./queries asks catalogPageByFilter for the one whose filter equals the listing's, in any order. listingIndexing on the . path then decides:

Listing index canonical
A category, unfiltered yes its own path
Filtered, and a curated page matches yes the curated page's path
Filtered, and none matches no null
A search no null

A page past the first keeps its ?page= in the canonical. A range filter never matches a curated page, so the reader does not ask for one.

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const state = readFilterState(new URLSearchParams(await searchParams));
  const curatedPath = await readCuratedListingPage(gqlClient, {
    categoryId: config.categoryId,
    filters: state.filters,
  });
  const { index, canonical } = listingIndexing({ state, pathname, curatedPath });
  return {
    robots: { index, follow: true },
    alternates: canonical ? { canonical } : undefined,
  };
}

Analytics

The . path has the listing's side of @evolve-storefront/analytics:

const list = listingName(
  { searchTerm: state.searchTerm, categoryName: category.name },
  { search: (term) => t("search-list", { term }) }, // "Zoeken: kraan"
);
<ProductListingProvider list={list} … /> // reports listFiltered and listSorted
<EmitOnView name="productListViewed" payload={{ list, items: listingViewedItems(listing) }} … />
<ProductCard onClick={() => emit("productClicked", {
  product, list, position: listingPosition(listing.pagination, index),
})} />
  • Positions count from 0 across the whole result, read from the listing's own pagination rather than from the URL.
  • Products without a sku are left out, since the sku is how a vendor joins the event to its catalog. Their gap in the numbering stays.
  • listingSearchPerformed(listing, searchTerm) returns the searchPerformed payload. It returns nothing for a category page, which performed no search.
  • The list name is the category's name, or the search with its term, so a vendor can tell racquets from rackets and a search from a category. A listing without a category name falls back to "product listing page".
  • list on ProductListingProvider makes the filters report listFiltered and the sort report listSorted. listingFiltered(list, change) gives the payload for a change made elsewhere. Without list, nothing is reported. listPageLoaded is the page's to emit, because the pager is server-rendered links.
  • Algolia's queryId goes on searchPerformed once productSearch returns it. The framework does not return it yet.

Parts

./product-listing

One page of a listing. Without children, ProductListing renders the result count, a layout toggle and the products. Each product goes through renderItem, which is generic over the listing's product, so fields a site adds to the query arrive typed:

<ProductListing
  listing={listing}
  search={searchParams}
  defaultLayout={savedLayout ?? "grid"}
  onLayoutChange={saveLayout}
  renderItem={(variant, { index }) => (
    <ProductCard product={variant} href={productHref(variant.product.slug)} />
  )}
/>
  • The layouts are the site's choice. layouts is ["grid"], ["grid", "list"] (the default) or ["grid", "table"]; the first is the fallback. With one layout the toggle renders nothing, and a defaultLayout that is not on offer, such as a cookie from before, falls back to the first.

  • The order table. ProductListingTable shows the page as rows of SKU, name, stock, price per unit, a quantity stepper and the add button. The price reads like a business card's: excluding VAT per the variant's unit ("€12.50 per box, excl. VAT") by default, or vatDisplay="gross". With "table" in layouts, the root needs table={{ href, renderAction }} (and optionally renderLink and vatDisplay). Each row keeps its own quantity, and the add button reads it, so the site wraps feature-cart's button in a small client component:

    "use client";
    export function TableAddButton({ sku, label }) {
      const quantity = useProductListingTableQuantity();
      return <CartAddProductButton sku={sku} quantity={quantity} aria-label={label} />;
    }
    

    The table is server-rendered; only the row and its stepper are client components.

  • The layout is an attribute. ProductListingLayout sets data-listing-layout to grid, list or table, and the grid and ProductCard style off it. Only the layout and its toggle are client components; the products stay server-rendered. A site's own card can follow the same attribute with Tailwind's in-data-[listing-layout=list]: variant.

  • Keeping the choice is the site's. The layout is uncontrolled by default. Pass onLayoutChange to store the choice, in a cookie for instance, and defaultLayout to restore it on the server, so the page does not switch after hydration.

  • index is the product's position on this page, empty entries included, so it can go straight into listingPosition.

  • The filters and the sort need a ProductListingProvider. It runs useFacetNavigation once for all of them, so the sidebar, the sheet and the chips show one state. A site renders it from a small client component that reads its router, and the listing below it stays server-rendered:

    "use client";
    export function ListingNavigation({ facets, children }) {
      const router = useRouter();
      return (
        <ProductListingProvider
          facets={facets}
          search={useSearchParams()}
          navigate={(params) => router.push(`?${params}`, { scroll: false })}
          rangeUnit={(facet) => (facet.key === "price" ? currencyRangeUnit("EUR") : undefined)}
        >
          {children}
        </ProductListingProvider>
      );
    }
    
  • The pager is links, built with pageHref from the search the root takes, so it works without JavaScript. Pass renderPageLink for the app's Link.

  • On a wide screen the filters are a sidebar, and on a narrow one they open in a sheet from the toolbar. Both are the same ProductListingFilters.

  • The sheet can apply on its button. filtersSheet={{ mode: "apply", config }} on the root (or mode on ProductListingFiltersSheet) keeps the sheet's changes as a draft. Each change asks the gateway for the facets and for a count (pageSize: 1), so the button reads "Show 12 products" and is disabled when nothing matches; pressing it applies the draft in one navigation. Pass the page's config so the count is scoped to the category. The sidebar stays live, and so does the sheet by default. The sheet needs a ClientGqlFetcherProvider above it in apply mode.

  • Parts: ProductListingGrid, ProductListingTable, useProductListingTableQuantity, ProductListingResultCount, ProductListingLayout, ProductListingLayoutSwitch, ProductListingLayoutToggle, useProductListingLayout, ProductListingFilters, ProductListingFiltersSheet, ProductListingActiveFilters, ProductListingSort, ProductListingPagination and useProductListing. Data attributes: product-listing, product-listing-toolbar, product-listing-grid, product-listing-item, product-listing-table, product-listing-table-row, product-listing-layout, product-listing-layout-toggle, product-listing-body, product-listing-sidebar, product-listing-main, product-listing-filters, product-listing-toggles, product-listing-filters-sheet, product-listing-active-filters, product-listing-sort and product-listing-pagination.

  • Subcategories. ProductListingSubcategories links to the current category's children. On a wide screen it is a list for the sidebar, headed by parent. On a phone it is a row of chips that scrolls sideways. The link to currentPath carries aria-current="page", and a category without children renders nothing. It takes plain { id, name, path } entries and renderLink for the app's Link. The listing query does not carry a category's children, so the site passes them in. Data attributes: product-listing-subcategories and product-listing-subcategories-parent. variant="chips" keeps the row of chips at every width, with no parent heading, for above the grid.

  • A catalog page's promos. promos and renderPromo on the root place the page's promo cards in the grid: the first at position 4, the second at position 12, counting the cards shown. They show on the first page of a listing with no filter or search term in search, and a page too short to reach a position leaves that promo out. Render each through the content blocks' Promo. Data attribute: product-listing-promo.

  • A catalog page's layout. LISTING is the root with ProductListingSubcategories variant="chips" above it, and LANDING is CategoryLanding (below). The site picks the one by catalogPage.layout, which readCatalogLanding (from ./queries) reads together with the category's children for either layout.

./category-landing

The landing layout of a catalog page: tiles to the category's children, then a "Popular in {category}" rail of products. It is prop-driven and server-renderable; the site passes the children and the products in.

readCatalogLanding(gqlClient, { path, version, previewId }) reads a catalog page's layout, its category with its product count, and the category's children as tiles (CatalogPageLanding). It is null for an unknown page. The rail's products come from the listing query the page already has, sorted by popularity:

const landing = await readCatalogLanding(gqlClient, { path });
if (landing?.layout === "LANDING" && landing.category) {
  const popular = await readProductListing(gqlClient, {
    state: readFilterState(new URLSearchParams(), { sort: "POPULARITY", pageSize: 12 }),
    config,
  });
  const products = (popular?.items ?? []).flatMap(({ variant }) => (variant ? [variant] : []));
  // …render the landing below with landing.categories and products
}
<CategoryLanding
  category={{ name: category.name }}
  categories={children}
  products={popular}
  renderItem={(variant, { index, list }) => (
    <ProductCard product={variant} href={productHref(variant.product.slug)} />
  )}
  renderLink={({ children, ...link }) => <Link {...link}>{children}</Link>}
/>
  • A tile is { id, name, path, image?, productCount? }. A tile without a count shows none, so a missing count never reads as "0 products".
  • Either part renders nothing without entries: a category without children shows the rail alone, and one without popular products shows the tiles alone.
  • renderItem gets the 0-based index and the rail's heading as list, for productClicked. renderLink and renderImage take the app's Link and image.
  • allProducts={{ href, count }} puts a link to every product of the category beside the rail's heading: "All 1,240 products", or "All products" without a count.
  • headingLevel is the rail heading's level, 2 by default.
  • Parts: CategoryLandingTiles and CategoryLandingRail. Data attributes: category-landing, category-landing-tiles, category-landing-tile and category-landing-rail.
  • Messages: the CategoryLanding namespace.

./product-card

A product in a grid or a carousel: image, ribbon, name, price and availability, linked to the product page. It is server-renderable, and the app owns the routing and the image component:

<ProductCard
  product={variant}
  href={productHref(variant.product.slug)}
  onLinkClick={() => emit("productClicked", { product, list, position })}
  renderLink={({ href, children, ...props }) => <Link href={href} {...props}>{children}</Link>}
  renderImage={(props) => <Image {...props} width={352} height={235} />}
  compare={<CompareToggle sku={variant.sku} />}
  actions={<ShoppingListButton sku={variant.sku} />}
/>
  • layout sets the content for a consumer or a business buyer.

    • consumer (the default): the brand above the name, the rating with its review count, and a discounted price with the original struck through and the saving (data-component="product-card-discount"), all including VAT. With the variant's lowestPrice, a discount also shows the EU Omnibus line, "Lowest price in the 30 days before this discount" (product-card-lowest-price). Without a discount there is no such line.
    • business: the brand and article number on one line above the name (product-card-meta), the stock level (data-stock) above the price, and the price excluding VAT per the variant's unit ("per box, excl. VAT") with the first quantity tier that lowers it ("From €170.00 at 6 pieces").
    • vatDisplay defaults to gross for consumer and net for business.
    • A business listing offers the order table with layouts={["grid", "table"]} on the root and keeps the buyer's choice through onLayoutChange.
  • The price row sits at the foot of the card (product-card-buy, holding product-card-pricing and product-card-actions), so prices line up across a row of cards. The card fills its grid cell for that. On a narrow card the actions wrap under the price and take the full width.

  • packSize goes under the name, such as "12 pads × 100 sheets". The site supplies the text.

  • pricePlaceholder takes the price's place, such as a sign-in link where the store hides prices from guests. The tier hint goes with it.

  • Slots for what a site adds: ribbon replaces the product's own ribbon, compare sits above the name, actions sits beside the price (a quantity stepper with the add button, a shopping-list button), availability replaces the availability line (a delivery promise, say), and children goes below.

    • Wishlist, compare and quantity-plus-add belong to other features, so they arrive through these slots, as does a variant count such as "4 grip sizes". The compare slot is deliberately not placed over the image: the image link would crowd a control too small to tap apart from it.
  • Colors and a second image are opt-in, so a card that does not ask for them stays as it was:

    <ProductCard
      product={variant}
      swatches={<ProductCardColors colors={productCardColors(variant)} />}
      hoverImage={productCardHoverImage(variant)}
    />
    
    • ProductCardColors says how many colors there are ("6 colors") and shows a swatch per color, up to max (5), then "+1". It renders nothing with fewer than two. The swatches are decoration, so only the count is announced. Each swatch (product-card-swatch) takes the catalog's color from --swatch-color and carries data-value, as the color selector's does, so one brand rule on data-value maps both onto the design system's colors.
    • hoverImage fades in over the image while the pointer is on it (product-card-hover-image), on a device that can hover. It is decoration, with empty alt text. productCardHoverImage picks the variant's first image that is not its primary one.
  • The image link is out of the tab order. The name link is the one stop a keyboard makes. Both links report a click through onLinkClick.

  • The name falls back from the variant to the product, then to a translated "Product {sku}". A name that is missing in the locale arrives as "", and an empty link has no accessible name.

  • The name reads at the price's size and weight, whatever its heading level. That rule sits in styles.css under @layer components, so a brand's own [data-component="product-card-name"] outranks it without !important.

  • Styling hooks: the ribbon carries data-kind, and availability carries data-availability, keyed on the schema's ProductAvailabilityType.

  • The parts are exported for a card composed by hand:

    • ProductCardImage, ProductCardRibbon, ProductCardName, ProductCardPrice and ProductCardAvailability;
    • ProductCardBrand, ProductCardDiscount and ProductCardRating;
    • ProductCardMeta, ProductCardSku, ProductCardTierHint and ProductCardStock.
    • ProductCardColors, with productCardColors and productCardHoverImage.
  • Data: the listing's product selection now reads regularPrice, priceTiers, stock { level lowStockQuantity }, unit, images { url alt }, product.brand, product.reviews { amount averageRating }, and product.variantDimensions with only the color options' label value color.

./product-availability

Whether a product can be had, as a product page states it:

import { ProductAvailabilityStatus } from "@evolve-storefront/feature-product/product-availability";

<ProductAvailabilityStatus availability={variant.availability} />;

An icon and a label, in a tone from the theme: text-success in stock, text-destructive out of stock or discontinued, text-muted-foreground on backorder. The label and icon come from the same mapping as ProductCardAvailability, so a card and a page cannot say different things; the labels are Product.availability, keyed on the schema's ProductAvailabilityType and pinned against it. Hooks: product-availability with data-availability and data-tone. Renders nothing for an absent state.

./facet

A listing's facets as the parts each type calls for. Facet picks the part from __typename. The parts are also exported on their own: FacetOptions for options and categories, FacetToggle and FacetRange. They own no URL and fetch nothing. Each change comes back per kind, ready for useFacetNavigation from ./hooks:

<Facet
  facet={facet}
  isPending={isPending}
  searchable={isSearchable(facet)}
  rangeUnit={facet.key === "price" ? currencyRangeUnit(currency) : undefined}
  onOptionChange={(key, value, selected) => change({ type: "option", key, value, selected })}
  onRangeChange={(key, min, max) => change({ type: "range", key, min, max })}
  onToggleChange={(key, value) => change({ type: "toggle", key, value })}
/>
  • A range's unit is configured, never guessed from the key. currencyRangeUnit(currency) reads cents as whole units of the currency. Everything else is plain by default.
  • A searchable facet narrows its options as the shopper types and marks the match in <mark>. It needs no fuzzy-search or markdown dependency.
  • An option with a count of 0 is left out, unless it is picked, so it can still be unpicked. A facet with nothing left to pick is left out of ProductListingFilters (hasVisibleOptions). While a change loads, isPending disables every option.
  • The counts are the backend's. For a ticked option to leave its siblings pickable, the engine must count each facet without its own filter (disjunctive counts). Ticking Grohe then still shows Hansgrohe with its count.
  • A range has a field per bound next to the slider, in the same unit. A typed bound applies when the field is left or on Enter, and stays within the facet and the other bound.
  • Toggles come first. ProductListingFilters renders every toggle facet together at the top (data-component="product-listing-toggles"), then the other facets in the engine's order.
  • Data attributes: data-component="facet-options", facet-option, facet-toggle, facet-range and facet-range-fields. Each part also sets data-pending while a change loads.

./product-detail

The product page's layout, as parts the site fills:

import {
  ProductDescription,
  ProductDetail,
  ProductDetailAnchorNav,
  ProductDetailBuyAction,
  ProductDetailBuyBox,
  ProductDetailContent,
  ProductDetailGallery,
  ProductDetailRelated,
  ProductDetailSection,
  ProductDetailStickyBar,
  ProductHighlights,
} from "@evolve-storefront/feature-product/product-detail";

<ProductDetail>
  <ProductDetailGallery>{gallery}</ProductDetailGallery>
  <ProductDetailBuyBox>
    <Heading level={1}>{product.name}</Heading>
    <VariantSelector dimensions={product.variantDimensions} toHref={toHref} />
    <ProductDetailBuyAction>
      <CartAddProductButton sku={variant.sku} />
      {expressCheckout}
    </ProductDetailBuyAction>
  </ProductDetailBuyBox>
  <ProductDetailContent>
    <ProductDetailAnchorNav
      anchors={[
        { id: "overview", hasContent: true },
        { id: "specifications", hasContent: variant.specifications.length > 0 },
        { id: "reviews", hasContent: reviews.total > 0, count: reviews.total },
      ]}
    />
    <ProductDetailSection id="overview">
      <ProductHighlights attributes={variant.highlightedSpecifications} locale={locale} />
      <ProductDescription><Markdown>{variant.description}</Markdown></ProductDescription>
    </ProductDetailSection>
    <ProductDetailSection id="specifications">…</ProductDetailSection>
  </ProductDetailContent>
  <ProductDetailRelated>…</ProductDetailRelated>
</ProductDetail>
<ProductDetailStickyBar>
  <span>{variantLabel}</span>
  <CartAddProductButton sku={variant.sku} />
</ProductDetailStickyBar>
  • Layout. From lg, gallery and content on the left and the buy box sticky on the right across both; related sections full width below. Narrower, the parts stack in document order, so on a phone the buy box (sizes included) comes directly after the gallery and before any content. Tokens: --product-detail-buy-width (25rem), --product-detail-sticky-top (1rem; set it to clear a sticky site header; sections use it as their scroll margin too).

  • Sticky bar. ProductDetailStickyBar shows once ProductDetailBuyAction has scrolled up out of view, below lg only. Render it after ProductDetail, in the same scrolling parent, since it is position: sticky. ProductDetailBuyAction registers itself with the bar under its id when it mounts, so one that renders late (behind a purchase guard's pending state, say) is still watched. Pass the same id/watch pair to change it; a watch id that is not a ProductDetailBuyAction is looked up once, at mount. Wrap both buttons in feature-cart's AddToCartScope so they share one pending state.

  • Anchor navigation. Lists only the anchors with hasContent, and nothing with fewer than two. overview, specifications, reviews, questions, documents, accessories and comparison take their label and section title from ProductDetail.sections; any other id passes its own label, and its section its own title. The order is the site's: a consumer page leads with Overview, Specifications, Reviews, Questions; a business page with Specifications, Documents, Accessories, Reviews.

  • Content. ProductHighlights shows the first three highlighted specifications that have a value. ProductDescription clamps its children to --product-description-lines (4) and offers "Read more" only when they run longer; the site renders the Markdown.

  • Express checkout goes inside ProductDetailBuyAction, under the main button.

  • Price. ProductDetailPrice takes the variant's price, regularPrice and lowestPrice. When price is below regularPrice it strikes the regular price through, shows the saving as "-20%" (rounded down, from discountPercentage in the contract), and shows the EU Omnibus line "Lowest price in the 30 days before this discount" when lowestPrice is given. regularPrice and price come from the ProductDetailPrice document, which now reads lowestPrice too. ProductDetailLowestPrice also reads ProductVariant.lowestPrice, which is null while the variant is not discounted and on engines that record no price history (Medusa, SFCC), so the line then stays off. Hooks: product-detail-price (with data-discounted), product-detail-regular-price, product-detail-discount and product-detail-lowest-price.

  • Stock. ProductDetailStock says the selected variant's stock in one line: in stock, "Only 2 left", a pre-order with its expected date and "You pay when it ships", or sold out with a link to inStockAlternative ("In stock in Army green, L", through toHref and renderLink). readVariantStock(client, { slug, sku }) from ./queries reads both (ProductDetailStock). Renders nothing without stock. Hooks: product-detail-stock (with data-level), product-detail-stock-alternative.

  • Sold out replaces the button. ProductDetailStockAction renders its notify in place of the add button when the variant is sold out, and hands the button "Pre-order" as its label for a pre-order (stockBuyAction in the contract is the rule). ProductBackInStockForm asks for an email address; the site sends ProductVariantBackInStockSubscribe and passes isSubscribed. That mutation is the framework's opt-in back-in-stock module, so it lives in back-in-stock-documents.ts, outside documents.ts.

    <ProductDetailStockAction
      stock={variant.stock}
      notify={<ProductBackInStockForm onValidSubmit={({ email }) => subscribe({ sku, email })} isSubscribed={subscribed} />}
    >
      {({ label }) => <CartAddProductButton sku={variant.sku}>{label}</CartAddProductButton>}
    </ProductDetailStockAction>
    
  • Delivery promise. ProductDetailDeliveryPromise shows "Order before 22:00, delivered tomorrow", the carrier and the delivery cost for the shopper's postcode, with "Change" to edit it in place (onPostalCodeChange; the site reads the promise again). Not deliverable: the reason by its code and alternativesHref. "Tomorrow" and the deadline are read in timeZone, the store's. readDeliveryPromise(client, { slug, sku, country, postalCode }) reads ProductVariant.deliveryPromise, the same source as checkout's delivery dates. It needs the framework's opt-in delivery-options module, so its document lives in delivery-promise-documents.ts and a consumer's codegen picks it up only when it adds that file. Hooks: product-detail-delivery-promise (with data-deliverable), product-detail-delivery-change, product-detail-delivery-postcode-form, product-detail-delivery-alternatives.

  • Digital products. The buy box follows the variant's kind (PHYSICAL or DIGITAL, from ProductVariant.kind; add it to the site's variant query). A digital variant has no quantity and promises a download instead of delivery. A format choice, such as ebook or paperback, is an ordinary variant dimension, so VariantSelector shows it. kind is null when the engine does not tell, and then the variant reads as physical.

    <ProductDetailQuantity kind={variant.kind} value={quantity} onValueChange={setQuantity} />
    <ProductDetailBuyAction>
      <CartAddProductButton sku={variant.sku} quantity={orderQuantity(variant.kind, quantity)} />
    </ProductDetailBuyAction>
    <ProductDetailPromises
      kind={variant.kind}
      physical={<ProductDetailPromise icon="shipping">{t("delivery")}</ProductDetailPromise>}
    />
    

    ProductDetailQuantity renders nothing for a digital variant. orderQuantity (from .) adds 1 of a digital variant, so a quantity chosen for a paperback never reaches the ebook. ProductDetailPromises shows the site's physical promises, or for a digital variant "Download right after payment" and "No shipping"; digital replaces those two. Messages: ProductDetail.quantity, ProductDetail.promises.

  • Add-ons. ProductDetailAddOns lists extras such as an extra warranty, a checkbox each with its price. useAddOnSelection from ./hooks holds the ticked SKUs: it always starts empty, because EU consumer law forbids pre-ticked paid extras, and it drops what a newly chosen variant does not offer. The catalog has no add-on relation yet, so the site passes the offers as ProductAddOns. CartAddProductButton's addOns adds each as its own cart line, linked to the product's line. Renders nothing without add-ons. Message namespace: ProductDetail.add-ons.

    const selection = useAddOnSelection(addOns);
    
    <ProductDetailAddOns addOns={addOns} selected={selection.selected} onSelectedChange={selection.onSelectedChange} />
    <CartAddProductButton sku={variant.sku} quantity={quantity} addOns={selection.selected} />
    

    A trade-in estimate is not here yet: the framework has the provider port but no provider or schema field.

  • Bundles. ProductDetailBundle is "Complete the look" (kind="complete-the-look") or "Frequently bought together" (kind="bought-together"): the page's product, items to tick, a size select for an item with more than one variant, and a live total with discountPercent off each extra. The percentage is only shown; the merchant configures the real discount in the commerce engine. useBundleSelection from ./hooks holds the ticked items and sizes. Nothing starts ticked, and canAdd stays false until the product's own size and every ticked item's size are chosen. action takes feature-cart's CartAddBundleButton. Renders nothing without items. Message namespace: ProductDetail.bundle.

    readProductBundle(client, { slug }) from ./queries reads the merchant's bundle from Product.bundle as { items, discountPercent }: each variant labelled by its dimensions, isAvailable where it is in stock or on backorder, a variant without a sku or price left out. Null without a bundle. Product.bundle needs a framework that serves it, and on commercetools the product type's bundleProducts reference-set attribute; a site without them passes its own ProductBundleItems.

    Prices follow the page's VAT basis. Pass vatDisplay: "net" to readProductBundle on a business page so the items are read excluding VAT, the same as the product's own price, and the same vatDisplay to ProductDetailBundle, which then marks the prices "excl. VAT". Both default to gross.

    const bundle = await readProductBundle(client, { slug });
    const selection = useBundleSelection({ sku: variant?.sku ?? null, items: bundle?.items ?? [] });
    
    <ProductDetailBundle
      kind="complete-the-look"
      product={{ name: product.name, price: variant.price }}
      items={bundle?.items ?? []}
      selection={selection}
      discountPercent={bundle?.discountPercent}
      action={<CartAddBundleButton sku={selection.sku ?? ""} extras={selection.extras} disabled={!selection.canAdd} />}
    />
    
  • Related products. ProductDetailRelatedProducts shows products in a row that scrolls sideways, each through renderItem. readRelatedProducts(client, { slug }) from ./queries reads them from Product.relatedProducts; a site with its own recommendations passes those instead. Renders nothing without products.

  • Documents. ProductDetailDocuments lists a variant's files, each a download that opens in a new tab: what it is, then its file kind, size and language where the catalog says ("Datasheet · PDF · 1.2 MB · English"). readProductDocuments(client, { slug, sku }) reads ProductVariant.documents. The URLs do not expire, so a cached page keeps working links. Renders nothing without documents.

  • Accessories. ProductDetailAccessories shows what is sold for the variant: accessories and spare parts in separate rows under their own headings, or one row when there is only one kind. Each item goes through renderItem, the site's card with the cart's add button in its actions, because this package adds nothing to a cart. Each item carries its unit ("box"), with no quantity in it. readProductAccessories(client, { slug, sku }) reads ProductVariant.accessories.

  • Recently viewed. ProductDetailRecentlyViewed records product as viewed and shows the shopper's earlier ones, newest first, from localStorage; no request is made. One entry per product, the latest variant, at most limit (12) kept and show (8) shown. The stored copy drops its availability, which would go stale, and keeps its price. Empty on the server and on a first visit. useRecentlyViewed from ./hooks reads and records the same history elsewhere, such as a home page.

<ProductDetailRelated>
  <ProductDetailRelatedProducts
    products={await readRelatedProducts(serverClient, { slug })}
    renderItem={(product) => <ProductCard product={product} href={productHref(product)} />}
  />
  <ProductDetailRecentlyViewed
    product={variant}
    renderItem={(product) => <ProductCard product={product} href={productHref(product)} />}
  />
</ProductDetailRelated>

Hooks: product-detail, product-detail-gallery, product-detail-buy-column, product-detail-buy-box, product-detail-buy-action, product-detail-content, product-detail-anchor-nav, product-detail-section, product-highlights, product-description, product-detail-related, product-detail-related-products, product-detail-recently-viewed, product-detail-product-row, product-detail-product-row-item, product-detail-sticky-bar, product-detail-promises (with data-kind), product-detail-promise, product-detail-documents, product-detail-document (with data-kind), product-detail-accessories, product-detail-accessories-group (with data-kind).

./product-comparison

This product beside its alternatives, for technical products where the differences decide the purchase:

import { ProductComparison } from "@evolve-storefront/feature-product/product-comparison";

<ProductDetailSection id="comparison">
  <ProductComparison
    products={[product, ...alternatives]}
    renderProduct={(product) => <ProductCard product={product} href={productHref(product)} />}
    locale={locale}
  />
</ProductDetailSection>
  • Rows on which the products differ. One row per specification, in the first product's order and then any that the others add. A row where every product says the same is hidden unless showIdentical is set. A product without a value shows a dash, read out as "Not specified".
  • The first product is this page's. Its column carries data-current. Three alternatives is the intended count, and choosing them is up to the site; the data comes in through props. comparisonRows in the contract computes the rows for a table of the site's own.
  • Narrow screens scroll the table sideways and keep the labels in view.
  • Renders nothing with fewer than two products or with nothing to tell them apart, so give the comparison anchor hasContent: alternatives.length > 0.
  • The compare checkbox stays out of the buy box. The card's compare slot remains for a listing that has a compare tray.

Hooks: product-comparison, product-comparison-row, and data-current on the current product's cells.

./product-business

A business buyer's buy box: the codes they order by, the contract price and unit prices by quantity.

import {
  ProductContractPrice,
  ProductIdentifiers,
  ProductPriceTiers,
} from "@evolve-storefront/feature-product/product-business";
import { readBusinessPrices } from "@evolve-storefront/feature-product/queries";

const business = await readBusinessPrices(gqlClient, { slug, sku });

<Heading level={1}>{variant.name}</Heading>
<ProductIdentifiers {...business?.identifiers} />
<ProductContractPrice
  price={business?.price}
  listPrice={business?.listPrice}
  company={businessUnit.name}
/>
<ProductPriceTiers
  tiers={business?.tiers ?? []}
  quantity={quantity}
  onQuantityChange={setQuantity}
/>
  • readBusinessPrices reads sku, ean, mpn, price, listPrice and priceTiers of one variant (ProductDetailBusinessPrices) and returns each part's props. Ask it with the business unit's session, since the engine prices per business unit.
  • ProductIdentifiers lists SKU, MPN and EAN, each with a button that copies it. An absent code is left out.
  • ProductContractPrice leads with the price excluding VAT, labelled with the business unit when company is given. Under it come the price including VAT and, when higher, the list price struck through.
  • ProductPriceTiers shows unit prices by quantity, each tier's saving against the first as a whole percent (rounded down), and marks the tier the buy box's quantity falls in. It says how many more units reach the next tier, and with onQuantityChange it offers a button that raises the quantity to that tier. At the top tier it says the best price is reached instead. It renders nothing with fewer than two tiers. The same tiers feed ProductOrderMatrix.

A site that already selects these fields in its own product query can keep that query: pass ean, mpn and listPrice straight through, and turn the tiers into rows with toPriceTiers(variant.price, variant.priceTiers) from the package root. It puts the variant's own price in the row from one unit, because the engine returns only the tiers above it. Either way, delete the site's own tier mapping.

Hooks: product-identifiers (each entry with data-identifier), product-contract-price, product-contract-price-gross, product-contract-price-list, product-price-tiers (the current row with data-current), product-price-tiers-nudge, product-price-tiers-best.

Site parts: ordering as a business buyer

Four parts from ./product-business for a B2B buy box. Each is pure: the site reads the data and plugs in the action from feature-cart or feature-quote, since a feature never imports another.

import {
  ProductOrderMatrix,
  ProductOrderMatrixSheet,
  ProductPackSize,
  ProductQuoteRequest,
  ProductWarehouseStock,
} from "@evolve-storefront/feature-product/product-business";
import {
  readOrderMatrixVariants,
  readQuantityRule,
  readWarehouseStock,
} from "@evolve-storefront/feature-product/queries";
import { CartAddLinesButton } from "@evolve-storefront/feature-cart/cart-add-lines-button";

<ProductWarehouseStock
  stock={await readWarehouseStock(gqlClient, { slug, sku })}
  defaultWarehouse={businessUnit.warehouseKey}
  transferDays={5}
  promise={<ProductDetailDeliveryPromise promise={promise} />}
/>
<ProductOrderMatrixSheet>
  <ProductOrderMatrix
    variants={await readOrderMatrixVariants(gqlClient, { slug })}
    rows="color"
    columns="storage"
    tiers={tiers}
    renderAction={(lineItems, label) => (
      <CartAddLinesButton lineItems={lineItems}>{label}</CartAddLinesButton>
    )}
  />
</ProductOrderMatrixSheet>
<ProductQuoteRequest quantity={quantity} onValidSubmit={requestQuote} />
<ProductPackSize quantityRule={quantityRule} />
  • ProductWarehouseStock shows stockByWarehouse exactly: "126 in stock in Nieuwegein" for the buyer's own warehouse (defaultWarehouse) first, with promise under it, then what the others add within transferDays, then when an empty one expects more. It renders nothing where the engine keeps no stock per warehouse.
  • ProductOrderMatrix takes a quantity per variant across two dimensions, with each cell's stock: the warehouse total, or the stock level without one. A sold-out cell takes no quantity. The tier applies to the total across variants, so 4 + 10 pieces pay the 10+ price. The add goes through CartAddLinesButton, whose drawer sums up what was added and lists any line that was refused. ProductOrderMatrixSheet is the "Order several variants at once" button that opens it.
  • ProductQuoteRequest shows above threshold (100) pieces and opens a sheet with the quantity and a message, validated against QuoteRequestSchema. The site sends it, for example by putting the line in a cart and calling feature-quote.
  • ProductPackSize shows the variant's quantityRule from readQuantityRule: "Sold in packs of 6", and "Minimum order: 12" when the minimum is more than one pack. Pass the same rule to ProductDetailQuantity, which then starts at the minimum and steps by the pack. ProductListingTable reads quantityRule from each listing item.

Hooks: product-warehouse-stock (each line with data-kind: default, other, expected), product-order-matrix (each cell with data-level), product-order-matrix-summary, product-quote-request, product-quote-request-form, product-quote-request-confirmation, product-pack-size (each line with data-kind: pack, minimum).

./product-reviews

A product's approved reviews, for the reviews section. Server-renderable: filtering and paging are links, so a page reads its URL again.

const params = readReviewParams(searchParams); // reviews_page, reviews_rating, reviews_sort
<ProductDetailSection id="reviews">
  <ProductReviews
    summary={await readProductReviewSummary(client, { slug })}
    page={await readProductReviews(client, { slug, params })}
    params={params}
    search={searchParams}
    pathname={pathname}
  />
</ProductDetailSection>
  • The summary is the average with its count, the spread over 5 down to 1 stars, and how the size fits ("75% say it is true to size") where buyers were asked. Each rating links to the list filtered to it; the filtered one is marked and links back to every review.

  • ProductReviewsFit is that fit line on its own, for a size picker: renderHelp={(dimension) => dimension.name === "size" ? <ProductReviewsFit fitDistribution={summary.fitDistribution} /> : null}. It renders nothing when nobody reported a fit (hook product-reviews-fit).

  • The sort (ProductReviewsSort, hook product-reviews-sort) lists the reviews newest, highest or lowest rated first. Each is a link that keeps the rating filter and goes back to the first page; the current one is marked.

  • A review shows its stars, title, text, who wrote it ("A customer" without a name) and when, a verified purchase, the variant bought, how it fit, and its photos. Pass renderItem={(review) => …} for the site's own row; the summary, sort and pager stay.

  • ProductReviewsRating is the rating for the buy box: the average, the count and the share who recommend the product, as one link to #reviews (hook product-reviews-rating). It reads the same summary and renders nothing without reviews. The share counts 4 and 5 stars (productReviewRecommendShare); pass recommendShare={(summary) => …} to count it otherwise, or return null to leave it out.

    <ProductReviewsRating summary={await readProductReviewSummary(client, { slug })} />
    
  • The links keep the URL's other parameters, such as the chosen variant, and end in #reviews. reviewsHref builds them; pass renderLink for the app's Link.

  • The reviews module is opt-in on the gateway. A gateway without it fails any query that names its fields, so the documents (ProductReviewSummary, ProductReviewList) are apart from the product page's own. Only run them where the module is composed. With no reviews, ProductReviews renders nothing.

./product-questions

Questions about a product with their answers, for the questions section. The list is read-only: the PIM supplies the questions and answers, so there is no form for shoppers to ask one.

import { ProductQuestions } from "@evolve-storefront/feature-product/product-questions";

<ProductDetailAnchorNav
  anchors={[{ id: "questions", hasContent: questions.length > 0, count: questions.length }]}
/>
<ProductDetailSection id="questions">
  <ProductQuestions questions={questions} />
</ProductDetailSection>
  • Answers name their source. Each answer carries data-source (CUSTOMER_SERVICE or BUYER) and a label from ProductQuestions.source.
  • renderQuestion replaces the default ProductQuestionItem. The list supplies the li and the key.
  • Data waits on the framework. It has no product questions yet, and choosing the provider is still open. ProductDetailQuestions in ./queries is the proposed shape: it fails validation today, so leave it out of a consumer's codegen documents. ProductAnswerSource is a proposal too.

Hooks: product-questions, product-questions-empty, product-question, product-question-unanswered, product-answers, product-answer (with data-source), product-answer-source.

./variant-selector

A product's variant dimensions, each as the selector its options call for:

import { VariantSelector } from "@evolve-storefront/feature-product/variant-selector";

<VariantSelector
  dimensions={dimensions}
  toHref={({ slug, search }) => `${productDetailHref(slug)}?${search}`}
  renderLink={(props) => <Link {...props} />}
  renderImage={(props) => <Image {...props} width={64} height={64} />}
  renderHelp={(dimension) => (dimension.name === "size" ? <SizeChartSheet /> : null)}
/>;

Colour swatches when every option of a dimension is a colour option, images when every one is an image option, buttons otherwise (variantSelectorKind). ButtonSelector, ColorSelector and ImageSelector are the three, for a page placing them itself; pass them every dimensions so an option's link keeps the others' choice.

Choosing a variant is navigating to its address, so each option is a link, and the chosen one carries aria-current="page". An option no variant carries (no product slug or variant SKU) is shown as a disabled span titled VariantSelector.unavailable, so the dimension reads complete. A swatch takes its colour from the option's color only, never from its value, so a colour name CSS does not know (midnight) cannot render white; an option without a color shows its label instead. Every swatch carries its label for screen readers and shows it on hover and focus, and the legend names the chosen colour.

What an option shows comes from what the site selects on it:

  • availability, or variant { stock { level lowStockQuantity } }: a sold-out option is struck through (a slash on a swatch) and named sold out for screen readers, and a low-stock one says how many are left. The stock level wins where both are there.
  • variant { price { gross { centAmount currency } } }: a button option shows what it costs more or less than the selected one, and with the cheapest selected a line names the next one up ("For €130.00 more: 256 GB").

Hooks: variant-selector, button-selector, color-selector, image-selector, *-selector-unavailable, color-selector-swatch (with data-color-missing when there is no colour), color-selector-name, color-selector-chosen, color-selector-sold-out, button-selector-low-stock, button-selector-price-difference, button-selector-nudge, variant-selector-help, variant-choice-alert. A sold-out option's link carries data-sold-out, and a fieldset with nothing chosen data-unchosen.

Your own swatch colours

A swatch takes the catalog's colour from the --swatch-color custom property, painted by a rule in @layer components, and carries the option's value as data-value. To show your design system's colour instead of the catalog's hex, write a plain rule on the value. It is unlayered, so it wins without !important:

[data-component="color-selector-swatch"][data-value="navy"] {
  background-color: var(--color-swatch-navy);
}

A swatch without a colour in the data carries data-color-missing, shows the name and is left unpainted.

The choice in the URL

Each dimension is its own search parameter, and a size enters the URL only once the shopper picks one: /product/tank first, then ?color=red, then ?color=red&size=m. A deep link to a whole variant is ?variant=<sku>. On the server:

import { readVariantChoiceForProduct } from "@evolve-storefront/feature-product/queries";
import { variantSelection } from "@evolve-storefront/feature-product";

const { choice, sku } = await readVariantChoiceForProduct(serverClient, { slug, params });
const product = await getProduct(slug, sku); // variant(sku: $sku), variantDimensions(selectedSku: $sku)
const { dimensions, unchosen } = variantSelection(product.variantDimensions, { choice });
  • variantSelection shows no size selected until the choice names one, since the server always selects its default variant's. Which dimensions wait for the shopper is chooseExplicitly, ["size"] by default. A dimension with one option never waits.
  • unchosen is what is still missing. While it is not empty, render ChooseVariantButton ("Choose your size") in place of the add button, in the buy box and in the sticky bar alike. Pressing it brings the sizes into view, focuses the first and shows the error under them.
  • readVariantChoice and resolveVariant on the . path are the same steps without the request, for a site that already has variants { sku dimensions { key value } }. A choice no variant matches as a whole keeps its dimensions in order for as long as one does.

renderLink gets replace: true and scroll: false, which Next's Link takes as they are: switching a variant then fetches the page without a full load, replaces the history entry so Back still leads to the listing, and keeps the scroll position. The plain <a> default is a full navigation. Keep the gallery's position across a switch by not keying it on the SKU, and reset it where the new variant has other images.

The product's canonical URL is its path without these parameters: a size never gets its own. A colour that is its own product has its own slug, and so its own canonical.

./product-specifications

A product's specifications, a table per group:

import { ProductSpecifications } from "@evolve-storefront/feature-product/product-specifications";

<ProductSpecifications groups={variant.specifications} locale={locale} headingLevel={3} />;

Rows that say nothing are dropped — an empty text or list, an empty enum, a zero quantity such as 0 or 0 MB — and so is a group left without rows. A "no" is an answer and stays. Numbers and dates are formatted in the locale, a boolean as ProductSpecifications.yes/no. An attribute's description is a toggletip: a button named About {label}, so keyboard and touch reach it.

Each union member's value has a different type, so the selection aliases it; select the attributes as:

... on ProductSpecificationStringAttribute { __typename name label description stringValue: value }
... on ProductSpecificationNumberAttribute { __typename name label description numberValue: value }
... on ProductSpecificationBooleanAttribute { __typename name label description boolValue: value }
... on ProductSpecificationDateTimeAttribute { __typename name label description dateTimeValue: value }
... on ProductSpecificationEnumAttribute { __typename name label description enumValue: value { label value } }
... on ProductSpecificationEnumListAttribute { __typename name label description enumListValue: values { label value } }

hasSpecificationValue, visibleSpecificationGroups and formatSpecificationValue are exported for a page that lays the rows out itself. Hooks: product-specifications, product-specification-group, product-specification-row, product-specification-description.

Components

What you place on your pages. Each one has a preview, its props, and how to change its layout.

Hooks

7 hooks to read and change the data yourself, for when you build your own UI. All hooks

More in this package

  • Setup and server: what the package needs in your project.
  • Advanced: graphql documents, data and cache helpers, other functions and constants, types.