# Frontend Phase 4 — Catalog browse (Home A5) & nurse service builder (B7) — Report (2026-07-05)

Lights up the two faces of the configurable catalog: the **customer Home (A5)** front door and the
**nurse "add a service" builder + offerings list (B7 services half)**, over a new cached `services/catalog`
domain. Consumes the b5 `catalog` contract. Unlocks search & discovery (f6).

## What was built

### `services/catalog` domain (`client/src/services/catalog/`)
- **`types.ts`** — DTOs mirrored from [`catalog.md`](../../contracts/domains/catalog.md) (**camelCase** wire):
  `ServiceCategory`, `ServiceOptionGroup` (`serviceCategoryId: number|null` = cross-category, `isRequired`),
  `ServiceOptionValue`, `NurseServiceVariant` (`price` = **IRR digit-string**, `priceUnit`, `sessionCount`,
  `options[]`), `CreateVariantInput`/`UpdateVariantInput`, the `CatalogApi` seam, and helpers
  `isGroupApplicable` / `optionSetSignature` (the duplicate-listing signature). `PriceUnit` +the `PRICE_UNITS`
  array are the **only** closed enum — categories/groups/values are data-driven.
- **`keys.ts`** — `catalogKeys`: `categories()`, `categoryOptionGroups(id)`, `myVariants(params)` /
  `myVariantsLists()` (invalidation prefix), `variant(id)`.
- **`constants.ts`** — `USE_CATALOG_MOCK` (default `true`); `CATALOG_REFERENCE_STALE_TIME = Infinity` +
  `CATALOG_REFERENCE_GC_TIME` (reference data cached session-long, like geography); `MY_VARIANTS_STALE_TIME`;
  page sizes.
- **`apis/`** — `clientApi.ts` (real, action-style routes, camelCase bodies, `pageSize` pagination),
  `mockApi.ts` + `seed.ts` (in-memory, behind the seam), `index.ts` (config-selected seam).
- **`hooks/`** (one per file) — `useServiceCategories`, `useCategoryOptionGroups` (cached reference data);
  `useMyVariants` (paginated, auth-gated, self-scoped); `useCreateVariant`, `useUpdateVariant`
  (`setQueryData` the row + invalidate), `useSetVariantActive` (deactivate/reactivate). `index.ts` barrels hooks.
- **`names.ts`** — `pickCatalogName(row, locale)` locale-label helper (fa primary).

### Shared components (`client/src/components/`, each with a co-located `*.test.tsx`)
- **`CategoryTile`** — data-driven, tappable category tile (icon disc + localised label; `selected` state for
  the builder; robust `iconKey`→icon fallback to a generic `category` icon).
- **`PriceDisplay`** — renders `{Toman} {unit}` via the f0 money util + an i18n unit label off `price_unit`, and
  the **unit-aware estimated total** (`price × sessionCount`, integer-safe) only when a duration is present.
- **`VariantCard`** — a nurse offering: `display_name`, `PriceDisplay`, active/deactivated distinction (dimmed +
  neutral chip + "can't be booked" hint), Edit + Deactivate/Reactivate actions — **no delete affordance**.

### Money util (`client/src/utils/money.ts`)
- `tomanToRial(value)` — the sanctioned **Toman→IRR digit-string** conversion at the field boundary.
- `multiplyIrr(value, count)` — integer-safe `price × count` for the estimated total (never from price alone).
- Both unit-tested (`money.test.ts`).

### Screens
- **Customer Home (A5)** — `app/[locale]/(private-routes)/(customer)/page.tsx` (rewritten): greeting + avatar
  (`useMe` firstName / initial), **search bar** (navigates toward `/search?q=…`; execution deferred to f6),
  **data-driven category grid** (`useServiceCategories`; loading skeletons / empty / error+retry; tiles carry
  `service_category_id` → `/search?category_id=`), the complete-patient-record nudge (reuses the **cached f2
  `usePatients`** — no new fetch), and the preserved first-login onboarding gate.
- **`/search`** — deferred `PlaceholderScreen` stub so the Home CTAs don't dead-end (echoes the q/category).
- **Nurse Services & prices (B7)** — `app/[locale]/(private-routes)/nurse/services/` (new sidebar tab). `page.tsx`
  switches between `MyServicesList` (offerings — active/inactive, edit, soft-deactivate w/ confirm, reactivate,
  empty/skeleton) and `VariantBuilder` (create/edit).

### Builder (`VariantBuilder.tsx`)
- **Create** = 3-step stepper (reuses the f0 `StepperHeader`): category (CategoryTile grid) → options
  (single-select `ToggleButtonGroup` per group; required badge; **blocks advancing until every required group is
  answered**, cross-category groups included) → price+unit+duration. Price entered in **Toman** → submitted as an
  **IRR digit-string** (`tomanToRial`, no float on the path); **live unit-aware estimated total** via
  `PriceDisplay`; editable auto-generated `display_name`. The duplicate-listing **`409`** shows a friendly inline
  warning. **Edit** locks the category + option-set and edits only price/unit/duration/display.

