feature-product
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
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 reactRequirements4
@labdigital/graphql-fetcher>=3graphql>=16next-intl>=4react>=19Import its styles in the stylesheet where you load Tailwind. Without them the components render unstyled.
@import "@evolve-storefront/feature-product/styles.css";Wrap your app in
ProductListingProvider. Setup lists its props.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,sortandqare reserved. Tracking parameters are skipped:utm_*,gclid,gbraid,wbraid,dclid,fbclidandmsclkid. Every other parameter is a facet: a range when it ends in_min/_max, a toggle when it istrue/false, and otherwise a set of options. A site needs no stripping of its own.- A
sortthat is not aProductSortOrderTypevalue 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
maxPageSizeis clamped, and a bound that is not a number is dropped, neverNaN. A minimum of0counts 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 itspath, itsancestorsand aproductCount;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
facetsPromiseand the product grid takeslistingPromise, so each region suspends on its own search.useFacetNavigationandactiveFiltersalready 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, soisPendingcan 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.
ProductListingSearchCorrectionsays so, and links toexactSearchHref. That URL setsq_exact=1, soreadFilterStatereadsexactSearchTermand 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 thesearchPerformedpayload. 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". listonProductListingProvidermakes the filters reportlistFilteredand the sort reportlistSorted.listingFiltered(list, change)gives the payload for a change made elsewhere. Withoutlist, nothing is reported.listPageLoadedis the page's to emit, because the pager is server-rendered links.- Algolia's
queryIdgoes onsearchPerformedonceproductSearchreturns 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.
layoutsis["grid"],["grid", "list"](the default) or["grid", "table"]; the first is the fallback. With one layout the toggle renders nothing, and adefaultLayoutthat is not on offer, such as a cookie from before, falls back to the first.The order table.
ProductListingTableshows 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'sunit("€12.50 per box, excl. VAT") by default, orvatDisplay="gross". With"table"inlayouts, the root needstable={{ href, renderAction }}(and optionallyrenderLinkandvatDisplay). 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.
ProductListingLayoutsetsdata-listing-layouttogrid,listortable, and the grid andProductCardstyle 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'sin-data-[listing-layout=list]:variant.Keeping the choice is the site's. The layout is uncontrolled by default. Pass
onLayoutChangeto store the choice, in a cookie for instance, anddefaultLayoutto restore it on the server, so the page does not switch after hydration.indexis the product's position on this page, empty entries included, so it can go straight intolistingPosition.The filters and the sort need a
ProductListingProvider. It runsuseFacetNavigationonce 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
pageHreffrom thesearchthe root takes, so it works without JavaScript. PassrenderPageLinkfor the app'sLink.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 (ormodeonProductListingFiltersSheet) 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'sconfigso the count is scoped to the category. The sidebar stays live, and so does the sheet by default. The sheet needs aClientGqlFetcherProviderabove it inapplymode.Parts:
ProductListingGrid,ProductListingTable,useProductListingTableQuantity,ProductListingResultCount,ProductListingLayout,ProductListingLayoutSwitch,ProductListingLayoutToggle,useProductListingLayout,ProductListingFilters,ProductListingFiltersSheet,ProductListingActiveFilters,ProductListingSort,ProductListingPaginationanduseProductListing. 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-sortandproduct-listing-pagination.Subcategories.
ProductListingSubcategorieslinks to the current category's children. On a wide screen it is a list for the sidebar, headed byparent. On a phone it is a row of chips that scrolls sideways. The link tocurrentPathcarriesaria-current="page", and a category without children renders nothing. It takes plain{ id, name, path }entries andrenderLinkfor the app'sLink. The listing query does not carry a category's children, so the site passes them in. Data attributes:product-listing-subcategoriesandproduct-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.
promosandrenderPromoon 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 insearch, 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.LISTINGis the root withProductListingSubcategories variant="chips"above it, andLANDINGisCategoryLanding(below). The site picks the one bycatalogPage.layout, whichreadCatalogLanding(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.
renderItemgets the 0-basedindexand the rail's heading aslist, forproductClicked.renderLinkandrenderImagetake the app'sLinkand 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.headingLevelis the rail heading's level,2by default.- Parts:
CategoryLandingTilesandCategoryLandingRail. Data attributes:category-landing,category-landing-tiles,category-landing-tileandcategory-landing-rail. - Messages: the
CategoryLandingnamespace.
./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} />}
/>
layoutsets 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'slowestPrice, 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'sunit("per box, excl. VAT") with the first quantity tier that lowers it ("From €170.00 at 6 pieces").vatDisplaydefaults to gross forconsumerand net forbusiness.- A business listing offers the order table with
layouts={["grid", "table"]}on the root and keeps the buyer's choice throughonLayoutChange.
The price row sits at the foot of the card (
product-card-buy, holdingproduct-card-pricingandproduct-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.packSizegoes under the name, such as "12 pads × 100 sheets". The site supplies the text.pricePlaceholdertakes 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:
ribbonreplaces the product's own ribbon,comparesits above the name,actionssits beside the price (a quantity stepper with the add button, a shopping-list button),availabilityreplaces the availability line (a delivery promise, say), andchildrengoes 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)} />ProductCardColorssays how many colors there are ("6 colors") and shows a swatch per color, up tomax(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-colorand carriesdata-value, as the color selector's does, so one brand rule ondata-valuemaps both onto the design system's colors.hoverImagefades 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.productCardHoverImagepicks 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.cssunder@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 carriesdata-availability, keyed on the schema'sProductAvailabilityType.The parts are exported for a card composed by hand:
ProductCardImage,ProductCardRibbon,ProductCardName,ProductCardPriceandProductCardAvailability;ProductCardBrand,ProductCardDiscountandProductCardRating;ProductCardMeta,ProductCardSku,ProductCardTierHintandProductCardStock.ProductCardColors, withproductCardColorsandproductCardHoverImage.
Data: the listing's product selection now reads
regularPrice,priceTiers,stock { level lowStockQuantity },unit,images { url alt },product.brand,product.reviews { amount averageRating }, andproduct.variantDimensionswith 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,isPendingdisables 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.
ProductListingFiltersrenders 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-rangeandfacet-range-fields. Each part also setsdata-pendingwhile 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.
ProductDetailStickyBarshows onceProductDetailBuyActionhas scrolled up out of view, belowlgonly. Render it afterProductDetail, in the same scrolling parent, since it isposition: sticky.ProductDetailBuyActionregisters itself with the bar under itsidwhen it mounts, so one that renders late (behind a purchase guard's pending state, say) is still watched. Pass the sameid/watchpair to change it; awatchid that is not aProductDetailBuyActionis looked up once, at mount. Wrap both buttons in feature-cart'sAddToCartScopeso they share one pending state.Anchor navigation. Lists only the anchors with
hasContent, and nothing with fewer than two.overview,specifications,reviews,questions,documents,accessoriesandcomparisontake their label and section title fromProductDetail.sections; any other id passes its ownlabel, and its section its owntitle. The order is the site's: a consumer page leads with Overview, Specifications, Reviews, Questions; a business page with Specifications, Documents, Accessories, Reviews.Content.
ProductHighlightsshows the first three highlighted specifications that have a value.ProductDescriptionclamps 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.
ProductDetailPricetakes the variant'sprice,regularPriceandlowestPrice. Whenpriceis belowregularPriceit strikes the regular price through, shows the saving as "-20%" (rounded down, fromdiscountPercentagein the contract), and shows the EU Omnibus line "Lowest price in the 30 days before this discount" whenlowestPriceis given.regularPriceandpricecome from theProductDetailPricedocument, which now readslowestPricetoo.ProductDetailLowestPricealso readsProductVariant.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(withdata-discounted),product-detail-regular-price,product-detail-discountandproduct-detail-lowest-price.Stock.
ProductDetailStocksays 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 toinStockAlternative("In stock in Army green, L", throughtoHrefandrenderLink).readVariantStock(client, { slug, sku })from./queriesreads both (ProductDetailStock). Renders nothing without stock. Hooks:product-detail-stock(withdata-level),product-detail-stock-alternative.Sold out replaces the button.
ProductDetailStockActionrenders itsnotifyin place of the add button when the variant is sold out, and hands the button "Pre-order" as its label for a pre-order (stockBuyActionin the contract is the rule).ProductBackInStockFormasks for an email address; the site sendsProductVariantBackInStockSubscribeand passesisSubscribed. That mutation is the framework's opt-in back-in-stock module, so it lives inback-in-stock-documents.ts, outsidedocuments.ts.<ProductDetailStockAction stock={variant.stock} notify={<ProductBackInStockForm onValidSubmit={({ email }) => subscribe({ sku, email })} isSubscribed={subscribed} />} > {({ label }) => <CartAddProductButton sku={variant.sku}>{label}</CartAddProductButton>} </ProductDetailStockAction>Delivery promise.
ProductDetailDeliveryPromiseshows "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 andalternativesHref. "Tomorrow" and the deadline are read intimeZone, the store's.readDeliveryPromise(client, { slug, sku, country, postalCode })readsProductVariant.deliveryPromise, the same source as checkout's delivery dates. It needs the framework's opt-in delivery-options module, so its document lives indelivery-promise-documents.tsand a consumer's codegen picks it up only when it adds that file. Hooks:product-detail-delivery-promise(withdata-deliverable),product-detail-delivery-change,product-detail-delivery-postcode-form,product-detail-delivery-alternatives.Digital products. The buy box follows the variant's
kind(PHYSICALorDIGITAL, fromProductVariant.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, soVariantSelectorshows it.kindis 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>} />ProductDetailQuantityrenders nothing for a digital variant.orderQuantity(from.) adds 1 of a digital variant, so a quantity chosen for a paperback never reaches the ebook.ProductDetailPromisesshows the site'sphysicalpromises, or for a digital variant "Download right after payment" and "No shipping";digitalreplaces those two. Messages:ProductDetail.quantity,ProductDetail.promises.Add-ons.
ProductDetailAddOnslists extras such as an extra warranty, a checkbox each with its price.useAddOnSelectionfrom./hooksholds 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 asProductAddOns.CartAddProductButton'saddOnsadds 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.
ProductDetailBundleis "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 withdiscountPercentoff each extra. The percentage is only shown; the merchant configures the real discount in the commerce engine.useBundleSelectionfrom./hooksholds the ticked items and sizes. Nothing starts ticked, andcanAddstays false until the product's own size and every ticked item's size are chosen.actiontakesfeature-cart'sCartAddBundleButton. Renders nothing without items. Message namespace:ProductDetail.bundle.readProductBundle(client, { slug })from./queriesreads the merchant's bundle fromProduct.bundleas{ items, discountPercent }: each variant labelled by its dimensions,isAvailablewhere it is in stock or on backorder, a variant without a sku or price left out. Null without a bundle.Product.bundleneeds a framework that serves it, and on commercetools the product type'sbundleProductsreference-set attribute; a site without them passes its ownProductBundleItems.Prices follow the page's VAT basis. Pass
vatDisplay: "net"toreadProductBundleon a business page so the items are read excluding VAT, the same as the product's own price, and the samevatDisplaytoProductDetailBundle, which then marks the prices "excl. VAT". Both default togross.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.
ProductDetailRelatedProductsshowsproductsin a row that scrolls sideways, each throughrenderItem.readRelatedProducts(client, { slug })from./queriesreads them fromProduct.relatedProducts; a site with its own recommendations passes those instead. Renders nothing without products.Documents.
ProductDetailDocumentslists 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 })readsProductVariant.documents. The URLs do not expire, so a cached page keeps working links. Renders nothing without documents.Accessories.
ProductDetailAccessoriesshows 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 throughrenderItem, the site's card with the cart's add button in itsactions, because this package adds nothing to a cart. Each item carries itsunit("box"), with no quantity in it.readProductAccessories(client, { slug, sku })readsProductVariant.accessories.Recently viewed.
ProductDetailRecentlyViewedrecordsproductas viewed and shows the shopper's earlier ones, newest first, fromlocalStorage; no request is made. One entry per product, the latest variant, at mostlimit(12) kept andshow(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.useRecentlyViewedfrom./hooksreads 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
showIdenticalis 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.comparisonRowsin 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
comparisonanchorhasContent: alternatives.length > 0. - The compare checkbox stays out of the buy box. The card's
compareslot 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}
/>
readBusinessPricesreadssku,ean,mpn,price,listPriceandpriceTiersof one variant (ProductDetailBusinessPrices) and returns each part's props. Ask it with the business unit's session, since the engine prices per business unit.ProductIdentifierslists SKU, MPN and EAN, each with a button that copies it. An absent code is left out.ProductContractPriceleads with the price excluding VAT, labelled with the business unit whencompanyis given. Under it come the price including VAT and, when higher, the list price struck through.ProductPriceTiersshows unit prices by quantity, each tier's saving against the first as a whole percent (rounded down), and marks the tier the buy box'squantityfalls in. It says how many more units reach the next tier, and withonQuantityChangeit 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 sametiersfeedProductOrderMatrix.
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} />
ProductWarehouseStockshowsstockByWarehouseexactly: "126 in stock in Nieuwegein" for the buyer's own warehouse (defaultWarehouse) first, withpromiseunder it, then what the others add withintransferDays, then when an empty one expects more. It renders nothing where the engine keeps no stock per warehouse.ProductOrderMatrixtakes 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 throughCartAddLinesButton, whose drawer sums up what was added and lists any line that was refused.ProductOrderMatrixSheetis the "Order several variants at once" button that opens it.ProductQuoteRequestshows abovethreshold(100) pieces and opens a sheet with the quantity and a message, validated againstQuoteRequestSchema. The site sends it, for example by putting the line in a cart and calling feature-quote.ProductPackSizeshows the variant'squantityRulefromreadQuantityRule: "Sold in packs of 6", and "Minimum order: 12" when the minimum is more than one pack. Pass the same rule toProductDetailQuantity, which then starts at the minimum and steps by the pack.ProductListingTablereadsquantityRulefrom 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.
ProductReviewsFitis 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 (hookproduct-reviews-fit).The sort (
ProductReviewsSort, hookproduct-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.ProductReviewsRatingis the rating for the buy box: the average, the count and the share who recommend the product, as one link to#reviews(hookproduct-reviews-rating). It reads the same summary and renders nothing without reviews. The share counts 4 and 5 stars (productReviewRecommendShare); passrecommendShare={(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.reviewsHrefbuilds them; passrenderLinkfor the app'sLink.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,ProductReviewsrenders 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_SERVICEorBUYER) and a label fromProductQuestions.source. renderQuestionreplaces the defaultProductQuestionItem. The list supplies theliand the key.- Data waits on the framework. It has no product questions yet, and choosing the
provider is still open.
ProductDetailQuestionsin./queriesis the proposed shape: it fails validation today, so leave it out of a consumer's codegen documents.ProductAnswerSourceis 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, orvariant { 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 });
variantSelectionshows no size selected until the choice names one, since the server always selects its default variant's. Which dimensions wait for the shopper ischooseExplicitly,["size"]by default. A dimension with one option never waits.unchosenis what is still missing. While it is not empty, renderChooseVariantButton("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.readVariantChoiceandresolveVarianton the.path are the same steps without the request, for a site that already hasvariants { 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.

