# Stream K5: the product line page at `/products/`
(written 2026-09-16 Wed, from the accepted plan W1, W18 and W20; Mark agreed strongly with the shape on 16 Sep; part of the Thursday-night wave with K3 and K4)

**Runs:** Thu 17 Sep night into Fri 18, in the wave, after K2's hand-back. Reads the schema K2
built (attributes through the one accessor; places) and the words K1 fixed. Never alongside a
collection.

## Goal
`/products/<product-line-slug>` is the one public catalogue page: every product variant of the
line on it, a variant chosen by `?variant=<id>`, the price shown only when it is unambiguous
(W20's cases), a comparable card opening exactly the comparison it promised, old variant addresses
landing on their line, every line page `noindex, follow` until K6's approval, and JSON-LD stating
every offer without choosing one. No variant pages remain.

## What already exists, so you build on it
- `routers/catalog.py`, `catalog_queries.get_product` (the variant page read), `seo.py`'s
  server-rendered product body, JSON-LD and sitemap, `urls.py`, the SPA's `ProductPage.tsx`,
  `ProductCard.tsx`, `PriceTable.tsx`, `SiteSearch.tsx` and `lib/search.ts`; 22 files name the
  variant address today (grep `product_path` and `/products/`).
- `product_lines.slug` exists and is unique; `resolve_product_id` follows a merged variant to its
  survivor and `main.py` already 301s a merged-away address.
- The shopper's chosen airports live in the browser (`MyAirportsButton`, `lib/`), never on the
  server; `seo.py` already takes a `noindex` flag (today `noindex, nofollow`).
- K2 provides `attributes.of(variant)` (every attribute with its kind's display setting: picked,
  shown, fact) and the counting unit "shops counted once per primary place" as
  `places.count_units()` and `places.unit_join()`; every count on the page goes through them.

## The contract (plan W1, W20)
- **Address**: `/products/<line-slug>`; a variant within it `?variant=<id>`; the airports the
  comparison uses `&airports=LHR,CDG`; the canonical is always the bare line address.
- **Card deep link**: a comparable card links to the line with its variant and the airports its
  comparison used (the shopper's chosen set, else the card's own set). The page shows that
  comparison and nothing else first.
- **Old addresses**: `/products/<name>-<id>` (a variant id) answers 301 to
  `/products/<line-slug>?variant=<id>`; a merged-away id first follows to its survivor.
- **What the page prints as a price**, in this order: the shopper chose airports and the chosen
  variant is stocked at some of them: those prices, and the ones that do not stock it named; chosen
  airports but none stock it: say so, offer the airports that do; no airports chosen and the
  variant is at one place: that price and shop; at several: ask for an airport; several variants
  and none chosen: ask for a variant. A variant at one place never overrides a shopper's choice: it
  is named as elsewhere. Catalogue-only shops never price a page. Every price carries its date.
  "From $X" never appears.
- **Cards** keep a representative variant: among the line's publishable variants, most comparison
  units, then lowest id; never recency.
- **Picked attributes** are the selectors (quantity always; concentration; whatever K2's registry
  marks picked for the category); **shown** attributes are listed as facts about what shops carry
  (shades); **fact** attributes sit in the details block.
- **A crawler** sees the page in its no-airports state with JSON-LD `ProductGroup` (the line) and
  `hasVariant` `Product` entries each with its `Offer` rows (shop, price, currency, date), which
  states facts without choosing.
- **`noindex, follow`** on every line page until K6's `indexed` decision says otherwise; the sitemap
  lists only indexed lines (K6 owns the sitemap filter; K5 makes the page read the flag).

## Tasks, in order (ids on /plan; commit prefix `K5:`)
1. **K5.1 The read.** `catalog_queries.get_product_line(slug, variant_id, airports)`: the line, its
   brand, every live variant with attributes through the accessor, the listings and newest prices
   per shop for each, the comparison for the chosen variant and airports, the representative for
   cards. One query shape; the comparable rule unchanged (two or more comparison units).
2. **K5.2 The route and the redirects.** `/api/products/<slug>` and the page route; the old
   `/products/<name>-<id>` shape resolved to a 301; the query parameters; canonical to the bare line.
3. **K5.3 The page.** `ProductLinePage.tsx` (rename `ProductPage.tsx`): the selectors from picked
   attributes, the shown attributes list, the price block by the contract's cases, the airports
   parameter honoured and the shopper's chosen airports used when none is given, the details block.
   Reuse `PriceTable` and `ProductCard`; no new design work beyond the existing tokens.
4. **K5.4 Cards and search.** Every card (`ProductCard`, the savings tables, the airport and
   brand pages, search suggestions) links with the deep-link contract; the representative rule
   for cards.
5. **K5.5 Server-rendered body, JSON-LD and the head.** `seo.py`: the line body in the crawler's
   state, `ProductGroup` with `hasVariant` and offers, `noindex, follow` from K6's flag (default
   noindex), the canonical.
6. **K5.6 Tests.** `test_site_routes` (old address 301, the canonical, the query parameters),
   `test_seo_body` and `test_seo` (the group markup, the noindex, the offers), a test per W20 case
   with a fixture line of three variants at three shops, the card deep link, the representative
   rule on a tied line (the Rabanne 1 Million EDT and Elixir at 100 ml), the route inventory.

## Owns
`app/routers/catalog.py` (the line routes; the variant routes removed), `app/services/catalog_queries.py`
(the line reads; the variant page read removed), `app/services/seo.py` (the line body, JSON-LD,
the head's noindex read), `app/services/urls.py`, `app/models/schemas.py` (the line's response
shapes), `web/src/pages/ProductLinePage.tsx` and `.css`, `web/src/components/ProductCard.tsx`,
`PriceTable.tsx`, `SiteSearch.tsx`, `web/src/lib/search.ts`, `web/src/App.tsx` (the route), their
tests.

## Must not touch
Identity (`product_lines.py`, `keying.py`, `merges.py`: K3), the schema and the accessor's
internals (K2), the review area (K4), the sitemap filter, IndexNow and the redirect table (K6:
K5 calls `publish.is_indexed(line)` and `publish.redirect_for(slug)`, whose signatures K2's brief
fixes). A change K5 needs there goes to the running list as an issue owned by that stream.

## Rehearsal and acceptance
The local API against a copy of staging on `dfp-devdb` after K3's rederive (or before it, on the
old grouping, if K3 has not handed back; the page must not depend on the grouping): each W20 case
proven by a URL and its rendered price block; a card's link opened and the same comparison shown;
`curl` of an old variant address giving 301 and the new address giving `noindex, follow`;
`main/check.sh` green; the route inventory closed.

## Rollback
The variant routes stay on the branch behind a flag (`LINE_PAGES=false` restores them) until
Cannes; nothing in the data changes.

## Hand-back
Handoff (≤25 lines) with the URLs that prove each case, the flag, and "K5 green and ready".
