Files
baya-monorepo/archive/post-phase/ui/ui-phase-1-primitives-and-states.md
T
2026-08-02 18:48:32 +03:30

297 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI Phase 1 — Shared primitives & app-wide states
> **Mission:** build the shared component kit the pages keep hand-rolling — state views, page header, confirm
> dialog, card anatomy, `<Money>`, locale formatting, a Jalali date picker, status/timeline/countdown/rating
> upgrades, skeleton twins — give the app real route-level chrome (loading / error / 404 / per-route metadata),
> and fix the error→false-empty defects the missing kit caused. This phase **owns every shared primitive**
> (see the [README ownership rules](README.md)); phases 311 only consume what ships here.
>
> **Track:** frontend · **Depends on:** [Phase 0](ui-phase-0-design-language.md) (theme pass, icon family,
> `--bal-money-emphasis` / `--bal-rating*` / elevation / motion / focus tokens) · **Unlocks:** the kit every
> area redesign (phases 311) composes from
> **Before you start, read [../../phases/_shared/agent-operating-rules.md](../../phases/_shared/agent-operating-rules.md) and invoke the `frontend-designer` skill — both are mandatory.**
## 1. Context — where this sits
Balinyaar's feature layer is disciplined (tokens, RTL, four-state data pattern), but the *page primitives*
were only ever built for the backoffice. Everything user-facing is hand-rolled per page, so the app reads as
a starter and every restyle is a 20-file change. Verified in code:
- **29 hand-rolled dashed-border `Paper` empty/error blocks across 23 files** (grep `border: '1px dashed'`),
while `AdminEmptyState`/`AdminErrorState` sit unused outside `components/admin/`.
- **24 hand-assembled `formatIrrToToman(x) + t('currency_toman')` sites across 19 files**; `PriceBreakdown.tsx:62`
colors the grand total terracotta (`--bal-secondary`, ≈2.7:1 on white — a WCAG failure on the most
trust-critical number), a pattern repeated across five sibling money components.
- **23 copies of the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary across 15 files**, already drifting
(`admin/partners/[id]/page.tsx` passes the raw locale); **no relative-time formatting anywhere**; the OTP
resend clock renders Latin digits (`components/auth/OtpStep.tsx:27`).
- **Zero `loading.tsx` / `error.tsx` / `not-found.tsx` / `global-error.tsx`** under `client/src/app`; the only
crash UI is the starter `ErrorBoundary` (English `<h2>`, raw `componentStack` dump); one static `<title>`
serves ~60 routes; **no Jalali date input** — every date input is a native Gregorian `type="date"`.
- **The missing kit caused real defects.** A failed query renders as *success-with-no-data*:
- `(customer)/page.tsx:5768` — the page gate destructures only `data` from `usePatients()`; on error it
returns `<AppLoading />` forever (spinner-hang). The `CategoryGrid` in the same file branches all four
states correctly — the discipline exists, the gate doesn't.
- `(customer)/patients/page.tsx:83` (`isEmpty = !isLoading && …length === 0` → error shows «no patients»);
`nurse/requests/page.tsx:19` (error = empty inbox); `nurse/services/MyServicesList.tsx:37,42` (error =
«add your first service» CTA).
- `(customer)/profile/page.tsx:16` — an errored profile renders a **blank form** whose save could overwrite
server truth; `nurse/services/VariantBuilder.tsx:384``optionGroupsQuery` has no error branch, so failed
option groups are treated as "category has none", letting a nurse skip required options.
- **Silent mutations:** `nurse/profile/page.tsx:44` (avatar upload) and `:5464` (profile save) pass only
`onSuccess` — a failure gives zero feedback.
**What already exists (do not rebuild):**
- The admin primitives to *promote*, not rewrite (all in `client/src/components/admin/`, all tested):
`AdminPageHeader` (title/subtitle/actions, caller-owned i18n), `AdminEmptyState`/`AdminErrorState`,
`ConfirmDialog` (required-reason gating + busy-disable).
- `utils/money.ts` (BigInt IRR↔Toman, fa digits) and `utils/date.ts` (Shamsi via `Intl` `fa-IR-u-ca-persian`,
no date library); `CountdownTimer`'s architecture — server-frozen UTC deadline prop, self-owned 1s tick
(only it re-renders), single `onElapsed` fire, `dir="ltr"` tabular-nums locale digits (`CountdownTimer.tsx:84`).
- `StatusChip` as the single status color+icon source seven components delegate to; the exhaustive typed
enum→chip mappings; `TrustBadge`'s honest three states; the four-state pattern itself (~95 `isError`
branches, ~124 retries) — restyle and fill gaps, don't rebuild; the `dispatchToast → notistack` pipeline.
- Phase 0's output: `theme.components` pass, one icon family, the new tokens. Build on them.
## 2. Required reading (do this first)
- [audit/component-primitives.md](audit/component-primitives.md) (kit inventory, hand-roll counts),
[audit/feature-components.md](audit/feature-components.md) (card-anatomy drift, rating/terracotta/timeline
defects), [audit/cross-cutting-ux.md](audit/cross-cutting-ux.md) (route chrome / metadata / formatting) —
each carries the full evidence and a keep-list.
- [.claude/skills/frontend-designer/SKILL.md](../../../.claude/skills/frontend-designer/SKILL.md) — the design
contract; and `client/CLAUDE.md` — Golden rules, Project Structure, Unit Testing, Toast Notifications, i18n.
- Code to read before writing: `client/src/components/admin/{AdminPageHeader,AdminEmptyState,AdminErrorState,ConfirmDialog}.tsx`;
`client/src/components/{StatusChip,CountdownTimer,RatingInput,StepperHeader,RefundStatusCard,PriceBreakdown}/`;
`client/src/components/booking/BookingStatusTimeline/`; `client/src/utils/{money,date}.ts`;
`client/src/lib/api/client.ts` (which errors the fetch layer already toasts — 3.14 builds on it);
`client/src/theme/tokens.css` as phase 0 left it. Product framing: `product/overview/platform-summary.md`
(money display and Shamsi correctness are product rules, not preferences).
## 3. Scope — build this
New shared components live under `client/src/components/common/<Name>/` (component + `index.tsx` barrel +
co-located `.test.tsx`), exported from `@/components`, presentational with **caller-owned i18n** (already-
translated strings in, like `AdminPageHeader` documents). Strings a component *does* own (route chrome,
default retry labels) go in **both** `messages/en.json` and `messages/fa.json`.
### 3.1 State kit — `EmptyState`, `ErrorState`, `QueryStateGate` + the false-empty fixes
- Promote/generalize `AdminEmptyState`/`AdminErrorState` into shared `EmptyState` and `ErrorState` with a
branded look (phase 0 surface/radius tokens; registry icon slot; calm — retire the dashed border).
`EmptyState`: icon, title, body, optional CTA. `ErrorState`: **`onRetry` is required** («تلاش دوباره»).
`components/admin` keeps thin wrappers or its call sites migrate — either way no duplicated implementation
remains. (Custom illustration set: DEFERRED → phase 12.)
- Add a small `QueryStateGate` (props: `isLoading`, `isError`, `onRetry`, `isEmpty`, `skeleton`, `empty`,
`children`) rendering skeleton → error → empty → data in that order, **plus** write the convention into
`client/CLAUDE.md`: *an errored query must never render as empty; every error branch has a retry.* Pages
with naturally inline branching (infinite lists) may follow the convention without the component.
- Fix the six defects from §1 with it. The profile fix must render `ErrorState` instead of the blank form;
VariantBuilder's option-group error must block progression, not pretend "no options".
- Mechanical sweep: replace the 29 dashed-`Paper` blocks in 23 files with `EmptyState`/`ErrorState`. Copy
stays per-page (existing keys); only the shell unifies.
### 3.2 `PageHeader` + `ConfirmDialog` promotions
- Generalize `AdminPageHeader` into a shared `PageHeader`: `title`, `subtitle?`, `actions?`, plus a `backTo?`
affordance (RTL-flippable chevron via the phase 0 icon family; if phase 0 shipped no `back` icon, extend
`AppIcon/config.ts` minimally and note it — ownership rule). Adopt it **mechanically** where pages already
hand-roll the identical h5/h1 + subtitle block (patients, addresses, bookings, earnings, …); per-page header
design is each area phase's job (DEFERRED → 311). `AdminPageHeader` becomes a re-export or its call sites migrate.
- Move `components/admin/ConfirmDialog` to `components/common/ConfirmDialog` **preserving its contract**:
required-reason gating, busy state disabling both buttons, caller-owns-the-mutation. Migrate the admin
imports and the hand-rolled confirm flows only where mechanical (patients archive, addresses delete); the
remaining raw `<Dialog>` confirm sites are area-phase work (DEFERRED → 311).
### 3.3 Card kit — `SurfaceCard` + `AccentCard`
Encode the de-facto anatomy once: `SurfaceCard` = flat `Paper` (`elevation={0}`), 1px `divider` border, house
radius, a padding scale (`sm`/`md`/`lg` ≈ today's p:2 / 2.5 / 3); `AccentCard` = `SurfaceCard` + a
`borderInlineStart` accent taking a **semantic token name**, at one standardized width (pick one; end the
3px/4px drift). Migrate the ~12 hand-rollers mechanically — `EarningsRow`, `EarningsBalanceHeader`,
`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`, `PriceBreakdown`,
`BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`, the customer-home `NudgeCard`
visual unification only, zero content/behavior change.
### 3.4 `<Money>` primitive
One component for every displayed amount: takes a **served IRR digit-string**, renders Toman + unit label via
`utils/money.ts` (never computes); `size`/`tone` variants with emphasis driven by `--bal-money-emphasis`
(phase 0) — **not terracotta**; deduction rendering (muted/error tone with an explicit visual sign treatment,
never a bare minus inside RTL text); optional strikethrough. Replace the 24 hand-assembled sites in 19 files —
this fixes the `PriceBreakdown` total-contrast defect and the sibling terracotta-money sites (`RefundStatusCard`,
`BnplPlanCard`, `InstallmentScheduleRow`, `CancellationPolicyDisclosure`) in one pass. `PriceDisplay` (catalog
unit rates) stays; refactor it onto `<Money>` internals only if trivial — its contract is tested and correct.
### 3.5 Formatting utils — `utils/number.ts`
- `localeTag(locale)``'fa-IR' | 'en-US'` and `formatNumber(value, locale, options?)` — kill the 23
copy-pasted ternaries in 15 files (including the drifted `admin/partners/[id]/page.tsx` raw-locale call).
- `formatRelativeTime(iso, locale)` via `Intl.RelativeTimeFormat`; **rule (document in JSDoc): relative up to
~7 days («۲ ساعت پیش»، «دیروز»), then decay to the absolute Shamsi date** via `formatShamsiDate`. Consumers
land in phases 10/11; ship the util + tests now.
- `formatClock(totalSeconds, locale)` — locale-digit `mm:ss`/`hh:mm:ss`; migrate `CountdownTimer`'s inline
`Intl` pad onto it and fix `OtpStep.tsx:27`'s Latin-digit resend clock.
### 3.6 `JalaliDatePicker` + `JalaliDateField`
Shamsi-native date selection that **emits ISO Gregorian** (the wire stays Gregorian; display stays Shamsi):
a month-grid calendar (Persian month/weekday names, today marked, min/max props), a compact **day-chip-strip**
variant for near dates (next N days as tappable chips — the C4 booking form's shape), and `JalaliDateField`
(read-only input + picker popover) to replace native `type="date"` inputs. **Investigate and DECIDE the
arithmetic layer:** prefer `Intl` `fa-IR-u-ca-persian` (already proven in `utils/date.ts`) for display + a
minimal conversion for month math; a tiny, well-maintained jalali lib (e.g. `jalaali-js`, ~2 KB) is acceptable
if `Intl`-only arithmetic gets awkward — document the choice and measured bundle cost in the report; no
heavyweight date library. Locale-aware (`fa` Persian calendar, `en` may show Gregorian — same ISO output),
keyboard-navigable, RTL-correct, both schemes. Consumed by phases 4, 5, 8, 11 — consumer migration is theirs
(DEFERRED → 4/5/8/11).
### 3.7 `StatusChip` v2
Soft-tint default: `-soft` token backgrounds + strong text (extend `tokens.css` + `colors.ts` with any missing
`-soft` pairs, both schemes); solid fill reserved for high-alarm states; a documented status weight hierarchy
(neutral < info < progress < success < warning < alarm). `StatusChip` is the delegation point, so this is
mostly one file; update direct chip consumers mechanically and keep the exhaustive enum→chip mappings intact.
### 3.8 `StatusTimeline`
A designed vertical timeline: node = label + optional timestamp + optional note; states completed / current
(animated within the phase 0 motion tokens, reduced-motion-safe) / pending / terminal-failure. Migrate the
two StepperHeader-as-status-display misuses: `RefundStatusCard.tsx:76` (3-step refund progress) and
`components/booking/BookingStatusTimeline` (7-status booking lifecycle). **StepperHeader remains for real
wizards only** (onboarding, verification, BNPL) — write that into its JSDoc.
### 3.9 `CountdownTimer` v2
Keep the do-not-regress architecture (server-frozen deadline, isolated tick, single `onElapsed`, `dir="ltr"`
tabular digits). Add: a **progress-ring variant** (given `windowStart`); **urgency tiers** — calm teal →
amber → terracotta — with threshold props, replacing the binary `urgent`; **humanized coarse mode** above
~10 minutes («۲۵ دقیقه مانده», minute-grain updates) switching to the ticking clock below it; keep the `label`
slot; `aria-live="polite"` at coarse intervals (never per second). Swap the hourglass `pending` glyph for the
clock; drop the hard-coded `1.5rem` for size variants. Existing consumers (C5 tracker, nurse inbox, checkout,
EarningsRow dispute window) keep working unchanged — new props optional.
### 3.10 `RatingInput` v2
Switch fill colors to phase 0's `--bal-rating` / `--bal-rating-empty`; **fractional fill** (dual-layer or clip)
for read-only averages so 4.5 renders as 4.5 — then remove the `Math.round` workaround at
`search/nurse/[nurseId]/page.tsx:260` (it shows a 4.5 average as 5 stars — an honesty bug on the core trust
surface); size variants; keep the radiogroup/readOnly `role="img"` semantics and `data-*` hooks. Swap the star
color in `NurseResultCard` and `BookingRequestSummaryCard` to the same tokens.
### 3.11 Skeleton twins + `AppLoading` rebrand
Co-located `<X.Skeleton />` statics matching real anatomy (avatar disc, chip row, price line) for
`NurseResultCard`, `EarningsRow`, `PayoutHistoryRow`, `PatientCard`, `VisitNoteCard`, `TicketListCard`; adopt
at the loading branches that hand-guess heights (e.g. `search/results/page.tsx:75`). Rebrand `AppLoading` into
a branded splash (brand mark + subtle motion, reduced-motion-safe) used **only** for true full-page waits
(auth splash, gateway return). Of the 18 screens using it today, switch the ones with a shaped skeleton
available to skeletons; the rest keep the splash until their area phase.
### 3.12 Route chrome — loading / error / 404
Per-route-group `loading.tsx` with shell-shaped skeletons (`(customer)` header + card stack; `nurse`/`admin`/
`partner` sidebar-shell content; `(public-routes)` auth card). Branded `error.tsx` (per `[locale]` segment)
and `not-found.tsx` + the next-intl catch-all 404 pattern — calm Persian-first copy, brand mark, retry
(`reset()`) and «بازگشت به خانه» CTAs, keys in both catalogs. `global-error.tsx` (sits above `[locale]`,
renders its own `<html>` — it cannot use next-intl): minimal static **bilingual** fa+en copy — the one
sanctioned hard-coded-string exception; note it in `client/CLAUDE.md`. Rewrite `components/common/ErrorBoundary`
to the same design: i18n'd, branded, retry affordance, **stack/componentStack rendered only in development**
(logged, never shown, in prod).
### 3.13 Per-route metadata
Title template via one `generateMetadata` in `app/[locale]/layout.tsx`: `'%s | بالین‌یار'` (fa) /
`'%s | Balinyaar'` (en) + localized description. Establish and document the client-page pattern — `page.tsx`
becomes a thin RSC exporting `generateMetadata` and rendering the co-located `'use client'` body — and apply
it to the main landing pages now (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`,
`/partner`). Full per-page adoption continues in the area phases (DEFERRED → 311).
### 3.14 Mutation-error convention
Read `lib/api/client.ts` to confirm exactly which failures the fetch layer already toasts; then: **every
mutation whose failure is not surfaced inline or by the fetch layer gets an `onError` toast.** Fix the two
known silent ones — nurse profile save (`nurse/profile/page.tsx:5464`) and avatar upload (`:44`) — and write
the convention into `client/CLAUDE.md` (Toast Notifications section).
## 4. Mocks & seams in this phase
None — pure UI over existing `services/{domain}` seams; no new service, mock, or flag. Expected REQ count:
zero. If a genuine backend gap surfaces anyway, append it to
[../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
numbered **REQ-039 onward** (001038 are taken), keep the UI mock-tolerant behind the seams, never touch `server/`.
## 5. Critical rules you must not get wrong
1. **Presentational purity + caller-owned i18n stays.** Shared primitives take already-translated strings and
stable codes; no data-fetching, no wire-derived labels inside this layer.
2. **Money invariants stay.** Amounts are served IRR digit-strings through `utils/money.ts` (BigInt); `<Money>`
formats, never computes; `PriceBreakdown`'s dev reconciliation guard survives the card-kit migration.
3. **CountdownTimer architecture stays** — server-frozen deadline, isolated self-tick, single `onElapsed`;
deadlines never recomputed client-side. **ConfirmDialog's contract stays** through the move. **An error is
never an empty** — the defect class this phase kills; every error state keeps a retry.
4. **Don't break what delegates.** `StatusChip` is the single status source and the enum→chip mappings are
exhaustively typed — keep both; `StepperHeader` keeps serving the real wizards untouched; honest-copy
components (EscrowNotice verbatim fa, RefundStatusCard's no-success-framing-on-failure,
EarningsBalanceHeader's "owed back") keep their copy and state logic — restyle shells only.
5. **Design contract non-negotiables:** tokens not hexes (extend `tokens.css` + `colors.ts` together, both
schemes); logical/RTL-safe props only (keep the deliberate `dir="ltr"` islands for clocks/IBANs/phones);
both message catalogs in sync; MUI v9 API only; co-located tests for every shared component;
fetch/cookies/toast rules per `client/CLAUDE.md` untouched.
6. **Never add a layout above `[locale]`**`global-error.tsx` is a special file, not a layout, and is the
only file allowed static copy.
## 6. Definition of Done
On top of the shared [definition-of-done.md](../../phases/_shared/definition-of-done.md):
- [ ] `npm run check` green; `npm run test:ci` green (many shared components are touched — run it); every new
shared component has a co-located `.test.tsx`; `en.json`/`fa.json` in sync.
- [ ] All six error→false-empty/spinner defects from §1 render `ErrorState` with a working retry (verify by
forcing the query to fail); the profile error never shows an editable blank form.
- [ ] Sweep greps clean: `border: '1px dashed'` under `client/src/app` → zero; `currency_toman` outside
`utils/money.ts` + `<Money>`/`PriceDisplay` internals → zero; `locale === 'fa' ? 'fa-IR'` → only
`utils/number.ts`. The checkout total renders in `--bal-money-emphasis`, not terracotta.
- [ ] `loading.tsx`, `error.tsx`, `not-found.tsx`, `global-error.tsx` exist and render branded, localized
(404/error verified on `/fa` and `/en`); ErrorBoundary shows no stack in a production build; browser
tab titles differ per section using the `%s | بالین‌یار` template.
- [ ] `JalaliDatePicker`/`JalaliDateField` render Shamsi, emit ISO Gregorian, pass keyboard/RTL tests; the
arithmetic decision + bundle cost is documented in the report.
- [ ] A 4.5 rating average renders a half-filled fifth star on the nurse profile (no `Math.round`);
`RefundStatusCard` and the booking timeline render `StatusTimeline` (wizards still use `StepperHeader`).
- [ ] Nurse profile save and avatar upload toast on failure; the convention is in `client/CLAUDE.md`.
- [ ] Visual verification on the four axes — `/fa` + `/en` × light + dark — and mobile + desktop for the state
kit, card kit, route chrome, and the Jalali picker.
## 7. How to test (what a human can verify after this phase)
1. Stop the backend (or force queries to reject via devtools offline mode) and load `/fa`: customer home shows
a branded error state with «تلاش دوباره» — not an infinite spinner. Restore, retry → home renders. Repeat on
`/patients`, `/profile`, `/nurse/requests`, `/nurse/services`: an error state, never «empty» copy or a blank form.
2. Visit a garbage URL (`/fa/xyz`) → branded Persian 404 with a home CTA. Throw inside a page in dev → branded
error screen with retry; build production and confirm no stack is rendered.
3. Navigate between sections and watch the tab title change («جستجو | بالین‌یار», «رزروها | بالین‌یار», …).
4. Open search results while loading: skeletons match the card anatomy — no layout jump when data lands. Open
checkout (mock path): the total renders in the money-emphasis color with correct contrast in both schemes;
a deduction row (earnings commission) reads visually as a deduction.
5. Open the C5 request tracker: progress ring, coarse «X دقیقه مانده» above 10 minutes, ticking Persian-digit
clock below, escalating tint as thresholds pass; the `/login` OTP resend clock shows Persian digits on `/fa`.
6. Open a nurse profile with a fractional average: stars render the fraction. Open a refund status page:
vertical timeline with node states, not a form stepper.
7. Mount `JalaliDateField` (the phase's test page or the first consumer): pick «۱۵ مرداد» → the emitted value
is the correct ISO Gregorian date; keyboard navigation works; `/en` shows the Gregorian view.
8. Kill the network mid-save on the nurse profile → an error toast appears; the form keeps the draft.
## 8. Hand off & document (close the phase)
- Update `client/CLAUDE.md`: Project Structure (new `components/common/*` primitives, route-chrome files,
`utils/number.ts`), the QueryStateGate/error-never-empty convention, the mutation-error toast convention,
the metadata pattern, and the `global-error.tsx` exception.
- Write the report at `dev/shared-working-context/reports/ui-phase-1-report.md`: what shipped, the Jalali
decision + bundle cost, icon-registry additions (if any), the sweep counts, any REQ entries appended to
[for-backend.md](../../shared-working-context/frontend/requests/for-backend.md) (REQ-039+; expected none),
and any foundation gaps left for phase 2+.
- Save a memory note per operating-rules §8: the primitives kit now exists and later phases must consume it
(never fork local variants), the error-never-empty and mutation-toast conventions, and the Jalali decision.