# UI Phase 9 — Customer account & care circle — Report (2026-07-19)

## What was built

### Account hub (`profile/page.tsx`)
- Rebuilt from a flat settings form into an account hub: `ProfileSummary` identity header (avatar/initials
  via the new `initialsFallback` prop + name + server-masked phone), grouped tappable rows (اطلاعات شخصی /
  مخاطب اضطراری / نشانی‌ها / زبان / اعلان‌ها / پشتیبانی / خروج), an emergency-contact status card (complete →
  success check + `tel:`-only link; incomplete → a warm nudge), and a working خروج row (`ConfirmDialog` →
  `useLogout()`, the single logout path).
- `/profile` stays the single route — اطلاعات شخصی/زبان/مخاطب اضطراری open `FormDialogShell` sheets, not
  sub-routes. زبان's sheet hosts the phase-2 `LocaleSwitcher` (the actual UI locale switch) alongside the
  server-stored `preferredLanguage` field — two distinct concerns, no second locale mechanism built.
- The silent-load-error/blank-form defect (§3.1.4 of the phase doc) was **already fixed** before this phase
  started (confirmed against the current code, not the audit's snapshot — see "Docs updated" below); this
  phase's profile work is the hub redesign, not that fix.
- `ActorSwitcher` preserved verbatim (renders nothing for a single-role session).

### Care-circle reframe (`patients/page.tsx`, `PatientCard`, `PatientHeader`)
- **Naming decision:** «بیماران» → **«حلقهٔ مراقبت»** for the list/page title, nav label, and empty
  states (the safer default per the audit, given the «خودم» self-care relation). Individual-action copy
  (the add button, archive confirm) uses the neutral **«فرد»** ("person") rather than «عزیز» ("loved one"),
  which reads oddly for self-care. This is a **copy-only** rename — the `/patients` route, `services/patients`
  module, and every `patients.*`/`nav.patients` i18n **key name** are unchanged; only the translated
  **values** changed (both `en.json` and `fa.json`). Applied consistently: page title, nav tab, empty
  states, and the Home screen's `nudge_patient_*`/`patients_error` copy.
- **Avatars:** a new `InitialsAvatar` component (warm auto-colored initials, deterministic per-name hash →
  one of 6 new `--bal-avatar-*` token pairs added to `tokens.css`, both scheme blocks) is now a slot on the
  shared `PatientHeader`, so both the E1 card and the E2 record viewer got a face in one change.
  `ProfileSummary` also gained an `initialsFallback` prop (same util) for the account hub's identity header.
- **Visible record affordance:** `PatientCard`'s invisible `component="button"` area is now a pressable
  `ButtonBase` surface (hover/press + a trailing «forward» chevron) with an optional `lastVisitLabel` prop —
  **never populated** (the cached customer bookings list, `BookingListItemDto`, carries no `patientId` at
  all, so no client-side join is possible without an N+1 fetch per row; filed as REQ-057).
- Soft-archive semantics + copy kept (generalized wording, same behavior); `isError`/empty-state handling
  was already correct (see note above) and is unchanged.

### Care-record editing (`patients/[id]/record/page.tsx`)
- Replaced the whole-list free-text edit mode with **per-item bottom sheets** (`Drawer anchor="bottom"` on
  mobile, `Dialog` on desktop, each with its own dirty-gated discard-confirm): medications get name +
  structured **dose amount + unit** (قرص/کپسول/قطره/سی‌سی/واحد) + **frequency presets**
  (روزی ۱/۲/۳ بار، هر ۸ ساعت، در صورت نیاز + a free-text fallback when no preset fits) + **time-of-day
  chips** (stable codes `morning|noon|evening|night`); routine items get the same time-of-day chip row
  instead of the old free-text `routine_time`; tasks get a per-item sheet too (label + done), with a quick
  inline checkbox for toggling done without opening a sheet. Killing the whole-list mode removes the
  silent tab-switch draft loss by construction (each sheet is its own short-lived mount).
- **REQ-027 addendum** recorded in `requests/for-backend.md` (not a new REQ number): the structured shape
  (dose amount/unit, frequency preset codes, time-of-day codes) so the eventual real table matches what the
  UI now collects. `services/patientRecords/types.ts` + `apis/mockApi.ts` updated to the new shape.
- **سوابق (history)** is now a Shamsi month-grouped timeline (a subtle rail), each `VisitNoteCard` gains a
  done/undone task-summary line (`{done} از {total} انجام شد`) and, when `VisitNote.bookingId` is non-null, a
  «مشاهده رزرو» link to `/bookings/[id]` — the service name is shown only when best-effort derivable from an
  **already-cached** booking snapshot (`queryClient.getQueryData`, never fetched per-note — that would be an
  N+1). Paging kept (grouped within a page).
- The access gate (`useRecordAccess` before any clinical fetch, the access-denied card, the ownership
  banner) is untouched, verbatim.

### Addresses + the real map (`addresses/page.tsx`, `components/geography/`)
- `isError` was **not** handled before this phase (confirmed against current code) — fixed: `ErrorState` +
  retry, no more false-empty.
- **Real Neshan map behind the existing `AddressMapPicker` boundary.** `{ latitude, longitude }` in/out is
  unchanged, so `AddressForm` needed zero changes. `AddressMapPicker` now branches on `NESHAN_WEB_KEY`
  (`@/config`, from `NEXT_PUBLIC_NESHAN_KEY`):
  - **Set:** dynamically imports (`next/dynamic`, `ssr:false`) a new `NeshanMap` component — Leaflet
    (`leaflet` + `@types/leaflet`, newly added `npm` dependencies) with a Neshan raster tile layer, an
    address search box (`services/geography/neshan.ts`'s `searchNeshan`), a locate-me button (browser
    Geolocation), a draggable/tappable pin (a brand-colored `L.divIcon`, never Leaflet's default marker
    images), and a reverse-geocoded preview line (`reverseGeocodeNeshan`) — raw coordinates are never
    rendered. The container stays `dir="ltr"` (same RTL hazard `AddressMapPicker`'s own doc comment already
    called out for the grid stand-in's marker).
  - **Unset:** falls back to the original bounded-canvas grid stand-in, **kept verbatim** (including its
    raw lat/lng captions — this is the preserved dev/CI/jsdom path, not the primary experience) except the
    hard-coded `rgba()` pin drop-shadow is now the new `--bal-pin-shadow` token.
  - **Honest flag (operating-rules §9 — external unknown, not stalled on):** the Neshan tile URL template
    (`services/geography/constants.ts`'s `NESHAN_TILE_URL_TEMPLATE`) and the Search/Reverse response field
    names (`services/geography/neshan.ts`) were set from Neshan's public documentation **without live
    verification** — this sandbox has no network route to `platform.neshan.org`/`api.neshan.org` for a
    fetch tool, only for `WebSearch`. Endpoints (`api.neshan.org/v1/search`, `api.neshan.org/v5/reverse`)
    and the `Api-Key` header are confirmed via search-result snippets; the exact JSON field names
    (`items[].location.x/y`, `formatted_address`) and the raster tile host/path are best-effort. Every read
    is defensive (optional-chained, try/catch), so a shape drift degrades to "no results"/"no tiles" rather
    than a crash. **Re-verify against the current Neshan developer portal before relying on this with a
    real key.**
- `AddressCard` gained the pin-quality cue: «پین ثبت شده» / «پین ندارد» (warm warning tone) from
  `Address.latitude`/`longitude` being non-null.
- Fixed the `line_hint` fa copy bug (`messages/fa.json`) — «برای یافتن در نیاز دارد» → «برای یافتن درِ منزل
  نیاز دارد» — flagging it here so phase 12's global copy sweep doesn't double-edit it.
- Patient (`patients/page.tsx`) and address (`addresses/page.tsx`) forms now open in the new shared
  `FormDialogShell` (`components/common/FormDialogShell/`): full-screen below the `sm` breakpoint (a single
  scroll region, fixing the old double-scroll-against-keyboard defect), an app-bar header (title + close),
  and a dirty-gated discard-confirm on close/backdrop/escape — `PatientForm`/`AddressForm` report `dirty`
  via a new `onDirtyChange` prop (compares current fields to the `initial` prop).

### Form quality
- `PatientForm`: the single guessed full-name field is now **نام** / **نام خانوادگی** (structured, the wire
  already carries `firstName`/`lastName`); `lastName` falls back to `firstName` only when left blank (the
  wire's `lastName` is a required string) — never a client-side guess-split.
- Verified no surface renders the fabricated Jan-1 `birthDate` — every display path already went through
  `birthDateToAge` before this phase; age-only, as required.
- `GenderToggle` gained `width: '100%'` so its `flex: 1` children actually split evenly.
- `RelationSelect`'s selected state gained a check icon (+ a soft-tint background), no longer color-only
  (WCAG 1.4.1).
- Profile's `AppLoading` full-page spinner replaced with a form-shaped `Skeleton` (identity header + row
  placeholders), matching the loading language of patients/addresses/record.

## What is now testable (and exactly how)

1. Log in as a seeded customer (`0912000000x`) on a mobile viewport at `/fa`. Open the Profile tab → identity
   header (initials + name + masked LTR phone), rows for اطلاعات شخصی/مخاطب اضطراری/نشانی‌ها/زبان/اعلان‌ها/
   پشتیبانی/خروج. Tap خروج → confirm → lands on `/login`, session revoked.
2. Clear the emergency contact (via the row's edit) → the incomplete-state warm nudge; fill it in → a
   success check + `tel:` link.
3. Stop the API, reload `/profile`, `/patients`, `/addresses` → each shows `ErrorState` with retry (no
   blank form, no false-empty). Start the API, tap retry → data returns without a full reload.
4. Open the care circle (`/patients`) → every person has a colored-initials avatar and a visible chevron
   affordance; tap the card body → the record opens; the renamed «حلقهٔ مراقبت» title appears in the tab
   bar and the page header.
5. In the record, tap "افزودن دارو" → the bottom sheet (mobile) with dose amount + unit, frequency preset
   chips (+ free-text fallback when none is chosen), and صبح/ظهر/عصر/شب chips; save → the row shows the
   structured summary. Switch tabs mid-edit and come back → nothing lost (the sheet only exists while open).
   Try closing a dirty sheet → discard-confirm.
6. Open سوابق → notes grouped by Shamsi month on a rail; a note whose `bookingId` is set shows «مشاهده رزرو»
   linking to `/bookings/[id]`.
7. Add an address on mobile → the form opens full-screen with an app-bar header. Without
   `NEXT_PUBLIC_NESHAN_KEY` set (the default in this environment), the grid stand-in still works exactly as
   before (raw lat/lng shown — the documented fallback). With a real key set, the real map (search box,
   locate-me, draggable pin, reverse-geocoded preview) should appear instead — **not verified end-to-end
   in this sandbox** (no real Neshan key available; see the honest flag above). Back on the list, an
   address without a pin shows «پین ندارد»; one with a pin shows «پین ثبت شده».
8. Repeat 1, 4, 5, 7 on `/en` (LTR) and in dark mode.

## What is mocked / waiting on a real service

- `patientRecords` stays **mock-primary** for the family-owned record (REQ-027 — unchanged domain
  boundary); this phase only changed the **shape** collected/persisted by the mock (see the REQ-027
  addendum above), not the mock/real seam itself.
- The Neshan web map is **client config**, not a backend seam (per the phase doc: "not a backend seam —
  with a grid-stand-in fallback when unset") — no `mocks-registry.md` entry filed; the fallback grid *is*
  the registered dev/CI behavior, already documented in `AddressMapPicker`'s own doc comment.
- `services/geography/neshan.ts`'s tile URL and response-shape assumptions are unverified against live
  Neshan documentation (see the honest flag above) — this is a **content-accuracy risk to re-check before
  production use with a real key**, not a mock.

## Contracts

- Consumed: no new backend contract this phase (the touched domains — profiles/patients/addresses/
  patientRecords — already had their contracts from earlier phases).
- Filed to `dev/shared-working-context/frontend/requests/for-backend.md`:
  - **REQ-027 addendum** (not a new number) — the structured medication/routine field shape.
  - **REQ-057** — `lastVisitAt` (+ optional `visitCount`) on the patient read model (`PatientDto`).
  - **REQ-058** (deferred, non-blocking) — optional patient photo upload; the shipped solution is
    initials-only and needs no backend.

## Docs updated

- `client/CLAUDE.md` "Project Structure": `profile/`, `patients/`, `patients/[id]/record/`, `addresses/`
  route notes; the `components/common/` list (new `InitialsAvatar/`, `FormDialogShell/`); `PatientForm`,
  `PatientCard`, `PatientHeader`, `ProfileSummary`, and `geography/` component bullets.
- `client/.env.sample`: documented `NEXT_PUBLIC_NESHAN_KEY` (the client web key — separate from the
  server's `NeshanGeocoder` key, which lives in server config and is never touched here).
- **Correction to the phase's own premise:** the audit (`dev/post-phase/ui/audit/customer-account.md`,
  authored 2026-07-17 13:22) predates a same-day `ui phase 2` commit (2026-07-17 19:05) that already added
  the `isError`/`ErrorState` handling the audit describes as missing in `profile/page.tsx` and
  `patients/page.tsx`. Verified against the current code (not re-derived from the stale audit text) before
  starting — `addresses/page.tsx` genuinely still lacked it and was fixed in this phase. Flagging this so a
  future phase doesn't re-"fix" an already-fixed defect based on the audit's original wording.

## Follow-ups for later phases

- Re-verify `services/geography/neshan.ts` + `NESHAN_TILE_URL_TEMPLATE` against the current
  `platform.neshan.org` developer docs once a real web key is available to test against.
- REQ-057 (`lastVisitAt`) — once served, wire `PatientCard`'s existing `lastVisitLabel` prop in
  `patients/page.tsx`.
- Phase 12's global copy sweep: the `address.line_hint` fa fix already landed here — don't re-edit it.
- A read-mode "daily schedule" view (meds grouped صبح/ظهر/شب) and an `AddressCard` static-map thumbnail are
  both explicitly deferred per the phase doc (§3.3.3, §3.4.3) — not started.
