# Client surfaces

Sources of truth: `main/web/src/pages/`, `main/web/src/lib/`, `main/web/src/components/`,
`main/app/routers/`, `main/app/models/`, `import/`.

(`import/progress.json` and `import/items.json` change at every checkpoint by design; a newer
commit there does not date this page.)

A client surface is a page built for Adam, Mark or rian to steer the build itself: reviewing
what shipped, deciding scope, tailoring the launch quote, or checking the plan. It is distinct
from the shopper storefront (home, products, savings, exclusives, awards, airports, product
pages), which is documented in ARCHITECTURE.md. Client surfaces share one comment mechanism, need
the `client.view` permission (`ACCOUNTS.md`), and are excluded from search indexing.

## Surfaces

### The feature board (`/discuss`)
`DiscussionPage` (`web/src/pages/DiscussionPage.tsx`). Renders a video walkthrough block, the
"big picture" lines (`BIG_PICTURE` in `web/src/lib/review.ts`), the feature board itself, and a
short list of open decisions plus a general comment thread. The feature board shows every entry
in `BOARD` (`web/src/lib/review.ts`), grouped by `scope` (launch, after, later, tbd) and filtered
by build `status` (live, partial, building, planned). Each card carries a status chip, a scope
chip, a description, a "done" line and a "next" line, a set of priority buttons (effort tiers for
built features, timing tiers for unbuilt ones), and its own comment thread. Tapping a priority
button calls `useSetFeaturePriority`, which upserts a row in `feature_priorities`
(`app/routers/discussion.py` `POST /api/discussion/feature-priorities`, table `FeaturePriority` in
`app/models/catalog.py`), one row per `feature_key`, latest choice wins. The open-decisions and
resolved-items lists read `discussion_items` (`GET /api/discussion`, table `DiscussionItem`), and
the "big picture" stats line reads live catalogue counts via `useStats`. This page is for Adam.

### The quote page (`/quote`)
`QuotePage` (`web/src/pages/QuotePage.tsx`). A tailorable soft-launch quote: the always-included
core lines (`CORE` in `web/src/lib/quote.ts`), a set of optional lines (`OPTIONS`) each with a
switch, and a few presets that toggle a whole set of options at once. Every line shows its list
price and its introductory price (`priceOf`, computed from `DISCOUNT` in `quote.ts`, never
hardcoded). Some options depend on others (`REQUIRES` in `quote.ts`): switching a line on pulls in
its dependency, switching a dependency off switches its dependants off too. Toggling a line calls
`useSetQuoteSelection`, which upserts `quote_selections` (`app/routers/quote.py`
`POST /api/quote/selections`, table `QuoteSelection`), one row per `item_key`. Submitting the form
at the bottom calls `useSendQuoteRequest`, which inserts a `quote_requests` row (table
`QuoteRequest`) freezing the chosen items and total at that moment, so a later change to the
selections does not alter a quote already sent. A collapsible "later" section lists features not
in reach for soft launch (`LATER` in `quote.ts`), each with its own comment thread. This page is
for Adam.

