Files
baya-monorepo/archive/post-phase/ui/ui-phase-9-customer-account-and-care-circle.md
T
2026-08-02 18:48:32 +03:30

23 KiB
Raw Blame History

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 02 · 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:1618 handles only isLoading from useCustomerProfile(); on error the form renders with initial={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 7577).
  • Query errors collapse into false-empty. patients/page.tsx:83const isEmpty = !isLoading && patients.length === 0; never reads isError, so a failed query tells a family «هنوز بیماری ثبت نشده». Identical bug in addresses/page.tsx:91.
  • The care record is undiscoverable and error-prone to edit. PatientCard.tsx:6685 renders the tap-to-open area as an unstyled component="button" (background: 'none', border: 'none') — nothing signals the richest screen in the area exists. Inside, record/page.tsx edits each tab as a whole-list mode with per-tab useState drafts (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 439445).
  • The map is not a map. components/geography/AddressMapPicker.tsx:2835 documents 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 no fullScreen and 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 systemCustomerProfile (services/profiles/types.ts:4450) and Patient are text-only; avatarUrl is 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, patientRecords are wired and (except patientRecords, 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: useRecordAccess before any clinical fetch, usePatient(patientId, { enabled: canView }) (record/page.tsx:5052), access-denied card at 5974, ownership banner at 108116. 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)

3. Scope — build this

3.1 Profile → account hub (profile/page.tsx)

Rebuild the Profile tab as the customer's account center:

  1. Identity header — warm auto-colored initials (same util as 3.2) + full name + masked phone from /me (useMe().phone via maskIranMobile from @/components/PhoneNumberField, in a dir="ltr" span). The first place the customer sees themselves in the app.
  2. Grouped tappable rows below the header — اطلاعات شخصی / مخاطب اضطراری / نشانی‌ها / زبان / اعلان‌ها / پشتیبانی / خروج. نشانی‌ها → /addresses, اعلان‌ها → /notifications, پشتیبانی → /support/tickets; اطلاعات شخصی and زبان open focused edit surfaces (prefer bottom sheets so /profile stays the single route; sub-routes require a client/CLAUDE.md Project Structure update). The زبان row owns the server-stored preferredLanguage and 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).
  3. 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.
  4. 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)

  1. 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 /patients route, services/patients, and the patients.* i18n key names stay unchanged.
  2. 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 to tokens.css both scheme blocks; note the token addition as a foundation extension in your report — phase 0 owns theme/). PatientHeader is 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).
  3. Visible record affordance on PatientCard — replace the invisible button with a CardActionArea-style hover/press surface or an explicit «مشاهده پرونده» chevron row. Add a last-visit meta line («آخرین ویزیت: ۱۲ تیر») via an optional lastVisitLabel prop — sourced best-effort from the cached customer bookings list; the server-truth field is a REQ (§4). Omit when unknown; never fabricate.
  4. Fix error→false-empty: isError branch with phase-1 ErrorState + retry on both patients/page.tsx and addresses/page.tsx. A failed query must never render «هنوز بیماری ثبت نشده» + add CTA.
  5. Keep soft-archive semantics and its copy verbatim (do-not-regress).

3.3 Care record editing (patients/[id]/record/page.tsx)

  1. 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 codes morning|noon|evening|night → صبح/ظهر/عصر/شب). Routine items get the same chip row instead of the free-text routine_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.
  2. 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 + mockApi for 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.
  3. Visit-note history becomes a timeline: group سوابق by Shamsi month with a subtle rail; each VisitNoteCard gains a done/undone task summary line; when VisitNote.bookingId is non-null (services/patientRecords/types.ts:7981) 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).
  4. Do not touch the access gateuseRecordAccess before 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/)

  1. Real Neshan web tiles behind the existing AddressMapPicker boundary — the component's props/output ({ latitude, longitude }) don't change, so AddressForm is untouched. Client-side embed (official Neshan web SDK or Leaflet + Neshan tiles), dynamically imported with ssr: false. Key from NEXT_PUBLIC_NESHAN_KEY — the web key is separate config from the server's NeshanGeocoder adapter (refinement phase 8; lives under server/src/Infrastructure/…/Seams/Real/ — do not touch it). Document the variable in client/.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.
  2. 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 CascadingRegionSelect choice. Tokenize the pin's hard-coded rgba drop-shadow (line 116) too.
  3. Pin-quality cue on AddressCard: Address.latitude is 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).
  4. Full-screen mobile form dialogs for the patient form (patients/page.tsx:160) and the address form (addresses/page.tsx:170): fullScreen below the sm breakpoint 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 shared components/FormDialogShell/ (co-located test) and note the foundation extension in your report.
  5. 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)

  1. Structured name fields: replace the single full-name field with نام / نام خانوادگی (the wire already takes firstName/lastName/displayName; today splitName at PatientForm.tsx:3238 guesses, with lastName falling back to firstName). displayName = the joined value. If the Patient read DTO lacks firstName/lastName for edit-prefill, fold that into the REQ from 3.2.
  2. Birth-year presentation honesty: age.ts:10 fabricates a Jan-1 birthDate from 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 raw birthDate.
  3. Loading consistency: profile's full-page AppLoading (profile/page.tsx:18) → a form-shaped skeleton, matching the skeleton language of patients/addresses/record.
  4. Small a11y fixes from the audit (check an earlier phase didn't already land them): GenderToggle gets width: '100%' so its flex: 1 children actually split (today it renders content-width, misaligned against fullWidth fields); RelationSelect selected 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:

  1. REQ-(next): patient care metadatalastVisitAt (optionally visitCount) on the patient read model, plus firstName/lastName on the read DTO if absent (3.2/3.5).
  2. REQ-027 addendum (not a new number): the structured care-record field shape — medication { doseAmount, doseUnit, frequencyCode|frequencyText, timeOfDay[] }, routine timeOfDay codes (3.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. useRecordAccess gates before any clinical fetch (usePatient stays enabled: 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 (patientRecords rule).
  • AddressMapPicker's RTL engineering survives the tile swap: the canvas stays dir="ltr", marker positioning stays inline-style (the stylis RTL plugin flips left and translate — see the comment at lines 5759). Real map containers get the same dir="ltr" island treatment.
  • CascadingRegionSelect architecture 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.
  • GenderToggle stays 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 deliberate dir="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 check green; npm run test:ci green with updated tests for every touched shared component (PatientHeader/PatientCard/PatientForm/GenderToggle/RelationSelect/AddressMapPicker/ AddressCard/any new FormDialogShell).
  • en.json/fa.json in sync; the «بیماران» rename applied consistently in both; the fa.json line_hint bug fixed.
  • Visual verification on the four axes (/fa + /en × light + dark), mobile and desktop — verify mobile at /fa first.
  • 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.
  • /profile shows 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 bookingId exists.
  • Addresses: with NEXT_PUBLIC_NESHAN_KEY set — 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. AddressCard shows the pin-quality cue. .env.sample documents 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)

  1. 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.
  2. Clear the emergency contact → the warm "why nurses need this" nudge; fill it → check + tel: link.
  3. 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.
  4. 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.
  5. 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.
  6. Open سوابق → notes grouped by Shamsi month on a rail; a booking-linked note links to /bookings/[id].
  7. 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.
  8. 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 (new components/ entries such as FormDialogShell, 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.