# Collections & membership listings

Curated product groupings (manual or smart). The membership listing —
`/collections/:id/products` — is the collection page's data source: it reads
the membership JOIN (multi-collection products appear in every collection
they belong to), honors the collection's `default_sort`, and accepts a
per-request `order` override. Money is EUR decimal major units.

SDK module: `@cartbase/storefront/api/collections`.

**Channel scope (Shopify publish-to-channel semantics):** a collection with
sales-channel links is visible ONLY on those channels; a collection with no
links is visible everywhere. Pass your channel as `sales_channel_id` — the
list excludes scoped-away collections, and the membership listing 404s them.

---

## GET /api/store/collections

- **Purpose** — list collections (navigation, collection index pages).
- **Auth** — anon: `x-client-id` required.
- **Request**

```jsonc
// query (all optional)
{
  "q": "essen",                 // case-insensitive substring on title
  "handle": "essentials",       // exact — THE handle lookup (no /:handle route)
  "sales_channel_id": "sc_…",   // channel scope (see above)
  "limit": 50,                  // 1–200, default 50
  "offset": 0
}
```

- **Response** — `{ collections, count, offset, limit }`, ordered by title:

```jsonc
{
  "collections": [
    {
      "id": "pcol_01tst00000000000000000001",
      "title": "Essentials",
      "handle": "essentials",
      "type": "manual",             // "manual" | "smart"
      "description": null,
      "image_url": null,
      "default_sort": "manual",     // used by /products when no order override
      "conditions": [],             // smart-collection rules (admin-authored)
      "match": "all",               // smart matching: "all" | "any"
      "seo_title": null,            // null = fall back to title
      "seo_description": null,      // null = fall back to description
      "metadata": null,
      "created_at": "2026-07-01T00:00:00.000Z",
      "updated_at": "2026-07-01T00:00:00.000Z"
    }
  ],
  "count": 1, "offset": 0, "limit": 50
}
```

- **Working curl** — the seeded catalog carries the `essentials` collection:

```bash
COLLECTIONS=$(curl -sf "$BASE/api/store/collections?handle=essentials" \
  -H "x-client-id: $CLIENT_ID")
echo "$COLLECTIONS" | grep -q '"collections"'
echo "$COLLECTIONS" | grep -q '"handle":"essentials"'
COL_ID=$(echo "$COLLECTIONS" | grep -o '"id":"pcol_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$COL_ID"
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`.
- **SDK** — `listCollections(client, query?)`.
- **Components** — navigation, collection index grid.
- **Settings** — collection channel links; smart-collection conditions
  (membership recomputes on rule/product change).

---

## GET /api/store/collections/:id

- **Purpose** — retrieve one collection (header/SEO block of a collection
  page). By-handle lookup goes through the list (`?handle=`).
- **Auth** — anon: `x-client-id` required.
- **Request** — no query. NOTE (code truth): the single read takes no
  `sales_channel_id` — channel scope applies to the list and the membership
  listing, not here.
- **Response** — `{ "collection": { ...same shape as list rows... } }`
- **Working curl**

```bash
COLLECTION=$(curl -sf "$BASE/api/store/collections/$COL_ID" -H "x-client-id: $CLIENT_ID")
echo "$COLLECTION" | grep -q '"collection"'
echo "$COLLECTION" | grep -q '"default_sort"'
```

- **Errors** — 404 `not_found`.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/collections/pcol_doesnotexist$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

- **SDK** — `retrieveCollection(client, collectionId)`.
- **Components** — collection page header.
- **Settings** — SEO overrides.

---

## GET /api/store/collections/:id/products

- **Purpose** — the collection page's product grid: membership join,
  published products only, ordered by the collection's `default_sort` with
  an optional `order` override.
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
- **Request**

```jsonc
// query (all optional)
{
  "order": "price_asc",       // override: manual | title_asc | title_desc |
                              //   price_asc | price_desc | newest | oldest |
                              //   best_selling (90-day aggregate).
                              //   Unknown values are IGNORED (default_sort used).
  "sales_channel_id": "sc_…", // a collection scoped to OTHER channels 404s
  "currency_code": "eur",     // pricing context → calculated_price
  "region_id": "reg_…",
  "limit": 50,                // 1–100, default 50
  "offset": 0
}
```

- **Response** — `{ products, count, offset, limit }` — products carry the
  FULL canonical product shape (see products.md), incl. `calculated_price`
  when a pricing context is given. `count` is the visible membership size.
- **Working curl** — seeded membership (backfilled from
  `products.collection_id`) contains the three fixture products:

```bash
MEMBERS=$(curl -sf "$BASE/api/store/collections/$COL_ID/products?currency_code=eur" \
  -H "x-client-id: $CLIENT_ID")
echo "$MEMBERS" | grep -q '"products"'
echo "$MEMBERS" | grep -q '"handle":"linen-shirt"'
echo "$MEMBERS" | grep -q '"calculated_price"'
```

```bash
# Sort override: price_asc puts the Wool Beanie (EUR 23) before the Linen
# Shirt (EUR 45) — asserted as RELATIVE order so unrelated rows can't break it.
curl -sf "$BASE/api/store/collections/$COL_ID/products?order=price_asc" \
  -H "x-client-id: $CLIENT_ID" | node -e "
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
  const j=JSON.parse(Buffer.concat(c));
  const h=j.products.map(p=>p.handle);
  const a=h.indexOf('wool-beanie'), b=h.indexOf('linen-shirt');
  if(a<0||b<0||a>b){console.error('price_asc order wrong: '+h.join(','));process.exit(1)}
})"
```

- **Errors** — 404 `not_found` (unknown collection, or scoped away from the
  given `sales_channel_id`), 400 `validation_failed`, 400 `invalid_region`.
- **SDK** — `listCollectionProducts(client, collectionId, query?)`.
- **Components** — product card grid + sort dropdown (emit the `order`
  values above).
- **Settings** — collection `default_sort` + manual position order
  (drag-reorder in admin); price lists; channel links.