### The structure page (`/structure`)
`StructurePage` (`web/src/pages/StructurePage.tsx`), backed by `web/src/lib/structure.ts`. A
site-structure proposal: a live strip of counts from `/api/stats`, a one-page site map
(`SiteMap` component, fed by `MAP_HUBS`/`MAP_CROSS`/`MAP_PRODUCTS` in `structure.ts`), proposed
URL patterns and build thresholds (`URLS`, `THRESHOLDS`), a field-by-field breakdown of what each
page type shows and where each field comes from (`PAGES`, `ORIGINS`), a schema.org plan
(`SCHEMA_PLAN`), prose sections (`SECTIONS`, including what the review settled and what is still
open), and the questions with what was settled under each (`QUESTIONS`). Every section and
question carries its own comment thread, keyed `structure:<key>`; a key is never renamed or
removed, because comments attach by exact key. This page
reads no backend route of its own; all its content is static copy in `structure.ts`, described in
that file as measured against the live database and re-measured by hand rather than queried live
(the figures in `structure.ts` are typed by hand from measured queries, so they are a dated
snapshot; the file's own header says re-measure rather than edit). This page is for
Mark and Adam.

### The plan page (`/plan`)
`PlanPage` (`web/src/pages/PlanPage.tsx`). A live view of the build plan: waves across the top,
one lane per stream, tasks as status-coloured chips, with percent-done and days-to-delivery
stats. It polls `GET /api/plan` (`app/routers/plan.py`) on an interval set in the page. That route reads
`import/progress.json` fresh on every request and computes all tallies server-side so the file
itself never carries a count that can go stale. `progress.json` is mounted read-only into the
container and is edited on the host at session hand-back (`main/scripts/plan-set.py`, under a
file lock; `import/items.json` the same way through `items.py`), so both trackers change at
every checkpoint by design and carry no documentation of their own; its top-level keys are `updated_at`,
`updated_by`, `delivery`, `cannes`, `note`, `waves`, and `streams`, and its `note` field documents
the status vocabulary (todo, doing, done, blocked, deferred). This page is deliberately unlinked
from navigation and marked noindex: per its own file comment, it is for rian, and it is "not
hidden from the client so much as not put in front of him."

### The demo settings page (`/settings`)
`SettingsPage` (`web/src/pages/SettingsPage.tsx`), backed by `web/src/lib/settings.tsx` and
`web/src/lib/features.ts`. Two groups of switches, described fully under "Curator settings and
flags" below. This page is for whoever is running or demoing the site (rian or Adam), not a
shopper.

## Comments

Both `DiscussionPage`'s own `CommentThread` and the shared `FeatureThread` component
(`web/src/components/FeatureThread.tsx`) render the same look and behaviour: existing comments
(author, date, body), then either a "reply"/"leave a comment" button or an open comment form,
drawn only for an account holding `client.participate`.

A thread is keyed one of two ways, enforced by a database check constraint on
`discussion_comments` requiring exactly one of the two to be set (`app/models/catalog.py`,
`DiscussionComment`):
- **`item_id`**: a comment tied to one row in `discussion_items` (the open-decisions cards on
  `/discuss`). Posted via `POST /api/discussion/{item_id}/comments`.
- **`feature_key`**: a free-form string key, posted via `POST /api/discussion/feature-comments`
  and read via `GET /api/discussion/feature-comments`. This is what `FeatureThread` uses
  everywhere it appears: a board feature's own `key` on `/discuss`, `structure:<section-key>`
  (or `structure:map`, `structure:urls`, `structure:schema`,
  `structure:general`) on `/structure`, `quote:<item-key>` (or a slugified `quote:later-<name>`)
  on `/quote`, and the literal string `"general"` for the catch-all thread at the bottom of
  `/discuss`.

There is no reply/author distinction in the data model: a comment is `{author, author_id, body,
created_at}`. The author comes from the session, never a typed name: `author_id` is the
signed-in account (the effective one under View As) and `author` its display name as a snapshot,
the raw record. Comments typed before the app's own login keep their text with a NULL link
unless `backfill authors` mapped the name. A comment is editable by its author under
`client.participate` and by anyone under `discussion.curate`; the `item:*` threads (the running
list on `/plan`) are filtered from the list read and answer 404 to a post or an edit unless the
caller holds `plan.view` (`app/routers/discussion.py`).

## Curator settings and flags

`DemoSettings` (`web/src/lib/settings.tsx`) is a browser-local preferences object, persisted to
`localStorage` under the key `dfp.settings`, provided through `SettingsProvider`/`useSettings`.
Its fields:
- **`curator`** (default off): shows the add-item panel, the resolve/reopen toggle on each card,
  and the "Resolved" section on `/discuss`, and only for an account holding `discussion.curate`
  (the owner's by absence at launch): the switch is a browser preference, the permission is the
  server's. Checked in `DiscussionPage.tsx` only; no nav link anywhere in the app is gated by
  `curator`.
- **`photosOnly`**: hides products with no photo, for showing the site to someone without gaps.
- **`showDataNotes`**: shows or hides the data-honesty explanatory notes (price-is-an-observation,
  photo provenance) intended to stay on for real visitors.
- **`samples`**: a per-key map (`sampleOn(settings, key)`, absent = visible) controlling whether an
  unbuilt/sample surface is shown at all: the announcement bar (`announce`) and one entry per
  teaser feature in `FEATURES` (`web/src/lib/features.ts`: airport guides, price tracker, reviews,
  guides, news, newsletter, map, alerts). Turning a sample off hides its nav link, its footer
  "coming soon" link, and its `/feature/:slug` teaser card everywhere at once. The `editorial` key
  still exists in the settings page but gates nothing: the home page's sample editorial block was
  replaced by the real `LatestArticles` (nothing until an article is published), and the
  newsletter band is the real `SubscribeForm`, shown regardless of the `newsletter` sample.

`flags` (`web/src/lib/flags.ts`) is a separate, server-controlled mechanism: build-time feature
switches read once from `window.__DFP_FLAGS__`, which the backend injects into the served HTML
shell at startup (`app/main.py`, sourced from `Settings.spa_flags` in `app/config.py`, itself set
by `.app.env` variables `feature_my_airports`, `feature_featured_home`, `feature_rich_cards`,
`feature_teasers`). In local dev, with no injected script, every flag defaults to on. Unlike
`DemoSettings`, flags are not user-togglable in the browser; they gate entire routes and features
per deployment (`myAirports` gates `/savings` and `/trip`, `teasers` gates `/feature/:slug` and
whether the nav even lists teaser links, `featuredHome` and `richCards` gate storefront widgets
not covered by this doc).

## Positioning rules

- House style holds on every surface, client and shopper alike: no em dash in string content,
  never "cheap" or "free" in what the client reads, nothing implying the code is the client's
  (`tests/test_house_style.py` walks the SPA sources; `structure.ts` and `review.ts` carry the
  rule in their headers).
- **Which reads and writes each account may make** is declared in one place,
  `app/services/access.py`, and enforced by its middleware (`ARCHITECTURE.md` step 2,
  `ACCOUNTS.md`). The client pages and their reads need `client.view`; comments and comment
  edits, priorities, quote selections and requests, to-do complete, reopen and uploads need
  `client.participate` (both on the seeded `admin` level Adam and Mark hold); adding, editing or
  resolving a decision card needs `discussion.curate`; `/plan`, `/issues` and the running list's
  reads need `plan.view` and its actions `items.act`; `/sources` and the kill switch need
  `sources.manage`. The four owner-tier permissions are held by no level at launch, so they are
  the owner's by absence. The SPA draws each control from the `capabilities` block of
  `GET /api/bw/me`; the server refuses the route regardless of what the SPA draws.
- The routes `/discuss`, `/quote`, `/structure`, `/settings` and `/plan` are all marked
  `noindex, nofollow` (`app/services/seo.py`, `STATIC_HEADS`).
- `/structure` and `/plan` are not linked from anywhere in the app's navigation or footer
  (verified by searching every `.tsx` file for a link to either path); a visitor reaches them
  only by a direct URL. `/discuss`, `/quote` and `/settings` are linked from the footer's "Behind
  the scenes" column (`web/src/components/SiteFooter.tsx`) regardless of any setting.
- `/plan`'s own file comment states it is unlinked and noindexed on purpose: built for rian, and
  deliberately not surfaced to the client rather than hidden from him (`web/src/pages/PlanPage.tsx`).
  An account without `plan.view` that types the address gets the not-found shell, on the server
  and in the SPA: a client is not told the page exists.
- No navigation link in the app is conditioned on `curator`; that flag only changes controls
  rendered inside `/discuss` itself (add-item panel, resolve/reopen buttons, the Resolved section).
- Nav and footer entries for the sample/teaser features are conditioned on both the `teasers`
  build flag (`web/src/lib/flags.ts`, checked in `SiteHeader.tsx`) and the per-key `samples`
  setting (`sampleOn`, checked in `SiteHeader.tsx`, `SiteFooter.tsx` and `HomePage.tsx`); either
  one being off hides the corresponding link.
- The whole site, storefront and client surfaces alike, is members-only until the go-live flip
  (`SITE_ACCESS`, `ACCOUNTS.md`): an anonymous visitor sees the sign-in page and nothing else.
  The app's own login is the only barrier once the server's gate lifts from the dev host (the
  owner-only `srv-gw id-gate` step in `RUNBOOK.md`); no client surface gains a second hostname
  (`agents.md`).

## Sponsor placements (under review, 11 Sep)

Ten placements are built into the pages and framed to belong where they sit
(`web/src/lib/sponsors.ts` `SPONSOR_POSITIONS`): native tiles in the home savings grid, the
airport shelf and the product page's related grid, each drawn like a product card from a
responsive display ad's assets (`NativeCreative`: square image, headline of 30 characters,
description of 90, brand name of 25, call to action) and taking one card's place, so the grid
shows one more card while it is off (the airport shelf pages 23 bottles instead of 24, the
pages staying contiguous); presented-by billboards on the home and airport pages; strips on the
product page and after an article; a sticky 300 by 600 skyscraper in a rail beside the article
text (off, the text centres; left out below 1100px); and 300 by 250 rectangles beside the
introductions of Travel exclusives and Award winners. The server-rendered bodies draw every page
as it is with no creative; creative arriving in `SPONSORS` changes the airport and article
layouts in the browser only, which the mirrors (`seo.py`) must then follow. A
placement renders nothing until it has creative, or until a reviewer switches it on from
`/settings` "Sponsor placements", which shows a labelled placeholder of the exact size (the
native tile: a sample ad at the real limits) in this browser only; each row's "See it on a
sample page" switches it on and opens a real page of its kind at it (`#sponsor-<id>`: the
home page, the airport with the most products, the top featured product, the newest article). Adam picks the sparse set from those; the chosen ones get their sizes on
his `/todo` row "Sponsor creative" (waiting until then). Creative arrives as versioned files
under `public/sponsors/`, never overwritten in place. `/settings` also carries the accent
picker for the same review.
