8.4 KiB
Frontend Phase 3 — Addresses, map picker & nurse coverage areas (b4)
Track: frontend · Consumes: dev/contracts/domains/geography-addresses.md (backend-phase-4)
· Unlocks: f7 booking request (needs a chosen address) + f6 search (needs nurse coverage areas)
· Date: 2026-07-03 · Gate: npm run check green · npm run test:ci green (129 tests, +17 across 5 new suites) · npm run build green (routes /[locale]/addresses, /[locale]/nurse/coverage generated)
What shipped
Both actors get their place on the map — pure geography, no money, no clinical data.
Three domain services (mirroring the patients/nurse template)
services/geography— the cached province → city → district reference lookups.types.ts(Province/City/District+ theGeographyApiseam),keys.ts(geographyKeys.{provinces,cities,districts}),constants.ts(USE_GEOGRAPHY_MOCK,GEO_STALE_TIME = Infinity+GEO_GC_TIME,CITY_CENTROIDS+cityCentroid()),names.ts(pickRegionName),apis/{clientApi,mockApi,seed,index}.ts, hooksuseProvinces/useCities(enabled on province)/useDistricts(enabled on city). Aggressively cached: each level is fetched once per session and shared across both editors (and later f6 search).services/addresses— the customer address book.types.ts(CustomerAddress= wireCustomerAddressDto- client-augmented
provinceId;CreateAddressInput;AddressesApi),keys.ts,constants.ts, full seam+mock+client, hooksuseAddresses/useCreateAddress/useUpdateAddress/useDeleteAddress/useSetPrimaryAddress— every mutation invalidatesaddressKeys.lists()(set-primary flips two rows, so invalidate, don't hand-patch).
- client-augmented
services/serviceAreas— the nurse coverage areas.types.ts(NurseServiceArea;AddServiceAreaInput;ServiceAreasApi; the pureareaExistsdup-guard),keys.ts,constants.ts, seam+mock+client, hooksuseServiceAreas/useAddServiceArea/useRemoveServiceArea(add/remove invalidate the list).
Two shared composites + two form/card composites (src/components/geography/, each tested)
CascadingRegionSelect— province → city → district dependent MUI selects, driving the cached geography queries itself so both editors drop it in with onlyvalue/onChange. Each level enables on its parent, resets its children on change; a whole-city-only city surfaces the whole-city affordance; district is optional. Guards edit-prefill against out-of-range values before options load.AddressMapPicker— the map-pin stand-in (see Mocks). A tappable/draggable marker canvas that emits real{ latitude, longitude }; marker positioned with inlinestyle(physical left/top) +dir="ltr"so the RTL stylis plugin can't mirror the pin off its click point. Centres on the chosen city's centroid.AddressForm— the add/edit body: cascade + map pin + title + street + set-primary toggle; validates city required, pin required, title + street required, district optional. EmitsCreateAddressInput.AddressCard— presentational list card: title + primary badge (reuses the f0StatusChip), region label, street line, edit/delete/set-primary; set-primary shows only on non-primary cards (never two primaries).
Screens
- Customer address book (
/addresses, reached from a profile-hub link) — cards with primary badge, add/edit dialog (cascade + map pin), set-primary, soft-delete confirm, empty + skeleton states. - Nurse coverage editor (
/nurse/coverage, new sidebar tab) — area chips (whole-city shown explicitly), an add control (cascade + whole-city/specific-districts scope toggle), inline duplicate block (clientareaExistsfast path + the server 409 mapped to the same message), remove confirm, and the empty-state "won't appear in search" warning.
Wiring
constants/routes.ts:ADDRESSES,NURSE_COVERAGE.AppIconregistry:location,delete,coverage.- i18n: new
geo/address/coveragenamespaces +nav.coveragein bothen.jsonandfa.json(identical key sets, RTL-first). Colours fromtokens.cssonly. client/CLAUDE.mdProject Structure + i18n namespaces + the reference-data caching convention updated.
What is now testable, and exactly how
Run cd client && npm run dev, sign in (f1-b2 OTP). All three services default to their client mock, so the
flows work without the backend running.
- Cascading dropdowns + caching. Customer → Profile → Manage addresses → Add address. Province → city → district cascade; a whole-city-only city (Mashhad/Isfahan/…) shows the whole-city affordance. Re-open Add address: the lists come from cache (React Query Devtools shows no refetch).
- Add with a map pin + set primary. Pick city (+ optional district), drop a pin, enter title + street, toggle primary, save → the card shows a primary badge. Add a second, set it primary → exactly one badge moves. Save without a city or pin → inline errors.
- Nurse coverage + duplicate block. Nurse → Coverage. Empty → the "won't appear in search" warning. Add a whole-city area (a chip); add a city + district area; add the same pair again → inline "already covered", no request fired; remove → the chip disappears.
- i18n / RTL. Flip
fa↔en: every label/empty/error/duplicate string translates; the cascade, chips, and map controls mirror correctly; colours match the brand tokens. npm run check,npm run test:ci,npm run buildall pass.
What is mocked client-side (and how f-next swaps it)
All three services are behind a services/{domain} seam with a USE_*_MOCK flag (default true) and a
real clientApi already wired to the contract routes — the swap is a one-line flag flip per service. The
IGeocoder seam is backend-owned (backend-phase-4); the client only sends the picked coordinates. The
AddressMapPicker is a stand-in (no Neshan/Google tiles) behind a component boundary — a real map drops in
without touching AddressForm. See the mock registry for the exact rows + make-it-real steps.
Contract consumed + gaps filed
Consumed dev/contracts/domains/geography-addresses.md (camelCase wire, snake_case query params, action-style
routes, 409 on duplicate coverage, single-primary address). Two gaps filed in
for-backend.md:
- REQ-008 — accept the client-picked
latitude/longitudeon address create/update (the contract geocodes server-side today; f3 requires the user's dropped pin for EVV precision). The client sends them + echoes locally. - REQ-009 — add
provinceIdtoCustomerAddressDtoso the edit form can prefill the province→city cascade (the city list is fetched per-province). Client-augmented behind the seam meanwhile.
Adversarial review (pre-merge)
Ran a 5-dimension multi-agent review (contract fidelity · conventions/i18n · single-primary · coverage dedup/cascade · map/RTL/caching) with an adversarial verify pass. 3 findings confirmed and fixed:
- (high, RTL)
AddressMapPickermarker's centeringtransformwas insx, so stylis-plugin-rtl flipped its X on the defaultfalocale — the pin rendered ~one icon-width off the tap point. Moved the transform into the inlinestylealongside the left/top offsets. - (med, contract)
addresses/serviceAreaslistsentpage_size(snake_case), which the server'sPageSizebinder ignores (only the geo lookups are explicitly snake_cased) → truncated lists once the real endpoints are used. Changed both topageSize, matching the patients template. - (med, UX) The coverage "specific districts" scope dead-ended on a whole-city-only city (district-less)
with contradictory
no_districts↔district_requiredmessages. The page now reads the cached districts to force whole-city for such cities (disables the "districts" toggle), so the add never dead-ends.
Follow-ups for later phases
- f7 booking request consumes the chosen customer address (id + coordinates).
- f6 search consumes nurse coverage areas (whole-city rows match every district; reuse the
geographycachegeographyKeys, don't reinvent). The same-gender filter is f6, not here.
- When REQ-008/REQ-009 land, flip
USE_ADDRESSES_MOCKtofalse. - Swap the
AddressMapPickerstand-in for a real map (mock registry row).