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

23 KiB

Frontend Phase 3 — Addresses, map picker & nurse coverage areas

Mission: give both actors their place on the map. Customers build an address book — add an address by dropping a map pin and choosing province → city → district from cascading dropdowns, with one address marked primary — so a later booking knows where the nurse goes. Nurses build a coverage-area editor — a list of cities (whole city) or city+district areas they will travel to — so search can fan them out geographically. This is pure geography: no money, no clinical data. It consumes the geography-addresses contract from backend-phase-4 and unlocks the booking request flow (f7), which needs a chosen address and a matched coverage area.

Track: frontend · Depends on: frontend-phase-2-b3 (profiles, patients, nurse bank account) + the backend-phase-4 contract · Unlocks: frontend-phase-7-b8 (booking request flow) Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

Balinyaar is a trust-first home-nursing marketplace: families book vetted nurses who travel to the patient's home. A booking can't happen until the platform knows two geographic factswhere the patient is (a customer address) and which areas a nurse will travel to (their service areas). This phase builds the UI for both, on top of the geography hierarchy the backend seeds. Geography here is named regions, not GPS radii: the dropdowns are provincescitiesdistricts, and a map pin only adds precise coordinates for later EVV distance checks — it does not replace the region choice.

What already exists (do not rebuild) — built by prior frontend phases:

  • f0 foundations — the three actor app shells (customer mobile + 5-tab bottom nav, nurse, admin), the services/{domain} + TanStack Query caching pattern (the auth service is the canonical template: types.ts / keys.ts / apis/clientApi.ts / hooks/use{Action}.ts / index.ts), the contracts→types pattern, the money/format utils, the shared composite components (OTP input, phone field, stepper/progress header, status chip), and the i18n namespace baseline in both messages/en.json and messages/fa.json. See frontend-phase-0.md and reports/frontend-phase-0-report.md.
  • f1-b2 auth — phone-OTP login, the role router, roles in AuthContext (customer / nurse / admin). You read the current role to decide which editor to mount. See frontend-phase-1-b2.md.
  • f2-b3 profiles — the customer profile + patient CRUD (the address book lives alongside patients in the customer area), and the nurse profile + bank-account settings (the coverage-area editor lives alongside them in the nurse area). The services/patients and services/profiles domains and their settings screens are the layout siblings you slot next to. See frontend-phase-2-b3.md and reports/frontend-phase-2-report.md.

