# Dynamic table

External component that renders a live data table from the PointMe Panel API (transfer partners, offers, directories). Configure via Contentful; data is fetched and cached server-side.

**Editor config builder (non-production):** [`/debug/panel-table`](/debug/panel-table)

> **Named presets require a code change.** You cannot invent a new preset name in the CMS alone — add it in `src/lib/pointMePanel` (PRESET_BASE + mappers + `presetMeta`).  
> **Filter combos and filter pills on existing presets do not** require a code change.

## External type

| Field | Value |
|-------|--------|
| externalComponentType | **`Dynamic table`** |

## Contentful fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| cmsLabel | Symbol | Yes | Internal CMS label only (not shown on site) |
| externalComponentType | Symbol | Yes | Must be `Dynamic table` |
| heading | Symbol | No | Optional section heading above the table |
| data | Object | Yes | `PanelTableConfig` JSON (see below) |

Other external fields (background colour, copy, links, etc.) are available but unused by this renderer.

## PanelTableConfig (`data`)

| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `preset` | string | `transfer-partners-grouped` | One of the 9 named presets |
| `filters` | object | `{}` | Panel API filters + optional post-fetch `categoryId` (see below) |
| `sortable` | boolean | `true` | Header sort controls (thin JS). Table HTML is always SSR’d with default A–Z on first column; set `false` for static headers only |
| `filterPills` | array | — | Exclusive client-side filter chips (optional; deferred for Chase hub launch polish) |
| `defaultFilterPillId` | string | first / `default:true` | Optional override for which pill starts selected |
| `showDebugInfo` | boolean | `false` | Shows resource, query, row count, bytes under the table (use in draft/preview) |
| `emptyMessage` | string | (built-in) | Custom empty/error copy |
| `revalidateSeconds` | number | `3600` (1h) | Override Next Data Cache TTL for this config |

### filters

| Key | Type | Used by | Notes |
|-----|------|---------|-------|
| `originProgramId` | string | transfers (required), offers optional | e.g. `ultimate-rewards` — **sent to Panel** |
| `destinationProgramId` | string | transfers / offers optional | Narrow to one partner program — **sent to Panel** |
| `productId` | string | transfers optional | Narrow to one card product — **sent to Panel** |
| `grouped` | boolean | transfers | Grouped preset defaults `true`; by-card defaults off — **sent to Panel** |
| `active` | boolean | transfer-offers | Active-offers preset always forces `active=true` — **sent to Panel** |
| `categoryId` | string | transfers only | **Post-fetch only** (not a Panel query param). Use `airline` or `hotel` to split partner tables. Changes column profile (below). |

### Transfer column profiles (`categoryId`)

| `filters.categoryId` | Columns |
|----------------------|---------|
| *(omit)* | Partner program · Average value per point · Type · Alliance · Transfer ratio · Transfer time (+ Card if by-card) |
| `airline` | Partner program · Average value per point · Alliance · Transfer ratio · Transfer time |
| `hotel` | Partner program · Average value per point · Transfer ratio · Transfer time |

Partner program cells show **name only** (no logos). Panel may still return `logoId`; it is not rendered.

**Average value per point** is always the second column on transfer partner tables (SE-46). Values come from Panel `program-valuations?primary=true&valueType=average` (cents, e.g. `1.3¢`), joined on destination program id. Partners with no valuation show **—**. Not a CMS field.

Airlines without an alliance show **Unaligned** (not `—`).

### filterPills (client UI)

Exclusive pills above the table. **All rows stay in the HTML** (SEO); JS hides non-matching rows. No-JS visitors see every row.

| Field | Type | Notes |
|-------|------|-------|
| `id` | string | Stable key (`all`, `instant`, …) |
| `label` | string | Button text |
| `default` | boolean | Initial selection (first `true` wins if several) |
| `match` | rule[] | Omitted/empty = show all rows. Else **AND** all rules |

#### Match rules

`column` is a **header label** (case-insensitive, partial OK) or **0-based index**.

| `op` | Extra fields | Notes |
|------|--------------|-------|
| `eq` / `neq` / `contains` / `startsWith` / `endsWith` | `value: string` | String compare |
| `in` | `values: string[]` | Any match |
| `gt` / `gte` / `lt` / `lte` | `value: number` | Numeric (uses `sort` key when present) |
| `between` | `min`, `max` numbers | Inclusive |

Optional `using`: `"sort"` (default — `data-sort-value`) or `"display"` (visible text). Prefer `sort` for Bonus % and dates.

Unknown column → rule fails closed (row hidden for that pill).

## Sorting

