# 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.