23 KiB
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 3–11 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 3–11) composes from Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke thefrontend-designerskill — 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
Paperempty/error blocks across 23 files (grepborder: '1px dashed'), whileAdminEmptyState/AdminErrorStatesit unused outsidecomponents/admin/. - 24 hand-assembled
formatIrrToToman(x) + t('currency_toman')sites across 19 files;PriceBreakdown.tsx:62colors 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.tsxpasses 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.tsxunderclient/src/app; the only crash UI is the starterErrorBoundary(English<h2>, rawcomponentStackdump); one static<title>serves ~60 routes; no Jalali date input — every date input is a native Gregoriantype="date". - The missing kit caused real defects. A failed query renders as success-with-no-data:
(customer)/page.tsx:57–68— the page gate destructures onlydatafromusePatients(); on error it returns<AppLoading />forever (spinner-hang). TheCategoryGridin 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—optionGroupsQueryhas 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:54–64(profile save) pass onlyonSuccess— 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) andutils/date.ts(Shamsi viaIntlfa-IR-u-ca-persian, no date library);CountdownTimer's architecture — server-frozen UTC deadline prop, self-owned 1s tick (only it re-renders), singleonElapsedfire,dir="ltr"tabular-nums locale digits (CountdownTimer.tsx:84).StatusChipas 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 (~95isErrorbranches, ~124 retries) — restyle and fill gaps, don't rebuild; thedispatchToast → notistackpipeline.- Phase 0's output:
theme.componentspass, 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.cssas 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/AdminErrorStateinto sharedEmptyStateandErrorStatewith a branded look (phase 0 surface/radius tokens; registry icon slot; calm — retire the dashed border).EmptyState: icon, title, body, optional CTA.ErrorState:onRetryis required («تلاش دوباره»).components/adminkeeps 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 intoclient/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
ErrorStateinstead of the blank form; VariantBuilder's option-group error must block progression, not pretend "no options". - Mechanical sweep: replace the 29 dashed-
Paperblocks in 23 files withEmptyState/ErrorState. Copy stays per-page (existing keys); only the shell unifies.
3.2 PageHeader + ConfirmDialog promotions
- Generalize
AdminPageHeaderinto a sharedPageHeader:title,subtitle?,actions?, plus abackTo?affordance (RTL-flippable chevron via the phase 0 icon family; if phase 0 shipped nobackicon, extendAppIcon/config.tsminimally 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 → 3–11).AdminPageHeaderbecomes a re-export or its call sites migrate. - Move
components/admin/ConfirmDialogtocomponents/common/ConfirmDialogpreserving 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 → 3–11).
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'andformatNumber(value, locale, options?)— kill the 23 copy-pasted ternaries in 15 files (including the driftedadmin/partners/[id]/page.tsxraw-locale call).formatRelativeTime(iso, locale)viaIntl.RelativeTimeFormat; rule (document in JSDoc): relative up to ~7 days («۲ ساعت پیش»، «دیروز»), then decay to the absolute Shamsi date viaformatShamsiDate. Consumers land in phases 10/11; ship the util + tests now.formatClock(totalSeconds, locale)— locale-digitmm:ss/hh:mm:ss; migrateCountdownTimer's inlineIntlpad onto it and fixOtpStep.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 → 3–11).
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:54–64) 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 (001–038 are taken), keep the UI mock-tolerant behind the seams, never touch server/.
5. Critical rules you must not get wrong
- 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.
- 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. - 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. - Don't break what delegates.
StatusChipis the single status source and the enum→chip mappings are exhaustively typed — keep both;StepperHeaderkeeps 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. - Design contract non-negotiables: tokens not hexes (extend
tokens.css+colors.tstogether, both schemes); logical/RTL-safe props only (keep the deliberatedir="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 perclient/CLAUDE.mduntouched. - Never add a layout above
[locale]—global-error.tsxis 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 checkgreen;npm run test:cigreen (many shared components are touched — run it); every new shared component has a co-located.test.tsx;en.json/fa.jsonin sync.- All six error→false-empty/spinner defects from §1 render
ErrorStatewith 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'underclient/src/app→ zero;currency_tomanoutsideutils/money.ts+<Money>/PriceDisplayinternals → zero;locale === 'fa' ? 'fa-IR'→ onlyutils/number.ts. The checkout total renders in--bal-money-emphasis, not terracotta. loading.tsx,error.tsx,not-found.tsx,global-error.tsxexist and render branded, localized (404/error verified on/faand/en); ErrorBoundary shows no stack in a production build; browser tab titles differ per section using the%s | بالینیارtemplate.JalaliDatePicker/JalaliDateFieldrender 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);RefundStatusCardand the booking timeline renderStatusTimeline(wizards still useStepperHeader). - 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)
- 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. - 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. - Navigate between sections and watch the tab title change («جستجو | بالینیار», «رزروها | بالینیار», …).
- 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.
- Open the C5 request tracker: progress ring, coarse «X دقیقه مانده» above 10 minutes, ticking Persian-digit
clock below, escalating tint as thresholds pass; the
/loginOTP resend clock shows Persian digits on/fa. - 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.
- Mount
JalaliDateField(the phase's test page or the first consumer): pick «۱۵ مرداد» → the emitted value is the correct ISO Gregorian date; keyboard navigation works;/enshows the Gregorian view. - 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 (newcomponents/common/*primitives, route-chrome files,utils/number.ts), the QueryStateGate/error-never-empty convention, the mutation-error toast convention, the metadata pattern, and theglobal-error.tsxexception. - 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.