# 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: `@cartbase/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-id` required; `x-publishable-api-key` optional
  (channel scope, products-listing semantics); Bearer JWT optional (group
  pricing).
- **Request**

```jsonc
// 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**

```jsonc
{
  "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:

```bash
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"'
```

```bash
# 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"'
```

```bash
# 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":\[\]'
```

```bash
# 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`.

```bash
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 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).

---

## 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: 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**

```jsonc
{
  "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:

```bash
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`.

```bash
# 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.
