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-addressescontract 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 facts — where 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 provinces → cities → districts, 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 (theauthservice 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 bothmessages/en.jsonandmessages/fa.json. See frontend-phase-0.md andreports/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/patientsandservices/profilesdomains and their settings screens are the layout siblings you slot next to. See frontend-phase-2-b3.md andreports/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.mdand../_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), thecustomer_addressesCRUD + set-primary endpoints, and thenurse_service_areasadd/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,409on conflict) andmoney-and-types.md(name_fa/name_enreference 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/reversehelper, aset_primaryendpoint, or the coordinate field names), do not guess — append the request to../../shared-working-context/frontend/requests/for-backend.mdand mock it behind theservices/{domain}seam meanwhile (operating-rules §6). - Product docs (the business truth — read before designing the forms):
../../../product/data-model/02-geography.md— the hierarchy (provinces1:Ncities1:Ndistricts), why it's tables not static lists (sort_order/is_activedrive ordered, toggleable dropdowns), and whynurse_service_areasis a named-district join (adistrict_id = NULLrow = 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_addressesnotes in../../../product/data-model/01-identity-and-access.md: encrypted address + coordinates, filteredUNIQUE(customer_id) WHERE is_primary = 1(exactly one primary).
- Invoke the
frontend-designerskill — 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, theApp*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 forgeographyandaddresses),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.ts—Province,City,District(each{ id, name_fa, name_en, sort_order, is_active };Citycarriesprovince_id,Districtcarriescity_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— wrapsclientFetchover the contract'sListProvinces/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). LongstaleTime(e.g.Infinityor hours) and a generousgcTimeso 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 MUISelects (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 anonChange— 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 mirroringauth/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 invalidatesaddressKeys.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_enby locale), a primary badge (reuse the f0StatusChip), 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-textaddress_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.
- The
- 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), hooksuseServiceAreas.ts,useAddServiceArea.ts,useRemoveServiceArea.ts. Add/remove invalidateserviceAreaKeys.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_idis null). An add-area control built from the §3.1<CascadingRegionSelect>plus a "whole city" vs "specific districts" toggle: choosing "whole city" sendsdistrict_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 (treatingnulldistrict as a real value), show an inline "you already cover this area" message and don't fire the request. The server also returns409on theUNIQUE(nurse_id, city_id, district_id)violation — handle that409as 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.
- Duplicate prevented inline: before calling the add mutation, check the in-cache list — if the
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/reversehelper; 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
IGeocoderseam 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 mockclientApibehind the sameservices/{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 tofor-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 = nullarea or address means the entire city; it is a deliberate selection, never an empty/invalid field. The duplicate check and the chip label must treatnullas 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_activeprovinces/cities/districts appear in dropdowns, ordered bysort_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
setPrimaryso 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's409to the same inline message. - Cache the geo hierarchy aggressively. Provinces/cities/districts use a long
staleTimeso 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 stableonChangerefs, 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,Buttonare MUI/App*;<CascadingRegionSelect>and<AddressMapPicker>are shared composites insrc/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.jsonandfa.json; verify RTL mirroring of the cascade and chips; colours fromtokens.css.
6. Definition of Done
The shared definition-of-done.md, plus:
services/geography,services/addresses,services/serviceAreasexist following theauthservice template (types from the contract,keys.ts,apis/clientApi.ts, one hook per file), with the documented caching: longstaleTimefor 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 checkis green;npm run test:ciis green (shared components added);en.jsonandfa.jsonare in sync and RTL-correct.- Any contract gap is in
for-backend.md; the map stand-in and any pre-merge mockclientApiare in the mock registry;client/CLAUDE.mdProject Structure is updated for the newservices/{geography,addresses,serviceAreas}andsrc/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:
- 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.
- 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.
- 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), the409shows the same message. Remove an area → it disappears. - i18n / RTL. Flip locale to
enand back tofa: every label/empty/error/duplicate string translates, the cascade and chips mirror correctly in RTL, and colours match the brand tokens. npm run checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs: update the Project Structure tree in
../../../client/CLAUDE.mdfor the newservices/geography,services/addresses,services/serviceAreasdomains and thesrc/components/geography/composites; note the aggressively-cached geo reference-data pattern (longstaleTime, 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 ofaddress_line, aset_primaryroute, ageocode/reversehelper) 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 mockclientApis 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
projectmemory note for the non-obvious decisions — the geo cache strategy (longstaleTime, sharedgeographyKeysfactory 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 inMEMORY.md.