add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
# 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`.