# 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 `<CascadingRegionSelect>` 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 `<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](./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** `<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`](../../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](../_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`](../../../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
  **`<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`.