You build the geography UI only. The geo hierarchy (provinces/cities/districts) is reference data the backend seeds and serves; you cache it and render dropdowns. You never seed or mutate it.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/frontend-conventions-checklist.md — how you work, the gate, the handoff, and the tick-list (RSC boundary, clientFetch/services, Query caching, minimal re-renders, MUI primitives, i18n both locales, tokens, RTL).
  • The contract you consume: ../../contracts/domains/geography-addresses.md (published by backend-phase-4) — the source of truth for the geo lookup endpoints (ListProvinces / ListCities / ListDistricts), the customer_addresses CRUD + set-primary endpoints, and the nurse_service_areas add/remove endpoints, with their exact routes, payload shapes, enums, status codes, and which fields are masked (the encrypted street address). Plus the conventions it assumes: api-conventions.md (envelope, snake_case routes, pagination, localisation header, 409 on conflict) and money-and-types.md (name_fa/name_en reference data, UTC timestamps, coordinates are not money — but treat lat/lng as the contract declares them).

    If the published contract is missing a shape you need (e.g. a geocode/reverse helper, a set_primary endpoint, or the coordinate field names), do not guess — append the request to ../../shared-working-context/frontend/requests/for-backend.md and mock it behind the services/{domain} seam meanwhile (operating-rules §6).

  • Product docs (the business truth — read before designing the forms):
    • ../../../product/data-model/02-geography.md — the hierarchy (provinces 1:N cities 1:N districts), why it's tables not static lists (sort_order/is_active drive ordered, toggleable dropdowns), and why nurse_service_areas is a named-district join (a district_id = NULL row = the entire city), UNIQUE(nurse_id, city_id, district_id).
    • ../../../product/business/04-search-and-matching.md — how coverage areas feed search (city required, district optional; a city-level row means whole city), the white-space second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom) the area model must serve, and why districts are optional. (The same-gender filter lives in f6, not here — don't build it.)
    • The customer_addresses notes in ../../../product/data-model/01-identity-and-access.md: encrypted address + coordinates, filtered UNIQUE(customer_id) WHERE is_primary = 1 (exactly one primary).
  • Invoke the frontend-designer skill — this is mandatory for every screen, form, dropdown, chip, map panel, and empty/loading/error state you build. It is the design/brand contract (teal/terracotta palette, tokens, typography, the App* library, the mobile RTL shell, the bottom-nav placement). All visual work goes through it; do not hand-roll colours or spacing.
  • Code to mirror: client/src/services/auth/* (the service template you copy for geography and addresses), client/src/services/patients/* and the address-book/patient screens from f2-b3 (the same list → add/edit → set-default interaction pattern), and the shared composite components from f0 (StatusChip, the stepper/progress header) you reuse.

3. Scope — build this

Two services and three screen areas. Real names below — build them exactly.

3.1 services/geography — cached reference-data lookups (cascading dropdowns)

The geo hierarchy is reference data that almost never changes, so it is cached aggressively and shared by every consumer (this phase's two editors, plus search in f6). Build it once, here.

  • services/geography/types.tsProvince, City, District (each { id, name_fa, name_en, sort_order, is_active }; City carries province_id, District carries city_id) — derived from the contract.
  • services/geography/keys.ts — a query-key factory: geographyKeys.provinces(), geographyKeys.cities(provinceId), geographyKeys.districts(cityId).
  • services/geography/apis/clientApi.ts — wraps clientFetch over the contract's ListProvinces / ListCities(province_id) / ListDistricts(city_id) endpoints. Active-only, sort_order-ordered (the server already filters/orders; don't re-sort client-side beyond what the contract guarantees).
  • services/geography/hooks/ — one hook per file: useProvinces.ts, useCities.ts (enabled only when a province is selected), useDistricts.ts (enabled only when a city is selected). Long staleTime (e.g. Infinity or hours) and a generous gcTime so a province/city's children are fetched once per session and served from cache on every revisit and across both editors.
  • A reusable <CascadingRegionSelect> composite (shared, src/components/geography/) that renders the three dependent MUI Selects (province → city → district), driving the child queries, with per-level loading, disabled inactive regions, and the "whole city" affordance when a city has no districts (or the user leaves district empty). It exposes { provinceId, cityId, districtId } and an onChange — both the address form and the coverage editor reuse it. City is required; district is optional — leaving district empty is a real choice, never an error.

3.2 Customer address book + map-pin add/edit (the customer area)

Mounted in the customer area next to patients (f2-b3). Build:

  • services/addresses — full domain service mirroring auth/patients: types.ts (CustomerAddress { id, title, city_id, district_id|null, address_line (masked/full per contract), latitude, longitude, is_primary }), keys.ts (addressKeys.list(), addressKeys.detail(id)), apis/clientApi.ts (list / create / update / delete / set-primary), and hooks: useAddresses.ts (list), useCreateAddress.ts, useUpdateAddress.ts, useDeleteAddress.ts, useSetPrimaryAddress.ts. Every mutation invalidates addressKeys.list() (set-primary also flips the old primary, so invalidate, don't hand-patch) so the list reflects reality without an over-fetch elsewhere.
  • Address book screen — list of the customer's addresses as cards: title, city/district label (name_fa/name_en by locale), a primary badge (reuse the f0 StatusChip), and per-card edit / delete / "set as primary" actions. Empty state ("no addresses yet → add your first") and a loading skeleton. Deleting/setting-primary confirms inline.
  • Add / Edit address form (a dialog or routed sub-screen — match the f2 patient-edit pattern):
    • The <CascadingRegionSelect> from §3.1 (province → city → district).
    • A map pin picker <AddressMapPicker> (shared, src/components/geography/): a map component (or the lightweight stand-in described in §4) where the user drags/taps to drop a pin; it emits { latitude, longitude }. Center it on the chosen city when possible. The picked coordinates are sent with the create/update request.
    • A title (e.g. "خانه"/"محل کار") and a free-text address_line (the street detail — note it is the encrypted field per the contract; render the masked value the contract returns on read, full only where the contract allows on the owner's own edit).
    • A "set as primary" toggle. Validation: city required, a pin required (surface the "map-pin-required" error inline if missing), district optional.
  • Single-primary rule on the client: the UI presents primary as a single-select; the server enforces the filtered unique index, but the client must never show two primaries — after a setPrimary, invalidate the list so exactly one card shows the badge.

3.3 Nurse coverage-area editor (the nurse area)

Mounted in the nurse area next to the profile/bank settings (f2-b3). Build:

  • services/serviceAreas — domain service mirroring the template: types.ts (NurseServiceArea { id, city_id, district_id|null }), keys.ts (serviceAreaKeys.list()), apis/clientApi.ts (list / add / remove), hooks useServiceAreas.ts, useAddServiceArea.ts, useRemoveServiceArea.ts. Add/remove invalidate serviceAreaKeys.list().
  • Coverage-area editor screen — a chip/list of the nurse's areas, each chip labelled by city, and by city + district when a district is set ("whole city" shown explicitly when district_id is null). An add-area control built from the §3.1 <CascadingRegionSelect> plus a "whole city" vs "specific districts" toggle: choosing "whole city" sends district_id: null; choosing "specific districts" requires picking a district (and lets the nurse add several district rows under the same city).
    • Duplicate prevented inline: before calling the add mutation, check the in-cache list — if the (city_id, district_id) pair already exists (treating null district as a real value), show an inline "you already cover this area" message and don't fire the request. The server also returns 409 on the UNIQUE(nurse_id, city_id, district_id) violation — handle that 409 as the same inline message (belt-and-braces; the client check is the fast path, the server is the source of truth).
    • Empty state — no areas yet → a warning that the nurse won't appear in search until they add at least one coverage area (per the search business doc). Remove-area confirms inline.

3.4 i18n + tokens

Every user-visible string (titles, field labels, "whole city", "specific districts", "set as primary", the empty/error/duplicate messages, the map "drop a pin" helper) is a key in both en.json and fa.json, in sync, under sensible namespaces (e.g. address, coverage, geo, reusing common). fa is default and RTL — verify the dropdowns, chips, and map controls mirror correctly. Colours come from tokens.css; no hardcoded hex in sx.

(DEFERRED) — out of scope, do not build

  • Same-gender filter / search filters — built in frontend-phase-6-b7.
  • Map-based discovery (browsing nurses on a map) — DEFERRED per the search doc; this phase's map is only a pin-picker for one address.
  • Reverse-geocoding "find my city from the pin" — only if the b4 contract ships a geocode/reverse helper; otherwise the region is chosen by dropdown and the pin only refines coordinates. If you want it and the shape is missing, request it (don't invent it).
  • Admin geo management (CRUD of provinces/cities/districts) — admin console, f15.

4. Mocks & seams in this phase

This is a frontend phase: the only seam you own is the client-side services/{domain} boundary.

  • Geocoding / maps is mocked server-side behind the IGeocoder seam introduced in backend-phase-4 — you do not introduce or own it. The client just sends the picked { latitude, longitude }; the server (mock today) does any address↔coordinate work. Reuse it via the contract, don't re-create it.
  • The map component itself: use a real map widget if one is already in the client; otherwise build a lightweight stand-in <AddressMapPicker> — a static map image / simple draggable-marker panel that still emits real { latitude, longitude } — behind a small component boundary so a real Neshan/Google map drops in later without touching the form. Record this stand-in in your frontend report so it's swapped cleanly.
  • If backend-phase-4 isn't merged when you start: build all three services (geography, addresses, serviceAreas) against a mock clientApi behind the same services/{domain} seam (canned provinces/cities/districts incl. Tehran's 22 districts and a couple of white-space cities; in-memory address & area lists honouring single-primary and the duplicate rule), append any contract gap to for-backend.md, and add a row to the mock registry (../../shared-working-context/reports/mocks-registry.md) so the swap to the real endpoints is a one-file change per service.

5. Critical rules you must not get wrong

  • District is optional, "whole city" is a real choice — not missing data. A district_id = null area or address means the entire city; it is a deliberate selection, never an empty/invalid field. The duplicate check and the chip label must treat null as a real value.
  • City is required. No address and no coverage area may be saved without a city; surface the error inline. (And a pin is required on an address — surface "map-pin-required" inline.)
  • Inactive regions disappear. Only is_active provinces/cities/districts appear in dropdowns, ordered by sort_order. Don't render toggled-off regions (the server filters; don't reintroduce them).
  • Exactly one primary address per customer. The UI presents primary as single-select and invalidates the list after setPrimary so exactly one badge shows; never display two primaries. The server's filtered unique index is the source of truth — surface its outcome, don't fight it.
  • Duplicate coverage areas are blocked. Enforce (city_id, district_id) uniqueness inline before firing the add, and map the server's 409 to the same inline message.
  • Cache the geo hierarchy aggressively. Provinces/cities/districts use a long staleTime so dropdowns are served from cache on revisit and shared across both editors (and later search) — refetching reference data on every dropdown open is a defect this phase exists to prevent. Address/area lists, by contrast, invalidate on every mutation.
  • Named regions, not radii. Don't model coverage as a GPS radius; the pin is only extra precision on an address for later EVV — the bookable geography is the city/district choice.
  • RSC/client boundary & re-renders. The forms, map, and dropdowns are client components; keep state colocated low (the form owns its {provinceId, cityId, districtId, lat, lng}), use stable onChange refs, and let TanStack Query own server state — no needless re-renders as the user clicks through the cascade.
  • MUI primitives stay MUI; composites stay shared. Select, TextField, Chip, Dialog, Button are MUI/App*; <CascadingRegionSelect> and <AddressMapPicker> are shared composites in src/components/geography/ (both reused by ≥1 screen), each with a co-located *.test.tsx.
  • i18n both locales, RTL-first, tokens for colour. Every string in en.json and fa.json; verify RTL mirroring of the cascade and chips; colours from tokens.css.

6. Definition of Done

The shared definition-of-done.md, plus:

  • services/geography, services/addresses, services/serviceAreas exist following the auth service template (types from the contract, keys.ts, apis/clientApi.ts, one hook per file), with the documented caching: long staleTime for geo, mutation-invalidation for addresses & areas.
  • The customer address book lists addresses, adds/edits one via the cascading dropdowns + map pin, and sets a primary (exactly one badge), with empty/loading/error states.
  • The nurse coverage-area editor adds whole-city and city+district areas, shows them as chips, blocks a duplicate inline (and on 409), and warns when empty that the nurse won't appear in search.
  • <CascadingRegionSelect> and <AddressMapPicker> are shared composites with co-located tests; MUI primitives are reused, not re-implemented.
  • npm run check is green; npm run test:ci is green (shared components added); en.json and fa.json are in sync and RTL-correct.
  • Any contract gap is in for-backend.md; the map stand-in and any pre-merge mock clientApi are in the mock registry; client/CLAUDE.md Project Structure is updated for the new services/{geography,addresses,serviceAreas} and src/components/geography/ folders.

7. How to test (what a human can verify after this phase)

Run the client: cd client && npm run dev, sign in (f1-b2 OTP), and:

  1. Cascading dropdowns + caching. As a customer, open Add address. Select a province → the city dropdown loads its cities; select a city → the district dropdown loads (or shows "whole city" when the city has none). Open Add address again — the province/city/district lists come from cache (no refetch; confirm in React Query Devtools). Inactive regions never appear.
  2. Add an address with a map pin + set primary. Pick city + district, drop a pin on the map (the form sends real lat/lng), enter a title + street, toggle "set as primary", save. It appears in the address book with a primary badge. Add a second address, set it primary → exactly one badge moves; the first is no longer primary. Try saving without a city or without a pin → inline errors.
  3. Nurse coverage areas + duplicate block. Switch to the nurse area → Coverage. With no areas, see the "won't appear in search" warning. Add a whole-city area (district empty) → a chip appears. Add a city + district area → another chip. Try adding the same (city, district) again → an inline "already covered" message, no request fired; if you force it past the client (or the server is hit), the 409 shows the same message. Remove an area → it disappears.
  4. i18n / RTL. Flip locale to en and back to fa: every label/empty/error/duplicate string translates, the cascade and chips mirror correctly in RTL, and colours match the brand tokens.
  5. npm run check and npm run test:ci pass.

8. Hand off & document (close the phase)

  • Docs: update the Project Structure tree in ../../../client/CLAUDE.md for the new services/geography, services/addresses, services/serviceAreas domains and the src/components/geography/ composites; note the aggressively-cached geo reference-data pattern (long staleTime, shared key factory) as a reusable convention so f6 search reuses it, not reinvents it. Fix any doc drift you touch.
  • Contract: consume ../../contracts/domains/geography-addresses.md — derive every type from it; do not guess shapes. Any missing/ambiguous shape (coordinate field names, masking of address_line, a set_primary route, a geocode/reverse helper) is appended to ../../shared-working-context/frontend/requests/for-backend.md — you never edit backend files.
  • Handoff & report: append your phase summary to ../../shared-working-context/frontend/STATUS.md; write ../../shared-working-context/reports/frontend-phase-3-report.md — what shipped (the two editors + three services + two composites), what is now testable and exactly how (the steps in §7), what is mocked client-side (the map stand-in; the pre-merge mock clientApis if used) and how f-next swaps each, the contract consumed, and follow-ups (the address chosen here feeds the f7 booking request; coverage areas feed f6 search). Update the mock registry (../../shared-working-context/reports/mocks-registry.md) for the map stand-in and any mocked client API.
  • Memory: save a project memory note for the non-obvious decisions — the geo cache strategy (long staleTime, shared geographyKeys factory reused by addresses, coverage, and search), the <CascadingRegionSelect> / <AddressMapPicker> composites, and the "district_id = null = whole city" rule the booking and search phases must honour — with a one-line pointer in MEMORY.md.