Files
2026-06-28 21:59:59 +03:30

332 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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* (f1f3, 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.