Files
baya-monorepo/dev/post-phase/ui/ui-phase-1-primitives-and-states.md
T
2026-07-17 13:22:04 +03:30

23 KiB
Raw Blame History

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); phases 311 only consume what ships here.

Track: frontend · Depends on: Phase 0 (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 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:384optionGroupsQuery 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 (kit inventory, hand-roll counts), audit/feature-components.md (card-anatomy drift, rating/terracotta/timeline defects), audit/cross-cutting-ux.md (route chrome / metadata / formatting) — each carries the full evidence and a keep-list.
  • .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 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:

  • 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 (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.