Search & related products
Configurable storefront search (search-discovery card) plus the PDP's related-products rail. Results are the SAME canonical product objects the products listing serves (see products.md) — reuse your product-card renderer as-is. Money is EUR decimal major units.
SDK module: @barter/storefront/api/search.
Matching — title/subtitle/description full-text (Cyrillic-correct 'simple' config, prefix match) ∪ typo tolerance via trigram similarity on the title ("linnen" finds "Linen Shirt") ∪ SKU prefix ∪ exact tag value. Draft products, other tenants, and products outside the publishable key's channels NEVER appear.
Synonyms (admin-authored) — expansion is BIDIRECTIONAL and SINGLE-LEVEL
(bounded — expansions never re-expand; capped at 24 terms): a query word
matching a row's term adds its synonyms; a query word matching one of a
row's synonyms adds the term + its sibling synonyms. So with the admin row
term: "зехтин", synonyms: ["olive oil"], EITHER direction finds the
product. Terms are normalized lowercase.
Pins & boosts (admin-authored) — ordering is: (1) pins for this EXACT normalized query, in position order — pins are INJECTED even without a text match, but only if they pass filters + channel scope + published (the leak rule applies to pins too); then (2) globally-boosted products WITHIN the match set, in position order; then (3) relevance rank with a deterministic tie-break. Global boosts reorder, never inject.
Cache rule — responses carry calculated_price when a pricing context
is given; they vary by customer group — never cache shared when a JWT was
present.
GET /api/store/products/search
- Purpose — the search results page + autocomplete backend.
- Auth — anon:
x-client-idrequired;x-publishable-api-keyoptional (channel scope, products-listing semantics); Bearer JWT optional (group pricing). - Request
// query — q is REQUIRED (min 1 char), everything else optional
{
"q": "linen",
"collection_id": "pcol_a,pcol_b", // CSV
"category_id": "pcat_a", // CSV
"tag_id": "ptag_a", // CSV
"type_id": "ptyp_a", // CSV
"price_min": 10, // major units, on the product's cheapest base price
"price_max": 50,
"availability": "in_stock", // in_stock | out_of_stock
// Option filters — dynamic keys, repeatable; OR within an option, AND
// across options. URL-encode titles ("Length (cm)" → option.Length%20(cm)):
"option.Size": "S,M",
"currency_code": "eur", // pricing context (default price facet currency: eur)
"region_id": "reg_…",
"limit": 20, // 1–100, default 20
"offset": 0
}- Response
{
"results": [ /* canonical product objects — see products.md */ ],
"facets": [
{
"key": "price", // "price" | "availability" | "type" |
// "tags" | "collection" | "options.<Title>"
"label": "Price",
"type": "range", // "range" for price, "value" otherwise
"buckets": [
{ "value": "20-40", "label": "€20 – €40", "count": 3,
"min": 20, "max": 40 } // min/max on range buckets only; max null = open top
]
},
{
"key": "availability", "label": "Availability", "type": "value",
"buckets": [ { "value": "in_stock", "label": "In stock", "count": 5 } ]
}
],
"total": 5,
"offset": 0,
"limit": 20
} Facets follow the tenant's Settings → Search & discovery → Filters
config (order + enabled; defaults: price, availability, type, tags,
collection). The faceting count rule: each facet's bucket counts are
computed on the result set filtered by every OTHER active filter — the
facet's own dimension is excluded, so selecting "Size: M" never zeroes the
other sizes. Empty buckets are omitted; a facet with no buckets is
omitted (the dev tenant has no tags/types, so those facets simply don't
appear). Price buckets: auto (deterministic nice-number split of
observed prices, ≤10 buckets) or fixed admin ranges; apply one by
passing its min/max back as price_min/price_max. Value-facet
buckets carry ids as value (type/tags/collection) or the option value
string (options.<Title>).
- Working curl — seeded catalog: Linen Shirt (45), Wool Beanie (23),
Leather Belt (60), all in the
essentialscollection:
SEARCH=$(curl -sf "$BASE/api/store/products/search?q=linen¤cy_code=eur" \
-H "x-client-id: $CLIENT_ID")
echo "$SEARCH" | grep -q '"results"'
echo "$SEARCH" | grep -q '"facets"'
echo "$SEARCH" | grep -q '"total"'
echo "$SEARCH" | grep -q '"handle":"linen-shirt"'
echo "$SEARCH" | grep -q '"calculated_price"'
# Default facet config puts price + availability on a priced, stocked catalog:
echo "$SEARCH" | grep -q '"key":"price"'
echo "$SEARCH" | grep -q '"key":"availability"'# Typo tolerance (title trigram): "linnen" still finds the Linen Shirt.
curl -sf "$BASE/api/store/products/search?q=linnen" -H "x-client-id: $CLIENT_ID" \
| grep -q '"handle":"linen-shirt"'
# SKU prefix match:
curl -sf "$BASE/api/store/products/search?q=LIN-SHIRT" -H "x-client-id: $CLIENT_ID" \
| grep -q '"handle":"linen-shirt"'# Option filter: only the Linen Shirt has Size S; the beanie (Color) and the
# belt (Length (cm)) don't match option.Size at all.
OPT=$(curl -sf "$BASE/api/store/products/search?q=linen&option.Size=S" \
-H "x-client-id: $CLIENT_ID")
echo "$OPT" | grep -q '"handle":"linen-shirt"'
# A value no variant carries → empty result set (envelope intact):
curl -sf "$BASE/api/store/products/search?q=linen&option.Size=XXL" \
-H "x-client-id: $CLIENT_ID" | grep -q '"results":\[\]'# Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel
# (shirt + beanie). "leather" matches anon but returns nothing under the key.
curl -sf "$BASE/api/store/products/search?q=leather" -H "x-client-id: $CLIENT_ID" \
| grep -q '"handle":"leather-belt"'
curl -sf "$BASE/api/store/products/search?q=leather" \
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
| grep -q '"results":\[\]'- Errors — 400
validation_failed(missing/emptyq, badavailability, limit > 100), 400missing_client_id, 400invalid_publishable_key, 400invalid_region.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/products/search" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400
curl -s "$BASE/api/store/products/search" -H "x-client-id: $CLIENT_ID" \
| grep -q '"code":"validation_failed"'- SDK —
searchProducts(client, query)— pass option filters asoptions: { Size: ["S","M"] }; the SDK folds them intooption.<Title>params. - Components — search page, facet sidebar, autocomplete.
- Settings — Search & discovery: synonyms, pins (per-query) + global boosts, facet order/enabled, price bucket strategy (auto/fixed).
GET /api/store/products/:idOrHandle/related
- Purpose — the PDP's related-products rail. Manual admin picks first
(position order), then a DETERMINISTIC fallback fills to
limit: same primary collection (newest first), then most-shared-tags.auto_filled: truewhen any fallback item is present. - Auth — anon:
x-client-id; optional publishable key — the anchor product must be visible to the key, and scoped-away products never appear as related items; optional Bearer JWT (group pricing). - Request — query
{ limit? (1–24, default 12), currency_code?, region_id? }. Accepts a product id (prod_…) or handle. - Response
{
"products": [ /* canonical product objects, self-excluded */ ],
"count": 2,
"auto_filled": true // at least one item came from the fallback chain
}- Working curl — the seeded products share the
essentialscollection, so the shirt's rail fills from it:
RELATED=$(curl -sf "$BASE/api/store/products/linen-shirt/related?currency_code=eur" \
-H "x-client-id: $CLIENT_ID")
echo "$RELATED" | grep -q '"products"'
echo "$RELATED" | grep -q '"auto_filled"'
echo "$RELATED" | grep -q '"count"'- Errors — 404
not_found(unknown id/handle, draft, or anchor outside the key's channels), 400validation_failed, 400invalid_region.
# Channel scope on the ANCHOR: the belt is outside the B2B key's channels.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/products/leather-belt/related" \
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
test "$STATUS" = 404- SDK —
listRelatedProducts(client, idOrHandle, query?). - Components — PDP related rail.
- Settings — manual picks: Admin → Product → Related (drag-ordered, ≤24); the fallback chain needs no configuration.