# 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](./frontend-phase-2-b3.md) (profiles, > patients, nurse bank account) + the **backend-phase-4** contract · **Unlocks:** > [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow) > **Before you start, read [`../_shared/agent-operating-rules.md`](../_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 (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](./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](./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](./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`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_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`](../../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`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes, pagination, localisation header, `409` on conflict) and [`money-and-types.md`](../../contracts/conventions/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`](../../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`](../../../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`](../../../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`](../../../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.ts` — `Province`, `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 `` composite (shared, `src/components/geography/`) that renders the three dependent MUI `Select`s (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 `` from §3.1 (province → city → district). - A **map pin picker** `` (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 `` 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](./frontend-phase-6-b7.md). - **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** `` — 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`](../../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*`; `` and `` 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](../_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. - [ ] `` and `` 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`](../../../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`](../../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`](../../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`](../../shared-working-context/frontend/STATUS.md); write [`../../shared-working-context/reports/frontend-phase-3-report.md`](../../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 `clientApi`s 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`](../../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 **`` / `` composites**, and the **"`district_id = null` = whole city"** rule the booking and search phases must honour — with a one-line pointer in `MEMORY.md`.