### Wiring
- i18n: `catalog` / `services` / `search` namespaces + `home` additions + `nav.services` (both locales, in sync,
  313 keys). Icons: `services`, `category`, `elderly`, `post_surgery`, `infant`, `chronic`, `companionship`.
  Routes: `SEARCH`, `NURSE_SERVICES`. Nurse sidebar gains **Services**.

## What is now testable (and exactly how)

Run `npm run dev` (mock is on by default — no backend needed). Follow the phase §7 steps:
1. **Home** — sign in as a customer → greeting + avatar, search bar, and the **category grid** (5 seeded
   categories ordered by `sortOrder`); the patient nudge shows/hides off the cached patient state (React Query
   Devtools: the `patients` query is **reused**, not refetched).
2. **Locale + RTL** — toggle `fa`↔`en`: labels translate, `dir` flips, tiles/grid mirror; Persian unit labels
   (ساعتی/روزانه/شبانه‌روزی) read correctly.
3. **Build a variant** — sign in as a nurse → `/nurse/services` → `+ افزودن خدمت` → pick **مراقبت از سالمند**;
   in options, try **Next** without answering **نوع شیفت** (required) → blocked, the required badge turns red;
   answer it → Next; enter a **Toman** price + unit (ساعتی) + a duration → the **estimated total** updates from
   `price × duration`; edit the auto `display_name`; submit → the card appears (`… تومان ساعتی`).
4. **Duplicate** — create a second variant with the **same category + same option-set** → the friendly
   `409` duplicate warning shows inline; no crash / no generic toast.
5. **Edit + deactivate** — edit a variant's price/name → the list reflects it without a full refetch (Devtools:
   `setQueryData` + single invalidation). Deactivate → confirm dialog → the row dims to the deactivated state
   with the "can't be booked" hint; **no delete** option. Reactivate from the inactive row.
6. **Caching** — Devtools: `catalogKeys.categories()` / `categoryOptionGroups` are served from cache across Home
   and every builder step (no per-step refetch); a variant mutation invalidates only `myVariants`.
7. **Gate** — `npm run check` + `npm run test:ci` pass.

## What is mocked / waiting on a real service
- **`CatalogApi`** — `client/src/services/catalog/apis/mockApi.ts` (+ `seed.ts`), behind `USE_CATALOG_MOCK`
  (default `true`). Faithfully reproduces the b5 create validation (`400` missing-required / bad-price) and the
  `(nurse, category, option-set)` duplicate **`409`**; categories mirror the real seed; the variant store starts
  **empty** (so the empty state demos). See [`mocks-registry.md`](./mocks-registry.md) (`CatalogApi` row).
  **Swap = one line** (`USE_CATALOG_MOCK = false`); `catalogClientApi` is already wired to the action-style routes.
  Caveat recorded there: the mock seeds representative **option groups the fresh backend does not** (an admin
  authors them per category) — after the swap, categories have no groups until seeded server-side.

## Contracts
- **Consumed:** [`dev/contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (backend-phase-b5) —
  types/services derive from it; no shape guessed.
- **Requested:** `REQ-010` in [`for-backend.md`](../frontend/requests/for-backend.md) — confirm/align the list
  pagination query-param name (`pageSize`, per the proven b4 binding, vs the doc's `page_size`).
- **Produced:** none (frontend produces no contract).

## Docs updated
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — *Project Structure* (customer `search` stub, nurse `services`
  route, `services/catalog` domain, `CategoryTile`/`PriceDisplay`/`VariantCard`); the reference-data caching note
  (catalog = second long-lived cached domain); the i18n namespaces list (`catalog`/`services`/`search`, `home`).
- This report + `STATUS.md` + `mocks-registry.md` + `for-backend.md` (REQ-010).
- No product-doc rule change was needed; the estimated-total presentation rule (total only from
  price × session_count, never price alone) is captured here and enforced in `PriceDisplay`/the builder.

## Follow-ups for later phases
- **f6 (search & discovery)** — the Home **search bar** hands a `q` / `service_category_id` to `/search` (today a
  placeholder); f6 builds the results, filters, and nurse cards, and can **reuse** `CategoryTile`, `PriceDisplay`,
  the cached `useServiceCategories`/`useCategoryOptionGroups`, and the `catalog`/`search` namespaces. The **variant
  builder is what populates the index f6 reads** (a nurse must have ≥1 active variant + coverage to appear).
- **Backend** — deliver `REQ-010` (pagination param confirmation), then f6 can flip `USE_CATALOG_MOCK=false`.
- **Deferred (unchanged):** admin catalog manager (→ f15), nurse availability slots, public nurse-profile service
  rows (→ f6 C3), holiday/surge pricing.
