# Categories, tags, types

Taxonomy reads for navigation trees and filter UIs. Categories are
hierarchical (`parent_category_id`) with optional ancestor/descendant tree
embedding; tags and types are flat value lists whose ids feed the `tag_id` /
`type_id` filters on product listings and search.

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

**Visibility:** only ACTIVE, non-internal categories exist on the store
surface — enforced by the anon RLS policy, so an inactive/internal category
404s even when retrieved by id.

---

## GET /api/store/product-categories

- **Purpose** — category list / one tree level (pass `parent_category_id`
  to walk levels, or the tree flags to embed whole branches).
- **Auth** — anon: `x-client-id` required.
- **Request**

```jsonc
// query (all optional)
{
  "q": "appa",                       // case-insensitive substring on name
  "handle": "apparel",               // exact
  "parent_category_id": "pcat_…",    // children of this category
  "include_ancestors_tree": true,    // embed parent_category chains
  "include_descendants_tree": true,  // embed category_children recursively
  "limit": 50,                       // 1–200, default 50
  "offset": 0
}
```

- **Response** — `{ product_categories, count, offset, limit }`, ordered by
  `rank`:

```jsonc
{
  "product_categories": [
    {
      "id": "pcat_01tst00000000000000000001",
      "name": "Apparel",
      "handle": "apparel",
      "description": null,
      "parent_category_id": null,
      "rank": 0,
      "is_active": true,        // always true on the store surface
      "is_internal": false,     // always false on the store surface
      "mpath": null,
      "seo_title": null,        // null = fall back to name
      "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",
      // only when include_descendants_tree=true:
      "category_children": [ /* same shape, recursive */ ],
      // only when include_ancestors_tree=true:
      "parent_category": null
    }
  ],
  "count": 1, "offset": 0, "limit": 50
}
```

- **Working curl** — the seeded catalog carries the `apparel` category:

```bash
CATEGORIES=$(curl -sf "$BASE/api/store/product-categories?handle=apparel" \
  -H "x-client-id: $CLIENT_ID")
echo "$CATEGORIES" | grep -q '"product_categories"'
echo "$CATEGORIES" | grep -q '"handle":"apparel"'
CAT_ID=$(echo "$CATEGORIES" | grep -o '"id":"pcat_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CAT_ID"
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`.
- **SDK** — `listCategories(client, query?)`.
- **Components** — category navigation tree, breadcrumbs.
- **Settings** — Admin → Categories: `is_active` / `is_internal` flags,
  rank ordering, SEO overrides.

---

## GET /api/store/product-categories/:id

- **Purpose** — retrieve one category, optionally with its tree (category
  landing page + breadcrumbs in one call).
- **Auth** — anon: `x-client-id` required.
- **Request** — query `{ include_ancestors_tree?, include_descendants_tree? }`.
- **Response** — `{ "product_category": { ... } }` (tree fields only when
  requested; trees contain visible categories only).
- **Working curl**

```bash
CATEGORY=$(curl -sf \
  "$BASE/api/store/product-categories/$CAT_ID?include_descendants_tree=true" \
  -H "x-client-id: $CLIENT_ID")
echo "$CATEGORY" | grep -q '"product_category"'
echo "$CATEGORY" | grep -q '"category_children"'
```

- **Errors** — 404 `not_found` (unknown, deleted, or inactive/internal —
  RLS hides them even by id).

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

- **SDK** — `retrieveCategory(client, categoryId, query?)`.
- **Components** — category page header, breadcrumbs.
- **Settings** — as the list.

---

## GET /api/store/product-tags

- **Purpose** — tag list (filter chips). Use the returned ids as `tag_id`
  filters on `/products` and `/products/search`.
- **Auth** — anon: `x-client-id` required.
- **Request** — query `{ q?, value?, limit?, offset? }` (`q` substring,
  `value` exact; `limit` 1–200, default 50).
- **Response** — ordered by value:

```jsonc
{
  "product_tags": [
    { "id": "ptag_…", "value": "summer", "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 dev tenant seeds no tags, so this asserts the
  envelope (the shape contract), not contents:

```bash
TAGS=$(curl -sf "$BASE/api/store/product-tags" -H "x-client-id: $CLIENT_ID")
echo "$TAGS" | grep -q '"product_tags"'
echo "$TAGS" | grep -q '"count"'
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`;
  retrieve (`GET /api/store/product-tags/:id` → `{ "product_tag": …}`) 404s
  on unknown ids:

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

- **SDK** — `listProductTags(client, query?)` /
  `retrieveProductTag(client, tagId)`.
- **Components** — filter sidebar chips.
- **Settings** — tags are authored on products in admin.

---

## GET /api/store/product-types

- **Purpose** — type list; ids feed `type_id` filters on listings/search.
- **Auth** — anon: `x-client-id` required.
- **Request** — query `{ q?, value?, limit?, offset? }` (same semantics as
  tags).
- **Response** — `{ product_types, count, offset, limit }`, rows
  `{ id, value, metadata, created_at, updated_at }`, ordered by value.
- **Working curl**

```bash
TYPES=$(curl -sf "$BASE/api/store/product-types" -H "x-client-id: $CLIENT_ID")
echo "$TYPES" | grep -q '"product_types"'
echo "$TYPES" | grep -q '"count"'
```

- **Errors** — 400 `missing_client_id`, 400 `validation_failed`; retrieve
  (`GET /api/store/product-types/:id` → `{ "product_type": …}`) 404s on
  unknown ids:

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

- **SDK** — `listProductTypes(client, query?)` /
  `retrieveProductType(client, typeId)`.
- **Components** — filter sidebar.
- **Settings** — types are authored on products in admin.