- **Text** columns: A–Z via locale compare  
- **Bonus %**: numeric via hidden sort key (`100` not `"100%"`)  
- **Starts / Ends**: display `MM-DD-YYYY`; sort by ISO `YYYY-MM-DD`  
- **Transfer ratio / time**: numeric sort keys from Panel fields  
- **Average value per point**: numeric cents via hidden sort key (`1.3` not `"1.3¢"`)  
- Sort icons: dual-chevron (Figma `swap_vert`); active column purple  

## Presets (all 9)

| Preset | Resource | Required filters | Columns |
|--------|----------|------------------|---------|
| `transfer-partners-grouped` | `transfers` | `originProgramId` | Partner program · Average value per point · Type · Alliance · Transfer ratio · Transfer time |
| `transfer-partners-by-card` | `transfers` | `originProgramId` | Same + **Card** |
| `active-transfer-offers` | `transfer-offers` | — (forces `active=true`) | From · To · Type · Bonus · Starts · Ends |
| `programs-directory` | `programs` | — | Program · Company · Category · Currency · Alliance |
| `products-directory` | `products` | — | Product · Program |
| `airlines-directory` | `airlines` | — | Airline · IATA · Country · Alliance |
| `countries-directory` | `countries` | — | Country · ISO · Continent |
| `currencies-directory` | `currencies` | — | Currency · ISO |
| `continents-directory` | `continents` | — | Continent |

## Example JSON (`data` field)

### Chase Ultimate Rewards — airline partners (hub article)

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true,
    "categoryId": "airline"
  }
}
```

### Chase Ultimate Rewards — hotel partners (hub article)

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true,
    "categoryId": "hotel"
  }
}
```

### Chase Ultimate Rewards — all partners (single table)

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true
  }
}
```

### Chase UR + filter pills (Figma-style)

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true
  },
  "filterPills": [
    { "id": "all", "label": "All Partner Programs", "default": true },
    {
      "id": "best-ratios",
      "label": "Best Transfer Ratios",
      "match": [
        { "column": "Transfer ratio", "op": "eq", "value": "1:1", "using": "display" }
      ]
    },
    {
      "id": "instant",
      "label": "Instant Transfer Times",
      "match": [
        { "column": "Transfer time", "op": "eq", "value": "Instant", "using": "display" }
      ]
    }
  ]
}
```

### Active transfer offers + 50%+ pill

```json
{
  "preset": "active-transfer-offers",
  "filterPills": [
    { "id": "all", "label": "All offers", "default": true },
    {
      "id": "big-bonus",
      "label": "50%+ bonus",
      "match": [{ "column": "Bonus", "op": "gte", "value": 50, "using": "sort" }]
    }
  ]
}
```

### Chase UR — by card (ungrouped)

```json
{
  "preset": "transfer-partners-by-card",
  "filters": {
    "originProgramId": "ultimate-rewards"
  }
}
```

### Sortable false (static table)

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true
  },
  "sortable": false
}
```

### Draft with debug footer

```json
{
  "preset": "transfer-partners-grouped",
  "filters": {
    "originProgramId": "ultimate-rewards",
    "grouped": true
  },
  "showDebugInfo": true
}
```

## Pitfalls

| Issue | Detail |
|-------|--------|
| **originProgramId required for transfers** | Without it, the table fails validation (never pulls the full transfer dump). |
| **Bonus sort needs sort keys** | Display is `25%`; sort uses numeric amount. Always ship via mappers — do not hand-edit cells. |
| **Offer dates are MM-DD-YYYY** | Time is stripped; sort uses ISO day. |
| **filterPills are client-only** | They do not re-query Panel. Wrong column labels hide rows (fail closed). |
| **products are thin** | Products directory has name + program only — no fee, earn rate, or card image yet. |
| **cards API 404** | There is no Panel `cards` resource; use `products` (thin) for card-ish lists. |
| **alliance Unaligned vs —** | Airlines with no alliance show **Unaligned**; hotels show **—** (hotel tables omit the Alliance column). |
| **logos** | Not shown. Partner program column is text only (customer preference). |
| **categoryId is post-fetch** | Split airline/hotel tables with `categoryId`; it does not reduce the Panel payload size. |
| **New named presets** | Require a **code change** (not just CMS JSON). Filter combos / pills / `categoryId` on existing presets are fine. |

## Usage

1. Create or open an **External component** entry.
2. Set **External component type** to `Dynamic table`.
3. Set **CMS Label** and optional **Heading**.
4. Paste `PanelTableConfig` JSON into **Data**.
5. Prefer building JSON on **`/debug/panel-table`** (non-production) — choose a preset, filters, optional filterPills JSON, copy, live preview.

Spike notes: [docs/spikes/se-10045-4-panel-api.md](../../spikes/se-10045-4-panel-api.md)
