# Frontend Phase 4 — Catalog browse & nurse service builder

> **Mission:** light up the two faces of the configurable service catalog. For the **family/customer**
> this is the **Home (A5)** screen — greeting, the search bar, the four-tile service-category grid
> (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), and the complete-patient-record nudge —
> the front door of the whole app. For the **nurse** this is the **"add a service" builder (B7 services)**:
> a stepper that walks pick-category → answer required/optional option groups → set price + price unit,
> producing a priced **variant** (the atomic bookable unit), plus the list/edit/deactivate surface for a
> nurse's own offerings. This is what makes nurses *appear* in search (f6) and what a customer browses, so
> it must be exactly right about money, required-option validation, and reference-data caching.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md) (addresses & geo, cascading province/city/district, nurse coverage editor) + backend **phase b5** contract (`catalog`) · **Unlocks:** search & discovery ([`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.

---

## 1. Context — where this sits

We are at the hinge between *identity/geo* (f1–f3, done) and *discovery/booking* (f6+). A nurse cannot
be found until she has at least one **active, priced variant**; a customer has nowhere to start until the
**Home category grid** exists. This phase builds both, against the backend's **Catalog & pricing** domain
(b5): admin-seeded `service_categories` → `service_option_groups` → `service_option_values`, and the nurse
layers `nurse_service_variants` → `nurse_service_variant_options`. The bookable unit is the **variant**, never
the nurse and never the category — search, booking, and pricing all operate on variants downstream.

The product framing: transparent, **nurse-set** pricing per variant is a deliberate differentiator versus the
opaque "توافقی/negotiable" incumbents — so the price the nurse enters and the way we *display* it (price +
unit, with session count) is brand-load-bearing, not a detail.

**What already exists (do not rebuild) — built by prior frontend phases:**
- **f0** ([`frontend-phase-0.md`](./frontend-phase-0.md)) — the three actor app **shells** and route groups
  (customer mobile shell with the **5-tab bottom nav** خانه/رزروها/بیماران/کیف‌پول/پروفایل · nurse shell ·
  admin shell); the **`services/{domain}` + TanStack Query** reference pattern (`types.ts` / `keys.ts` /
  `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`); the **types-from-contract** convention; the
  **shared composite components** including the **stepper/progress header**, **status chip**, OTP input,
  phone field; the **money/format util** (`formatIrrToToman`, integer-safe IRR parse, Shamsi date display)
  in `src/utils/`; the i18n namespace conventions in both `messages/en.json` and `messages/fa.json`; the
  RTL baseline and `tokens.css` brand colours. **Reuse all of it — do not re-create the shell, the stepper,
  the money util, or the services pattern.**
- **f1-b2** — phone-OTP login, the role router, and roles in `AuthContext`. You read the current role to
  decide *customer Home* vs *nurse builder* chrome; you do **not** touch auth.
- **f2-b3** — onboarding, patient CRUD, customer & nurse profiles, nurse bank-account settings. The
  **patient list/record state** is what the Home "complete patient record" nudge points at; the **nurse
  profile** is the parent the variant builder hangs off (B7 = "تکمیل پروفایل و خدمات"; this phase owns the
  **services part**, the profile-bio/photo part is already in f2).
- **f3-b4** — address book + map picker + cascading **province → city → district** dropdowns, and the **nurse
  coverage-area editor** (`nurse_service_areas`). You do **not** rebuild geo; the *service areas* a nurse
  declares there are what fan the variant into search (f6) — out of scope here, just don't regress it.

> The variant builder produces *pricing*; the coverage editor (f3) produces *geography*; **search (f6)**
> joins them. This phase ships neither search nor the index — wiring the Home search bar to results is f6.

## 2. Required reading (do this first)

**Product / domain (business truth — read before designing any screen):**
- [`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
  — the catalog model in plain language: admin defines categories + configurable option groups/values; each
  **nurse defines variants** (category + chosen option values + own `price` + `price_unit`); the five price
  units (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); `display_name` auto-generates from
  option labels but is nurse-editable; **deactivate not delete**; catalog snapshotted onto bookings. This is
  the *why* behind every validation rule below.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline.
  Study **A5 (خانه / Home)**: greeting + avatar, search bar (`جستجوی خدمت یا پرستار…`), the **service-category
  grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), the **complete-patient-record nudge card**,
  and the **bottom tab nav** (Home active). And **B7 (تکمیل پروفایل و خدمات)**: the services-and-prices list
  (`مراقبت سالمند — ساعتی ۲۸۰٬۰۰۰ تومان/ساعت`, `+ افزودن خدمت`) — this phase builds the *services* half of B7.
  Note the legend (green=verified, amber=pending) and the deep-green brand / cream surface / Vazirmatn font.

**Contract to consume (the source of truth for shapes — do not guess):**
- [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) — written by **backend-phase-5**.
  This is where the real routes, request/response payloads, enums, and pagination live. Read it end-to-end
  before writing a single type. If it is not yet published or a needed shape is missing, follow the seam
  procedure in §4 (mock behind `services/catalog` and append a request for backend).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
  envelope (`OperationResult`/`ApiResult`, already unwrapped by `clientFetch`), `snake_case` routes/JSON,
  status codes, **mandatory pagination** (`page`/`page_size` → `items`+`total`) on the variant list,
  `name_fa`/`name_en` reference-data localisation.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
  **money is IRR Rials as an integer string on the wire**, parsed integer-safe and rendered via the f0
  money util; **Toman is display-only**; enums cross as stable string codes (`per_hour`/`per_session`/
  `per_half_day`/`per_day`/`per_24h`) mirrored as string-literal unions with **i18n labels, never a label
  hardcoded off the code**.

**Engineering & design rules:**
- [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) and
  [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, `services/{domain}` +
  Query caching + invalidate-on-mutation, one-hook-per-file, minimal re-renders, MUI v9 primitives reused,
  both locales in sync, tokens-based colours, RTL.
- **Invoke the `frontend-designer` skill** for *all* visual work here (Home, the category grid/tiles, the
  builder stepper UI, the variant list/cards, every empty/loading/error/success state). It is the brand/
  design contract — palette, tokens, typography, the `App*` library, layout shells, the hard UI rules. Do
  not hand-craft visuals outside it.
- The existing **f0 `services/auth/*`** and the **f3 `services/geo` + `services/address`** services — copy
  their exact structure for the new `services/catalog`. Reuse the f0 **stepper** and **status chip**; do not
  fork them.

## 3. Scope — build this

Two surfaces (customer Home, nurse builder) + the supporting `services/catalog` domain. Build every state
(loading / empty / error / validation / success) the digest's "Notes for UI" calls for.

### 3.1 `services/catalog` domain (the data layer the screens consume)
Create `client/src/services/catalog/` mirroring the f0 pattern:
- `types.ts` — string-literal unions + DTOs derived from [`catalog.md`](../../contracts/domains/catalog.md):
  `PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h'`; `ServiceCategory`
  (`id`, `name_fa`, `name_en`, `sort_order`, optional `icon`/`slug`); `ServiceOptionGroup` (`id`,
  `service_category_id` nullable = cross-category, `name_fa`/`name_en`, **`is_required`**, `sort_order`);
  `ServiceOptionValue` (`id`, `option_group_id`, `name_fa`/`name_en`, `sort_order`); `NurseServiceVariant`
  (`id`, `service_category_id`, `price` as **IRR digit-string**, `price_unit`, `session_count` nullable,
  `display_name`, `is_active`, the chosen `options: { option_group_id, option_value_id }[]`). Mirror the
  exact casing/nullability from the published swagger — do not invent.
- `keys.ts` — a query-key factory: `catalogKeys.categories()`, `catalogKeys.categoryOptionGroups(categoryId)`,
  `catalogKeys.myVariants()` (nurse), `catalogKeys.variant(id)`.
- `apis/clientApi.ts` — wrappers over `clientFetch` for each route in the contract (see §3.4). Add
  `apis/serverApi.ts` (`serverFetch`) **only** if the Home category grid is prefetched in an RSC (see 3.2).
- `hooks/` (one hook per file): `useServiceCategories.ts`, `useCategoryOptionGroups.ts` (query),
  `useMyVariants.ts` (paginated nurse list), `useCreateVariant.ts`, `useUpdateVariant.ts`,
  `useDeactivateVariant.ts` (mutations). **Reference data (categories, option groups/values) is cached with
  a long `staleTime`/`gcTime`** — it changes rarely; do not refetch it on every screen. Variant mutations
  **invalidate `catalogKeys.myVariants()`** (and `setQueryData` the edited row where it avoids a refetch).
- `index.ts` — barrel export.

### 3.2 Customer **Home (A5)** — `app/[locale]/(private-routes)/<customer>/home`
The primary landing screen, inside the customer shell with the **bottom tab nav (Home active)**:
- **Greeting + avatar** — `سلام، {firstName}` from `AuthContext`/`useCurrentUser` (f1); avatar from the
  customer profile (f2). RSC where it cleanly removes a round-trip; no `next/headers` in client components.
- **Search bar** — `جستجوی خدمت یا پرستار…`. **Render the input here, but search execution is f6** — tapping
  it navigates toward the (future) search route / sets the query; do **not** implement results, the index,
  or filters in this phase. Tag results/filters **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- **Service-category grid** — a tile per `service_category` from `useServiceCategories()`, ordered by
  `sort_order`, label by locale (`name_fa`/`name_en`), seed-matching the wireframe's four
  (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) but **data-driven** (never hardcode the category
  list — EAV configurability is load-bearing). Tapping a tile carries the chosen `service_category_id` into
  the (future) search flow. Build **loading skeleton tiles**, **empty** (no categories seeded → friendly
  message) and **error/retry** states.
- **Complete-patient-record nudge card** — shown when the signed-in customer has no patient *or* an incomplete
  record (derive from the f2 patient state already in cache — do **not** add a new fetch if f2's query
  already holds it); CTA routes to add/complete a patient (f2 screens). Hide when the record is complete.
- A small reusable **`CategoryTile`** and **`HomeNudgeCard`** composite live at the shared level if reused;
  page-only composition stays in the page.

### 3.3 Nurse **"add a service" builder + offerings list** — `app/[locale]/(private-routes)/<nurse>/services`
The services half of **B7**, inside the nurse shell:

**(a) Offerings list (`ListMyVariants`)** — `useMyVariants()` (paginated):
- Each row/card shows `display_name`, the **price rendered via the f0 money util** as
  `{formatIrrToToman(price)} تومان {unitLabel}` (unit label is an i18n key off `price_unit`), and an
  **active vs deactivated** visual distinction (reuse the f0 **status chip**) with a "deactivated can't be
  booked" hint on inactive rows. Row actions: **Edit**, **Deactivate** (with a confirm).
- States: **loading** (skeleton rows), **empty** (no offerings yet → prominent `+ افزودن خدمت` CTA), populated.

**(b) Create/Edit variant builder (`CreateVariant` / `UpdateVariant`)** — a **stepper** (reuse the f0
stepper/progress header), launched by `+ افزودن خدمت` or Edit:
1. **Step 1 — category.** Single-select from `useServiceCategories()`. On select, fetch that category's
   option groups via `useCategoryOptionGroups(categoryId)` (cached). (On *edit*, category is fixed — changing
   it would change identity; lock it and explain.)
2. **Step 2 — options.** Render each `service_option_group` for the category (plus any cross-category group
   where `service_category_id` is null) as a single-select of its `service_option_value`s. **Mark required
   groups** (`is_required`) and **block advancing until every required group is answered** (one value per
   group — `UNIQUE(variant_id, option_group_id)` is enforced server-side; the UI enforces single-select).
   Optional groups may be left unanswered.
3. **Step 3 — price + unit (+ duration).** A **price** field (Toman input → store/submit as IRR digit-string
   via the f0 integer-safe parse; **never a float**), a **`price_unit`** select (the five units, i18n
   labels), and an optional **`session_count`/duration**. Show a **live estimated total** computed from
   **price + unit + session_count together** — e.g. for `per_hour` with a duration, surface the
   `formatIrrToToman(price × hours)` estimate — **do not compute or display a total from price alone**. Show
   the auto-generated **`display_name`** (from the chosen option labels) as an editable field.
- **Submit:** `useCreateVariant()` / `useUpdateVariant()`. On the **duplicate-listing conflict** (server `409`
  on `(nurse_id, category, option-set)`), show a **friendly inline warning** ("شما قبلاً خدمتی با همین مشخصات
  دارید") and let the nurse adjust — don't silently fail or generic-toast it. Success → invalidate
  `myVariants`, route back to the list with the new/edited row visible.
- States to build: loading (fetching catalog), per-step validation errors (missing required option, missing/
  invalid price), the duplicate warning, success.

**(c) Deactivate (`DeactivateVariant`)** — `useDeactivateVariant()`; a confirm dialog explaining the variant
becomes **unbookable and drops out of search**; **soft only — never a hard delete**. On success, flip the
row to the deactivated visual state (`setQueryData`/invalidate).

### 3.4 Catalog browse (categories) — the read surface
The category-browse query the Home grid and the (future) search filters both consume: `ListCategories` and
`GetCategoryOptionGroups` via `services/catalog`. Build the **catalog-browse view** as the data-driven grid
in 3.2 (Home) reusing `CategoryTile`; a standalone "all categories" browse screen is optional and may be
**(DEFERRED → f6)** if the Home grid + search cover it — your call, but if you build it, reuse the same
hooks/components.

**Endpoints consumed (final names from [`catalog.md`](../../contracts/domains/catalog.md) — these mirror the
b5 capabilities; use the contract's exact `snake_case` routes):**
- `GET api/v1/catalog/categories` → categories (reference data, cached).
- `GET api/v1/catalog/categories/{id}/option_groups` (with values) → the skeleton the builder renders.
- `POST api/v1/nurse/variants` → CreateVariant.
- `PUT api/v1/nurse/variants/{id}` → UpdateVariant / EditDisplayName.
- `POST api/v1/nurse/variants/{id}/deactivate` → Deactivate (soft).
- `GET api/v1/nurse/variants` (paginated) → ListMyVariants (active + inactive).

**Out of scope (tag explicitly):**
- Search results / filters / the nurse-result cards / the search index — **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- The **admin catalog manager** (category/option-group/value CRUD) — **(DEFERRED → admin console [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md))**; the admin seeds the catalog server-side for now.
- Nurse **availability** slots/calendar — **(DEFERRED**, soft-constraint, not on this path).
- The **public nurse profile** services rows a customer sees — **(DEFERRED → f6 C3)**.
- Holiday/surge pricing, companionship tier, per-category commission — **(DEFERRED** per product doc).

## 4. Mocks & seams in this phase

This is a **frontend** phase: the only seam is the data seam behind `services/catalog`.
- **Reuse the `services/{domain}` seam pattern from f0.** Every catalog call goes through
  `services/catalog/apis/clientApi.ts` (over `clientFetch`) — never a raw `fetch()`.
- **If the b5 `catalog` contract is published and merged**, derive `types.ts` from it and call the real
  endpoints — no mock.
- **If a needed shape is missing or the contract isn't live yet**, build a **mock `clientApi`** behind the
  same `services/catalog` seam (returning realistically-shaped data: a few seeded categories, an option
  group with `is_required` true/false, a couple of variants across price units, and a `409` path to exercise
  the duplicate warning), **and**:
  - append the gap to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
    (per operating-rules §6 — you request, backend delivers; never edit backend files), and
  - record the mock in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
    + your phase report (per operating-rules §7) with exactly how f-next swaps it for the real endpoint.
- No new external-service seam is introduced here (Elasticsearch / `INurseSearch` belongs to **search**,
  f6/b7 — do not pull it forward).

## 5. Critical rules you must not get wrong

- **Money correctness — IRR is integer, never a float.** Money is **IRR Rials as an integer string on the
  wire**; parse it integer-safe and render it **only** through the f0 money util (`formatIrrToToman`). Toman
  is **display-only**; convert Toman input → IRR digit-string at the field boundary. **No floating-point**
  anywhere on the price path (input, state, or submit) — float coercion is a defect.
- **price + unit + session_count drive the displayed total — never compute the total from price alone.** The
  estimated total = `price` interpreted by `price_unit` combined with `session_count`/duration. A bare
  `price` is a unit rate, not an engagement total. Render the unit label from an **i18n key off the
  `price_unit` code**, never a label hardcoded in the component.
- **Reference data is cached.** Categories and option groups/values are admin-seeded reference data — fetch
  once with a long `staleTime`/`gcTime` and reuse from cache; do not refetch them per screen or per step.
  Variant mutations invalidate `myVariants` (and `setQueryData` the edited row) so you never needlessly
  refetch the list.
- **Validate every required option group.** The builder must not submit until **every `is_required` group has
  exactly one value chosen**; optional groups may be empty; one value per group (single-select mirrors the
  server's `UNIQUE(variant_id, option_group_id)`).
- **Deactivate is soft — never hard-delete.** A deactivated variant must read as unbookable and is understood
  to drop out of search; there is no delete affordance.
- **Duplicate-listing is a friendly conflict, not a crash.** Surface the server `409` on
  `(nurse_id, category, option-set)` as inline, actionable copy.
- **Data-driven catalog (no hardcoded enums).** Categories, option groups, and option values come from the
  API and render by `sort_order` + locale label — **never** hardcode the category/option list as constants.
  The **only** closed enum is `price_unit` (the five units).
- **RTL-first, both locales.** `fa` is default and RTL; every user-visible string is a key in **both**
  `en.json` and `fa.json`, in sync. Persian unit labels (ساعتی / روزانه / شبانه‌روزی, …) must read correctly.
- **RSC/client boundary & re-renders.** No `next/headers`/`next-intl/server` in client components; keep
  builder step state colocated (low) so typing in the price field doesn't re-render the whole stepper;
  stable references where it pays.
- **MUI primitives stay MUI; reuse the shared stepper & status chip** from f0 — do not fork a new root
  primitive or a second stepper.
- **Tenancy is server-enforced, but don't leak it in UI:** the nurse only ever sees/edits *her own* variants
  (`GET api/v1/nurse/variants` is self-scoped) — never build a UI that lists or edits another nurse's
  offerings.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus these phase specifics:
- [ ] `services/catalog` exists (types/keys/apis/hooks/index) mirroring the f0 pattern; reference data
      cached with deliberate `staleTime`/`gcTime`; variant mutations invalidate/`setQueryData` `myVariants`.
- [ ] **Home (A5)** renders inside the customer shell with the bottom nav: greeting + avatar, the search bar
      (navigates toward f6, results not built here), the **data-driven category grid** (loading/empty/error
      states), and the complete-patient-record nudge (derived from cached f2 state, hidden when complete).
- [ ] The **nurse builder** (stepper category → required/optional options → price+unit+duration) enforces all
      required groups, shows the live unit-aware estimated total, auto-generates an editable `display_name`,
      submits as IRR digit-string, and handles the **duplicate `409`** with friendly inline copy.
- [ ] The **offerings list** shows active vs deactivated distinctly, supports **edit** and **soft
      deactivate** (confirm dialog), with empty/loading states.
- [ ] All money rendered via the f0 money util; the **`price_unit`** labels are i18n keys in **both** locales,
      RTL-correct; `en.json`/`fa.json` in sync.
- [ ] Types derive from [`catalog.md`](../../contracts/domains/catalog.md); any gap is logged in
      `for-backend.md` and mocked behind the `services/catalog` seam (recorded in the mock registry).
- [ ] `npm run check` green; `npm run test:ci` green for any shared component added (e.g. `CategoryTile`,
      a price-unit display, the variant card) with co-located `*.test.tsx`.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route segments (customer `home`, nurse
      `services`) and the `services/catalog` domain + any new shared component.

## 7. How to test (what a human can verify after this phase)

Run `npm run dev` (and ensure the b5 endpoints are reachable, or the `services/catalog` mock is active).

1. **Home renders.** Sign in as a customer → the Home screen shows the greeting, avatar, search bar, and the
   **category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) ordered by `sort_order`. With
   no patient on file, the **complete-patient-record nudge** is visible; after completing a patient (f2) it
   disappears (no extra refetch — verify in React Query Devtools that the patient query is reused).
2. **Locale + RTL.** Switch `fa`↔`en` → labels translate, `dir` flips, the grid and tiles mirror correctly;
   Persian unit labels read right.
3. **Build a variant.** Sign in as a nurse → `+ افزودن خدمت` → step through: pick a category; in the options
   step, try to advance **without** answering a required group → **blocked** with a clear message; answer it →
   advance; set a **price (in Toman)** and a **unit** (e.g. ساعتی) with a duration → the **estimated total**
   updates from price × duration (not from price alone); edit the auto-generated `display_name`; submit →
   the new variant appears in the offerings list, price shown as `… تومان ساعتی`.
4. **Duplicate warning.** Create a second variant with the **same category + same option set** → the builder
   shows the friendly duplicate-listing warning (server `409`) and lets you change it, without a crash or a
   generic error toast.
5. **Edit + deactivate.** Edit a variant's price/`display_name` → list reflects it without a full refetch
   (Devtools: `setQueryData`/single invalidation). Deactivate a variant → it flips to the deactivated visual
   state with the "can't be booked" hint; there is **no delete** option.
6. **Caching.** In React Query Devtools confirm `catalogKeys.categories()` / `categoryOptionGroups` are
   served from cache across Home and the builder (no repeated network calls per step); a variant mutation
   invalidates only `myVariants`.
7. **Gate.** `npm run check` and `npm run test:ci` pass.

## 8. Hand off & document (close the phase)

- **Docs to update (same change):**
  - [`client/CLAUDE.md`](../../../client/CLAUDE.md) *Project Structure* — add the customer `home` and nurse
    `services` route segments, the `services/catalog` domain, and any new shared component (`CategoryTile`,
    price-unit display, variant card). Note the reference-data caching convention if it's the first long-lived
    cached domain.
  - If you discover or decide a catalog/pricing rule the product docs don't capture (e.g. how the estimated
    total is presented for each unit), record it in
    [`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
    — don't invent rules; record decisions, and flag any open question in your report.
- **Contract to consume:** [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (b5) —
  types/services derive from it; do not guess shapes. Any missing/ambiguous shape → append to
  [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
  (you request; backend delivers — never edit backend files). The frontend produces no contract.
- **Handoff & report (per operating-rules §6–§7):**
  - Append your phase summary to
    [`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md).
  - Write `dev/shared-working-context/reports/frontend-phase-4-report.md`: what shipped (Home A5, the nurse
    variant builder + offerings list, `services/catalog`), **what is now testable and exactly how** (the §7
    steps), what is mocked vs live behind the catalog seam and **how f6 swaps it**, the contract consumed,
    and the follow-ups handed to **f6** (the Home search bar now hands a `service_category_id` to search; the
    variant builder is what populates the index f6 reads).
  - Update [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
    if you mocked any catalog endpoint (seam, what's faked, config, how to make it real).
- **Memory (per operating-rules §8):** save a `project`-type memory note for the non-obvious decisions —
  the price/unit/session_count display rule (total never from price alone), the IRR-string-in/Toman-display
  money handling on the builder, and the reference-data caching choice — with a one-line `MEMORY.md` pointer.
  Don't record what the code/docs already make obvious.
