23 KiB
UI Phase 9 — Customer account & care circle
Mission: the customer account area is functionally complete but emotionally wrong for a home-care product: a flat settings form with no sign-out, care recipients as clinical rows behind an invisible tap target, a whole-list free-text care-record edit mode that loses drafts, and an address "map" that is a blank coordinate grid feeding the EVV proximity check. Reframe the area around the people being cared for — an account hub, a care circle with faces, per-item structured record editing, a real Neshan map — and fix the real defects along the way (silent profile-load error, error→false-empty, cramped dialogs).
Track: frontend · Depends on: Phases 0–2 · Unlocks: the account area feels like caring for people, not filling forms
Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke the frontend-designer skill — both are mandatory.
1. Context — where this sits
Balinyaar's customer is a family member arranging care for someone they love — a parent, a spouse,
sometimes themselves. The account area (client/src/app/[locale]/(private-routes)/(customer)/ — profile/,
patients/, patients/[id]/record/, addresses/) is where that relationship lives, and today it reads
like an admin table. Every problem below is verified in code:
- Profile is a flat form, and a failed load silently blanks it.
profile/page.tsx:16–18handles onlyisLoadingfromuseCustomerProfile(); on error the form renders withinitial={profile ?? null}— a save from that blank state overwrites server truth with empty fields. No identity header, no phone, no sign-out; the only status affordance is a color-only completeness line (lines 75–77). - Query errors collapse into false-empty.
patients/page.tsx:83—const isEmpty = !isLoading && patients.length === 0;never readsisError, so a failed query tells a family «هنوز بیماری ثبت نشده». Identical bug inaddresses/page.tsx:91. - The care record is undiscoverable and error-prone to edit.
PatientCard.tsx:66–85renders the tap-to-open area as an unstyledcomponent="button"(background: 'none', border: 'none') — nothing signals the richest screen in the area exists. Inside,record/page.tsxedits each tab as a whole-list mode with per-tabuseStatedrafts (lines 197/275/346) destroyed on tab switch; dose/frequency/ time-of-day are plain free text (routine_time, line 321); سوابق is a flat card list with bare «قبلی/بعدی» paging (lines 439–445). - The map is not a map.
components/geography/AddressMapPicker.tsx:28–35documents itself as "NOT a real map (no Neshan/Google tiles), only a bounded canvas" and surfaces raw lat/lng captions — yet the pin it produces feeds nurse arrival and the EVV proximity check. - Forms are crammed into
maxWidth="sm"dialogs on a phone-first shell with nofullScreenand no dirty-state guard (patients/page.tsx:160,addresses/page.tsx:170); backdrop-click discards work. - No avatar/photo concept exists in the customer identity system —
CustomerProfile(services/profiles/types.ts:44–50) andPatientare text-only;avatarUrlis nurse-only.
What already exists (do not rebuild):
- Phase 0 theme/brand, Phase 1 primitives (ErrorState/EmptyState kit, PageHeader, card kit, skeleton twins), Phase 2 customer shell + the sign-out affordance — this phase gives sign-out its home, not its first existence.
- The
services/{domain}layer:profiles,patients(+age.ts),addresses,patientRecordsare wired and (exceptpatientRecords, REQ-027) real. This phase is presentation + defect fixes. PatientHeader(shared by E1 card + E2 record),PatientForm,PatientCard,CascadingRegionSelect,AddressForm,AddressCard,AddressMapPicker— all tested; restyle, don't fork.- The care record's non-leaking access gate:
useRecordAccessbefore any clinical fetch,usePatient(patientId, { enabled: canView })(record/page.tsx:50–52), access-denied card at 59–74, ownership banner at 108–116. Preserve verbatim. - Soft-archive semantics + copy, the dashed-border empty states («اولین آدرس را اضافه کنید تا پرستار بداند کجا بیاید»), content-shaped skeletons, inline per-field validation with error-clearing.
2. Required reading (do this first)
- audit/customer-account.md — the 16 problems + 10 opportunities + the keep-list this phase executes; every file/line above comes from it.
- Code, in this order:
profile/page.tsx,patients/page.tsx,patients/[id]/record/page.tsx,addresses/page.tsx(all underclient/src/app/[locale]/(private-routes)/(customer)/), thenclient/src/components/{PatientCard,PatientHeader,PatientForm,RelationSelect,GenderToggle}/andclient/src/components/geography/{AddressMapPicker,AddressForm,AddressCard,CascadingRegionSelect}.tsx, thenclient/src/services/{profiles,patients,addresses,patientRecords}/types.tsandclient/src/services/patients/age.ts. - ../../../.claude/skills/frontend-designer/SKILL.md — the design contract (invoke the skill, don't just read it).
- Product: ../../../product/business/01-actors-and-onboarding.md (who the "patient" actually is — relations include «خودم») and the Persian glossary in ../../../product/overview/platform-summary.md — both feed the naming decision in 3.2; ../../../product/business/06-evv-and-service-delivery.md for why the address pin matters (advisory EVV proximity).
- The REQ tracker tail: ../../shared-working-context/frontend/requests/for-backend.md — REQ-001…038 taken at audit time; earlier UI phases may have appended more. Take the next free number.
client/CLAUDE.md— Golden rules + the(customer)part of Project Structure.
3. Scope — build this
3.1 Profile → account hub (profile/page.tsx)
Rebuild the Profile tab as the customer's account center:
- Identity header — warm auto-colored initials (same util as 3.2) + full name + masked phone from
/me(useMe().phoneviamaskIranMobilefrom@/components/PhoneNumberField, in adir="ltr"span). The first place the customer sees themselves in the app. - Grouped tappable rows below the header — اطلاعات شخصی / مخاطب اضطراری / نشانیها / زبان / اعلانها /
پشتیبانی / خروج. نشانیها →
/addresses, اعلانها →/notifications, پشتیبانی →/support/tickets; اطلاعات شخصی and زبان open focused edit surfaces (prefer bottom sheets so/profilestays the single route; sub-routes require aclient/CLAUDE.mdProject Structure update). The زبان row owns the server-storedpreferredLanguageand hands the actual locale switch to phase 2's switcher — don't build a second locale mechanism. The خروج row is the sign-out's home: confirm dialog →useLogout()(the single logout path — never hand-roll cookie clearing). - Emergency contact as a status card, not two bare fields: complete → success check + contact name +
a
tel:link (tel-only, per the f14 emergency rule); incomplete → a warm nudge explaining why nurses need it («پرستار باید بداند در شرایط اضطراری با چه کسی تماس بگیرد») + edit CTA. - Fix the silent load error: on
useCustomerProfile()error render the phase-1 ErrorState with retry — never the editable form. The blank-form-overwrites-server-truth path must be impossible.
3.2 Care-circle reframe (patients/page.tsx, PatientCard, PatientHeader)
- Naming: consult the product docs + glossary (§2) before renaming «بیماران». Candidates: «عزیزان شما»
/ «حلقه مراقبت». Mind the «خودم» relation — the term must not be absurd for self-care («حلقه مراقبت» is
the safer default). Record the decision in your report and apply it consistently (page title, nav
label, empty states, the A5 home nudge). Copy-level rename only: the
/patientsroute,services/patients, and thepatients.*i18n key names stay unchanged. - Avatar slot on
PatientHeader— the patients API has no photo field, so ship warm auto-colored initials: a deterministic util (name hash → one of ~6 new--bal-avatar-*token pairs added totokens.cssboth scheme blocks; note the token addition as a foundation extension in your report — phase 0 ownstheme/).PatientHeaderis shared by the E1 card and E2 record, so one change gives both a face; update its co-located test. Real photo upload is (DEFERRED → optional REQ, see §4). - Visible record affordance on
PatientCard— replace the invisible button with aCardActionArea-style hover/press surface or an explicit «مشاهده پرونده» chevron row. Add a last-visit meta line («آخرین ویزیت: ۱۲ تیر») via an optionallastVisitLabelprop — sourced best-effort from the cached customer bookings list; the server-truth field is a REQ (§4). Omit when unknown; never fabricate. - Fix error→false-empty:
isErrorbranch with phase-1 ErrorState + retry on bothpatients/page.tsxandaddresses/page.tsx. A failed query must never render «هنوز بیماری ثبت نشده» + add CTA. - Keep soft-archive semantics and its copy verbatim (do-not-regress).
3.3 Care record editing (patients/[id]/record/page.tsx)
- Per-item bottom sheets replace whole-list edit mode. Each medication/routine/task row gets edit (and
each tab an add CTA) opening a bottom sheet (
Drawer anchor="bottom"; standard dialog on desktop). Medication sheet: name + structured dose (amount + unit select قرص/کپسول/قطره/سیسی/واحد) + frequency presets («روزی ۱ بار» … «هر ۸ ساعت» / «در صورت نیاز» + free-text fallback) + time-of-day chips (stable codesmorning|noon|evening|night→ صبح/ظهر/عصر/شب). Routine items get the same chip row instead of the free-textroutine_time; tasks stay label + done. A dirty sheet gets a discard-confirm; killing the whole-list mode removes the silent tab-switch draft loss by construction. - REQ posture: the family-owned record is REQ-027 mock territory (no backend). Keep the UI seam-tolerant
behind
services/patientRecords— extend the client model +mockApifor the structured fields, and append the structured shape as an addendum to REQ-027 in the tracker (dose amount/unit, frequency preset codes, time-of-day codes) so the eventual table matches what the UI collects. - Visit-note history becomes a timeline: group سوابق by Shamsi month with a subtle rail; each
VisitNoteCardgains a done/undone task summary line; whenVisitNote.bookingIdis non-null (services/patientRecords/types.ts:79–81) link «مشاهده رزرو» →/bookings/[id], showing the service name only when derivable from the cached booking — never block on it. Keep paging (grouped within pages is fine). A read-mode "daily schedule" view (meds grouped صبح/ظهر/شب) is (DEFERRED — not in this chain). - Do not touch the access gate —
useRecordAccessbefore any clinical fetch, the access-denied card, and the ownership banner survive the redesign verbatim.
3.4 Addresses + the real map (addresses/page.tsx, components/geography/)
- Real Neshan web tiles behind the existing
AddressMapPickerboundary — the component's props/output ({ latitude, longitude }) don't change, soAddressFormis untouched. Client-side embed (official Neshan web SDK or Leaflet + Neshan tiles), dynamically imported withssr: false. Key fromNEXT_PUBLIC_NESHAN_KEY— the web key is separate config from the server'sNeshanGeocoderadapter (refinement phase 8; lives underserver/src/Infrastructure/…/Seams/Real/— do not touch it). Document the variable inclient/.env.sample(the repo uses.env.sample, not.env.example). When the key is unset, fall back to the current grid stand-in so dev/CI/jsdom tests keep working — keep the fallback code, don't delete it. - Map features: address search box (Neshan geocode), locate-me (GPS) button, draggable pin, and a
reverse-geocoded pin preview («پین روی: خیابان ولیعصر…») replacing the raw lat/lng captions — coordinates
never render in the UI again. The pin still only refines coordinates; the bookable geography remains
the
CascadingRegionSelectchoice. Tokenize the pin's hard-codedrgbadrop-shadow (line 116) too. - Pin-quality cue on
AddressCard:Address.latitudeis nullable (services/addresses/types.ts:38) — show «پین ثبت شده» / «پین ندارد» (the latter with a warm fix-it nudge, since a missing pin degrades the nurse's arrival + EVV). A static map thumbnail per card is (DEFERRED — revisit once tiles are proven). - Full-screen mobile form dialogs for the patient form (
patients/page.tsx:160) and the address form (addresses/page.tsx:170):fullScreenbelow thesmbreakpoint with an app-bar header (title / close / save) and a dirty-state discard-confirm on close/backdrop. If phase 1 shipped a form-dialog primitive, use it; otherwise add sharedcomponents/FormDialogShell/(co-located test) and note the foundation extension in your report. - Fix the fa copy bug in
messages/fa.json:228: «…جزئیاتی که پرستار برای یافتن در نیاز دارد.» → «…برای یافتن درِ منزل نیاز دارد.» (or «برای یافتن نشانی نیاز دارد.»). Phase 12 owns the global copy sweep — fix this one here since you touch the form, and flag it in your report so phase 12 doesn't double-edit.
3.5 Form quality (PatientForm, loading consistency)
- Structured name fields: replace the single full-name field with نام / نام خانوادگی (the wire already
takes
firstName/lastName/displayName; todaysplitNameatPatientForm.tsx:32–38guesses, withlastNamefalling back tofirstName).displayName= the joined value. If the Patient read DTO lacksfirstName/lastNamefor edit-prefill, fold that into the REQ from 3.2. - Birth-year presentation honesty:
age.ts:10fabricates a Jan-1birthDatefrom the collected age — that stays (wire mapping), but the UI must never render the fabricated full date anywhere; display age-only (age_years), and consider collecting سال تولد instead of سن if it reads warmer. Verify no surface prints rawbirthDate. - Loading consistency: profile's full-page
AppLoading(profile/page.tsx:18) → a form-shaped skeleton, matching the skeleton language of patients/addresses/record. - Small a11y fixes from the audit (check an earlier phase didn't already land them):
GenderTogglegetswidth: '100%'so itsflex: 1children actually split (today it renders content-width, misaligned againstfullWidthfields);RelationSelectselected state gains a check icon + fill (border-color-only today — fails WCAG 1.4.1). Both are shared → update their co-located tests; never loosen GenderToggle's never-defaulted, non-deselectable constraint.
4. Mocks & seams in this phase
No new mocks or seams. The area's domains stay as they are: profiles/patients/addresses real,
patientRecords mock-primary behind its existing seam (REQ-027). The Neshan web embed is client config
(NEXT_PUBLIC_NESHAN_KEY), not a backend seam — with a grid-stand-in fallback when unset.
Backend gaps become REQ entries appended to the tracker — check its tail and number onward (REQ-001…038 taken at audit time). Expected filings:
- REQ-(next): patient care metadata —
lastVisitAt(optionallyvisitCount) on the patient read model, plusfirstName/lastNameon the read DTO if absent (3.2/3.5). - REQ-027 addendum (not a new number): the structured care-record field shape — medication
{ doseAmount, doseUnit, frequencyCode|frequencyText, timeOfDay[] }, routinetimeOfDaycodes (3.3). - REQ-(next), optional & product-gated: patient photo upload — the UI ships initials-only either way.
5. Critical rules you must not get wrong
- The non-leaking access-denied gate stays.
useRecordAccessgates before any clinical fetch (usePatientstaysenabled: canView); a 403/denied renders the access-denied card with zero clinical data. Preserve the family-ownership banner. Clinical text is never logged, never in localStorage, never in a query string (patientRecordsrule). AddressMapPicker's RTL engineering survives the tile swap: the canvas staysdir="ltr", marker positioning stays inline-style(the stylis RTL plugin flipsleftandtranslate— see the comment at lines 57–59). Real map containers get the samedir="ltr"island treatment.CascadingRegionSelectarchitecture stays — parent-gated enabling, per-level progress adornments, the explicit "whole city" MenuItem as a real choice. The map pin refines, never replaces, the region choice.GenderTogglestays never-defaulted and non-deselectable (same-gender matching). **Soft-archive copy- optimistic-with-explanatory-error stays**; the archive confirm's dismiss stays the safe/neutral button.
- Sign-out goes through
useLogout()— the single logout path (server revoke + cookie clear + LOG_OUT + cache drop). No cookie handling in page code. - Design-contract non-negotiables that bite here: i18n keys in both catalogs; tokens not hexes (new
avatar colors =
--bal-*pairs in both scheme blocks); RTL logical props (phone numbers get deliberatedir="ltr"islands); dark mode on every new surface; MUI v9 API only; shared components keep co-located tests; fetch/cookies only via@/lib/api+@/lib/cookies.
6. Definition of Done
On top of the shared definition-of-done.md:
npm run checkgreen;npm run test:cigreen with updated tests for every touched shared component (PatientHeader/PatientCard/PatientForm/GenderToggle/RelationSelect/AddressMapPicker/AddressCard/any newFormDialogShell).en.json/fa.jsonin sync; the «بیماران» rename applied consistently in both; thefa.jsonline_hint bug fixed.- Visual verification on the four axes (
/fa+/en× light + dark), mobile and desktop — verify mobile at/fafirst. - With the API stopped: profile shows ErrorState + retry (no editable blank form); patients and addresses show ErrorState + retry (no false-empty). With the API back, retry recovers in place.
/profileshows identity header (initials + name + masked LTR phone), grouped rows, emergency-contact status card, and a working خروج row (confirm → logged out →/login).- Care circle: every person has a colored-initials avatar (stable across reloads), a visible «مشاهده پرونده» affordance, and archive/edit still work.
- Care record: add/edit medication via bottom sheet with structured dose/frequency/time-of-day; tab
switches lose nothing; سوابق is a month-grouped timeline with booking links when
bookingIdexists. - Addresses: with
NEXT_PUBLIC_NESHAN_KEYset — real tiles, search, locate-me, draggable pin, reverse-geocoded preview, no raw lat/lng anywhere; with the key unset — the grid fallback still works and tests pass.AddressCardshows the pin-quality cue..env.sampledocuments the variable. - Patient + address forms are full-screen dialogs on mobile with app-bar header and dirty-state confirm. REQ entries filed per §4; REQ-027 addendum recorded.
7. How to test (what a human can verify after this phase)
- Log in as a seeded customer (
0912000000x) on a mobile viewport at/fa. Open the Profile tab → see your initials, name, and masked phone (digits LTR); rows for اطلاعات شخصی/مخاطب اضطراری/نشانیها/زبان/ اعلانها/پشتیبانی/خروج. Tap خروج → confirm → you land on/login, session revoked. - Clear the emergency contact → the warm "why nurses need this" nudge; fill it → check +
tel:link. - Stop the API, reload
/profile,/patients,/addresses→ each shows an error card with retry — no blank form, no «هنوز بیماری ثبت نشده». Start the API, tap retry → data returns without a full reload. - Open the care circle → each person has a colored-initials avatar and a visible «مشاهده پرونده» chevron; tap it → the record opens. The renamed title appears here and in the tab bar.
- In the record, tap "add medication" → bottom sheet with dose amount + unit, frequency preset chips, and صبح/ظهر/عصر/شب chips; save → the row renders the structured summary. Start editing, switch tabs, come back → nothing lost. Close a dirty sheet → discard confirm.
- Open سوابق → notes grouped by Shamsi month on a rail; a booking-linked note links to
/bookings/[id]. - Add an address on mobile → the form opens full-screen with app-bar header; with a Neshan key set, search «ولیعصر», drag the pin, tap locate-me → the preview line shows the reverse-geocoded street, never raw coordinates. Back on the list, that address shows «پین ثبت شده»; an old pin-less address shows «پین ندارد». Tap close with unsaved changes → discard confirm; cancel keeps your draft.
- Repeat 1, 4, 5, 7 on
/en(LTR) and in dark mode — avatars, map island, timeline, and status cards all render correctly on all four axes.
8. Hand off & document (close the phase)
- Update
client/CLAUDE.md"Project Structure" for anything added/renamed (newcomponents/entries such asFormDialogShell, the avatar util, any profile sub-routes) and the(customer)route notes for the profile hub + the copy-level care-circle rename. - Append the REQ entries + the REQ-027 addendum to ../../shared-working-context/frontend/requests/for-backend.md.
- Write the frontend report at
dev/shared-working-context/reports/ui-phase-9-report.md: the naming decision and why, the avatar-token additions (foundation extension), the Neshan embed choice + fallback behavior, the fa copy fix (flag it for phase 12's sweep), REQs filed, deferrals. - Save a memory note per operating-rules §8: account-hub structure, the naming decision, the Neshan web-key
config (
NEXT_PUBLIC_NESHAN_KEY, fallback-to-grid), and the REQ-027 structured-fields addendum.