# 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.