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