27 KiB
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 (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) — the three actor app shells and route groups (customer mobile shell with the 5-tab bottom nav خانه/رزروها/بیماران/کیفپول/پروفایل · nurse shell · admin shell); theservices/{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) insrc/utils/; the i18n namespace conventions in bothmessages/en.jsonandmessages/fa.json; the RTL baseline andtokens.cssbrand 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 + ownprice+price_unit); the five price units (per_hour/per_session/per_half_day/per_day/per_24h);display_nameauto-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 behindservices/catalogand append a request for backend).../../contracts/conventions/api-conventions.md— the envelope (OperationResult/ApiResult, already unwrapped byclientFetch),snake_caseroutes/JSON, status codes, mandatory pagination (page/page_size→items+total) on the variant list,name_fa/name_enreference-data localisation.../../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.mdand../../../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-designerskill 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, theApp*library, layout shells, the hard UI rules. Do not hand-craft visuals outside it. - The existing f0
services/auth/*and the f3services/geo+services/addressservices — copy their exact structure for the newservices/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 fromcatalog.md:PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h';ServiceCategory(id,name_fa,name_en,sort_order, optionalicon/slug);ServiceOptionGroup(id,service_category_idnullable = 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,priceas IRR digit-string,price_unit,session_countnullable,display_name,is_active, the chosenoptions: { 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 overclientFetchfor each route in the contract (see §3.4). Addapis/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 longstaleTime/gcTime— it changes rarely; do not refetch it on every screen. Variant mutations invalidatecatalogKeys.myVariants()(andsetQueryDatathe 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}fromAuthContext/useCurrentUser(f1); avatar from the customer profile (f2). RSC where it cleanly removes a round-trip; nonext/headersin 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_categoryfromuseServiceCategories(), ordered bysort_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 chosenservice_category_idinto 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
CategoryTileandHomeNudgeCardcomposite 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 offprice_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:
- Step 1 — category. Single-select from
useServiceCategories(). On select, fetch that category's option groups viauseCategoryOptionGroups(categoryId)(cached). (On edit, category is fixed — changing it would change identity; lock it and explain.) - Step 2 — options. Render each
service_option_groupfor the category (plus any cross-category group whereservice_category_idis null) as a single-select of itsservice_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. - 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_unitselect (the five units, i18n labels), and an optionalsession_count/duration. Show a live estimated total computed from price + unit + session_count together — e.g. forper_hourwith a duration, surface theformatIrrToToman(price × hours)estimate — do not compute or display a total from price alone. Show the auto-generateddisplay_name(from the chosen option labels) as an editable field.
- Submit:
useCreateVariant()/useUpdateVariant(). On the duplicate-listing conflict (server409on(nurse_id, category, option-set)), show a friendly inline warning ("شما قبلاً خدمتی با همین مشخصات دارید") and let the nurse adjust — don't silently fail or generic-toast it. Success → invalidatemyVariants, 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 throughservices/catalog/apis/clientApi.ts(overclientFetch) — never a rawfetch(). - If the b5
catalogcontract is published and merged, derivetypes.tsfrom it and call the real endpoints — no mock. - If a needed shape is missing or the contract isn't live yet, build a mock
clientApibehind the sameservices/catalogseam (returning realistically-shaped data: a few seeded categories, an option group withis_requiredtrue/false, a couple of variants across price units, and a409path to exercise the duplicate warning), and:- append the gap to
../../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- your phase report (per operating-rules §7) with exactly how f-next swaps it for the real endpoint.
- append the gap to
- No new external-service seam is introduced here (Elasticsearch /
INurseSearchbelongs 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 =
priceinterpreted byprice_unitcombined withsession_count/duration. A barepriceis a unit rate, not an engagement total. Render the unit label from an i18n key off theprice_unitcode, 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/gcTimeand reuse from cache; do not refetch them per screen or per step. Variant mutations invalidatemyVariants(andsetQueryDatathe edited row) so you never needlessly refetch the list. - Validate every required option group. The builder must not submit until every
is_requiredgroup has exactly one value chosen; optional groups may be empty; one value per group (single-select mirrors the server'sUNIQUE(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
409on(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 isprice_unit(the five units). - RTL-first, both locales.
fais default and RTL; every user-visible string is a key in bothen.jsonandfa.json, in sync. Persian unit labels (ساعتی / روزانه / شبانهروزی, …) must read correctly. - RSC/client boundary & re-renders. No
next/headers/next-intl/serverin 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/variantsis 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/catalogexists (types/keys/apis/hooks/index) mirroring the f0 pattern; reference data cached with deliberatestaleTime/gcTime; variant mutations invalidate/setQueryDatamyVariants.- 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 duplicate409with 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_unitlabels are i18n keys in both locales, RTL-correct;en.json/fa.jsonin sync. - Types derive from
catalog.md; any gap is logged infor-backend.mdand mocked behind theservices/catalogseam (recorded in the mock registry). npm run checkgreen;npm run test:cigreen for any shared component added (e.g.CategoryTile, a price-unit display, the variant card) with co-located*.test.tsx.client/CLAUDE.mdProject Structure updated for the new route segments (customerhome, nurseservices) and theservices/catalogdomain + 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).
- 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). - Locale + RTL. Switch
fa↔en→ labels translate,dirflips, the grid and tiles mirror correctly; Persian unit labels read right. - 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-generateddisplay_name; submit → the new variant appears in the offerings list, price shown as… تومان ساعتی. - 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. - 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. - Caching. In React Query Devtools confirm
catalogKeys.categories()/categoryOptionGroupsare served from cache across Home and the builder (no repeated network calls per step); a variant mutation invalidates onlymyVariants. - Gate.
npm run checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs to update (same change):
client/CLAUDE.mdProject Structure — add the customerhomeand nurseservicesroute segments, theservices/catalogdomain, 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 aservice_category_idto search; the variant builder is what populates the index f6 reads). - Update
../../shared-working-context/reports/mocks-registry.mdif you mocked any catalog endpoint (seam, what's faked, config, how to make it real).
- Append your phase summary to
- 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-lineMEMORY.mdpointer. Don't record what the code/docs already make obvious.