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.


  • Purpose — the search results page + autocomplete backend.
  • Auth — anon: x-client-id required; x-publishable-api-key optional (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 essentials collection:
SEARCH=$(curl -sf "$BASE/api/store/products/search?q=linen&currency_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/empty q, bad availability, limit > 100), 400 missing_client_id, 400 invalid_publishable_key, 400 invalid_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"'
  • SDKsearchProducts(client, query) — pass option filters as options: { Size: ["S","M"] }; the SDK folds them into option.<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).

  • 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: true when 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 essentials collection, 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), 400 validation_failed, 400 invalid_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
  • SDKlistRelatedProducts(client, idOrHandle, query?).
  • Components — PDP related rail.
  • Settings — manual picks: Admin → Product → Related (drag-ordered, ≤24); the fallback chain needs no configuration.