Files
baya-monorepo/dev/phases/frontend/frontend-phase-4-b5.md
T
2026-06-28 21:59:59 +03:30

27 KiB
Raw Blame History

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 (addresses & geo, cascading province/city/district, nurse coverage editor) + backend phase b5 contract (catalog) · Unlocks: search & discovery (frontend-phase-6-b7.md) Before you start, read ../_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_categoriesservice_option_groupsservice_option_values, and the nurse layers nurse_service_variantsnurse_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) — 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 — 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 — 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 — 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 — the envelope (OperationResult/ApiResult, already unwrapped by clientFetch), snake_case routes/JSON, status codes, mandatory pagination (page/page_sizeitems+total) on the variant list, name_fa/name_en reference-data localisation.
  • ../../contracts/conventions/money-and-types.mdmoney 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 and ../../../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: 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).
  • 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 listapp/[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_values. 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 — 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).
  • The admin catalog manager (category/option-group/value CRUD) — (DEFERRED → admin console 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:
  • 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, 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; 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 faen → 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 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 — don't invent rules; record decisions, and flag any open question in your report.
  • Contract to consume: ../../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 (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.
    • 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 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.