some ui phase improvement planning
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Admin backoffice + partner portal (client/src/app/[locale]/(private-routes)/admin + /partner, client/src/components/admin, client/src/layout)
|
||||
|
||||
## Current state
|
||||
|
||||
The backoffice is in far better shape than "15 hand-rolled tables": there is a real shared primitive layer in client/src/components/admin — AdminDataTable (typed columns, dense, horizontal-scroll container, align defaults to 'inherit' for RTL), AdminPageHeader, AdminPager (prev/next only), AdminEmptyState, AdminErrorState, and a genuinely good ConfirmDialog (required-reason gating, loading-disables-buttons, destructive color) — and every console actually uses them. Every list page follows the same skeleton→error→empty→table/cards branch, filters live in the page-header actions slot or a bordered filter row (tickets/audit use a draft-vs-applied Apply pattern), and status colors flow through the shared StatusChip whose colors resolve from --bal-* semantic tokens (dark scheme covered in src/theme/tokens.css; I found zero hard-coded hexes anywhere in the admin/partner surface). Specialized composites (RefundPanel, DocumentViewer with on-demand signed-URL re-request, AdminMessageBubble with visually distinct internal notes, SupportAlertCard with a severity borderInlineStart accent, ConfigRow, AuditLogRow with an expandable field diff, PartnerSettlementRow with VAT-on-commission PriceBreakdown) cover the domain-heavy screens. Money renders as Toman via shared utils, dates as Shamsi, IBANs/reference codes are dir="ltr"-wrapped, and PII discipline (write-then-masked IBAN, never-echoed credential numbers, non-leaking partner access-denied state) is visible in the UI code itself.
|
||||
|
||||
What drags it down is everything around the pages. The shell is the untouched open-source starter: TopBarAndSideBarLayout renders a default MUI AppBar with a centered nowrap title, a logo IconButton that doubles as the sidebar opener with a hard-coded English 'Open Sidebar' tooltip, physical left/right anchor constants in layout/config.ts, physical paddingLeft/Right compensation, and an 8px page gutter with no content max-width — the classic old-MUI-example look the owner already flagged, and the admin console inherits it wholesale. Worse, sidebar navigation is functionally degraded: SideBarNavItem computes selection via pathname.startsWith(path) against a locale-less ROUTES path while next-intl (localePrefix always, confirmed in client/middleware.ts) prefixes every pathname with /fa or /en — so the active console is never highlighted, and every sidebar click first hits a locale-normalization redirect. Above that, admin workflow affordances are thin: no filter/page state in the URL (back/refresh loses queue position), no sorting or text search on queues (verification filters only by status; tickets has no date/assignee/updated column), the pager indicator says only "صفحه {page}" with no total, users/notifications routes are PlaceholderScreens (notifications is a live sidebar item for every admin), config and holidays fetch page 1 forever with no pager, and people are everywhere referred to as raw numeric IDs — role grants, partner-center admin assignment, and nurse-roster assignment are typed into bare number inputs with no lookup. The partner portal is a small, clean, read-only surface (home/nurses/bookings/settlement on the same primitives) whose one glaring defect is rendering raw English snake_case wire codes as booking statuses to Persian-speaking center staff.
|
||||
|
||||
## Problems (20)
|
||||
|
||||
- **[high]** `client/src/layout/components/SideBarNavItem.tsx` — Active-item highlighting in the admin/partner sidebar never fires: selection compares a locale-less ROUTES path against the locale-prefixed pathname from next/navigation usePathname (localePrefix is always-on per client/middleware.ts), so startsWith is always false. Side effect: sidebar links navigate to locale-less URLs and eat a 307 locale redirect on every click.
|
||||
- evidence: line 28: `const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;` — pathname is '/fa/admin/…', path is '/admin/…'
|
||||
- **[high]** `client/src/layout/TopBarAndSideBarLayout.tsx` — The whole backoffice sits in the untouched starter shell: default-blue-shadow MUI AppBar with a centered nowrap title, logo IconButton doubling as the sidebar opener, physical paddingLeft/paddingRight compensation keyed on anchor.includes('left') (works only by grace of stylis-plugin-rtl), and an 8px content gutter with no max-width — a dense worklist page starts 8px from the viewport edge and reads as an old MUI example, not a branded console.
|
||||
- evidence: lines 53-60 physical padding keyed on anchor strings; line 71 hard-coded `'Open Sidebar'` tooltip; line 102 `paddingLeft: 1, paddingRight: 1` main gutter
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx` — Partner-facing booking statuses render as raw English snake_case wire codes ('pending_payment', 'in_progress') in both the filter menu and the table chip — an untranslated, un-StatusChip'd surface shown to external Persian-speaking center staff, breaking the app-wide localized-status-chip convention.
|
||||
- evidence: line 17 comment 'labels are the codes themselves'; line 45 `<Chip … label={b.status} />`; lines 66-69 `<MenuItem …>{s}</MenuItem>`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/admin/roles/page.tsx` — High-stakes audited actions target users by hand-typed numeric ID with no lookup or name echo-back: role grants here, partner-center admin assignment and sponsored-nurse assignment in partners pages. One mistyped digit grants super_admin to the wrong account or links the wrong nurse — and there is no Users console to even look an ID up (users page is a placeholder).
|
||||
- evidence: GrantRoleDialog lines 176-182 `<TextField label={t('role_col_user')} type="number" …>`; same pattern in partners/[id]/page.tsx lines 226-245 (assign nurse) and partners/page.tsx lines 238-244 (adminUserId)
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx` — Assign-to-self silently falls back to user ID 1 when the current user isn't hydrated — an alert could be assigned to whoever user #1 is instead of the acting admin.
|
||||
- evidence: line 36: `const meId = authState.currentUser?.id ?? 1;`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/config/page.tsx` — Config list and its history drawer are hard-wired to page 1 with no pager — any platform_configs rows beyond the first page are invisible and uneditable from the UI. Same defect on holidays (useHolidays({}, 1)), where the calendar grows every year and will silently truncate.
|
||||
- evidence: line 63 `usePlatformConfigs(1)`; line 182 `useConfigChangeHistory(configKey, 1, …)`; holidays/page.tsx line 38 `useHolidays({}, 1)`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx` — No admin filter/page state is URL-synced anywhere in the backoffice: applied filters and page live in component state, so browser back from a ticket, a refresh, or sharing a link with a colleague loses the queue position — detail pages even hand-roll 'back' buttons that router.push to the bare list. For a worklist tool this is a daily-use tax.
|
||||
- evidence: lines 39-41 `useState<AdminTicketFilters>` + `useState(1)` with no searchParams; tickets/[id]/page.tsx line 90 `router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx` — The admin ticket thread has no status controls at all — no close/reopen and no assignee (services/tickets exposes no close mutation), so a resolved case can never leave the 'open' queue from the UI; and the message list has no scroll-to-latest, so long threads open scrolled to the oldest message.
|
||||
- evidence: lines 118-127 render only chips for status; hooks dir has useAdminTicket/usePostAdminMessage but no close/assign hook; line 150 plain `maxHeight: 520, overflowY: 'auto'` Box with no scroll anchoring
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx` — The internal-note vs public-reply distinction exists only as a small ToggleButtonGroup above the composer; the composer itself looks identical in both modes and the send button label never changes — an admin can easily post an internal note publicly. The bubbles are well-differentiated after the fact, but the safety cue is needed before send.
|
||||
- evidence: lines 163-190: ToggleButtonGroup + plain TextField + single `{t('ticket_send')}` button; only the placeholder string changes with mode
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/verification/page.tsx` — The highest-traffic trust queue has no text search (nurse name/phone), no sorting, no queue counts, and no age/SLA signal — only a 3-value status select. The desk cannot prioritize by 'waiting longest' or find a specific applicant; submittedAt renders but is not sortable.
|
||||
- evidence: lines 101-117: the only filter is a status TextField select; AdminDataTable (components/admin/AdminDataTable.tsx) has no sort affordance at all
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/audit/page.tsx` — Every date input across the backoffice is a native Gregorian type="date" field (audit from/to, payout preview window, holiday date, credential issued/expires) while every displayed date is Shamsi — Iranian ops staff must mentally convert calendars to filter or enter data; there is no Jalali picker anywhere.
|
||||
- evidence: lines 63-78 two `type="date"` fields; payouts/page.tsx lines 235-250; holidays/page.tsx lines 139-146; verification/[nurseId]/page.tsx lines 415-433
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx` — Detail-page headers follow four different patterns: verification case uses AdminPageHeader + back link, ticket thread has no page header (h6 inside a Paper), payout batch hand-rolls a raw h5, partner-center detail hand-rolls h5 + chips and its back button misuses the 'partners' icon as a back glyph. No shared detail-header/breadcrumb primitive exists.
|
||||
- evidence: lines 52-64 raw `Typography variant="h5"`; partners/[id]/page.tsx lines 80-90 `startIcon="partners"` on the back button; tickets/[id]/page.tsx line 110 h6-in-Paper
|
||||
- **[medium]** `client/src/components/admin/AdminPager.tsx` — The pager shows only the current page with no total ('صفحه ۳'), even though every caller computes pageCount; there is no total-results count, page-size control, or jump — weak for an ops tool paging 20-25 rows at a time through large queues.
|
||||
- evidence: client/messages/fa.json line 1194 `"page_indicator": "صفحه {page}"` (the non-admin namespace at line 992 has 'صفحه {page} از {total}' — the admin one lost the total)
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx` — The notifications sidebar item is shown to every admin (show: true in AdminLayout) but leads to a PlaceholderScreen dead-end; the users route is likewise a placeholder — starter-style stub screens shipped inside a production nav.
|
||||
- evidence: whole file is `<PlaceholderScreen …/>`; AdminLayout.tsx line 34 `{ title: t('notifications'), …, show: true }`; users/page.tsx same placeholder
|
||||
- **[medium]** `client/src/components/admin/AuditLogRow.tsx` — Actors and owners render as bare '#42' style numeric IDs across audit rows, alert cards, role grants, and payout previews — investigating 'who did this' requires leaving the tool; there is no name-resolution layer or link-to-user anywhere.
|
||||
- evidence: line 48 `{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}`; SupportAlertCard.tsx line 74 `#${alert.ownerUserId}`; roles/page.tsx line 60 `render: (g) => `#${g.userId}``
|
||||
- **[low]** `client/src/layout/components/SideBar.tsx` — Hard-coded English strings in the fa-default shell chrome: the logout tooltip and the sidebar-open tooltip are untranslated literals.
|
||||
- evidence: line 76 `title="Logout Current User"`; TopBarAndSideBarLayout.tsx line 71 `'Open Sidebar'`
|
||||
- **[low]** `client/src/components/admin/AuditLogRow.tsx` — The expandable diff row's chevron is static — it never rotates and the clickable header has no aria-expanded/button semantics, so open/closed state is invisible and the row isn't keyboard-toggleable.
|
||||
- evidence: line 53 `<AppIcon icon="expand" …/>` with no rotation transform; lines 38-42 onClick on a plain Stack
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/admin/config/page.tsx` — Dead ternary on the config edit field type — both branches are 'text'.
|
||||
- evidence: line 152 `type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}`
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx` — Misleading constant/comment: TODAY_ISO is an empty string yet the comment claims it is 'seeded below via state default' — a new holiday's date field simply starts blank.
|
||||
- evidence: line 106 `const TODAY_ISO = ''; // seeded below via state default so no Date at module load`
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx` — The payout window default is computed with toISOString() (UTC), so near local midnight in Tehran the prefilled start/end dates are off by one day from the admin's wall-clock date.
|
||||
- evidence: line 56 `const isoDate = (d: Date): string => d.toISOString().slice(0, 10);`
|
||||
|
||||
## Opportunities (11)
|
||||
|
||||
- **Purpose-built backoffice shell to replace the starter chrome** (impact: high, effort: large) — One shell change fixes the whole area's first impression and daily ergonomics: slim top bar (breadcrumb trail + global search + bell), a collapsible rail sidebar with working active-state (use next-intl's locale-aware pathname/Link), content area with ~1440px max-width and 24px gutters, cream surface with the deep-teal rail. Fix the locale-less sidebar hrefs and the 'Open Sidebar'/'Logout' literals at the same time. Every one of the 21 pages inherits it for free.
|
||||
- **useAdminListState: URL-synced filters + page as a shared hook** (impact: high, effort: medium) — A small hook that reads/writes filters and page to searchParams (the customer search flow already does this pattern) and is adopted by all nine queue pages. Fixes back-button/refresh/share-a-link wholesale, makes detail-page 'back' a real history back, and costs each page a two-line change since filter state is already isolated.
|
||||
- **UserPicker/NursePicker autocomplete to kill raw-ID inputs** (impact: high, effort: medium) — One shared async Autocomplete (search by name/phone, renders name + masked phone + id, echoes the resolved name in the confirm dialog) dropped into role grant, partner admin assignment, sponsored-nurse assignment, and alert assignment. Turns the scariest wrong-target failure mode in the backoffice into a non-issue and gives the ConfirmDialog copy a human name instead of '#42'. Needs one small lookup endpoint (or the future users console's list API).
|
||||
- **Workbench home: queue counts and aging on the console cards** (impact: high, effort: medium) — The admin overview cards are pure links today. Add per-console live counts (pending verifications, open tickets, unresolved alerts, reviews awaiting moderation, next payout window + eligible total) and an 'oldest waiting' age chip. This converts the landing page from a menu into the morning triage screen — the single highest-leverage screen for an ops team, and the card grid already exists.
|
||||
- **Verification desk redesign (the flagship queue)** (impact: high, effort: large) — This is trust — the product — and worth real design: tab counts per status, name/phone search, sortable age column with SLA coloring, and a split-pane case view (queue rail + case detail) with keyboard next/prev so a reviewer never round-trips to the list between cases. The case page already has the right bones (StepCard, DocumentViewer, credential form); it needs the throughput layout around them, plus side-by-side document/identity comparison for the identity cross-check the docstring promises.
|
||||
- **Ticket console: status/assignee lifecycle + safer internal mode** (impact: high, effort: medium) — Add close/reopen and assign-to-me on the thread header (needs the small backend mutation), unread/last-activity + assignee columns in the queue, scroll-to-latest on thread open, and make the composer visibly amber (warning-soft background + 'ثبت یادداشت داخلی' send label) whenever internal mode is on. Tickets are where refunds, emergencies, and coordination all converge — second-highest-traffic surface after verification.
|
||||
- **Shared Jalali date picker** (impact: medium, effort: medium) — One JalaliDatePicker component (input + calendar in Shamsi, emits ISO Gregorian on the wire) replacing every native type="date" across audit filters, payout windows, holidays, and credential forms. Directly reduces data-entry errors for the finance and trust desks and removes the display/input calendar mismatch.
|
||||
- **Payout run: explicit money-movement summary + typed confirmation** (impact: medium, effort: small) — The preview dialog lists eligible nurses but the final ConfirmDialog is generic copy. Show the batch total (sum to move), count, and processing date in the confirm step and require typing the amount or the word تایید for the run — the standard guard for an action that irreversibly moves money. Also localize the skipped-reason strings (currently raw server text rendered dir="ltr").
|
||||
- **AdminDataTable v2: sorting, sticky header, total count** (impact: medium, effort: small) — Add optional per-column sort (server param already keyed by filter object), a sticky header for long pages, and a footer line 'نمایش ۱–۲۰ از ۱۲۴' wired to the total every caller already has — plus restore '{page} از {total}' in the admin pager message. Small changes to one component + one i18n line lift all eleven tables at once.
|
||||
- **Partner portal polish: localized statuses, booking detail, invoice export** (impact: medium, effort: small) — Map the seven booking codes to the existing StatusChip kinds + fa labels (small, fixes the worst partner-facing defect), link a sponsored-booking row to a scoped read-only detail, and add a CSV/Excel export on settlement for the center's accountant — the actual consumer of that screen. Keeps the portal light-touch while making it feel finished.
|
||||
- **Real Users console replacing the placeholder** (impact: medium, effort: large) — A read-first user directory (search by phone/name, role chips, verification state, links into their nurse/customer profile, tickets, bookings, audit trail) that becomes the hub the numeric IDs all over the backoffice can deep-link to. Pairs with the UserPicker endpoint; also gives the roles grid a place to launch from.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The shared composite layer itself — AdminDataTable/AdminPageHeader/AdminPager/AdminEmptyState/AdminErrorState/ConfirmDialog in client/src/components/admin are used by every console; there is one table, one pager, one empty/error/confirm pattern, all unit-tested. Any redesign should restyle these primitives, not fork per-page markup.
|
||||
- ConfirmDialog's action-safety contract: every irreversible/audited action (approve/reject verification, moderate review, revoke role, run/retry payout, resolve alert, verify center) goes through it, with required-reason gating for reject/hide/resolve, loading that disables both buttons (double-submit-proof), and error color on destructive confirms.
|
||||
- Token discipline and dark-mode-by-construction: zero hard-coded hexes in the entire admin/partner surface; StatusChip and all accents resolve from --bal-* semantic tokens defined for both schemes in src/theme/tokens.css, plus palette-aware 'divider'/'action.hover' everywhere else.
|
||||
- RTL correctness in content code: borderInlineStart severity accents (SupportAlertCard, payout failure block), dir="ltr" wrappers on every IBAN/reference-code/latin-reason string, AdminDataTable's align:'inherit' default, and the config-history Drawer anchoring by locale.
|
||||
- Trust/PII handling expressed in the UI: DocumentViewer fetches short-lived signed URLs on demand with an expired→re-request affordance; settlement IBAN is write-then-masked (blank field + masked placeholder, never echoed); credential numbers are accepted but never displayed; internal ticket notes are visually unmistakable (dashed warning border + badge) and isolated to admin types.
|
||||
- Consistent loading/empty/error triad on every list page — skeleton stacks sized to the content, dashed-border empty states with domain icons, and an inline retry error panel; no page dumps a spinner-only or blank state.
|
||||
- The draft-vs-applied filter pattern on tickets and audit (typing never refetches; Apply commits the query key) — the right behavior for server-keyed caches, worth spreading, not replacing.
|
||||
- Server-authority posture: capability flags (useAdminCapabilities) only hide controls, money is never recomputed client-side (PriceBreakdown renders server decompositions; payout eligibility/holiday shift come from the server), and the roles console honestly banners its mock-backed status.
|
||||
- Shamsi-first display formatting via formatShamsiDate/DateTime and Toman via formatIrrToToman across every admin and partner money/date render.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Auth & first-run experience (login → OTP → role routing → select-role → customer onboarding)
|
||||
|
||||
## Current state
|
||||
|
||||
Login lives at client/src/app/[locale]/(public-routes)/login/page.tsx, which renders LoginFlow (client/src/components/auth/LoginFlow.tsx): a two-step phone-OTP machine (PhoneStep → OtpStep) inside AuthCard — a 420px outlined Paper with a BrandMark lockup (icon + wordmark + tagline "مراقبت مطمئن در خانه"). One login stack serves both actors, parameterized by ?role=nurse; after verify, RoleRouter resolves /me behind a branded AuthSplash and routes to the family app, nurse app, admin console, or /select-role for role-less users. The middleware (client/middleware.ts) redirects every unauthenticated hit to /{locale}/login — the login screen is literally the product's front door; there is no public landing page. The whole thing sits inside PublicLayout → TopBarAndSideBarLayout, the untouched starter dashboard shell: a fixed AppBar titled "Unauthorized - Balinyaar", a logo icon-button that opens an empty Drawer, and BottomBar plumbing with zero items.
|
||||
|
||||
Mechanically the flow is strong: PhoneNumberField normalizes Persian/Arabic digits, caps at 11, forces LTR entry; OtpInput is a 5-box group with auto-advance, backspace-to-previous, paste distribution, digit normalization, and dir="ltr"; the resend countdown is seeded from the server's resendAvailableInSeconds via a leak-free useCountdown; wrong-code, expired, lockout (with resend as the escape hatch) and 429 states are all explicitly handled. Visually, however, it is a default-MUI form: no illustration, no brand surface, no trust content, and the "logo" (AppIcon icon="logo") resolves to the starter's PencilIcon — a hard-coded multicolor Twemoji pencil whose path fills ignore the passed var(--bal-primary).
|
||||
|
||||
select-role (client/src/components/auth/SelectRole.tsx) is a clean radio-card picker (accessible role="radio"/aria-checked) with generic filled MUI icons (nurse = a house icon). Customer onboarding (client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx) is a 2-step wizard (relation → patient form) using a default MUI Stepper (StepperHeader) and RelationSelect radio cards where every option shares the same 'account' icon. It renders inside the full CustomerLayout (5-tab bottom nav, support icon, notification bell), and CustomerHomePage force-redirects any customer with zero patients into it — there is no skip path and no welcome moment. Colors come from a well-built token system (client/src/theme/tokens.css) with a complete dark scheme, and the Mikhak Persian typeface loads only on fa routes.
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/src/components/common/AppIcon/config.ts` — The brand 'logo' used on every auth screen (BrandMark, TopBar logo button) is the starter kit's Twemoji pencil. Its SVG paths carry hard-coded fills (#EA596E red, #FFCC4D yellow, #D99E82, #CCD6DD), so BrandMark's color="var(--bal-primary)" is silently ignored — the first thing a family sees on a healthcare-trust product is a cartoon pencil, identical in light and dark mode.
|
||||
- evidence: config.ts line 115 `logo: PencilIcon`; client/src/components/common/AppIcon/icons/PencilIcon.tsx lines 8-24 hard-coded `fill="#EA596E"` etc.; client/src/components/auth/BrandMark.tsx line 21 passes `color="var(--bal-primary)"` which cannot override path-level fills
|
||||
- **[high]** `client/src/layout/PublicLayout.tsx` — The login screen — the product's only front door (middleware redirects all unauthenticated traffic here) — is wrapped in the starter dashboard shell: a fixed AppBar whose title is the hard-coded English string 'Unauthorized - Balinyaar' shown untranslated on the fa default locale, a pencil-logo IconButton that opens a completely empty sidebar Drawer (SIDE_BAR_ITEMS = []), and dead BottomBar plumbing (BOTTOM_BAR_ITEMS = []).
|
||||
- evidence: line 9 `const TITLE_PUBLIC = 'Unauthorized - Balinyaar'`; lines 14/19 empty nav arrays; TopBarAndSideBarLayout.tsx line 71 hard-coded tooltip `'Open Sidebar'`
|
||||
- **[high]** `client/src/components/OtpInput/OtpInput.tsx` — No autoComplete="one-time-code" on the inputs and no WebOTP integration, so iOS/Android never offer the SMS code as a keyboard suggestion and Chrome cannot auto-read it. Automatic OTP fill is table-stakes in the Iranian market (Snapp/Digikala/Tapsi all auto-fill); its absence makes every login feel worse than the apps users compare against.
|
||||
- evidence: slotProps.htmlInput (lines 121-128) sets inputMode/maxLength/aria-label but no autoComplete; repo-wide grep for 'one-time-code'/'OTPCredential' finds nothing
|
||||
- **[high]** `client/src/components/auth/PhoneStep.tsx` — No terms-of-service / privacy consent anywhere in the login flow. In a phone-OTP market login IS signup — entering a phone number creates an account — yet there is no 'با ورود، شرایط استفاده و حریم خصوصی را میپذیرید' line and no terms/privacy routes exist in the app. Legal exposure and a missing trust cue on a product whose entire pitch is trust.
|
||||
- evidence: PhoneStep renders only title/subtitle/field/CTA/role-switch (lines 56-102); grep for شرایط/حریم/terms/privacy in client/messages/fa.json finds no auth-namespace strings and no terms page exists under (public-routes)
|
||||
- **[medium]** `client/src/components/auth/AuthCard.tsx` — The login card is a bare outlined Paper with zero brand or trust presence: no illustration, no teal/cream hero treatment, no mention of nurse verification, licensed-nurse vetting, or escrowed payment. For a trust-first healthcare marketplace with no landing page, the first impression carries no evidence of trustworthiness at all — it reads as a generic admin-template form.
|
||||
- evidence: lines 13-30: Stack + Paper elevation={0} borderColor:'divider' — the entire visual identity of the screen
|
||||
- **[medium]** `client/src/components/auth/OtpStep.tsx` — The masked phone echo is interpolated into an RTL Persian sentence with no bidi isolation. '0912•••1234' is two European-number runs separated by neutral bullets; the Unicode bidi algorithm can reorder the segments in an RTL paragraph (the classic digits-around-neutrals reversal), rendering the number scrambled for exactly the string that tells users where their code went.
|
||||
- evidence: line 100 `{t('otp_sent_to', { phone: maskIranMobile(phone) })}` — no <bdi>, dir="ltr" span, or LRM wrapping; fa.json line 636 embeds {phone} mid-sentence
|
||||
- **[medium]** `client/src/components/auth/PhoneStep.tsx` — The 429 rate-limit message renders as helperText while error={invalid} is false, so it appears in low-contrast grey secondary text with no error styling on the field — the one state where the user is blocked and most needs to notice the message is the least visible one.
|
||||
- evidence: line 75-76 `error={invalid} helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}` — rateLimited never sets error
|
||||
- **[medium]** `client/src/components/auth/SelectRole.tsx` — The first decision a new user makes is presented with generic filled MUI icons: nurse = a Home (house) icon, customer = AccountCircle. A house for 'I am a nurse' is semantically confusing, and selection feedback is only a border-color change — no background tint, no check indicator, no warmth on what should be a welcoming moment.
|
||||
- evidence: lines 21-24 `{ role: 'customer', icon: 'account' }, { role: 'nurse', icon: 'home' }`; lines 74-83 selected state = borderColor only
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx` — First-run onboarding renders inside the full CustomerLayout app shell — 5-tab bottom nav, support icon, notification bell — so a user who hasn't finished setup can tab away mid-wizard, and there is no focused 'welcome' framing. Combined with the home page force-redirecting zero-patient customers here, families cannot browse or search nurses at all until they register a patient, with no 'skip for now' affordance.
|
||||
- evidence: onboarding sits in the (customer) route group whose layout.tsx wraps children in CustomerLayout; (customer)/page.tsx line 63 `if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`)`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx` — All four relation options (پدر/مادر، همسر، فرزند، خودم) are given the identical 'account' icon, producing four visually indistinguishable cards in the very first product interaction after signup.
|
||||
- evidence: line 31 `RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }))`
|
||||
- **[low]** `client/src/components/auth/OtpStep.tsx` — The resend countdown renders Latin digits ('01:23') inside a Persian sentence; Persian-market apps localize timers to Persian numerals (۰۱:۲۳). formatMmSs uses raw String/padStart with no locale-aware digit formatting.
|
||||
- evidence: lines 24-28 `formatMmSs` + line 140 `{t('resend_in', { time: formatMmSs(countdown.seconds) })}`
|
||||
- **[low]** `client/src/components/common/AppButton/AppButton.tsx` — Starter-kit AppButton defaults every button to margin: theme.spacing(1), forcing every auth/onboarding call site to pass sx={{ m: 0 }} to undo it — spacing becomes inconsistent the moment anyone forgets, and the workaround is repeated on at least four auth CTAs.
|
||||
- evidence: lines 9-11 `DEFAULT_SX_VALUES = { margin: 1 }`; countered by sx={{ m: 0 }} in PhoneStep.tsx:88, OtpStep.tsx:132, SelectRole.tsx:106, onboarding/page.tsx:70
|
||||
- **[low]** `client/middleware.ts` — The auth redirect discards the original destination — no returnUrl/next param is carried to /login — so any deep link (a shared nurse profile, a booking detail from an SMS) dumps the user on their role home after login instead of where they were going.
|
||||
- evidence: line 29 `NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url))` with no query param for the attempted pathname
|
||||
- **[low]** `client/src/components/OtpInput/OtpInput.tsx` — Backspace on an empty box only moves focus to the previous box without clearing it, so deleting a mistyped code takes two key presses per digit — minor friction on the highest-frequency correction gesture.
|
||||
- evidence: lines 89-93: `if (event.key === 'Backspace' && !chars[index]) focusBox(index - 1)` — never clears chars[index-1]
|
||||
- **[low]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Persistent-sidebar offset uses physical paddingLeft/paddingRight keyed on anchor strings 'left'/'right' rather than logical properties — an RTL hazard pattern in the shell that hosts the public/auth routes (dormant on login because the variant is temporary, but live starter debt in the shared chrome).
|
||||
- evidence: lines 53-60 `paddingLeft: … anchor?.includes('left') ? SIDE_BAR_WIDTH : undefined` (and mirrored paddingRight)
|
||||
- **[low]** `client/src/layout/components/SideBar.tsx` — Hard-coded English strings in chrome reachable from the auth shell: the logout tooltip 'Logout Current User' and the empty drawer that opens from the login screen's pencil button containing only a dark-mode switch — untranslated, purposeless starter UI on the fa default locale.
|
||||
- evidence: line 76 `title="Logout Current User"`; PublicLayout passes items=[] so SideBarNavList renders nothing
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **Rebrand the login as a trust-forward hero screen** (impact: high, effort: medium) — Since login is the product's only front door, redesign it as a branded moment: cream (--bal-bg-default) backdrop, a real Balinyaar logotype, a warm nurse-and-family illustration, and 2-3 trust bullets under the card (پرستاران دارای پروانه نظام پرستاری و احراز هویتشده، پرداخت امن نزد بالینیار تا پایان خدمت، پشتیبانی) — the same escrow/verification facts the product already implements. Drop PublicLayout's dashboard chrome entirely for auth routes (a minimal logo-only header is enough).
|
||||
- **WebOTP + one-time-code autofill with auto-submit** (impact: high, effort: small) — Add autoComplete="one-time-code" to the OTP inputs, wire navigator.credentials.get({otp}) (WebOTP) with an AbortController fallback, and format the SMS with the @origin #code convention server-side. Combined with the existing onComplete auto-verify, most users would never type the code — the single biggest perceived-quality jump available for the flow, at Snapp/Digikala parity.
|
||||
- **Real logo asset replacing the Twemoji pencil** (impact: high, effort: small) — Commission/derive a simple Balinyaar mark (currentColor SVG so the existing AppIcon color plumbing and dark scheme work), register it as ICONS.logo, and align favicon.ico + site.webmanifest icons with it. One-file swap that fixes the brand mark on login, splash, error, select-role, and the app shells simultaneously.
|
||||
- **A focused first-run journey with a welcome moment** (impact: high, effort: medium) — Give onboarding its own chrome-free layout (like AuthCard, no bottom nav), open with a one-screen welcome ('خوش آمدید — برای شروع، بگویید مراقبت برای چه کسی است'), use distinct relation iconography (elderly/family icons already exist in ICONS), add a 'بعداً تکمیل میکنم' skip that lands on a browse-capable home with a persistent complete-your-profile nudge, and close with a small success state before Home. Turns a forced form into a warm ramp and removes the browse-blocking wall.
|
||||
- **Terms/privacy consent line + static pages** (impact: high, effort: small) — Add the standard implicit-consent line under the login CTA linking to new /terms and /privacy public routes. Closes the legal gap and doubles as a trust cue; the (public-routes) group already exists to host them.
|
||||
- **Public landing page for unauthenticated visitors** (impact: high, effort: large) — Replace the blanket redirect-to-login with a real marketing front door at /: value proposition, how-it-works (search → book → escrow → confirmed care), trust/verification explainer, service categories, and a prominent nurse-recruitment CTA (?role=nurse). This is also the only path to SEO for a marketplace whose customers arrive via search.
|
||||
- **Persian-digit localization utility** (impact: medium, effort: small) — A tiny formatNumber/formatDigits helper on Intl.NumberFormat(locale) applied to the resend timer (and reusable for prices/dates app-wide), so fa users see ۰۱:۲۳ instead of 01:23 everywhere a timer or count renders.
|
||||
- **Carry a returnUrl through login** (impact: medium, effort: small) — middleware appends ?next=<attempted path>; RoleRouter honors it (validated same-origin, role-permitting) before falling back to resolveRoleDestination. Makes SMS deep links, shared nurse profiles, and session-expiry re-logins land where the user intended.
|
||||
- **OTP delivery fallback affordances** (impact: medium, effort: medium) — After a failed resend cycle or lockout, surface an escalation path — 'کد را دریافت نکردید؟' with a voice-call OTP option or a support link. Iranian SMS delivery is flaky enough (promotional-SMS blocking is widespread) that best-in-class local apps all offer a second channel; today the dead end is silent.
|
||||
- **Elevate select-role into an illustrated fork** (impact: medium, effort: small) — Two larger illustrated cards (family receiving care vs. nurse professional), a selected-state fill using --bal-primary-soft plus a check glyph, and a reassurance line that the other role can be added later (dual-role sessions are already supported by RoleGuard). This screen is each new user's first branded decision — worth more than two grey bordered rows.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- OTP input mechanics are genuinely well-built: auto-advance, paste distribution across boxes, Persian/Arabic→ASCII digit normalization, and dir="ltr" forcing on both the OTP group and the phone field so codes/numbers read correctly inside the RTL layout (client/src/components/OtpInput/OtpInput.tsx, client/src/components/PhoneNumberField/PhoneNumberField.tsx)
|
||||
- Server-driven resend cooldown (resendAvailableInSeconds seeds the countdown) with a leak-free one-shot useCountdown, and the deliberate rule that resend stays available during lockout as the recovery path (OtpStep.tsx line 91)
|
||||
- Explicit, distinct error states throughout: wrong/expired code, max-attempts lockout via OTP_LOCKED_CODE, and 429 rate-limit handling — plus the ' ' helperText placeholder that prevents layout jump when errors appear (PhoneStep.tsx line 76)
|
||||
- Auto-verify on the fifth digit with a disabled CTA until complete, and the masked phone echo (0912•••1234) protecting the number on the OTP screen (fix its bidi wrapping, keep the masking)
|
||||
- The routing hardening is excellent UX engineering: RoleRouter + branded AuthSplash so the wrong actor shell never flashes, AuthAccountError as an explicit recovery instead of silently downgrading a nurse to the customer shell, and RoleGuard's redirect-with-toast on role mismatch
|
||||
- One login stack parameterized by intendedRole — no forked customer/nurse login trees — with the intent carried through to select-role pre-selection
|
||||
- The token system in src/theme/tokens.css: complete, thoughtfully lifted dark scheme, soft tints, and brand-harmonized feedback colors; BrandMark's wordmark correctly uses primary.main so it tracks the scheme
|
||||
- Accessible selection semantics on the radio cards (role="radio", aria-checked, tabIndex, Enter/Space handlers) in SelectRole and RelationSelect, and per-box aria-labels on the OTP inputs
|
||||
- Mikhak Persian typeface loaded only on fa routes (preload:false + conditional className in the locale layout) — correct i18n-aware font strategy
|
||||
- Onboarding's relation choice pre-shapes the patient form and hides the already-answered relation field (no double-asking), and the home-page redirect gate waits for a settled patients list so a fresh create never bounces the user back into onboarding
|
||||
@@ -0,0 +1,72 @@
|
||||
# Customer booking-request + booking lifecycle screens (C4 request form → C5 pending tracker → bookings list → booking detail/EVV → cancel → refund status → review)
|
||||
|
||||
## Current state
|
||||
|
||||
The flow is complete and functionally rich but visually generic. C4 (`client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx`) is a single long page of default MUI selects (patient, service variant, saved address, native date/time inputs, a 3-way gender ToggleButtonGroup, notes with counter), with per-field dashed-border empty states, a form skeleton, and a domain-code→message error mapper. C5 (`bookings/request/[id]/page.tsx`) polls the request and renders the shared `BookingRequestSummaryCard`, a raw-MUI `StepperHeader` 3-step tracker, `CountdownTimer` (server-frozen deadline, LTR-forced Persian digits), five distinct terminal cards, and a cancel confirm Dialog. The bookings list (`bookings/page.tsx`) is a flat stack of bordered Paper rows — counterparty name, Shamsi date, session count, `StatusChip`, Toman total, "view" button — with skeleton/error/empty branches but no tabs, filters, pagination, or row click.
|
||||
|
||||
## Problems (19)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx` — In the cancel-request confirmation dialog, the DISMISS button is labeled with the destructive action's own label: `t('cancel_request')` = "انصراف از درخواست". Clicking the button that says "Cancel request" actually keeps the request and closes the dialog; the real destructive button is `cancel_confirm_yes`. Users who want to cancel will click the dismiss button; users who want to keep it may click confirm. Dismiss must read "بازگشت"/"نگه داشتن درخواست".
|
||||
- evidence: Lines 223–225: `<AppButton variant="text" onClick={() => setConfirmCancel(false)}>{t('cancel_request')}</AppButton>` inside DialogActions, next to the contained error confirm button.
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` — All required-field inline errors are unreachable dead code. The submit button is `disabled={!requiredChosen …}`, but `setAttempted(true)` only runs inside `handleSubmit` — which can never fire while a required field is missing. So `error={attempted && patientId === ''}` etc. never render, and the only feedback for an incomplete form is a silently disabled button with no explanation of what's missing.
|
||||
- evidence: Line 439 `disabled={!requiredChosen || genderMismatch || createRequest.isPending}` vs line 128 `setAttempted(true)` (only in handleSubmit) and line 230 `error={attempted && patientId === ''}`.
|
||||
- **[high]** `client/src/services/bookingRequests/hooks/useCustomerRequests.ts` — The customer has NO way back to a pending request. `useCustomerRequests` (the customer requests inbox hook) is exported but consumed by zero pages; the bottom nav (CustomerLayout: Home/Bookings/Patients/Wallet/Profile) has no requests entry, and `bookings/page.tsx` lists only post-payment bookings (`list_empty_body`: "پس از تایید پرستار و پرداخت…"). If the user leaves C5 or closes the app while a request is pending — or during the 30-minute payment window — the request is orphaned unless they remember the URL.
|
||||
- evidence: Grep: `useCustomerRequests` appears only in services/bookingRequests/index.ts and its own hook file; CustomerLayout.tsx lines 33–37 show the five nav items with no requests route.
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` — The request form never shows WHO the request is for. `useNurseProfile` is fetched (used only for the gender-mismatch check and hidden display-context), but no nurse name, avatar, rating, or verification badge renders anywhere on the page — the header is just "request_title" + a generic subtitle. In a trust-first nursing marketplace, asking a family to hand over patient + home address to an unnamed party is the single biggest trust failure in the flow.
|
||||
- evidence: Lines 206–215 render only `t('request_title')`/`t('form_subtitle')`; `profile.nurseName` is referenced only inside the `context` object at line 144.
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` — Date selection uses the browser-native Gregorian `type="date"` input while every displayed date in the product is Shamsi (`formatShamsiDate`). Persian users must mentally convert Jalali → Gregorian to book a visit, then see the confirmation back in Shamsi. Time inputs are fine; the date picker needs a Jalali calendar.
|
||||
- evidence: Lines 335–347: `<TextField type="date" label={t('date_label')} …/>` with `slotProps={{ inputLabel: { shrink: true } }}`.
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` — The address preview embeds the fake-map stand-in (`AddressMapPicker` with `pointerEvents: 'none'`) — a 220px grid-pattern canvas with a pin and lat/lng labels that the component's own doc admits "is NOT a real map". In a read-only confirmation context it communicates nothing a text line doesn't, looks like placeholder scaffolding, and eats a large chunk of the form's vertical space.
|
||||
- evidence: Lines 315–327 wrap `<AddressMapPicker …/>` in `<Box sx={{ pointerEvents: 'none' }}>`; AddressMapPicker.tsx lines 29–35 document the stand-in nature.
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx` — The bookings list has no status tabs/filters, no upcoming-vs-past grouping, and no pagination even though the service is paginated (BOOKINGS_PAGE_SIZE=20, `total` returned) — booking #21 is unreachable. Rows aren't clickable (only the small outlined button navigates), the error state has no retry, and the empty state has no CTA into search. Scannability is one-note: every status renders identically except the chip.
|
||||
- evidence: Line 19 `useBookingList('customer')` with no page/status params; lines 39–54 error/empty branches with no action; row nav only via AppButton at lines 91–99.
|
||||
- **[medium]** `client/src/components/booking/BookingDetailView/BookingDetailView.tsx` — The booking detail header omits the facts a customer most needs: no visit address/location, no headline date/time (buried per-session), no nurse avatar or contact affordance, no total-at-a-glance. The header is service name + booking ref + two tiny label/value pairs — a customer opening "my booking" cannot answer where/when without scanning session cards.
|
||||
- evidence: Lines 63–95: header Paper contains only `service ?? t('bd_title')`, `bd_ref`, and two `HeaderFact`s (patient, nurse name).
|
||||
- **[medium]** `client/src/components/CountdownTimer/CountdownTimer.tsx` — The countdown — the emotional core of C5 — is bare `HH:MM:SS` digits next to a generic 'pending' icon, ticking every second. There is no progress ring/bar showing how much of the response window remains, no humanized framing ("پاسخ معمولاً تا چند ساعت"), and for multi-hour windows a per-second ticker reads as anxiety-inducing rather than calm. The 'urgent' variant only swaps the color to terracotta.
|
||||
- evidence: Lines 92–109: label caption + `AppIcon icon="pending"` + a 1.5rem tabular-nums span; no other presentation.
|
||||
- **[medium]** `client/messages/en.json` — A directional arrow is hard-coded inside the translated CTA copy: fa "ادامه پرداخت ←" and en "Continue to payment ←" — in English (LTR) the forward arrow should be →, so the EN button points backwards; and the button already carries `endIcon="payment"`, duplicating the affordance. Directionality must come from layout/icons, never from string literals.
|
||||
- evidence: fa.json:445 / en.json:445 `"continue_payment": "Continue to payment ←"`; consumed at bookings/request/[id]/page.tsx lines 182–191.
|
||||
- **[medium]** `client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx` — The date·time-range label is not bidi-isolated: `whenLabel` concatenates Shamsi date, '·', and "start – end" times with no `dir="ltr"` wrapper (unlike SessionCard, which wraps its identical time range in a `dir="ltr"` span). With Persian (AN-class) digits in an RTL paragraph, the range can visually render end-before-start.
|
||||
- evidence: Line 60 builds `whenLabel`; lines 121–125 render it in a plain Typography with `textAlign: 'end'` — compare SessionCard.tsx line 99's `<Typography component="span" dir="ltr" …>{timeLabel}`.
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx` — The review form lacks context and expectation-setting: no recap of the booking being reviewed (service/date), no up-front note that reviews are moderated before publishing (the user only learns post-submit via the 'under review' state), no visible character counter for the 2000-char body (input silently sliced), and no hover/selected labels on the stars (just 5 identical icons).
|
||||
- evidence: Lines 135–185: heading is only nurse name; line 150 `onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))}` with no counter; RatingInput has no per-star labels.
|
||||
- **[medium]** `client/src/components/StatusChip/StatusChip.tsx` — Every status in the entire flow renders as a solid, fully-saturated pill (solid success/error/warning/info backgrounds with cream text), all at equal visual weight — a cancelled booking shouts as loudly as a disputed one, and screens with several chips (list rows, sessions, refund card, review state) read as a loud, cold dashboard rather than the calm clinical-warm tone. Soft-tinted chips (bg-soft + strong fg) would fit the brand and let severity actually rank.
|
||||
- evidence: Lines 15–22: `verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)' … }` — solid token fill for all six kinds.
|
||||
- **[low]** `client/src/components/booking/BookingDetailView/BookingDetailView.tsx` — The i18n fallback key `unnamed_nurse` ("پرستار", intended as the no-name placeholder) is repurposed as the field LABEL for the nurse in the header facts — semantically wrong key reuse that will break the moment the fallback copy changes.
|
||||
- evidence: Line 92: `<HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />`.
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` — Negative-margin layout hacks stitch related elements together instead of composed grouping: the price display is pulled up under the service select with `mt: -1.5`, and the notes counter is pulled under its TextField with `mt: -2` — fragile spacing that breaks when helper text appears.
|
||||
- evidence: Line 272 `<Box sx={{ mt: -1.5 }}>` and line 424 `sx={{ …, textAlign: 'end', mt: -2 }}`.
|
||||
- **[low]** `client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx` — The cancelled terminal row uses the brand-teal info tint (`--bal-primary-soft`) as its background while the sibling StatusNote in BookingDetailView uses `--bal-divider` for the same cancelled state — two different 'neutral' treatments for one status on one screen, and teal reads as informational/brand, not terminated.
|
||||
- evidence: Line 45 `bgcolor: 'var(--bal-primary-soft)'` vs BookingDetailView.tsx line 185 `tone === 'neutral' ? 'var(--bal-divider)'`.
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx` — The cancellation reason select pre-defaults to 'changed_mind', so users can submit without ever choosing — biasing the reason analytics and skipping a moment of reflection the trust-first flow otherwise builds carefully.
|
||||
- evidence: Line 58: `useState<CancelReasonCategory>('changed_mind')` with no empty/placeholder option.
|
||||
- **[low]** `client/src/components/StepperHeader/StepperHeader.tsx` — StepperHeader is a raw default-MUI Stepper (default numbered circles, default connectors, default typography) and it is the status-communication backbone of the whole lifecycle — C5 tracker, booking timeline, refund progress, cancel flow all render this unstyled starter component, which is the single most 'MUI beginner example' element in the flow.
|
||||
- evidence: Lines 20–28: bare `<Stepper activeStep alternativeLabel>` with zero styling beyond `py: 2`.
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx` — `useMyReviewForBooking(bookingId)` fires unconditionally on the review page, while the detail page carefully gates the same hook with `{ enabled: reviewable }` — inconsistent pattern and a wasted request for non-eligible bookings.
|
||||
- evidence: Line 43 `const myReview = useMyReviewForBooking(bookingId);` vs bookings/[id]/page.tsx line 52's gated call.
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **Customer requests inbox: merge requests + bookings under one tabbed surface** (impact: high, effort: medium) — Wire the already-built `useCustomerRequests` hook into a customer-facing surface: segmented tabs on /bookings (در انتظار پاسخ / فعال / گذشته). Pending-request rows carry a live mini-countdown chip and deep-link to C5; accepted-awaiting-payment rows surface the payment deadline as the row's primary CTA. This closes the orphaned-request hole (the highest-stakes UX gap: money-adjacent deadlines the user cannot find again) with zero new backend work.
|
||||
- **Redesign C4 as a trust-anchored request flow** (impact: high, effort: medium) — Put a sticky nurse identity card at the top (avatar, name, rating + review count, verified badge, gender) so the family always sees who they're inviting home; add a 3-step 'what happens next' strip (درخواست → پاسخ پرستار → پرداخت) reinforcing the money-free promise already in form_subtitle. Replace the dead-end disabled submit with always-enabled submit + scroll-to-first-error. Optionally split into two steps: details → review-and-send with a compact BookingRequestSummaryCard preview, which doubles as the fix for the missing pre-submit recap.
|
||||
- **Jalali date picker + time-window presets** (impact: high, effort: large) — Replace native type=date with a Shamsi calendar picker (weekend/holiday aware — the ops holiday table already exists server-side), and replace free start/end time fields with tappable window chips (صبح ۸–۱۲ / بعدازظهر ۱۲–۱۶ / عصر ۱۶–۲۰ + custom). This removes the Gregorian mental conversion, kills the end<=start error class for most users, and later becomes the seam for nurse-availability hints.
|
||||
- **Booking detail 'next visit' hero + designed vertical timeline** (impact: high, effort: medium) — Rebuild the detail header as an actionable hero: next upcoming session ("ویزیت ۲ · فردا ۹:۰۰"), the visit address, nurse avatar with message/support entry, and add-to-calendar. Replace the generic horizontal Stepper with a designed vertical timeline (per-stage icons, timestamps where known, terracotta marker on the current stage, distinct terminal branch rendering) — this is where 'status via raw chips + default stepper' should become the product's signature trust visual.
|
||||
- **Countdown as a calm progress ring** (impact: medium, effort: small) — Wrap the CountdownTimer digits in a circular progress ring fed by (deadline − createdAt) so users see the fraction of the window remaining, drop per-second ticking above 10 minutes remaining (show 'حدود ۳ ساعت'), switch to seconds only in the final minutes, and add a one-line 'we'll notify you' note tied to the notifications bell so users feel safe leaving the page.
|
||||
- **Live 'nurse is on site' presence for in-progress bookings** (impact: medium, effort: small) — The customer already receives EVV banners per session — elevate this into a headline presence state on the detail (and list row): 'پرستار در محل است · ورود ۰۹:۰۲' while checked-in. It converts the EVV plumbing into the platform's most visceral trust cue for the family member who is not at home with the patient.
|
||||
- **Cancellation off-ramps before the kill switch** (impact: medium, effort: medium) — Before the fee disclosure, offer alternatives: 'تغییر زمان' (reschedule request via support ticket until real rescheduling exists) and 'گفتگو با پشتیبانی'. Add a one-line human note about nurse impact. The existing disclosure is excellent; giving an exit that isn't destruction both reduces cancellations and reads as fair.
|
||||
- **Post-completion review nudge on the list** (impact: medium, effort: small) — Completed bookings without a review should show a compact star-strip CTA directly on the bookings-list row (the eligibility + my-review hooks already exist), instead of relying on the user opening the detail and finding the button below the money summary.
|
||||
- **Terminal-state cards with smarter recovery** (impact: medium, effort: medium) — C5's rejected/expired cards all funnel to generic search. Offer 'درخواست دوباره از همین پرستار با زمان دیگر' (prefilled C4) when the rejection reason isn't gender/coverage, and 'پرستاران مشابه' (same service + area) otherwise — recovering the booking intent rather than restarting discovery from zero.
|
||||
- **Soft-tint status system + status-differentiated list rows** (impact: medium, effort: small) — Introduce a soft chip variant (token -soft backgrounds + strong text, reserving solid fills for EVV banners) and give list rows a status-colored inline-start border accent so the eye can rank a page of bookings without reading every chip — aligning the whole flow with the calm/warm brand direction.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline is genuinely excellent in this area: zero hard-coded hexes across all audited files — every color resolves through --bal-* semantic tokens with both light/dark schemes defined (StatusChip, EvvStatusBanner, RatingInput even document the rule in comments).
|
||||
- Server-truth discipline: CountdownTimer never computes deadlines client-side (renders diff vs server-frozen instant, LTR-forced Persian digits); BookingStatusTimeline never advances a step client-side; money is display-only IRR digit-strings through formatIrrToToman — never summed or re-split in the UI (BookingMoneySummary doc + props contract).
|
||||
- Two-stage disclosure as a hard UI gate: the customer's care-instructions query never fires (enabled only for nurse on confirmed+), replaced by a designed lock affordance with honest copy ('visible to your assigned nurse and support only') — BookingDetailView CareSection.
|
||||
- The cancellation flow's trust architecture: full pre-submit disclosure of policy tier, refund %/fee %, concrete Toman split via PriceBreakdown, per-session refundable/locked breakdown, admin-approval explainer, and an explicit acknowledgement checkbox gating confirm (CancellationPolicyDisclosure + cancel page).
|
||||
- Honest refund UX: a failed refund suppresses ALL success-framed progress/amount/ETA (explicit comment in RefundStatusCard), BNPL's ~7–10-business-day window is surfaced plainly in RefundEtaBanner, and retry is deliberately absent (admin-only).
|
||||
- EVV advisory semantics done right: out-of-range is warning-toned (never error), GPS-unavailable is neutral, and a mismatch never blocks the flow — tri-state handled end-to-end (EvvStatusBanner styleFor + SessionCard).
|
||||
- Every screen in the flow has real skeleton loading states shaped like the final layout (FormSkeleton, StatusSkeleton, DetailSkeleton, review skeletons) plus distinct empty/error branches — no spinner-only pages.
|
||||
- RTL/Persian craft in most components: dir="ltr" isolation on countdown digits, session time ranges and refund references; borderInlineStart logical accents; textAlign:'end'; Shamsi dates via fa-IR-u-ca-persian and Persian digits via Intl everywhere; the AddressMapPicker's documented inline-style workaround against stylis RTL flipping.
|
||||
- First-class caregiver-gender preference with culturally-informed hint copy, never silently defaulted, and gender-mismatch blocked inline before the round-trip with the server staying authoritative (request form lines 109–113).
|
||||
- The single terracotta-accent rule holds: --bal-secondary appears only at money/urgency moments (payment countdown, payment CTA, payout rows, nurse-view chip) — exactly the sparing-accent brand intent.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Checkout & money surfaces (customer side)
|
||||
|
||||
## Current state
|
||||
|
||||
The card flow is a four-screen chain: C6 checkout (`bookings/checkout/page.tsx`) renders an acceptance StatusChip, an EngagementSummary mini-card (variant/nurse/patient/Shamsi date), a terracotta CountdownTimer for the payment deadline, the reconciling `PriceBreakdown` (service cost × visit count / commission / VAT / total), the shared `EscrowNotice`, and a terracotta contained pay CTA plus an outlined BNPL branch button. `checkout/return/page.tsx` fires an idempotent gateway-return report then polls `usePaymentOutcome`, rendering a pending StateCard (CircularProgress + PaymentStatusBadge + manual «بررسی دوباره»), a designed failure card with retry/back, and a window-expired card; success hands off to `checkout/confirmation/page.tsx` (64px verified icon, total-paid panel, view-booking + invoice CTAs, optional «پرداختشده با اقساط» line). BNPL is a 4-step wizard (`checkout/bnpl/page.tsx` + MethodStep/PlanStep/EligibilityStep/ScheduleStep) with a MUI StepperHeader, ButtonBase selection cards (`BnplPlanCard`, provider rows with two-letter glyph logo stand-ins), consent-gated eligibility and contract steps, and its own return surface; `checkout/bnpl/gateway/page.tsx` is a dev provider harness still shipped in the route tree. The invoice page (`bookings/[id]/invoice/page.tsx`) reuses PriceBreakdown, shows invoice number (dir=ltr), Shamsi issue date and read-only مودیان status, and prints via a visibility-scoped print area with a dark→light token flip. `/wallet` is a thin shell around `WalletInstallments` — provider-reported BNPL plans only (terracotta outstanding-balance card, InstallmentScheduleRow due list, provider-ownership note).
|
||||
|
||||
All money passes through the BigInt-safe `utils/money.ts` (`formatIrrToToman` → Intl `fa-IR` Persian digits + grouping; Toman display-only, Rial digit-strings on the wire; a dev-mode guard in PriceBreakdown asserts rows sum to the total). Styling is token-disciplined (`var(--bal-*)` everywhere, no hard-coded hexes found in this area, both schemes defined in `tokens.css`), RTL hygiene is genuinely good (logical `textAlign:'start'`, `insetInlineStart`, LTR-forced countdown clock and invoice number). The weaknesses are compositional rather than mechanical: every screen is a flat single column of `elevation={0}` bordered Papers with h6-as-h1 headings, amount+«تومان» is hand-composed ad hoc on six-plus surfaces (no shared Money primitive beyond the catalog-specific PriceDisplay), and the trust moments (total, escrow, confirmation receipt) are visually underweighted for a payment product.
|
||||
|
||||
## Problems (18)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx` — The top-level Wallet bottom-nav tab (labelled «کیفپول» / title «کیفپول و اقساط») contains only BNPL installment plans — no payment history, no refund status, no receipts, no balance. For every customer who paid by card (the default path, BNPL is mock-gated) this is a permanently empty tab showing «طرح اقساط فعالی ندارید», which both under-delivers on the 'wallet' promise and wastes 1 of 5 nav slots on the money product's most trust-sensitive surface.
|
||||
- evidence: WalletPage renders only <WalletInstallments /> (wallet/page.tsx:9); empty state at WalletInstallments.tsx:50-51 is a PlaceholderScreen about installments only
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx` — The post-payment confirmation has no payment reference: no transaction/tracking code (کد پیگیری), no payment date-time, no payment method, no booking number. Iranian users screenshot payment receipts and expect a reference number to quote in disputes; here the receipt panel is just amount + variant + nurse name. Worse, the amount panel is fetched via useCheckoutSummary with no error/loading handling — if that fetch fails the paid amount silently disappears (lines 63-87 render nothing on !summary) and the 'receipt' is just a title and two buttons.
|
||||
- evidence: lines 51-113: only totalIrr, variantLabel, nurseName rendered; `{summary ? (...) : null}` with no fallback
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx` — Weak visual hierarchy for a payment page: the page h1 is a plain `variant="h6"` (line 159), the total — the single most important number — is a small `subtitle2` row inside the breakdown card (PriceBreakdown.tsx:62), and the pay CTA is the last element of a scroll column after countdown + breakdown + escrow notice, not sticky and not paired with the amount. On mobile (the primary experience) the user must scroll past everything to find the button, and nothing on screen answers 'how much am I about to pay' at the moment of tapping pay.
|
||||
- evidence: checkout/page.tsx:155-226 — flat Stack; CTA at lines 203-212 with no sticky container and no amount on/near the button
|
||||
- **[medium]** `client/src/components/PriceBreakdown/PriceBreakdown.tsx` — Currency-unit ambiguity — the classic Iranian Toman/Rial trust hazard: individual breakdown rows render bare grouped numbers with no «تومان» label (line 53), only the total row appends the unit (line 63). InstallmentScheduleRow.tsx:57 omits the currency label entirely on every installment amount, while BnplPlanCard and MethodStep do show it. Amounts are wire-Rials rendered as Toman, so an unlabelled number is exactly the case users second-guess.
|
||||
- evidence: PriceBreakdown.tsx:52-55 rows have no currency label; InstallmentScheduleRow.tsx:57 `{formatIrrToToman(row.amountIrr, locale)}` with no unit
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/return/page.tsx` — Mislabelled CTA in the invalid-link state: the button reads t('pay_with_card') («پرداخت با کارت») but navigates to the bookings list, not a card checkout — the label promises a payment action the click cannot perform.
|
||||
- evidence: lines 100-104: `onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}` with label `{t('pay_with_card')}`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx` — During the pay-initiate busy state (isPending/isSuccess) only the card CTA is disabled — the «پرداخت اقساطی» BNPL button stays fully tappable (no `disabled={busy}`), so a user can launch the BNPL wizard while a card payment initiation/redirect is in flight, racing two payment paths for the same request.
|
||||
- evidence: lines 214-224: BNPL AppButton has onClick router.push but no disabled prop; `busy` (line 148) is applied only to the pay button (line 207)
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx` — Financial-provider selection uses two-letter text glyphs (DG/SP/TA/…) as logo stand-ins in a 40×28 tinted box. Choosing a credit provider from fake wordmark chips reads as unfinished and undermines exactly the trust the BNPL step needs; the code itself marks them as stand-ins awaiting real assets.
|
||||
- evidence: lines 17-24 PROVIDER_GLYPH map + comment 'real logos land with the provider assets'
|
||||
- **[medium]** `client/src/components/BnplPlanCard/BnplPlanCard.tsx` — The down payment is communicated only as a percentage plus a LinearProgress bar (lines 77-98) — a static fact styled as a loading indicator — and the actual down-payment amount in Toman is never shown anywhere in the plan card or D2 step; the user must mentally compute percent × total to know what they'll pay today. The plan's total repayment cost (with fee) is likewise absent from the card.
|
||||
- evidence: lines 79-96: percent label + `<LinearProgress variant="determinate" value={asPercent(...)}>`, no Toman figure
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx` — The «مبلغ کل» header card shows the first plan's total before any selection and silently swaps to the selected plan's total on tap (fee plans differ from interest-free ones) — an amount that changes without explanation, with no label saying which plan it reflects and no fee delta called out.
|
||||
- evidence: lines 54-56: `const shownPlan = plans.find(...) ?? plans[0];` feeding the 'total_amount' header
|
||||
- **[medium]** `client/messages/fa.json` — The brand is spelled two different ways in fa: `common.brand` = «بالین یار» (plain space) while `payment.issuer_platform` = «بالینیار» (ZWNJ, the product-docs spelling). An inconsistently spelled brand name — on money surfaces and a fiscal invoice of all places — is a direct trust leak; the invoice page even carries a code comment acknowledging the mismatch instead of fixing it.
|
||||
- evidence: fa.json common.brand «بالین یار» vs payment.issuer_platform «بالینیار»; invoice/page.tsx:148-149 comment
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx` — The invoice is print-capable but not audit-worthy: no customer name, no service/nurse identification, no service date, no booking reference, no payment method/transaction id, and no seller fiscal identity (tax/economic ID, address) that a real Iranian VAT invoice carries — just number, issue date, three lines and a مودیان chip. As the document families keep for reimbursement/dispute it is too thin.
|
||||
- evidence: lines 138-184: header + MetaRow(invoiceNumber, issuedAt) + 3-row PriceBreakdown + moadian chip is the entire document
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/return/page.tsx` — The payment-pending state stacks three redundant signals of the same fact — title «در حال تایید پرداخت…», a generic CircularProgress, and a PaymentStatusBadge 'pending' chip (lines 139-147) — a spinner-centric default rather than a designed wait state; the failure state similarly doubles an error icon with a 'failed' chip (lines 113-115). Functional, but reads default-MUI at the flow's most anxious moment.
|
||||
- evidence: lines 139-147 pending StateCard; lines 112-135 failure StateCard
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx` — MessageCard is copy-pasted verbatim into bnpl/page.tsx (lines 178-209 there) and StateCard is duplicated between checkout/return and bnpl/return — four private near-identical terminal-state cards across one flow, guaranteeing visual drift in the surface where consistency signals reliability.
|
||||
- evidence: checkout/page.tsx:255-286 vs bnpl/page.tsx:178-209; return/page.tsx:151-180 vs bnpl/return/page.tsx:158-187
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` — A dev-only provider-handoff harness (dashed-border card with 'pay success / pay fail' buttons) ships inside the customer route tree and is reachable by URL in production builds; the equivalent card-gateway harness was already deleted in refinement, this one remains.
|
||||
- evidence: file header comment 'a **test harness, not a product feature**'; USE_BNPL_MOCK=true in services/bnpl/constants.ts:18
|
||||
- **[low]** `client/messages/fa.json` — Directional arrow glyphs are baked into the translated CTA copy (fa `cta_pay` ends with «←», en with «→») instead of an icon slot on the button — brittle typography that any copy edit or font change degrades, and inconsistent with every other CTA in the flow which uses AppButton startIcon.
|
||||
- evidence: payment.cta_pay: «ادامه پرداخت ←» (fa) / 'Continue to payment →' (en)
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx` — The provider credit check — plausibly a multi-second external call — gives no in-progress feedback beyond a disabled button (line 163): no spinner, no label change (C6's pay button at least swaps to «در حال شروع…»), leaving the user staring at a dead form. The prefilled mobile field also uses `disabled` (low-contrast, skipped by screen readers) where read-only presentation would be clearer.
|
||||
- evidence: lines 140-146 disabled PhoneNumberField; line 163 `disabled={!consent || check.isPending}` with static label
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx` — Money-surface column widths are inconsistent: the wallet self-constrains to maxWidth 560 (line 24) while checkout/confirmation/invoice stretch to the shell's full 800px (CONTENT_MAX_WIDTH, components/config.ts:4) with edge-to-edge CTAs — the same flow renders at two different reading widths on desktop.
|
||||
- evidence: WalletInstallments.tsx:24 `maxWidth: 560` vs CustomerLayout.tsx:70 CONTENT_MAX_WIDTH=800
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx` — The main payable state offers no back/cancel affordance — the customer shell's TopBar has no back arrow and only the error/expired branches render navigation, so a user who wants to re-read the request before paying must rely on browser back or abandon via bottom nav under a ticking payment countdown.
|
||||
- evidence: lines 155-226 render no link back to the request; CustomerLayout TopBar startNode is a support icon only
|
||||
|
||||
## Opportunities (8)
|
||||
|
||||
- **Trust-forward checkout redesign: sticky pay bar + identity moment** (impact: high, effort: medium) — Restructure C6 around the two questions users ask at payment: 'who am I paying for' and 'how much'. Give the EngagementSummary the nurse's avatar + verified TrustBadge (the components exist elsewhere in the app), lift the total out of the breakdown into a prominent h4 figure, and pin a sticky bottom pay bar (total + «پرداخت» button) above the customer shell's bottom nav so amount and action are always co-located on mobile. Add the expected Iranian trust marks near the CTA: gateway/Shaparak logos and a lock + «پرداخت امن از طریق درگاه بانکی» line so users know a bank gateway redirect is coming.
|
||||
- **Real receipt on confirmation: reference code, timestamp, share** (impact: high, effort: small) — Turn the confirmation into a screenshot-worthy receipt card: transaction/tracking code (کد پیگیری) in a copyable dir=ltr row, Shamsi payment date-time, payment method, booking number, and the escrow reassurance restated ('مبلغ بهصورت امانی نگهداری میشود'). Add a share/save action and an 'SMS receipt sent' note. This is also where a 'what happens next' 2-step strip (nurse notified → visit day check-in) would extend trust past the payment.
|
||||
- **Make /wallet the customer money hub** (impact: high, effort: large) — The tab already exists and is empty for most users — fill it: payment history (all card + BNPL transactions with PaymentStatusBadge), refund entries reusing the existing RefundStatusCard, a receipts/invoices list deep-linking to the invoice page, and the current installments section beneath. This converts a dead nav slot into the single place families verify 'where my money went' — the core promise of an escrow marketplace.
|
||||
- **Escrow explainer beyond one sentence** (impact: high, effort: small) — EscrowNotice is one mandated sentence. Add an optional expandable 'چطور کار میکند' with a 3-step visual (پرداخت → امانت نزد بالینیار → آزادسازی پس از تایید پایان ویزیت) plus the cancellation/refund implication, linked from checkout and confirmation. Escrow is the platform's reason-to-pay-on-platform; one alert line under-sells it at the exact moment of maximum skepticism.
|
||||
- **Shared <Money> primitive** (impact: medium, effort: small) — Introduce one Money component (amount + «تومان» + size/tone/emphasis variants, optional strike-through for fee comparisons) and replace the six-plus ad-hoc `formatIrrToToman(...) {tc('currency_toman')}` compositions (confirmation, MethodStep, PlanStep, EligibilityStep, WalletInstallments, BnplPlanCard, PriceBreakdown rows, InstallmentScheduleRow). Guarantees the currency label is never dropped and money typography is identical everywhere.
|
||||
- **Honest BNPL plan comparison** (impact: medium, effort: medium) — On each plan card show the concrete Toman figures users actually decide with: down payment amount due today, monthly amount, and total repayment (with the fee delta vs interest-free made explicit, e.g. '+۴۵۰٬۰۰۰ تومان کارمزد'). Replace the LinearProgress down-payment bar with a plain labelled amount row. Consider a compact compare view when a provider offers 3+ plans.
|
||||
- **Designed payment wait/result states** (impact: medium, effort: medium) — Replace the spinner+chip+title pending card with a staged wait state (e.g. a 2-node progress: 'بازگشت از درگاه ✓ → در انتظار تایید بانک' with a calm animated indicator and expected duration), and give success a small warm moment (brand-toned check animation). Extract MessageCard/StateCard into one shared PaymentStateCard so card and BNPL flows can't drift.
|
||||
- **Fiscal-grade invoice + A4 print stylesheet** (impact: medium, effort: medium) — Extend the invoice with buyer name, service description + visit date(s), booking and transaction references, payment method, seller fiscal identity, and the مودیان tax reference once registered; add an @media print A4 layout (margins, footer with issue metadata) so the printed artifact looks like a document rather than a cropped web card.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The money pipeline itself: BigInt-safe utils/money.ts, Rial digit-strings on the wire, Toman display-only, Intl fa-IR Persian digits + grouping — no float money math anywhere in the area, and PriceBreakdown's dev-mode guard that displayed rows must reconcile to the total to the rial (PriceBreakdown.tsx:35-42).
|
||||
- EscrowNotice as a single shared, product-mandated component (with a thoughtful dark-scheme token comment) reused verbatim across surfaces instead of re-written copy.
|
||||
- State coverage discipline: every screen has a skeleton, designed error+retry, and empty states (no providers, no plans, empty wallet); benign 409s converge silently to the outcome read instead of surfacing scary toasts; idempotency-key-per-attempt on both pay and BNPL issue.
|
||||
- CountdownTimer: server-frozen deadline, self-contained 1s tick, LTR-forced clock with tabular-nums so HH:MM:SS survives RTL.
|
||||
- Token discipline and dark-mode care: no hard-coded hexes in the whole area, all colors via scheme-aware --bal-* tokens, and the invoice's data-mui-color-scheme flip so dark-mode users print on paper colors (invoice/page.tsx:101-113).
|
||||
- RTL hygiene: logical properties throughout (textAlign:'start', insetInlineStart in print rules), dir='ltr' on the invoice number and national-id input — no marginLeft/left hazards found in these files.
|
||||
- Terracotta used exactly as intended — the single money accent (pay CTA, BNPL selection borders/tints, outstanding-balance card, breakdown total) against calm teal/neutral chrome.
|
||||
- BNPL honesty architecture: ownership notes at the point of choice and in the contract step ('the agreement is customer ↔ provider'), consent checkboxes gating both credit check and contract, and every decline path offering the card fallback — never a dead end (EligibilityStep DeclinedPanel).
|
||||
- Invoice print mechanics: visibility-scoped print area so only the receipt prints, buttons excluded, position anchored with insetInlineStart.
|
||||
- VAT transparency: the invoice explicitly labels VAT as 'on Balinyaar's commission' with the served rate rendered as a percent — an unusually honest fee disclosure worth preserving through any redesign.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Shared component primitives + icon system (client/src/components/common/, components/config.ts, components/index.tsx, PlaceholderScreen, and the primitives pages are forced to hand-roll)
|
||||
|
||||
## Current state
|
||||
|
||||
The `client/src/components/common/` layer is eight wrappers inherited nearly verbatim from the karpolan react-mui starter: AppButton, AppIconButton, AppIcon (+ its `config.ts` string registry, `icons/PencilIcon.tsx`, `utils.ts`), AppLink (AppLinkNextNavigation.tsx), AppAlert, AppImage, AppLoading, and a class-based ErrorBoundary. Their defaults live as module constants in `components/config.ts` (APP_BUTTON_VARIANT='contained', APP_ALERT_SEVERITY='error', APP_ICON_SIZE=24, CONTENT_MAX_WIDTH=800) rather than in the theme — and `theme/theme.ts` has no `components` overrides at all, so everything MUI renders default-MUI. The wrappers carry starter DNA: AppButton ships a `margin: 1` on-all-sides default plus `label`/`text` duplicate props and spreads a `underline` prop onto non-link buttons; AppIconButton wraps its output in a useMemo keyed on a fresh `restOfProps` object; AppImage is entirely unused in product code; ErrorBoundary's fallback is raw English `<h2>{name} - Something went wrong</h2>` plus a componentStack dump, and it is the app's ONLY error surface — there is no error.tsx/not-found.tsx/loading.tsx anywhere in the App Router tree.
|
||||
|
||||
The icon system is `<AppIcon icon="name">` resolving through a ~85-entry lowercase registry (`common/AppIcon/config.ts`). Feature phases added coherent snake_case domain names (check_in, post_surgery, escrow-adjacent earnings/refunds/moderation icons — all MUI `*Outlined`), but they sit next to the starter's filled set (Home, Dashboard, EventNote, Groups, PeopleAlt, AccountBalanceWallet, MedicalServices, CheckCircle, Cancel, Star, Info, Settings, AdminPanelSettings) plus eight dead starter entries (daynight/night/day/visibilityon/visibilityoff/signup/login/settings — zero usages). Two structural defects: (1) AppIcon passes `size` as SVG width/height *attributes*, which MUI SvgIcon's class CSS (`width:'1em'; height:'1em'`, SvgIcon.js:45) overrides — so every `size={14..48}` request across 40+ callsites silently renders at 24px; only the one custom SVG respects size, and that SVG is (2) the starter's Twemoji *pencil* with hard-coded cartoon fills (#EA596E, #FFCC4D…), registered as `logo` and used as the brand mark in the top bar and auth splash.
|
||||
|
||||
Above `common/`, `components/index.tsx` exports ~35 domain composites (StatusChip, TrustBadge, PriceDisplay, OtpInput, DocumentUpload, NurseResultCard…), several of which are genuinely well built (token-driven `--bal-*` colors, i18n-agnostic contracts, BigInt-safe money). But the reusable *page* primitives were built only for the backoffice — AdminPageHeader, AdminEmptyState, AdminErrorState, ConfirmDialog, AdminDataTable, AdminPager all live in `components/admin/` and are used nowhere else — so 40+ customer/nurse pages hand-roll the same header (47 `component="h1"` occurrences), 24 files hand-roll dashed-border empty states, 12 page files assemble 57 raw MUI `<Dialog>` confirm flows, 81 files place 139 ad-hoc `<Paper>`s, 19 files hand-concatenate `formatIrrToToman(x) + t('currency_toman')`, and 17 files new up their own `Intl.NumberFormat/DateTimeFormat`. Loading language is split between bare AppLoading spinners (~18 screens) and per-page improvised Skeleton stacks.
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/src/components/common/AppIcon/AppIcon.tsx` — The `size` prop is silently broken for every MUI-registry icon: size is passed as SVG width/height attributes (propsToRender, lines 38-46), but MUI SvgIcon's emotion class sets `width:'1em'; height:'1em'` (node_modules/@mui/material/SvgIcon/SvgIcon.js:45) and class CSS beats presentation attributes — so all ~40 callsites requesting 14-56px (PlaceholderScreen size={48}, SelectRole size={28}, VisitNoteCard size={14}, TicketInboxScreen size={36}) render at the default 24px. Icon hierarchy across the whole app is flattened to one size; only the custom PencilIcon actually scales.
|
||||
- evidence: AppIcon.tsx:38-46 `propsToRender = { height: size, ... size, width: size }`; SvgIcon.js:45 `width: '1em', height: '1em'` in the styled root
|
||||
- **[high]** `client/src/components/common/AppIcon/icons/PencilIcon.tsx` — The brand mark of a trust-first nursing marketplace is the starter's Twemoji cartoon pencil with hard-coded fills (#D99E82 #EA596E #FFCC4D #292F33 #CCD6DD #99AAB5) that ignore the color prop. It is registered as `logo` and rendered in the top bar (TopBarAndSideBarLayout.tsx:70) and on the auth splash (auth/BrandMark.tsx:21, which passes color="var(--bal-primary)" to no effect). First-impression trust surface = a writing-tool emoji in off-brand colors.
|
||||
- evidence: PencilIcon.tsx:8-24 hard-coded Twemoji palette; BrandMark.tsx:21 `<AppIcon icon="logo" size={56} color="var(--bal-primary)" />`
|
||||
- **[high]** `client/src/components/common/ErrorBoundary.tsx` — The app's only crash UI is the starter ErrorBoundary: an unstyled, untranslated, LTR English `<h2>{name} - Something went wrong</h2>` plus a `<details>` block that dumps error.toString() and the full React componentStack to end users. No retry affordance, no brand styling, and it's what a Persian-speaking family sees if anything throws. There are also zero error.tsx / not-found.tsx / loading.tsx files in the entire App Router tree (glob over client/src/app returns nothing), so route-level failures and 404s fall through to framework defaults.
|
||||
- evidence: ErrorBoundary.tsx:44-53 renders `<h2>… Something went wrong</h2>` + `{this.state?.errorInfo?.componentStack}`; mounted at layout/TopBarAndSideBarLayout.tsx:104 and layout/CustomerLayout.tsx:78
|
||||
- **[high]** `client/src/components/common/AppButton/AppButton.tsx` — Starter default `margin: 1` on all sides (DEFAULT_SX_VALUES, lines 9-11) fights every real layout: 63+ callsites across 38 files pass `sx={{ m: 0 }}` just to neutralize it, and because the default only applies when NO sx is given, any button that passes other sx silently loses the margin — outer button spacing is therefore inconsistent app-wide. Default `color='inherit'` (line 45) instead of primary also forces `color="primary"` boilerplate on every CTA.
|
||||
- evidence: grep `<AppButton ... sx={{ m: 0` → 63 occurrences / 38 files (e.g. patients/page.tsx:97, admin/ConfirmDialog.tsx:96,105); 238 total AppButton usages
|
||||
- **[medium]** `client/src/components/common/AppIcon/config.ts` — The registry mixes three visual generations: ~13 filled starter icons (Home, Dashboard, EventNote, Groups, PeopleAlt, AccountBalanceWallet, MedicalServices, Settings, Info, AdminPanelSettings, CheckCircle, Cancel, Star) against ~55 `*Outlined` feature icons — nav rails and status chips read as an incoherent default-MUI grab bag (this is the 'ugly icons' the owner senses). It also still registers eight dead starter entries with zero usages: daynight, night, day, visibilityon, visibilityoff, signup, login, settings — violating the repo's own no-dead-code rule.
|
||||
- evidence: config.ts:5-33 filled imports vs :35-98 Outlined imports; usage grep: daynight/night/day/visibilityon/visibilityoff/signup/login/settings each = 0 hits outside the registry
|
||||
- **[medium]** `client/src/components/common/AppIcon/config.ts` — Missing icons for a full app: there is no back/forward/chevron-start navigation arrow anywhere (grep for ArrowBack|ChevronLeft|ChevronRight|KeyboardArrow across client/src = 0 matches), so detail pages (booking, nurse profile, request tracker) cannot render an RTL-flippable back affordance; also absent: share, copy, receipt/invoice, help/FAQ, kebab (more-vert) for card action menus, attach, phone (only `emergency`=LocalPhone), and a plain non-circled check. `expand` (ExpandMore) is the sole directional glyph.
|
||||
- evidence: grep ArrowBack|ChevronLeft|ChevronRight|arrow_back|KeyboardArrow → 'No matches found'; only 1 file in the app uses router.back()
|
||||
- **[medium]** `client/src/components/UserInfo/UserInfo.tsx` — Pure starter leftover shipped into the product shell: `user?: any` prop, hard-coded English fallbacks 'Current User', 'Loading...', 'User Avatar', and name/email fields that don't match the phone-OTP identity model — and SideBar renders `<UserInfo showAvatar />` with NO user prop at all, so every authenticated sidebar permanently shows an empty avatar with English 'Current User / Loading...' in the fa-default app.
|
||||
- evidence: UserInfo.tsx:6 `user?: any`, :34 `'Current User'`, :36 `'Loading...'`; layout/components/SideBar.tsx:56 `<UserInfo showAvatar />` (plus :76 hardcoded English tooltip 'Logout Current User')
|
||||
- **[medium]** `client/src/components/admin/ConfirmDialog.tsx` — The four page-level primitives that exist — ConfirmDialog, AdminPageHeader, AdminEmptyState, AdminErrorState — are exiled under components/admin/ and used only there, so customer/nurse pages hand-roll the identical patterns: patients/page.tsx builds its own confirm Dialog + title header + skeleton + dashed empty state; addresses/page.tsx has 7 raw <Dialog> usages, nurse/coverage 4, nurse/services/MyServicesList 4, bookings/request/[id] 4; 47 hand-rolled `variant="h5" component="h1"` headers across 44 files; 30 `border: '1px dashed'` empty-states across 24 files.
|
||||
- evidence: grep `<Dialog` under app/ → 57 occurrences /12 files; grep `component="h1"` → 47/44 files; patients/page.tsx:87-101 (header), 103-108 (skeleton), 110-120 (dashed empty state)
|
||||
- **[medium]** `client/src/utils/money.ts` — Excellent BigInt money utils exist but there is no `<Money>` display primitive, so 19 files hand-assemble `{formatIrrToToman(x)} {tc('currency_toman')}` and 17 files construct their own `Intl.NumberFormat/DateTimeFormat` — money rendering (grouping, Persian digits, signed negative styling on earnings/refunds) and date rendering are re-decided per page on the most trust-sensitive surfaces (checkout, invoice, earnings, refund status). PriceDisplay only covers catalog unit-rates.
|
||||
- evidence: grep currency_toman → 24 occurrences / 19 files (checkout/page.tsx, invoice/page.tsx, EarningsBalanceHeader…); grep Intl.(NumberFormat|DateTimeFormat) → 26 / 17 files outside utils
|
||||
- **[medium]** `client/src/components/config.ts` — Starter config as app-wide defaults: a bare `<AppAlert>` renders a FILLED ERROR alert (APP_ALERT_SEVERITY='error'), CONTENT_MAX_WIDTH=800 with the nonsensical starter comment 'CONTENT_MIN_WIDTH = 320 // CONTENT_MAX_WIDTH - Sidebar width'. These component defaults belong in `createTheme({ components })` — and theme/theme.ts (lines 20-35) defines NO components overrides at all, which is exactly why the whole app renders as default MUI.
|
||||
- evidence: components/config.ts:10 `APP_ALERT_SEVERITY = 'error'`, :4-5 starter comment; theme/theme.ts:20-35 createTheme with no `components` key
|
||||
- **[low]** `client/src/components/common/AppButton/AppButton.tsx` — Starter prop cruft: duplicate `label`/`text` props ('Alternate to .text'), a `// Missing props` comment block, jsdoc claiming a 'Box around to specify margins' that doesn't exist, and line 85 `{...{ ...restOfProps, underline }}` spreads `underline="none"` onto plain (non-link) MUI Buttons as an invalid DOM attribute.
|
||||
- evidence: AppButton.tsx:16-19 label/text + ':19 // Missing props'; :28 jsdoc 'with Box around'; :85 underline spread
|
||||
- **[low]** `client/src/components/common/AppIcon/AppIcon.tsx` — Unknown icon names `console.warn` in production and silently render MoreHoriz ('…') — a wrong icon name in a trust surface degrades to an ellipsis nobody notices; the invalid `size` attribute is also spread onto the DOM <svg>, and the documented `title` tooltip does nothing (title attribute on inline SVG is not a tooltip; MUI wants `titleAccess`).
|
||||
- evidence: AppIcon.tsx:33-35 warn + `ICONS.default` fallback; :8-13 Props documents `title` as hover hint
|
||||
- **[low]** `client/src/components/common/AppImage/AppImage.tsx` — Dead starter component: zero product usages (only its own test imports it) yet still exported from the common barrel; hard-codes `unoptimized={true}` citing a 'custom loader' that doesn't exist, defaults 256x256, English `alt='Image'` fallback.
|
||||
- evidence: grep <AppImage → matches only AppImage.test.tsx; AppImage.tsx:19 comment 'Uses custom loader + unoptimized'
|
||||
- **[low]** `client/src/components/common/AppLoading/AppLoading.tsx` — The only shared loading primitive is a bare centered CircularProgress ('3rem', starter constant) used as the full-page loading state on ~18 screens, while other screens improvise their own Skeleton stacks (e.g. patients/page.tsx `[0,1].map(<Skeleton variant="rounded" height={96}>)`) — two competing loading languages and no reusable ListSkeleton/CardSkeleton/DetailSkeleton.
|
||||
- evidence: AppLoading used in 18 page files (search, checkout, bnpl, profile…); 40+ files import MUI Skeleton directly with per-page layouts
|
||||
- **[low]** `client/src/components/common/AppIconButton/AppIconButton.tsx` — Cargo-cult useMemo wraps the rendered IconButton keyed on `restOfProps` — a new object every render — so it never memoizes anything; alpha() hover hack for non-MUI colors is starter residue.
|
||||
- evidence: AppIconButton.tsx:62-85 useMemo deps include restOfProps
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/page.tsx` — The nurse's post-login home is still a PlaceholderScreen ('dashboard' icon + generic placeholder body) — for the supply side of the marketplace, the landing screen is literally the empty-state scaffold; admin/users and admin/notifications are also placeholders.
|
||||
- evidence: nurse/page.tsx:7 `return <PlaceholderScreen icon="dashboard" ...>`; grep PlaceholderScreen under app/ → 4 files
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **One-file icon-system swap to a single coherent family** (impact: high, effort: medium) — The string-registry indirection means the entire app's iconography can be replaced by editing only AppIcon/config.ts: pick ONE family (all MUI *Rounded for warmth, or an inlined open set like Phosphor/Solar with softer strokes that suits 'clinical-but-human'), map all 85 names to it, add the missing names (back — RTL-flippable via a wrapper that rotates in rtl, receipt, copy, share, help, kebab, phone, plain check), delete the 8 dead starter entries, and type IconName strictly so unknown names fail at compile time instead of console.warn + '…'.
|
||||
- **Fix AppIcon sizing via fontSize, not attributes** (impact: high, effort: small) — Change AppIcon to drive MUI icons with `style={{ fontSize: size }}` (or sx) instead of width/height attributes. This single fix restores the intended 14-56px hierarchy at every existing callsite simultaneously — the highest leverage-per-line change available in the codebase.
|
||||
- **Real Balinyaar brand mark** (impact: high, effort: medium) — Replace the Twemoji pencil: design a simple symbol (e.g. a home + pulse/leaf motif in deep teal with a cream counter, terracotta only as micro-accent) as a currentColor SVG so BrandMark's `color="var(--bal-primary)"` actually works, and use it in the top bar, auth splash, favicon, and the future email/invoice header. For a trust-first healthcare product the mark is a functional trust cue, not decoration.
|
||||
- **Promote the admin primitives + build the missing shared kit** (impact: high, effort: large) — Move ConfirmDialog, PageHeader, EmptyState, ErrorState out of components/admin into common/ and add the primitives pages provably keep hand-rolling: Section/AppCard (one Paper recipe — 139 ad-hoc Papers today), ListSkeleton/CardSkeleton, Money (signed coloring, Persian digits, Toman label — 19 hand-rolled sites), DateText (Shamsi via existing utils/date.ts), DescriptionList (61 hand-rolled label/value rows), StatCard, FormSection, BackLink. Then sweep pages onto them. This is the difference between 'restyled starter' and 'design system'.
|
||||
- **Theme components layer instead of wrapper constants** (impact: high, effort: medium) — Add a `components` section to createTheme: MuiButton (defaultProps color='primary', disableElevation, no default margin — retire DEFAULT_SX_VALUES and the 63 `m:0` patches), MuiAlert (standard severity semantics, soft brand-tinted backgrounds), MuiPaper (outlined-by-default with --bal tokens), MuiChip radius, MuiTextField shape, focus-visible rings in teal. Wrappers then shrink to genuine additions (icon-by-name, link composition) instead of re-defaulting MUI per instance.
|
||||
- **Branded error / 404 / loading route files** (impact: high, effort: medium) — Add locale-aware error.tsx, global-error.tsx, not-found.tsx and per-shell loading.tsx with calm Persian copy, the brand mark, and a retry CTA; give ErrorBoundary the same visual fallback and stop printing componentStack to users (log it instead). Crash surfaces are trust surfaces in healthcare.
|
||||
- **Nurse home dashboard (currently a placeholder)** (impact: high, effort: large) — Replace the nurse PlaceholderScreen with a real home: today's visits with check-in shortcuts, pending request countdowns, earnings snapshot (reusing EarningsBalanceHeader), verification/credential-expiry nudges, and unread support tickets. The supply side currently lands on an empty scaffold every login.
|
||||
- **Richer trust presentation built on TrustBadge** (impact: high, effort: medium) — TrustBadge is a small chip; trust is the product. Add a VerificationPanel primitive for nurse profile/search detail: what was verified (identity, license, Shahkar), when, by whom, with an expandable 'how Balinyaar verifies' explainer — turning the existing honest badge state into a persuasive, inspectable trust story.
|
||||
- **Warm empty-state illustration set** (impact: medium, effort: medium) — Replace the 24 dashed-border Paper empty states with a shared EmptyState primitive that accepts a small branded SVG illustration (cream/teal line style, terracotta accent) per domain — patients, addresses, bookings, earnings, search-no-results — moving the tone from 'unconfigured dashboard' to 'calm, human product'.
|
||||
- **Fix the sidebar identity block** (impact: medium, effort: small) — Replace starter UserInfo with a typed ProfileSummary fed by the /me query (display name, masked phone with Persian digits, role label, TrustBadge for nurses) — the current always-'Loading...' English block undermines every authenticated screen.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The AppIcon string-registry indirection itself (`<AppIcon icon="verified">` + one config.ts) — it concentrates the entire icon system into a single swap point, and the snake_case domain names (check_in, post_surgery, escrow-era earnings/refunds/moderation) are well-chosen and consistently used.
|
||||
- StatusChip and TrustBadge (components/StatusChip, components/TrustBadge): fully token-driven via --bal-* semantic vars (auto dark-scheme), data-status/data-badge-state test hooks, and TrustBadge's honest-by-construction states (verified only when aggregate approved; expired visually distinct from never-verified, unverified deliberately non-alarming) — the right foundation for trust UI.
|
||||
- utils/money.ts + PriceDisplay discipline: BigInt integer-safe Rial↔Toman, Persian digit formatting, totals only ever price×count at the field boundary — never regress this on any new Money primitive.
|
||||
- components/admin/ConfirmDialog's interaction contract: required-reason gating, loading state that disables both buttons and prevents double-submit, caller-owns-the-mutation separation — promote it, don't rewrite it.
|
||||
- The 'already-translated props' presentational contract (PlaceholderScreen, AdminPageHeader, StepperHeader document it explicitly) keeping primitives i18n-agnostic, and their RTL-safe logical flex layouts with no directional CSS.
|
||||
- booking/format.ts and utils/date.ts: locale-aware clocks and Shamsi dates via Intl (fa-IR-u-ca-persian) with no date library, with honest null returns for open check-ins.
|
||||
- AppLink's Next.js+MUI composition: external links auto-get target=_blank + rel='noopener noreferrer', internal links go through NextLink, active-class support — works and is RTL-neutral.
|
||||
- The consistent `@/components` barrel import pattern — every page already imports primitives from one place, which makes the coming design-system sweep mechanical.
|
||||
@@ -0,0 +1,74 @@
|
||||
# CROSS-CUTTING UX pattern audit — client/src (loading/empty/error states, tokens, RTL, responsiveness, a11y, motion, toasts, metadata, formatting)
|
||||
|
||||
## Current state
|
||||
|
||||
The client has a two-tier quality profile: feature surfaces built during the f0–f15 phases are disciplined, while everything inherited from the MUI starter is untouched. Loading is a dual system — the starter `AppLoading` spinner (CircularProgress wrapper, 47 refs in 23 files) gates whole pages (customer home, checkout, auth splash), while MUI `Skeleton` is broadly adopted for list/detail loads (54 files). Nearly every data screen implements a real four-state branch (skeleton → error-with-retry → empty → data; ~95 `isError` refs across 55 files, ~124 retry/refetch refs), e.g. `CategoryGrid` in `(customer)/page.tsx:169-207`. Errors surface through a unified toast pipeline (`lib/toast/dispatchToast` → ToastBridge → notistack, used in 37 files including the fetch layer, brand-styled via tokens) plus inline `AppAlert`. Empty states are shared only in the backoffice (`components/admin/AdminEmptyState/AdminErrorState`); customer/nurse pages hand-roll the same dashed-border Paper 29 times across 23 files. There are zero Next.js `loading.tsx`/`error.tsx`/`not-found.tsx` files, the React `ErrorBoundary` is raw starter HTML with a stack trace, and the only `metadata` export in the whole app is the root layout's single static title.
|
||||
|
||||
Theming and internationalization are the strongest cross-cutting layers. Colors flow almost exclusively through `theme/tokens.css` custom properties (331 `var(--bal-*)` refs across 103 files; the only hard-coded hexes in .tsx are the starter PencilIcon SVG and tests), with complete light/dark schemes flipped by `data-mui-color-scheme`. RTL discipline is excellent: logical `start`/`end` everywhere, deliberate `dir="ltr"` islands for phone numbers, IBANs, OTP boxes, countdowns and coordinates (30+ sites), a direction-aware Emotion cache, and `PhoneNumberField` normalizing Persian/Arabic digits. Dates render Shamsi via `utils/date.ts` (`fa-IR-u-ca-persian`, 82 uses in 37 files) and numbers via `Intl.NumberFormat('fa-IR')` — though the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary is copy-pasted at 25+ call sites and there is no relative-time formatting anywhere. Responsive behavior relies on single-column flows and the mobile-first customer shell (TopBar + 5-tab BottomBar, content capped at 800px); explicit breakpoints appear only 12 times in 10 files and `useIsMobile` only in layouts. Motion is essentially absent (about 39 `transition` matches, almost all hover border-color; no keyframes, no reduced-motion handling). A11y is moderate: 47 `aria-` attributes in 28 of ~300 tsx files, with excellent pockets (OtpInput, NurseResultCard keyboard activation) and gaps (icon-only buttons named solely via Tooltip `title`). The nurse/admin/partner shells still run the starter `TopBarAndSideBarLayout` + `SideBar` + `UserInfo` chrome, `theme/theme.ts` defines no `components` overrides at all, and `AppButton` ships the starter's default `margin: 1` — visible as 213 `m: 0` workarounds across 82 files.
|
||||
|
||||
## Problems (19)
|
||||
|
||||
- **[high]** `client/src/components/common/ErrorBoundary.tsx` — The app-wide error boundary (wrapping every shell via TopBarAndSideBarLayout.tsx:104 and CustomerLayout.tsx:78) is raw starter UI: unstyled English '<h2>{name} - Something went wrong' plus the raw error.toString() and full componentStack inside a <details> — untranslated, unbranded, leaks internals to end users, and offers no retry/back affordance.
|
||||
- evidence: lines 44-53: `<h2>{this.props.name} - Something went wrong</h2> ... {this.state?.errorInfo?.componentStack}`
|
||||
- **[high]** `client/src/components/UserInfo/UserInfo.tsx` — The sidebar identity block is dead starter code: SideBar.tsx:56 renders `<UserInfo showAvatar />` with no user prop, so every nurse/admin/partner permanently sees the English literals 'Current User' and 'Loading...' in the drawer of the fa-default app.
|
||||
- evidence: lines 34-36: `{fullName || 'Current User'}` / `{userPhoneOrEmail || 'Loading...'}`; prop typed `user?: any` and never supplied
|
||||
- **[high]** `client/src/app/[locale]/layout.tsx` — The single metadata export in the entire app — every one of ~60 routes shares the title 'Balinyaar | بالینیار' and the placeholder description 'Balinyaar web application'; no generateMetadata, no per-page or per-locale titles, so browser tabs, history and share previews are indistinguishable.
|
||||
- evidence: lines 54-58; grep for generateMetadata/<title> across client/src returns only this file
|
||||
- **[high]** `client/src/components/common/AppIcon/config.ts` — The brand 'logo' icon is the starter's PencilIcon (a pencil is the logo in the nurse/admin top bar and auth), and the icon set mixes filled and outlined MUI weights (Star, CheckCircle, VerifiedUser, AccountCircle, Groups filled vs ~50 Outlined imports) — the direct source of the 'ugly icons' problem.
|
||||
- evidence: line 115: `logo: PencilIcon,`; lines 4-32 filled imports vs lines 35-98 Outlined imports
|
||||
- **[high]** `client/src/app/[locale]/(public-routes)/layout.tsx` — No route-level loading.tsx, error.tsx, not-found.tsx or global-error.tsx exists anywhere under client/src/app — unknown URLs render Next's default unbranded English 404, route transitions have no suspense fallback, and server-render failures show the default Next error screen (the public segment contains only /login).
|
||||
- evidence: Glob client/src/app/**/{loading,error,not-found}.tsx → 'No files found'
|
||||
- **[medium]** `client/src/components/common/AppButton/AppButton.tsx` — Starter default `margin: 1` on every button (DEFAULT_SX_VALUES) forces callers to write `sx={{ m: 0 }}` everywhere — 213 occurrences across 82 files — and passing any custom sx silently drops the default, making button spacing inconsistent by construction.
|
||||
- evidence: lines 9-11 and 50: `sx: propSx = DEFAULT_SX_VALUES` where `DEFAULT_SX_VALUES = { margin: 1 }`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/page.tsx` — No shared user-facing EmptyState/ErrorState component: the dashed-border Paper pattern (`p: 3-4, textAlign: 'center', border: '1px dashed', borderColor: 'divider'`) is hand-rolled 29 times across 23 files (this file x2, search/results/page.tsx x2, nurse/visits/page.tsx, bookings/request/page.tsx x2, …) while admin got AdminEmptyState — all text-only, no icon/illustration, inconsistent copy and CTA presence.
|
||||
- evidence: lines 176-195 vs identical blocks in search/results/page.tsx:79,115 — grep `border: '1px dashed'` → 29 hits in 23 files
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/page.tsx` — The nurse dashboard — the landing screen for the entire nurse role — is still a PlaceholderScreen stub ('placeholder_body'), despite requests/visits/earnings/verification data all existing in services.
|
||||
- evidence: line 7: `return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />`
|
||||
- **[medium]** `client/src/theme/theme.ts` — createTheme defines no `components` overrides at all — every MUI control (AppBar, Button, TextField, Chip, Tabs, Dialog) renders stock MUI apart from palette/radius/font, which is precisely why the app reads as a default-MUI starter rather than the calm warm brand.
|
||||
- evidence: lines 19-36: theme = cssVariables + colorSchemes + typography + shape only
|
||||
- **[medium]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Nurse/admin/partner chrome is untouched starter: untranslated English tooltips in the fa-default app ('Open Sidebar' here, 'Logout Current User' in components/SideBar.tsx:76), physical paddingLeft/paddingRight keyed off anchor strings, and TopBar.tsx still carries the starter comment `// boxShadow: 'none', // Uncomment to hide shadow` with a centered nowrap title that can clip long Persian titles.
|
||||
- evidence: line 71: `title={sidebarProps.open ? undefined : 'Open Sidebar'}`; TopBar.tsx:20,34
|
||||
- **[medium]** `client/src/components/notifications/NotificationRow.tsx` — No relative-time formatting exists anywhere in the client (grep for ago/RelativeTimeFormat only hits a mock) — notifications, ticket inbox rows, and audit entries all show full absolute Shamsi timestamps, which is the wrong grain for inbox-style UIs ('۲ ساعت پیش' expected).
|
||||
- evidence: grep `RelativeTime|timeago|ago` → only services/payouts/apis/mockApi.ts
|
||||
- **[medium]** `client/src/components/NurseResultCard/NurseResultCard.tsx` — The `locale === 'fa' ? 'fa-IR' : 'en-US'` Intl ternary is duplicated at 25+ call sites (here lines 17, 39; CountdownTimer.tsx:84; booking/format.ts:11,21,45; earnings pages; checkout) instead of a shared helper next to utils/date.ts — and it has already drifted: admin/partners/[id]/page.tsx:121 passes the raw app locale ('fa') which resolves to Gregorian-locale digits differently than 'fa-IR'.
|
||||
- evidence: grep `locale === 'fa' ? 'fa-IR'` → 25+ occurrences; admin/partners/[id]/page.tsx:121 uses `new Intl.NumberFormat(locale, …)`
|
||||
- **[medium]** `client/src/components/common/AppIconButton/AppIconButton.tsx` — Icon-only buttons are named for AT solely via MUI Tooltip `title` (aria-describedby, not an accessible name) and disabled buttons drop the Tooltip entirely — combined with overall thin aria coverage (47 aria- attributes across 28 of ~300 tsx files), most icon buttons have no accessible name.
|
||||
- evidence: lines 89-95: Tooltip wrap only when `title && !disabled`; no aria-label fallback
|
||||
- **[low]** `client/src/components/config.ts` — Explicit responsive design is nearly absent outside the shells — 12 breakpoint usages in 10 files app-wide and useIsMobile only in layout/ — so desktop renders as a centered 800px phone column (CONTENT_MAX_WIDTH = 800) with no use of wider viewports on any customer or nurse screen.
|
||||
- evidence: line 4: `export const CONTENT_MAX_WIDTH = 800`; grep `xs:|sm: |md: |breakpoints` in *.tsx → 12 hits
|
||||
- **[low]** `client/src/app/globals.css` — Starter reset still sets `max-height: 100vh` on html/body plus blanket `overflow-x: hidden` — max-height on body serves no purpose and is a latent scroll/sticky bug; the shells then re-implement their own scroll containers around it.
|
||||
- evidence: `html, body { max-width: 100vw; overflow-x: hidden; max-height: 100vh; }`
|
||||
- **[low]** `client/src/hooks/layout.ts` — Starter mobile-detection module kept verbatim: three alternative hooks with commented-out variants, a body-classList mutation hook, and SSR always guessing mobile (SERVER_SIDE_MOBILE_FIRST = true), which makes desktop users see a one-frame mobile-first layout shift after hydration in the sidebar shells.
|
||||
- evidence: lines 7-8, 39-48, 58-71, 76-79
|
||||
- **[low]** `client/src/theme/typography.ts` — The EN brand display font (Space Grotesk) is referenced in the font stack but never loaded — the comment admits 'Not currently wired to a font loader' — so English headings silently fall back to system fonts; no type-scale tuning (sizes/line-heights) exists for Persian body text either.
|
||||
- evidence: lines 3-5: `/** Space Grotesk … Not currently wired to a font loader */`
|
||||
- **[low]** `client/src/components/common/AppIcon/AppIcon.tsx` — Unknown icon names log a console.warn in production and silently render the MoreHoriz starter 'default' icon — misspelled icon keys degrade invisibly (icon prop is typed `IconName | string`, so typos compile).
|
||||
- evidence: lines 33-36: `console.warn(`AppIcon: icon "${iconName}" is not found!`)`
|
||||
- **[low]** `client/src/layout/CustomerLayout.tsx` — No motion system anywhere: page/content transitions, list item entrances and skeleton→content swaps are all hard cuts (only ~39 transition matches app-wide, almost all hover border-color), and there is no prefers-reduced-motion handling to pair one with — the app feels static rather than calm.
|
||||
- evidence: grep `keyframes|Fade|Grow|Collapse|animation` → 39 hits in 27 files, none page-level
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **One shared StateView system (empty / error / offline) for user-facing pages** (impact: high, effort: medium) — Promote the 29 hand-rolled dashed-Paper blocks into a single branded StateView component (icon or small warm illustration, title, body, optional CTA/retry), with the admin AdminEmptyState/AdminErrorState folded in as variants. Every list and detail page immediately gains consistent, warmer empty/error moments, and future screens get them for free.
|
||||
- **Route-level chrome: loading.tsx skeleton shells, branded error.tsx and not-found.tsx, ErrorBoundary rewrite** (impact: high, effort: small) — Add per-route-group loading.tsx (skeleton of each shell), a Persian-first branded 404 with a way home, and error.tsx/global-error.tsx sharing one calm 'something went wrong' design with a retry button; rewrite components/common/ErrorBoundary.tsx to the same design (dev-only stack). Small files, disproportionate perceived-quality lift — these are the screens users hit at their most anxious moments in a healthcare product.
|
||||
- **Brand pass via theme.components — kill the default-MUI look in one file** (impact: high, effort: medium) — Add component overrides to theme.ts: cream AppBar with teal text instead of the default filled bar, softer Paper/Card treatment, pill-ish buttons without AppButton's default margin (deleting 213 m:0 workarounds), branded TextField/Chip/Tabs focus and hover states, and terracotta reserved for the single accent. This is the highest-leverage restyle since zero overrides exist today — every screen changes at once.
|
||||
- **Real nurse dashboard replacing the placeholder** (impact: high, effort: medium) — nurse/page.tsx should compose data that already exists in services/: today's visits with check-in CTA (f8), pending requests with countdown (f7), verification progress ring (f5), this-week earnings (f12), and profile-completeness nudges. The nurse role currently lands on a stub — the single biggest missing screen in the app.
|
||||
- **Single-weight icon system + a real logomark** (impact: high, effort: medium) — Replace the mixed filled/outlined MUI set with one consistent weight (e.g. Material Symbols Rounded outlined, or a licensed healthcare set) mapped through the existing AppIcon registry — the abstraction makes the swap mechanical — and replace PencilIcon with an actual Balinyaar logomark used in TopBar, auth, favicon and the future 404/empty states.
|
||||
- **Trust-forward nurse cards and profile hero** (impact: high, effort: medium) — Trust is the product: extend NurseResultCard and the public nurse profile beyond the single ✓ badge with credential chips (پروانه نظام پرستاری), completed-visit counts, years of experience, response-time, and a 'payments held in escrow' reassurance strip reusing EscrowNotice; add a 'how we verify' sheet linked from every badge. All data exists in search/verification services or is one field away.
|
||||
- **Per-page titles + PageHeader unification** (impact: medium, effort: small) — Introduce a title template ('%s | بالینیار') with generateMetadata per route (localized), and generalize the admin-only AdminPageHeader into a shared PageHeader so customer/nurse pages stop hand-rolling h5/h1 blocks — fixing tab/history UX and heading consistency together.
|
||||
- **Locale formatting helpers: formatNumber + relative time** (impact: medium, effort: small) — Add utils/number.ts (wrapping the copy-pasted `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary) and a formatRelativeTime using Intl.RelativeTimeFormat('fa') for notifications, ticket inboxes and audit rows; migrate the 25+ inline call sites. Removes drift risk and makes inbox surfaces read naturally.
|
||||
- **Public landing + public nurse profiles** (impact: high, effort: large) — The anonymous web surface is only /login today. A trust-first marketplace needs a public front door: hero with search, how-it-works (request → escrow payment → verified visit → weekly payout), category grid (reusing CategoryTile), verified-nurse counters, and indexable public nurse profiles — the acquisition and SEO surface the product currently lacks entirely.
|
||||
- **Desktop-aware layouts + gentle motion pass** (impact: medium, effort: medium) — Above ~900px let key customer flows use the space (search results as list+detail or two-column checkout with a sticky order summary instead of the 800px phone column), and add one restrained motion layer (150–200ms content fade/slide, skeleton crossfade) behind a prefers-reduced-motion guard to make the app feel calm rather than static.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline: essentially zero hard-coded colors in feature .tsx (only the starter PencilIcon SVG and tests); 331 var(--bal-*) references across 103 files, with complete, deliberate light and dark schemes in theme/tokens.css — dark mode flips cleanly via data-mui-color-scheme.
|
||||
- RTL correctness as a habit: logical start/end and marginInline throughout, deliberate dir="ltr" islands for phone numbers, IBANs, OTP boxes, countdowns, ticket codes and map coordinates (30+ sites), direction-aware Emotion cache with stylis-plugin-rtl, and PhoneNumberField/PatientForm normalizing Persian/Arabic digit input.
|
||||
- Shamsi-first formatting: utils/date.ts renders fa-IR-u-ca-persian via Intl (82 uses across 37 files), money utils centralize IRR→Toman with fa-IR digits, and tabular-nums + dir=ltr is applied where numbers sit in RTL text.
|
||||
- The four-state data pattern (skeleton → error-with-retry → empty → data) is genuinely implemented across feature pages (e.g. CategoryGrid in (customer)/page.tsx:169-207) — ~95 isError branches and ~124 retry/refetch affordances; state coverage needs restyling, not rebuilding.
|
||||
- Unified toast pipeline: dispatchToast → ToastBridge → notistack, callable from non-React fetch code, brand-styled via tokens in NotistackProvider, used consistently across 37 files.
|
||||
- AdminDataTable: RTL-safe align='inherit', horizontal scroll inside its own container so wide worklists never break the page, typed column renderers, aria-label support.
|
||||
- TrustBadge's honest three-state design (verified/unverified/expired, 'never a hard-coded hex', unverified deliberately non-alarming) and the verified-only search invariant surfaced on every result card.
|
||||
- Customer shell fundamentals: mobile-first TopBar + 5-tab BottomBar with locale-aware longest-prefix active-tab matching, reading-width content column, and support/notification affordances in the header.
|
||||
- OtpInput: exemplary a11y/RTL work — dir=ltr group with role=group, per-box aria-labels, paste splitting, and automatic focus advance; NurseResultCard is keyboard-activatable with a visible focus ring.
|
||||
- Mikhak font strategy (fa-only attachment, preload:false so /en never downloads it) and the root-layout locale/dir/color-scheme wiring with its documented reasoning.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Customer account & care-circle management (profile, patients, patient care record, addresses + map picker)
|
||||
|
||||
## Current state
|
||||
|
||||
The area lives under `client/src/app/[locale]/(private-routes)/(customer)/` inside `CustomerLayout` (slim TopBar + 5-tab BottomBar, content column capped at CONTENT_MAX_WIDTH). `profile/page.tsx` is a single flat form: first/last name, a preferred-language select, an emergency-contact section (name + `PhoneNumberField`), one save button, and an outlined Paper card linking to the address book. `patients/page.tsx` is a header + `PatientCard` list with add/edit via `PatientForm` reused in a `Dialog maxWidth="sm"`, soft-archive with a confirm dialog, a 2-row Skeleton loader, and a dashed-border empty state with icon + CTA. `patients/[id]/record/page.tsx` is the care-record viewer: shared `PatientHeader`, a family-ownership banner on `--bal-primary-soft`, four scrollable Tabs (داروها/روتین/سوابق/وظایف); the three editable tabs use a whole-list "edit mode" (every row becomes small TextFields, save-all), history is read-only `VisitNoteCard`s with text prev/next pagination; access is gated by `useRecordAccess` with a non-leaking access-denied card. `addresses/page.tsx` mirrors the patients page: `AddressCard` list (primary badge via `StatusChip status="verified"`), add/edit dialog hosting `AddressForm` = title + `CascadingRegionSelect` (province→city→district with loading adornments and an explicit "whole city" option) + `AddressMapPicker` + multiline address line + set-primary switch.
|
||||
|
||||
Styling is disciplined and token-driven: everything is `elevation={0}` Paper with `border: 1px solid divider, borderRadius: 2`, colors come from `--bal-*` CSS variables (both schemes), text uses `text.secondary`, and RTL is handled with logical properties (`textAlign:'start'`, `marginInlineStart:'auto'`) — `AddressMapPicker` even pins itself `dir="ltr"` with inline styles and a comment explaining the stylis-RTL transform hazard. The weak points are structural rather than cosmetic: the "map" is a coordinate-grid stand-in (no tiles/search/geocoding, raw lat/lng shown), query errors collapse into empty/blank states, long forms are crammed into non-fullscreen modals on a mobile-first shell, there is no avatar/photo concept anywhere in the customer identity system, and the Profile tab lacks basic account affordances (no sign-out anywhere in the customer shell, no phone display, no locale switch).
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx` — Query errors collapse into the empty state: a failed usePatients() leaves data undefined so the page shows 'هنوز بیماری ثبت نشده' with an add CTA — telling a family their care recipients don't exist and inviting duplicate re-entry. No isError branch or retry exists. Identical bug in addresses/page.tsx line 91.
|
||||
- evidence: line 83: `const isEmpty = !isLoading && patients.length === 0;` — isError is never read from the query
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx` — Profile load error is silently swallowed: only isLoading is handled, so on a failed useCustomerProfile() the form renders blank (initial=null) and a save would overwrite server truth with empty fields. No isError state, no retry.
|
||||
- evidence: lines 16-18: `const { data: profile, isLoading } = useCustomerProfile(); ... if (isLoading) return <AppLoading />;` then `initial={profile ?? null}`
|
||||
- **[high]** `client/src/layout/CustomerLayout.tsx` — No sign-out affordance exists anywhere in the customer experience: CustomerLayout has only support/bell/dark-toggle chrome and the 5-tab BottomBar; logout lives only in SideBar.tsx, which the customer shell never renders, and the Profile tab (the natural home for it) has no account section, no phone-number display, and no sign-out. A logged-in customer literally cannot log out.
|
||||
- evidence: CustomerLayout renders TopBar(startNode=support, endNode=bell+DarkModeToggleButton)+BottomBar only; grep for logout hits layout/components/SideBar.tsx but no (customer) file
|
||||
- **[high]** `client/src/components/geography/AddressMapPicker.tsx` — The 'map pin picker' is a blank coordinate grid, not a map: no tiles, no address search, no geocode, no locate-me — a family user is asked to place a pin on a featureless 220px grid whose output feeds the later EVV proximity check, so a meaningless pin is near-guaranteed. It also surfaces raw latitude/longitude captions ('عرض: 35.71234') to consumers — developer-grade UI in the most trust-sensitive form of the account area.
|
||||
- evidence: lines 30-34 doc: 'It is NOT a real map (no Neshan/Google tiles), only a bounded canvas'; lines 143-152 render `{latLabel}: {value.latitude.toFixed(5)}`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx` — The long address form (title + 3 cascading selects + 220px map + multiline line + switch + actions) is hosted in a Dialog maxWidth='sm' that is not fullScreen on mobile — on the phone-first customer shell this yields a cramped double-scroll (DialogContent + keyboard) modal; additionally backdrop-click/onClose silently discards a half-completed form with no dirty-state guard. Same pattern for PatientForm in patients/page.tsx line 160.
|
||||
- evidence: line 170: `<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">` — no fullScreen={isMobile}, no discard confirmation
|
||||
- **[medium]** `client/src/services/profiles/types.ts` — No avatar/photo exists anywhere in the customer identity system: avatarUrl is nurse-only (NurseProfile line 29-31), CustomerProfile has none, and PatientHeader/PatientCard render text-only with no Avatar/initials slot. For a marketplace where a nurse walks into a stranger's home, a photo (or at least a generated-initials avatar) of the care recipient and the account holder is an expected identification and warmth affordance — its absence makes the patients list read as a data table of names.
|
||||
- evidence: CustomerProfile (lines 50-54) has firstName/lastName/preferredLanguage only; PatientHeader.tsx imports no Avatar
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx` — Care-record editing is a whole-list 'edit mode' per tab: tap edit → every medication becomes a stack of 4 free-text TextFields → save-all. There is no per-item add/edit sheet, dose/frequency/time-of-day are unstructured free text (routine_time line 321 is a plain TextField), and switching tabs while editing unmounts the tab component and silently destroys the draft (tab state lives in the parent, draft in the child) with no warning. For medication data this is an error-prone editing surface.
|
||||
- evidence: lines 118-135 Tabs drive `<EditableTabs tab={tab}/>` which conditionally mounts MedicationsTab/RoutineTab/TasksTab; each holds `const [draft, setDraft] = useState(...)` lost on unmount
|
||||
- **[medium]** `client/src/components/PatientCard/PatientCard.tsx` — The tappable identity area that opens the care record is an invisible affordance: an unstyled `component="button"` (background:none, border:none) with no chevron, no 'view record' label, no hover/pressed state — nothing signals that the card body is clickable, so the record viewer (the richest screen in the area) is undiscoverable; the only visible actions are edit/archive icons.
|
||||
- evidence: lines 66-85: `<Box component="button" ... sx={{ background: 'none', border: 'none', ... }}>{header}</Box>` — no visual affordance styles
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx` — Visit-note history (سوابق) presents longitudinal clinical info as a flat, undifferentiated card list with bare text 'قبلی/بعدی' pagination — no grouping by date/booking, no link to the booking the note came from, no indication of which nurse/service, no filtering. For the one place a family reviews their loved one's care over time, the presentation carries no narrative or hierarchy.
|
||||
- evidence: lines 431-448: `items.map((note) => <VisitNoteCard .../>)` followed by prev/next text buttons + 'page_of' caption
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx` — The Profile tab — one of only five bottom-nav destinations — is a flat settings form, not an account hub: no identity header, no avatar, no account phone, no links to notifications/support/bookings history/locale, and the 'profile completeness' cue is a single color-coded text line (color-only state signal). It reads as a leftover form page where users will expect the app's account center.
|
||||
- evidence: lines 75-77: completeness is `<Typography sx={{ color: isComplete ? 'var(--bal-success)' : 'text.secondary' }}>` — the page's only status affordance
|
||||
- **[low]** `client/messages/fa.json` — Persian copy bug in the address-line hint: 'پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن در نیاز دارد.' — 'برای یافتن در' is grammatically broken (word dropped, likely 'یافتن درِ منزل' or just 'یافتن نشانی'). This text sits under the most-filled field of the address form.
|
||||
- evidence: address.line_hint value in messages/fa.json
|
||||
- **[low]** `client/src/components/GenderToggle/GenderToggle.tsx` — ToggleButtonGroup is left at its default inline-flex sizing so the `flex: 1` on child buttons has no room to distribute — the required gender toggle renders content-width and visually misaligned against the fullWidth TextFields above it in PatientForm; the intended equal-half layout never materializes.
|
||||
- evidence: lines 43-49: sx sets `'& .MuiToggleButton-root': { flex: 1 }` but the group has no fullWidth/width:'100%'
|
||||
- **[low]** `client/src/components/RelationSelect/RelationSelect.tsx` — Radio-card selection is communicated by border color alone — no check icon, no fill change, no focus-visible or hover styling on the Paper cards — a color-only state signal that is weak in dark mode and fails WCAG 1.4.1 use-of-color for the selected state.
|
||||
- evidence: lines 53-55: `borderColor: selected ? 'primary.main' : 'divider'` is the entire selected treatment
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx` — Loading-state inconsistency within the same tab cluster: profile uses the full-page AppLoading spinner while patients/addresses/record all use content-shaped Skeletons — the account area flickers between two different loading languages.
|
||||
- evidence: line 18: `if (isLoading) return <AppLoading />;` vs patients/page.tsx lines 103-108 Skeleton rows
|
||||
- **[low]** `client/src/components/geography/AddressMapPicker.tsx` — Hard-coded rgba shadow on the pin (`drop-shadow(0 1px 2px rgba(0,0,0,0.35))`) instead of a token — the only non-token color in the audited area; slightly heavy against the light --bal-primary-soft canvas in dark mode.
|
||||
- evidence: line 116: `filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))'`
|
||||
- **[low]** `client/src/components/PatientForm/PatientForm.tsx` — The form collects only a whole-year age that is stored as a fabricated Jan-1 birthDate (age.ts), and a single full-name field naively split into first/last (splitName lines 32-38, lastName falls back to firstName). Acceptable MVP shortcuts, but the fabricated birth date will surface later (records, invoices) as a false precision the family never entered.
|
||||
- evidence: age.ts line 10: `return `${year}-01-01`;`
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **Real map with search + locate-me for addresses** (impact: high, effort: large) — Replace the grid stand-in behind the existing AddressMapPicker component boundary with real Neshan tiles (a Neshan adapter already exists server-side from refinement phase 8): address search box, GPS locate-me button, reverse-geocode preview of the pin ('پین روی: خیابان ولیعصر، ...'), and drop the raw lat/lng captions. This is the single highest-trust upgrade in the area since the pin feeds nurse arrival and EVV.
|
||||
- **Turn the Profile tab into an account hub** (impact: high, effort: medium) — Redesign profile/page.tsx as a settings hub: identity header (avatar/initials + name + masked phone from /me), then grouped tappable rows — personal info, emergency contact, addresses, preferred language, dark mode, support/tickets, about/terms — ending with a sign-out row. This fixes the missing-logout hole, gives the 5th nav tab the weight users expect, and creates a natural home for future items (payment methods, saved nurses).
|
||||
- **Care-circle reframe with patient avatars** (impact: high, effort: medium) — Rename/reframe 'بیماران' toward a care-circle ('عزیزان شما' / حلقه مراقبت), add an Avatar slot to PatientHeader (photo upload or warm auto-colored initials per person), and let the card lead with the person, not the data. In a home-care product the emotional register of this list matters; today it reads like an admin table of patients.
|
||||
- **Structured per-item care-record editing** (impact: high, effort: large) — Replace whole-list edit mode with per-item bottom sheets: an 'add medication' sheet with structured dose unit, frequency presets (روزی ۱ بار…), and time-of-day chips (صبح/ظهر/شب); routine items with a time-of-day chip row instead of free text; a draft-loss guard when leaving edit mode or switching tabs. Then a read-mode 'daily schedule' view (meds grouped by morning/noon/night) becomes possible — genuinely useful to both family and arriving nurse.
|
||||
- **Full-screen mobile form flows** (impact: medium, effort: small) — Make the patient and address dialogs fullScreen below the sm breakpoint (MUI useMediaQuery pattern) with an app-bar-style header (title + close + save), and add a dirty-state confirm before discarding. Small change, removes the most-felt mobile papercut in the area.
|
||||
- **Visit-note timeline with booking context** (impact: medium, effort: medium) — Group سوابق notes by month with a subtle timeline rail, show the service name and a link back to the booking each note came from, and add a done/undone task summary per note. Turns the flat card list into the 'story of care' — a differentiating trust surface no competitor form-clone will have.
|
||||
- **Proper error states with retry** (impact: medium, effort: small) — Introduce a shared QueryErrorCard (icon + 'مشکلی پیش آمد' + retry button) and use it on patients/addresses/profile/record instead of collapsing to empty/blank states; distinguish offline from server error. Pairs with converting profile's AppLoading to a form-shaped skeleton for loading consistency.
|
||||
- **Emergency-contact card with call affordance** (impact: medium, effort: small) — Present the emergency contact as its own status card (complete = green check + tel: link; incomplete = warm nudge explaining why nurses need it) rather than two bare fields, and surface incompleteness as a checklist item during booking. Reinforces the safety story that is the product's core promise.
|
||||
- **Address card map thumbnail + pin quality cue** (impact: low, effort: medium) — Once real tiles exist, show a small static-map thumbnail on each AddressCard and a 'pin set / pin missing' status so families can see at a glance which addresses a nurse can actually navigate to.
|
||||
- **Record affordance on PatientCard** (impact: medium, effort: small) — Add a visible 'مشاهده پرونده' chevron row (or make the whole card a hover/press-styled CardActionArea with the action icons overlaid) so the record viewer is discoverable; include last-visit date on the card as a teaser ('آخرین ویزیت: ۱۲ تیر').
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline is genuinely good across the whole area: every color is a --bal-* CSS variable or theme key (StatusChip.tsx even documents 'Never hard-code a hex here'), so dark mode works by construction — do not regress this.
|
||||
- AddressMapPicker's RTL engineering: dir="ltr" on the canvas plus inline-style marker positioning with an explicit comment on why the stylis RTL plugin would break `left`/`translate` — exemplary hazard handling; keep it when swapping in real tiles.
|
||||
- The non-leaking access-denied card and family-ownership banner on the care record (record/page.tsx lines 59-74, 108-116) — privacy-correct (no partial clinical data on 403) and a real trust cue; preserve verbatim in any redesign.
|
||||
- Consistent empty states on patients and addresses: dashed-border Paper, brand icon, warm two-line copy, CTA — the empty_body strings ('اولین آدرس را اضافه کنید تا پرستار بداند کجا بیاید') are the best copy in the app.
|
||||
- PatientHeader shared between the E1 list card and the E2 record viewer — one identity block, rendered identically in both places; keep this single source when adding avatars.
|
||||
- CascadingRegionSelect UX details: parent-gated enabling, per-level CircularProgress adornments, the explicit 'whole city' MenuItem (empty district as a real choice, never an error), and the out-of-range value guard for edit prefill.
|
||||
- Soft-archive semantics and copy for patients ('از فهرست شما حذف میشود اما سوابق رزروهای گذشته حفظ میماند') with an optimistic mutation plus an explanatory error toast when the card reappears — respectful and honest.
|
||||
- GenderToggle's never-defaulted, non-deselectable required gender — load-bearing for same-gender caregiver matching; keep the constraint regardless of restyling.
|
||||
- Skeleton loaders shaped like the content they replace on patients, addresses, and the record page (RecordSkeleton mirrors header/banner/tabs/card).
|
||||
- Inline single-field validation with error-clearing on change (name/phone/city/pin) and dedicated Persian error strings per field — the validation pattern itself is sound, only the surfaces around it need work.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Customer storefront — home, search, results, nurse public profile
|
||||
|
||||
## Current state
|
||||
|
||||
The storefront is four client-rendered screens, all behind auth: CustomerHomePage (client/src/app/[locale]/(private-routes)/(customer)/page.tsx — greeting+avatar, free-text search bar, data-driven CategoryTile grid, two NudgeCard prompts), SearchPage/C1 (search/page.tsx — a vertical filter form: category grid, CascadingRegionSelect province/city/district, 3-way gender ToggleButtonGroup, native date input, debounced Toman price range, and a live-count CTA driven by useSearchFilters + useNurseSearch), SearchResultsPage/C2 (search/results/page.tsx — URL-is-the-filter-state list of NurseResultCard with load-more, one-option sort select, skeleton/empty/error states), and NurseProfilePage/C3 (search/nurse/[nurseId]/page.tsx — avatar header, rating, TrustBadge + INO chip, attribute Chips, MUI Tabs for services (ServicePriceRow list) and reviews (infinite published-review list with RatingInput stars), and a bottom "درخواست رزرو" AppButton). Chrome comes from CustomerLayout (client/src/layout/CustomerLayout.tsx): a fixed default-MUI AppBar (TopBar with a centered static title "اپلیکیشن خانواده", support icon, notification bell, dark-mode toggle) plus a 5-tab MUI BottomNavigation BottomBar, content constrained to 800px.
|
||||
|
||||
Styling is disciplined but minimal: everything in this area resolves through the --bal-* CSS variables in client/src/theme/tokens.css (deep teal light + lifted-teal dark schemes, both defined), Mikhak is loaded for fa via next/font, MUI v9 CSS-vars theme with an RTL Emotion cache, and I found zero hard-coded hexes in the storefront pages or their components. Visually, however, it is a bare utility app, not a storefront: cards are 1px-border Papers with no elevation/warmth, the terracotta accent is used nowhere in the storefront (only checkout/BNPL screens use --bal-secondary), all icons are stock @mui/icons-material, the "logo" is the starter kit's multicolor cartoon-pencil SVG, and there is no hero, no value props, no how-it-works, and no public/guest-accessible page other than /login — the entire marketing face of the marketplace requires an account and a completed patient record to even see.
|
||||
|
||||
## Problems (21)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(public-routes)` — There is no public storefront at all. The only unauthenticated route is /login; home, search, results, and nurse profiles are all wrapped in RoleGuard(customer) via (customer)/layout.tsx, and the home page additionally redirects to onboarding until a patient record exists. A family evaluating the service cannot see a single nurse, price, or trust signal before registering — fatal for acquisition in a trust-first marketplace.
|
||||
- evidence: (public-routes)/ contains only layout.tsx and login/page.tsx; (customer)/layout.tsx wraps children in <RoleGuard expected={APP_ROLES.CUSTOMER}>
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/page.tsx` — The home free-text search bar is a dead affordance: HomeSearchBar pushes `?q=<query>` to /search, but SearchFilterScreen reads only `category_id` and silently discards `q`. The placeholder promises «جستجوی خدمت یا پرستار…» and the input does nothing.
|
||||
- evidence: page.tsx L130 pushes `?q=`; search/page.tsx L49-50 reads only params.get('category_id')
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/page.tsx` — If the patients query errors, the customer home hangs on a bare spinner forever — the gate `if (data == null || isEmpty) return <AppLoading />` has no isError/retry branch, so a transient API failure bricks the app's front door.
|
||||
- evidence: L66-68: `if (data == null || isEmpty) { return <AppLoading />; }` — usePatients() error never handled
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx` — The visit-date filter is a native `<input type="date">`, which renders a Gregorian calendar in browser chrome. The default locale is fa and every date the app displays is Shamsi (formatShamsiDate); Iranian families plan by the Jalali calendar, so this field is unusable-in-practice localization breakage on the main discovery flow.
|
||||
- evidence: L104-110: `<TextField type="date" … slotProps={{ inputLabel: { shrink: true } }} />`
|
||||
- **[high]** `client/src/components/NurseResultCard/NurseResultCard.tsx` — The result unit is the variant, but the card never names the service/variant — a nurse offering three variants appears as three near-identical cards (same avatar, name, rating) differing only in price, with no explanation. NurseSearchResult also carries no variant display name, so the card cannot label it even if it wanted to.
|
||||
- evidence: Card renders only name/badge/rating/distance/price; services/search/types.ts NurseSearchResult has no variant displayName field
|
||||
- **[high]** `client/src/components/common/AppIcon/icons/PencilIcon.tsx` — The brand 'logo' icon is the starter kit's emoji-style multicolor pencil SVG with hard-coded fills (#EA596E, #FFCC4D, #D99E82…) that ignore the color prop; it is rendered as the BrandMark on the auth splash and the sidebar logo. A cartoon pencil as the mark of a healthcare-trust brand actively undermines the product.
|
||||
- evidence: PencilIcon paths carry fill="#EA596E" etc.; AppIcon/config.ts maps `logo: PencilIcon`; components/auth/BrandMark.tsx renders <AppIcon icon="logo" size={56}/>
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx` — The populated results state has no filter recap and no way to edit filters — the header is only a count plus a fake sort control; `backToFilters` is rendered exclusively inside the EmptyState, so users must use browser-back to change city/gender/price.
|
||||
- evidence: L62-70 header renders count + sort only; backToFilters referenced only in EmptyState (L88, L131)
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx` — The sort dropdown is a non-functional control: a TextField select with a single MenuItem, hard-coded value="rating", and no onChange — it looks interactive but does nothing, which erodes perceived quality.
|
||||
- evidence: L67-69: `<TextField select … value="rating">` with one <MenuItem> and no onChange
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx` — The primary CTA «درخواست رزرو» sits at the very bottom of the page flow — after the full infinite reviews list when that tab is open — and is not sticky; and when no variant_id was carried it silently falls back to services[0] while the ServicePriceRow list offers no way to pick a specific service to book.
|
||||
- evidence: L62-64: `const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '')`; CTA is the last child of the page Stack (L92-101); ServicePriceRow has no onClick/select affordance
|
||||
- **[medium]** `client/src/services/search/types.ts` — Core trust data is fetched but never rendered: totalCompletedBookings and nurseGender exist on both NurseSearchResult and NurseProfile, yet neither the result card nor the profile shows completed-visit count or confirms the nurse's gender — exactly the cues a family choosing an in-home caregiver checks first.
|
||||
- evidence: types.ts L67 `totalCompletedBookings`, L73/L117 `nurseGender` — no usage in NurseResultCard.tsx or nurse/[nurseId]/page.tsx
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/(customer)/page.tsx` — The «تکمیل پروندهٔ بیمار» NudgeCard renders unconditionally forever — unlike the profile nudge (gated on hasCustomerProfile), there is no completeness check, so a customer with fully-filled patient records sees a permanent stale prompt occupying prime home real estate.
|
||||
- evidence: L96-102 renders NudgeCard with no condition; L103 gates only the profile nudge
|
||||
- **[medium]** `client/src/components/RatingInput/RatingInput.tsx` — Rating stars are colored with --bal-warning, a token documented in tokens.css as a toast/alert *background* meant to sit under cream text. As a foreground fill it renders dark mustard (#8a6418) in light mode and muddy olive (#97701f, ~3.7:1) on the dark #0f1c19 background — ratings, a primary trust signal, look dim rather than gold in both schemes.
|
||||
- evidence: RatingInput.tsx L53 and NurseResultCard.tsx L87 use `var(--bal-warning)`; tokens.css L50 comment: 'Feedback — toast / alert backgrounds'
|
||||
- **[medium]** `client/src/layout/components/TopBar.tsx` — The shell is stock starter chrome: a default-elevation fixed MUI AppBar with a nowrap centered Typography title, and CustomerLayout titles it with the system label «اپلیکیشن خانواده» instead of the brand «بالین یار» — the brand name never appears anywhere in the logged-in storefront.
|
||||
- evidence: TopBar.tsx L25-38 (centered whiteSpace:'nowrap' title, commented-out boxShadow toggle); CustomerLayout.tsx L46 `title={tShell('customer_app')}`
|
||||
- **[medium]** `client/src/components/common/AppButton/AppButton.tsx` — Starter-grade AppButton defaults to `margin: 1` on all sides, so every storefront call site must fight it with `sx={{ m: 0 }}` (10+ occurrences across home/search/results/profile alone); passing any sx also silently drops the default, making spacing inconsistent by construction.
|
||||
- evidence: AppButton.tsx L9-11 DEFAULT_SX_VALUES = { margin: 1 }; e.g. results/page.tsx L83/L100, nurse/[nurseId]/page.tsx L56/L98 all set `sx={{ m: 0 }}`
|
||||
- **[medium]** `client/src/components/common/AppIcon/config.ts` — The entire icon vocabulary is stock @mui/icons-material (ElderlyOutlined, ChildCareOutlined, VolunteerActivismOutlined…), giving category tiles and chrome the generic Material-dashboard look the brand explicitly wants to avoid; category icons are the emotional face of the home grid and read cold/clinical.
|
||||
- evidence: config.ts L4-98: every icon imported from @mui/icons-material; KNOWN_CATEGORY_ICONS in CategoryTile.tsx resolve to these
|
||||
- **[medium]** `client/src/layout/components/BottomBar.tsx` — The bottom navigation has no iOS safe-area handling — no env(safe-area-inset-bottom) padding — so on notch/home-indicator phones (the primary device class for this mobile-first app) the 5 tab targets sit under the system gesture bar.
|
||||
- evidence: L50-62: Paper + BottomNavigation with only `borderTop`, no safe-area padding
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx` — The live-count CTA «مشاهده N پرستار» — the best affordance on the screen — is the last element of a long scrolling form instead of a sticky bottom bar, so the count is invisible while adjusting the upper filters where it would guide relaxation/tightening in real time.
|
||||
- evidence: L130-140: AppButton rendered as final Stack child, no position:sticky
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx` — The gender facet re-implements an inline ToggleButtonGroup instead of extending the shared GenderToggle component, leaving two divergent gender-control implementations (different padding/fontWeight, no error affordance) to keep visually in sync.
|
||||
- evidence: search/page.tsx L86-100 inline group vs components/GenderToggle/GenderToggle.tsx
|
||||
- **[low]** `client/messages/fa.json` — The empty-results suggestion «شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را امتحان کنید» hard-codes city names and suggests an impossible relaxation — the patient lives where they live; a family cannot 'try Shiraz'. Reads as filler copy and dents credibility.
|
||||
- evidence: search.empty_suggest_city key
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx` — The verified badge is a static chip everywhere it appears — nothing lets a customer discover *what* was verified (identity, license, INO, background check), even though the verification pipeline is the product's core differentiator; on results it is also repeated identically on every card (all rows are verified by invariant), so it stops carrying information.
|
||||
- evidence: TrustBadge rendered with no onClick/link (page.tsx L141, NurseResultCard.tsx L82); TrustBadge.tsx has no interactive affordance
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx` — First paint of both search screens under Suspense is a bare centered CircularProgress (AppLoading) rather than the page's own skeleton layout, causing a spinner→skeleton→content double transition.
|
||||
- evidence: results/page.tsx L22 and search/page.tsx L35 `<Suspense fallback={<AppLoading />}>` while in-page skeletons exist at L72-77
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **Public landing + guest browse (the real storefront)** (impact: high, effort: large) — Create a public marketing home under (public-routes): hero with the existing tagline «مراقبت مطمئن در خانه», the category grid as entry points, a 3-step how-it-works (search verified nurses → escrow-protected payment → pay-out only after confirmed check-out), a verification-pipeline trust strip, and sample verified-nurse cards. Let guests run search and view nurse profiles read-only, deferring the login gate to the «درخواست رزرو» tap (intent is highest there). Nearly all pieces already exist as components — this is mostly routing + a hero section, and it is the single biggest acquisition lever the product has.
|
||||
- **Nurse profile as a trust dossier** (impact: high, effort: medium) — Redesign C3 around the question 'would I let this person into my mother's home?': a header card with photo, gender chip, years of experience, completed-visits count (data already fetched), and rating; an expandable 'verification checklist' section listing each passed check (identity ✓, nursing license ✓, INO membership ✓) fed by the badge state instead of one flat chip; a rating-distribution bar + review tag summary above the review list; per-service rows that are tappable to book that exact variant; and a sticky bottom CTA bar showing price-from + «درخواست رزرو». This screen is where the marketplace either earns or loses the booking.
|
||||
- **Jalali date selection** (impact: high, effort: small) — Replace the native Gregorian type="date" with a Shamsi-native control: a horizontal chip strip («امروز», «فردا», day+Shamsi-date chips for the next 7 days) plus an optional full Jalali picker. Small build (the Shamsi format util already exists) and removes the most jarring localization break in the funnel.
|
||||
- **Results page upgrade: recap chips, variant labels, honest sorting** (impact: high, effort: medium) — Add a tappable filter-recap chip row (category · city/district · gender · price) that deep-links back to C1 with state preserved; show the service/variant name on each card (requires adding displayName to the search row — already filed as a backend gap pattern); collapse multiple variants of one nurse into a single card with a price range and 'N خدمت' disclosure; and either implement price/distance sort or remove the single-option dropdown. Also surface totalCompletedBookings («۱۲۴ ویزیت موفق») on cards — a stronger differentiator than the uniform verified chip.
|
||||
- **Warmth pass: brand mark, custom icons, terracotta accents** (impact: high, effort: medium) — Replace the pencil logo with a real Balinyaar mark and put the brand (not «اپلیکیشن خانواده») in the top bar; commission/adopt a single warm stroke-icon set for the 5-6 category icons and bottom-nav tabs; introduce the terracotta accent sparingly in the storefront — e.g. the home greeting card background tint, the star rating fill (a proper warm gold/terracotta token instead of --bal-warning), and the profile CTA. Today the storefront is monochrome teal borders on white and reads like an internal tool.
|
||||
- **Sticky conversion bars** (impact: medium, effort: small) — Make the C1 live-count CTA and the C3 booking CTA sticky bottom bars (above the BottomBar, with safe-area padding). The live count becomes a real-time feedback instrument while filtering; the profile CTA stays reachable regardless of review-list depth. One shared SlotBar component covers both.
|
||||
- **Home that sells and remembers** (impact: medium, effort: medium) — Wire the free-text search to actually filter (variant/category name match) or remove the field; add a compact trust strip (پرداخت امن اسکرو · پرستاران تاییدشده · پشتیبانی ۲۴ ساعته); add a 'rebook' shortcut card sourced from the bookings cache («رزرو دوباره با خانم …» — repeat care is the dominant pattern in home nursing); and make nudges completeness-aware and dismissible.
|
||||
- **Tappable verification explainer** (impact: medium, effort: small) — Make every TrustBadge open a bottom sheet that narrates the verification pipeline ('این پرستار این مراحل را گذرانده است: …' with check dates). Turns a static chip into the product's trust story at exactly the moment of doubt, at trivial cost.
|
||||
- **Save/favorite and share nurses** (impact: medium, effort: medium) — Add a heart action on result cards and the profile plus a share-profile link. Families deliberate and compare with relatives before booking a caregiver; today there is no way to shortlist or send a profile to a sibling, forcing screenshot workflows.
|
||||
- **Nurse photo emphasis and richer media** (impact: medium, effort: large) — Today avatars are initial-letter fallbacks in teal discs. Prioritize real photos in the verification flow and give them prominence (larger profile photo, photo-forward result cards); longer-term, a short recorded self-introduction. In a market with low institutional trust, seeing the person is the strongest trust cue available.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline: zero hard-coded hexes in storefront pages/components — every color resolves through the --bal-* variables in client/src/theme/tokens.css, with complete light and dark scheme definitions, so a restyle can happen at the token layer.
|
||||
- All four data states are consistently implemented everywhere: skeleton grids sized to match real tiles (home/C1), error states with working retry, product-aware empty states, and a dedicated ProfileSkeleton on C3 — no blank screens.
|
||||
- RTL correctness: no physical marginLeft/left/textAlign:'left' anywhere in the audited area; logical properties used where needed (marginInlineStart:'auto' in ReviewCard), paired LTR/RTL themes with stylis-plugin-rtl, and Mikhak loaded for fa with full-body coverage.
|
||||
- Search architecture: the filter object is the URL is the React Query cache key (deep-linkable, back/forward-safe results), the C1 CTA shows a live result count, price inputs are debounced, and 'empty district = whole city' is an explicit, honestly-labeled choice in CascadingRegionSelect.
|
||||
- Trust honesty by construction: the C1 subtitle states only verified nurses appear, TrustBadge has three visually distinct states (verified/unverified/expired) driven by server state and semantic tokens, and the UI never re-derives or fakes verification.
|
||||
- Money handling: PriceDisplay renders exclusively through the IRR→Toman money util with fa digit grouping, unit labels from i18n keys, and totals only ever computed as price × sessionCount (BigInt-safe) — never a parsed float.
|
||||
- Accessibility groundwork: CategoryTile is a real ButtonBase with aria-pressed, NurseResultCard has Enter/Space key handling and a focus-visible outline, RatingInput exposes radiogroup/radio semantics, and the home search form uses role='search'.
|
||||
- The gender facet is treated with the care the domain demands: prominent placement, humane hint copy explaining same-gender preference, never defaulted, and carried explicitly into the booking request.
|
||||
- Persian copy quality: the fa strings are natural, specific, and warm (e.g. the patient-record nudge «…تا پرستار آماده حاضر شود») — not machine-translated placeholder text.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Shared feature components (client/src/components/* excluding common/, admin/, auth/, booking/, geography/, messaging/, notifications/)
|
||||
|
||||
## Current state
|
||||
|
||||
This layer is ~34 single-purpose presentational components, one folder each (component + index barrel + test), re-exported from client/src/components/index.tsx. Authorship is remarkably uniform: typed FunctionComponent, a JSDoc header explaining the domain rule the component encodes, caller-owned or namespace-scoped i18n (never hard-coded strings, except one component), data-* attributes for tests, and display-only money/dates through shared utils (formatIrrToToman/parseIrr BigInt, formatShamsiDate). The visual language that exists is: flat Paper (elevation={0}, 1px 'divider' border, borderRadius 2), a borderInlineStart accent stripe keyed to a semantic token for stateful panels (BankStatusPanel, EarningsBalanceHeader, PayoutHistoryRow failure, DocumentUpload error/rejected), and MUI Chip-based badges (StatusChip, TrustBadge, PaymentStatusBadge) whose colors come exclusively from the --bal-* CSS custom properties in src/theme/tokens.css (both light and dark schemes defined). RTL is handled with logical properties (borderInlineStart, marginInlineStart:'auto', textAlign 'start'/'end') plus deliberate dir="ltr" islands for IBANs, phone numbers, OTP boxes, transfer references, and the countdown clock.
|
||||
|
||||
Contrary to the "no design pass" expectation, this layer is NOT starter-grade — it was clearly written during the f0–f15 feature phases with real domain intent (honest refund/BNPL copy, escrow trust notice, negative-balance "owed back" framing, no-delete variant rows). The one true starter fossil is UserInfo (any-typed props, English 'Current User'/'Loading...' fallbacks, rendered in the sidebar for every logged-in user). The real weaknesses are systemic rather than per-component: (1) the card/row/badge anatomy is repeated by convention, not shared — the same Paper recipe is hand-rolled ~12 times with padding drifting across p:1.5/2/2.5/3 and accent stripes at 3px vs 4px, and the label/value row is re-implemented three times (SummaryRow in BookingRequestSummaryCard, MetaLine in PayoutHistoryRow, inline rows in PriceBreakdown); (2) the trust-critical surfaces are the flattest — TrustBadge is pixel-identical in anatomy and color to a generic StatusChip 'verified', rating stars are colored with the dark-ochre alert token over a near-invisible 14%-alpha empty state, and NurseResultCard carries only name/rating/distance/price; (3) terracotta (--bal-secondary #d98c6a) has quietly become the default "money text" color across six components and fails WCAG contrast on white; (4) selection states speak five different visual dialects (filled chip vs border-only vs border+tint vs terracotta vs default ToggleButton); and (5) everything composes stock MUI Material icons and raw MUI Stepper, so despite the token discipline the rendered result still reads default-MUI. Theme.ts has no component-level overrides, so any anatomy not written in sx falls back to MUI defaults.
|
||||
|
||||
## Problems (13)
|
||||
|
||||
- **[high]** `client/src/components/UserInfo/UserInfo.tsx` — Untouched starter scaffolding shipped in the authenticated sidebar: `user?: any` prop, hard-coded English fallbacks 'Current User' and 'Loading...' in a fa-default product, email-based fallback display in a phone-OTP product, and a 3rem glyph inside a 64px avatar. Violates the repo's own 'no starter scaffolding / no dead template code' rule and is the first thing every logged-in user sees (rendered by src/layout/components/SideBar.tsx:56).
|
||||
- evidence: lines 6 (`user?: any`), 34 (`{fullName || 'Current User'}`), 36 (`{userPhoneOrEmail || 'Loading...'}`)
|
||||
- **[high]** `client/src/components/PriceBreakdown/PriceBreakdown.tsx` — Terracotta used as small-text color fails WCAG contrast in light mode on the most trust-critical numbers. `--bal-secondary` #d98c6a on white ≈ 2.7:1 (AA needs 4.5:1 at these sizes) is the grand-total color here and also in RefundStatusCard.tsx:89 (refunded amount), BnplPlanCard.tsx:68 (monthly amount), InstallmentScheduleRow.tsx:55 (down payment), CancellationPolicyDisclosure.tsx:55 (fee line); `--bal-secondary-dark` #bf6f4d caption in BnplPlanCard.tsx:62 ≈ 3.8:1 also fails. This simultaneously breaks the 'terracotta as a SINGLE sparing accent' brand rule — it is now the default money color across 6+ components.
|
||||
- evidence: line 62: `sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}` on the total amount
|
||||
- **[high]** `client/src/components/RatingInput/RatingInput.tsx` — The trust-critical star rating renders badly three ways: filled stars use `var(--bal-warning)` — a dark-ochre alert-background token (#8a6418 light / #97701f dark) — so 'gold' stars read muddy brown; empty stars use `var(--bal-divider)` (a 14%-alpha rgba) and are near-invisible on white paper and dark surfaces alike; and fill is integer-only (`n <= value`), so fractional averages can't render — the nurse profile works around it with Math.round, displaying a 4.5 average as a perfect 5-star row (search/nurse/[nurseId]/page.tsx:260), overstating ratings on the platform's core trust surface. NurseResultCard.tsx:87 and BookingRequestSummaryCard.tsx:86 use the same ochre star.
|
||||
- evidence: line 53: `const color = n <= value ? 'var(--bal-warning)' : 'var(--bal-divider)';`
|
||||
- **[medium]** `client/src/components/TrustBadge/TrustBadge.tsx` — The platform's core trust mark has no distinct visual identity: it is anatomically identical to StatusChip (same small filled MUI Chip, same 16px icon, same `--bal-success` background that StatusChip uses for generic 'active'/'verified' states — compare StatusChip.tsx:16-17). A verified-identity healthcare credential and an 'active service variant' chip are indistinguishable at a glance, and there is no affordance to see WHAT was verified (identity, license, background check).
|
||||
- evidence: lines 18 + 39-46: verified = `{ bg: 'var(--bal-success)', ... }` rendered as a plain `<Chip size="small">`
|
||||
- **[medium]** `client/src/components/NurseResultCard/NurseResultCard.tsx` — The search decision point is information-thin for a trust-first marketplace: no service/variant name (each result row IS a bookable variant), no nurse gender indicator (same-gender matching is a load-bearing product rule carried in the query), no experience/completed-bookings count, no review snippet — just avatar, name, one badge, rating, optional distance, and price. Families are choosing an in-home caregiver off four data points.
|
||||
- evidence: lines 77-112 render only name + TrustBadge + rating/count + distance + price_from
|
||||
- **[medium]** `client/src/components/RelationSelect/RelationSelect.tsx` — Selection states are inconsistent across the five choice controls in this layer, and this one is the weakest: selected relation cards get only a 2px `primary.main` border — no background tint, no check glyph, no hover/pressed feedback — while ConditionChips/ReviewTagSelector use a filled primary chip, CategoryTile uses border+`--bal-primary-soft` tint, BnplPlanCard uses 2px terracotta border+tint, and GenderToggle falls back to the default gray MUI ToggleButton selected state. The role="radio" group also lacks roving tabindex/arrow-key navigation (every card is tabIndex={0}).
|
||||
- evidence: lines 53-55: `border: '2px solid', borderColor: selected ? 'primary.main' : 'divider'` is the entire selected treatment
|
||||
- **[medium]** `client/src/components/StepperHeader/StepperHeader.tsx` — A bare default-MUI Stepper wrap (numbered circles, default connector — theme.ts defines no component overrides), doing double duty as wizard progress (onboarding/verification) AND as a refund status timeline inside RefundStatusCard.tsx:76-79. A status tracker rendered as a form-wizard control, with no timestamps and the stock MUI look the owner is trying to escape.
|
||||
- evidence: lines 20-28: raw `<Stepper activeStep alternativeLabel>` with zero styling
|
||||
- **[medium]** `client/src/components/OtpInput/OtpInput.tsx` — Missing `autoComplete="one-time-code"` on the digit inputs, so iOS/Android SMS code autofill never triggers — on the product's ONLY login path, in a market where OTP login is the norm. (PhoneNumberField.tsx similarly omits `autoComplete="tel"`.) The multi-box pattern itself also fights autofill; a single hidden input with visual boxes would receive the OS-suggested code.
|
||||
- evidence: lines 121-128: `htmlInput: { inputMode: 'numeric', maxLength: 1, ... }` — no autoComplete
|
||||
- **[medium]** `client/src/components/EarningsRow/EarningsRow.tsx` — Shared card anatomy exists only by convention: the `Paper elevation={0} / 1px divider / borderRadius 2` recipe is hand-rolled here and in ~11 sibling components with drifting padding (p:1.5 InstallmentScheduleRow, p:2 PatientCard/VariantCard/VisitNoteCard, p:2.5 here/PriceBreakdown/PayoutHistoryRow, p:3 EarningsBalanceHeader) and accent stripes at 3px (EarningsBalanceHeader.tsx:97, PayoutHistoryRow.tsx:91) vs 4px (BankStatusPanel.tsx:67, EarningsBalanceHeader.tsx:55, DocumentUpload.tsx:164); the label/value row is re-implemented three times (SummaryRow, MetaLine, PriceBreakdown rows). No shared Card/Row primitive means every future restyle is a 12-file change and drift is inevitable.
|
||||
- evidence: line 57: `sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}` — the 12th copy of this literal
|
||||
- **[low]** `client/src/components/BnplPlanCard/BnplPlanCard.tsx` — Selecting a plan changes borderWidth 1→2, shifting the card contents by 1px (RelationSelect avoids this with a constant 2px border); and the card shows only monthly amount + fee% + down-payment% with no total-cost-of-credit line, so plans with different terms are not honestly comparable.
|
||||
- evidence: lines 49-50: `borderColor: selected ? ... , borderWidth: selected ? 2 : 1`
|
||||
- **[low]** `client/src/components/CountdownTimer/CountdownTimer.tsx` — Uses the 'pending' hourglass glyph as a clock (a 'schedule' clock icon exists in the registry), hard-codes fontSize '1.5rem' outside the type scale, and 'urgent' mode only swaps teal→terracotta — no progress ring/bar or intensifying treatment for the payment-deadline window it was built for.
|
||||
- evidence: lines 66 + 100-104: `urgent ? 'var(--bal-secondary)' : ...` and `<AppIcon icon="pending" ...>` beside `fontSize: '1.5rem'`
|
||||
- **[low]** `client/src/components/EarningsRow/EarningsRow.tsx` — The negative commission row is fed through the generic PriceBreakdown with no deduction treatment — same weight/color as positive rows, relying entirely on Intl fa-IR minus-sign placement inside an RTL paragraph; a deduction should read as one (parentheses, muted/error tone, or an explicit 'کسر' prefix).
|
||||
- evidence: lines 48-51: `String(-parseIrr(item.balinyaarCommissionIrr))` passed as a plain PriceBreakdown row
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx` — Loading skeletons are generic rectangles that don't match the card anatomy they replace (bare `Skeleton height={112}` for NurseResultCard rows) — a pattern repeated across pages because no card in this layer ships a skeleton twin; content jumps when avatars/chips/price rows pop in.
|
||||
- evidence: line 75: `<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />`
|
||||
|
||||
## Opportunities (12)
|
||||
|
||||
- **Extract the shared card/row/badge kit the layer already implies** (impact: high, effort: medium) — Codify the de-facto anatomy into 4-5 primitives and migrate the ~12 hand-rollers: `SurfaceCard` (flat Paper, one padding scale sm/md/lg, radius token), `AccentCard` (SurfaceCard + semantic borderInlineStart stripe at one width), `LabelValueRow` (replaces SummaryRow/MetaLine/PriceBreakdown rows, with an ltr-value option for refs/IBANs), `SelectableCard` (one selected language: constant border + soft tint + check glyph, used by RelationSelect/CategoryTile/BnplPlanCard/GenderToggle), and `MoneyText` (amount + currency + optional deduction styling, correct contrast). Every future brand pass then touches one file per primitive instead of twelve.
|
||||
- **Design a real trust system around TrustBadge** (impact: high, effort: medium) — Trust is the product: give the verified mark a proprietary shape (e.g. a teal shield/seal distinct from status chips), and make it expandable — tapping opens a bottom sheet listing what Balinyaar verified (identity ✓, nursing license ✓, background check ✓, IBAN ownership ✓) with dates, fed by the existing verification-status query. Reuse the same sheet on NurseResultCard, the nurse profile, and booking summary. This converts an ambient chip into an explorable trust artifact — the single highest-leverage design move available.
|
||||
- **NurseResultCard v2 — the decision card** (impact: high, effort: medium) — Add the variant/service name (the row is a variant), a gender indicator (load-bearing for matching), completed-visits count, and a one-line top review tag (e.g. «منظم و دقیق» from the review vocabulary); make the avatar larger and photo-forward with the trust seal overlapping it. Ship a `NurseResultCardSkeleton` twin. Consider a compact/comfortable density prop so the same card serves list and map views later.
|
||||
- **Dedicated rating tokens + fractional stars** (impact: high, effort: small) — Add `--bal-rating` / `--bal-rating-empty` token pairs (a warm amber-gold with a visible empty outline in both schemes) and give RatingInput fractional fill (clip-path or dual-layer) so a 4.5 average renders honestly instead of being rounded to 5. Swap NurseResultCard/BookingRequestSummaryCard star colors to the same token. Small change, visible on every trust surface.
|
||||
- **Retire terracotta as the money color** (impact: high, effort: small) — Define a `--bal-money-emphasis` token (ink/teal-dark in light, lifted cream in dark) for totals and amounts, reserving terracotta for the one primary financial CTA per screen (pay button, selected BNPL plan) per the brand's 'single sparing accent' rule. Fixes the contrast failures in PriceBreakdown/RefundStatusCard/BnplPlanCard/InstallmentScheduleRow/CancellationPolicyDisclosure in one pass.
|
||||
- **Swap the icon registry to a coherent humane set** (impact: medium, effort: medium) — All glyphs are stock MUI Material icons, which is a big contributor to the default-MUI feel. Because AppIcon centralizes the registry (common/AppIcon/config.ts), replacing Material with a single warm stroke set (Lucide/Phosphor/Solar, 1.5-2px stroke, rounded caps) is a one-file change that instantly re-skins all 34 feature components — including replacing the leftover Twemoji PencilIcon 'logo'.
|
||||
- **StatusTimeline component for refunds (and future booking states)** (impact: medium, effort: small) — Replace StepperHeader inside RefundStatusCard with a purpose-built vertical timeline: step label + timestamp + channel note per node, teal completed nodes, animated 'in transit' node. Reusable for booking lifecycle and verification progress; leaves StepperHeader to actual wizards.
|
||||
- **Answer 'when do I get paid?' in EarningsBalanceHeader** (impact: medium, effort: small) — The header shows four buckets but not the one thing nurses actually ask: the next weekly payout date (server-derivable from the batch schedule + holiday shift). Add a 'برداشت بعدی' line with the Shamsi date under the net balance, and optionally a mini trend of recent payouts.
|
||||
- **OTP autofill + single-input architecture** (impact: medium, effort: small) — Add autoComplete="one-time-code" now (one line), then refactor OtpInput to one hidden input driving visual digit boxes so OS keyboard code-suggestion works reliably; add autoComplete="tel" to PhoneNumberField. Directly reduces login friction for every user.
|
||||
- **Skeleton twins co-located with cards** (impact: medium, effort: small) — Ship `<X.Skeleton />` statics for NurseResultCard, EarningsRow, PayoutHistoryRow, PatientCard, VisitNoteCard matching their exact anatomy (avatar disc, chip row, price line), so pages stop hand-rolling height-guessed rectangles and loading feels designed.
|
||||
- **Total-cost honesty line on BnplPlanCard** (impact: medium, effort: small) — Add a served 'total you will pay' line (down payment + n×installment) so interest-free vs fee-bearing plans are comparable at a glance — an honest-lending pattern consistent with the platform's existing BNPL-honesty rules (RefundEtaBanner already models this tone).
|
||||
- **Rewrite or delete UserInfo** (impact: medium, effort: small) — Replace the starter UserInfo with a branded profile block: initials avatar off --bal-primary-soft (matching NurseResultCard/BookingRequestSummaryCard), i18n'd fallbacks, masked phone via the existing maskIranMobile util, and the user's role chip — or fold it into the redesigned sidebar entirely.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline is exemplary and must not regress: zero hard-coded hexes across all 34 feature components (verified by grep — only the starter PencilIcon in common/ has literals); every color resolves from --bal-* semantic tokens defined for both light and dark schemes, with in-code comments enforcing the rule (StatusChip.tsx:13-14, TrustBadge.tsx:14-16).
|
||||
- RTL discipline: logical properties throughout (borderInlineStart accent stripes, marginInlineStart:'auto' in VisitNoteCard, textAlign 'start'/'end') plus deliberate dir="ltr" islands for IBANs (BankStatusPanel:96, PayoutHistoryRow:71-79), phone/OTP digits, transfer references, and the HH:MM:SS countdown clock (CountdownTimer:103) — a grep for marginLeft/textAlign:'left' finds nothing.
|
||||
- Money invariants: all amounts are served IRR digit-strings formatted through the BigInt-safe money util; PriceBreakdown's dev-mode reconciliation guard (rows must sum to the total or console.error) catches upstream data bugs; no component ever computes money except the sanctioned price×sessionCount estimate in PriceDisplay.
|
||||
- Honest trust copy encoded as components: EscrowNotice's product-mandated verbatim fa escrow message, RefundEtaBanner's BNPL ~7-10-business-day honesty, RefundStatusCard suppressing ALL success framing (progress/amount/ETA) on failed refunds, EarningsBalanceHeader's explicit 'owed back' state instead of a bare minus sign — these are design decisions, not accidents; any restyle must preserve the copy and the state logic.
|
||||
- DocumentUpload's complete state machine — idle → uploading (progress %) → success (✓ + preview) → error (retry) → rejected (reason + re-upload, never a dead end) — with client-side type/size validation, object-URL cleanup, and server-metadata as the only 'uploaded' truth.
|
||||
- CountdownTimer's isolation architecture: self-owned 1-second tick so only it re-renders, server-frozen deadline (never recomputed client-side), single onElapsed fire, tabular-nums digits.
|
||||
- Exhaustive typed enum→chip mappings (PaymentStatusBadge, EarningsRow, PayoutHistoryRow, InstallmentScheduleRow): a wire-enum change fails the build instead of rendering an unmapped status — keep this pattern in any badge redesign.
|
||||
- StatusChip as the single source of status color+icon that BankStatusPanel, VariantCard, PaymentStatusBadge, EarningsRow, PayoutHistoryRow, RefundStatusCard, CancellationPolicyDisclosure all delegate to — the consolidation point already exists; redesign the one component, not seven.
|
||||
- Accessibility groundwork: keyboard handlers + role=button on tappable cards (NurseResultCard, DocumentUpload dropzone), radiogroup/radio semantics (RatingInput, RelationSelect), aria-pressed on toggle chips, focus-visible outline on NurseResultCard, and data-* hooks on every component for tests.
|
||||
- Presentational purity with caller-owned i18n (labels are i18n keys off stable codes, never derived from wire values) — this is precisely what makes a ground-up visual redesign cheap and low-risk; don't let a restyle introduce data-fetching or hard-coded strings into this layer.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Notifications, messaging/tickets, and content surfaces across actors
|
||||
|
||||
## Current state
|
||||
|
||||
Notifications are a small, well-factored system: `NotificationBell` (container, polled `useUnreadCount` at 60s interval, auth-gated) + `NotificationBellView` (Badge over `AppIcon icon="notifications"`, tokenized error-red badge) mounted in both `CustomerLayout` (top bar end) and `NurseLayout` (headerActions). The bell navigates to a full-page `NotificationCenter` (`client/src/components/notifications/NotificationCenter.tsx`), shared by `(customer)/notifications/page.tsx` and `nurse/notifications/page.tsx`, which renders an unread-first flat list of `NotificationRow` cards (ButtonBase, unread = dot + fontWeight 800 + `--bal-primary-soft` tint), mark-read-on-open (optimistic), mark-all-read, load-more by growing a `limit`, and skeleton/empty/error-retry states. Deep links are centralized and role-aware in `services/notifications/deepLink.ts`; icons per kind in `notificationIcon.ts`. Admin has no notification center — `admin/notifications/page.tsx` is a `PlaceholderScreen`.
|
||||
|
||||
Messaging is ticket-based (the only sanctioned channel, by product design): `TicketInboxScreen` (title + "contact support" CTA + always-on `EmergencyBanner` + flat `TicketListCard` list) shared by customer `/support/tickets` and `/nurse/support/tickets`; `TicketThreadScreen` (`referenceCode` header card + `TicketMessageList` bubbles + sticky `MessageComposer`) shared by the two `[id]` pages. `MessageBubble` does mine/theirs alignment with logical corner radii (RTL-mirrors automatically), teal fill for mine, paper+border for theirs, author label only on theirs, full Shamsi date-time per bubble. Sends are genuinely optimistic (`usePostMessage`): clientMessageId reconciliation, draft cleared only on server confirm, pending bubble at 0.75 opacity with a "sending…" label, rollback + caption error on failure; the composer is remount-keyed per ticket so drafts never cross threads. `ContactSupportDialog` handles category/subject/body then shows the created `referenceCode` with a "view thread" action; `BookingSupportEntry` hangs off booking detail and shows the nurse-only post-confirmation `EmergencyBanner` with the sole sanctioned `tel:` click-to-call. The admin thread (`admin/tickets/[id]/page.tsx`) is a separate build: a 520px scrollbox of `AdminMessageBubble` (internal notes = dashed warning border + badge), a reply/internal ToggleButtonGroup composer, and an inline collapsible `RefundPanel`. Everything is styled from `--bal-*` CSS tokens (both schemes), all icons are stock MUI Material icons via the `AppIcon` registry, all list surfaces have loading/empty/error states, and copy in `messages/fa.json` is human and decent. Critically, `USE_TICKETS_MOCK = false` (real API) while `unreadCount`/`lastMessageAt` on `TicketSummary` are documented as mock-only (REQ-028), and the thread query has no refetch interval — so several "messaging" behaviors exist only under the mock.
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/src/components/messaging/TicketInboxScreen.tsx` — The inbox has no pagination and no status/category filter: `useMyTickets({})` pins page 1 / pageSize 20 with no load-more UI, so any ticket beyond the first 20 is unreachable. This is not theoretical — a coordination ticket is auto-created for every confirmed booking, so an active nurse's inbox outgrows one page quickly. The hook already supports `status` and `page`; the screen just never exposes them.
|
||||
- evidence: line 35: `const { data, isLoading, isError, refetch } = useMyTickets({});` — no setLimit/setPage anywhere, unlike NotificationCenter's load-more
|
||||
- **[high]** `client/src/components/messaging/TicketListCard.tsx` — On the real API (USE_TICKETS_MOCK=false in services/tickets/constants.ts:15) the inbox's core messaging affordances are dead: `unreadCount` and `lastMessageAt` are mock-only (REQ-028 gap, types.ts:56-59), so no unread pill, no bold-unread subject, and times silently fall back to `createdAt`. There is also no last-message preview snippet at all (not even in the type). Users cannot tell which ticket has new support/nurse activity — the single most important signal of an inbox.
|
||||
- evidence: types.ts:56-59 "REQ-028 gap — mock-only until delivered"; TicketListCard.tsx:44 `(ticket.unreadCount ?? 0) > 0`
|
||||
- **[high]** `client/src/services/tickets/hooks/useTicket.ts` — The thread never live-updates: `useTicket`/`useTicketThread` have staleTime 15s but no `refetchInterval`, so a support or nurse reply that arrives while the user is sitting on the thread never appears until they blur/refocus the window or navigate away. For the platform's only communication channel this is below messaging baseline — the user sends a message and stares at a screen that will not answer.
|
||||
- evidence: useTicket.ts:15-21 — queryKey/staleTime/gcTime only, no refetchInterval (contrast useUnreadCount.ts:21 which polls)
|
||||
- **[high]** `client/src/components/messaging/TicketMessageList.tsx` — No scroll management anywhere in the thread: messages render as a plain flow list with no scroll-to-newest on open or on send/receive, so a long thread opens at the top (oldest message) and the user must manually scroll down past the history every visit. The admin thread has the inverse bug — a `maxHeight: 520, overflowY: 'auto'` box that also opens scrolled to the top, hiding the newest messages below the fold inside the scrollbox.
|
||||
- evidence: TicketMessageList.tsx:51-63 — no ref/useEffect/scrollIntoView; admin/tickets/[id]/page.tsx:150
|
||||
- **[medium]** `client/src/components/messaging/MessageBubble.tsx` — The bubble timestamp forces `direction: 'ltr'` on a Persian Shamsi string (formatShamsiDateTime for fa yields e.g. "۲۵ تیر ۱۴۰۵، ۱۴:۳۰" with a Persian month name). Forcing an RTL-language string into an LTR paragraph makes bidi reorder its segments visually (time/date tokens swap around the comma). `direction:'ltr'` is correct for the Latin referenceCode but wrong here.
|
||||
- evidence: MessageBubble.tsx:71 `direction: 'ltr'` on the timeLabel Typography
|
||||
- **[medium]** `client/src/components/common/AppIcon/config.ts` — The `send` icon (SendOutlined paper plane) is never mirrored for RTL. The theme's stylis-plugin-rtl mirrors generated CSS, not SVG glyphs, so in the default fa layout the send arrow points right — back into the text field instead of toward the inline send direction. Material's own RTL guidance lists Send among icons that must be mirrored.
|
||||
- evidence: config.ts:85 `import SendIcon from '@mui/icons-material/SendOutlined'`; no scaleX(-1)/rtl transform anywhere in client/src (grep confirmed)
|
||||
- **[medium]** `client/src/components/messaging/MessageComposer.tsx` — Enter always sends — including on mobile touch keyboards, where Enter is how users write a multi-line message. The customer shell is explicitly mobile-first; standard chat behavior is Enter=send on desktop only, and a send button tap on touch. There is also no way to insert a newline on mobile at all (Shift+Enter doesn't exist on touch keyboards).
|
||||
- evidence: MessageComposer.tsx:46-51 `if (event.key === 'Enter' && !event.shiftKey) { … submit(); }` with no pointer/viewport check
|
||||
- **[medium]** `client/src/components/messaging/TicketInboxScreen.tsx` — The alarm-red EmergencyBanner renders permanently at the top of every ticket inbox with no `contactPhone` (the inbox never has one — the tel: contact only exists on the nurse's post-confirmation booking read). The copy tells users to "first call the emergency contact" on a surface that cannot show any number, which is confusing, and a full error-colored banner as permanent inbox chrome produces alarm fatigue and pushes the actual ticket list below the fold on mobile.
|
||||
- evidence: line 57 `<EmergencyBanner onOpenTicket={…} />` — contactName/contactPhone never passed; EmergencyBanner.tsx:58 renders the call button only `phone ? …`
|
||||
- **[medium]** `client/src/components/notifications/NotificationRow.tsx` — Navigable and non-navigable notifications are visually identical ButtonBase cards: a `kind:'none'` row (deepLink returns null) still ripples on click and silently only marks itself read — the tap appears to do nothing. No chevron/affordance distinguishes rows that open something, and the ButtonBase has no hover state and no visible :focus-visible style, so keyboard users get no focus indication on either notifications or ticket cards (TicketListCard has the same construction).
|
||||
- evidence: NotificationRow.tsx:30-43 sx has static bgcolor/border only; NotificationCenter.tsx:46-50 `if (target) router.push(…)` with no UI differentiation
|
||||
- **[medium]** `client/src/components/notifications/NotificationBell.tsx` — The bell is navigation-only: clicking it always route-pushes to the full notifications page, even on the nurse/admin desktop shells where the expected pattern is a popover preview (recent items + mark-all + "view all"). Full-page context switch for glancing at notifications is heavy on desktop; there is also no visual acknowledgment (animation/pulse) when the polled count increments.
|
||||
- evidence: NotificationBell.tsx:31 `onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}`
|
||||
- **[medium]** `client/src/components/messaging/TicketMessageList.tsx` — The thread has zero chat typography structure: every bubble carries a full "day longMonth year, hh:mm" Shamsi timestamp (huge for chat), there are no date separators, no grouping of consecutive messages from the same author, and `system` messages (author_system = "سیستم") render as ordinary left-side bubbles instead of centered event lines — so an auto-created coordination thread reads as a raw list, not a conversation.
|
||||
- evidence: TicketMessageList.tsx:58 `timeLabel={formatShamsiDateTime(message.createdAt, locale)}` for every message; no separator logic in the map
|
||||
- **[medium]** `client/src/layout/CustomerLayout.tsx` — New support activity is invisible in the app chrome: the top-bar support entry is a bare AppIconButton with no unread badge (unlike the notification bell), and the 5-tab BottomBar has no support presence at all — so a customer with an unanswered support reply sees nothing anywhere unless a notification also fires. Partly blocked by REQ-028, but the affordance isn't even scaffolded.
|
||||
- evidence: lines 48-53 `<AppIconButton icon="support" …/>` with no Badge wrapper
|
||||
- **[low]** `client/src/components/messaging/MessageComposer.tsx` — Send failure feedback is a bare caption line above the input with no retry button and no aria-live region — the optimistic bubble disappears (rolled back) and the only trace is small error text; a user mid-scroll can easily believe the message was sent. Screen readers are never told the send failed.
|
||||
- evidence: lines 55-59 — plain Typography, no role="alert"/aria-live, no retry action
|
||||
- **[low]** `client/src/components/messaging/TicketThreadScreen.tsx` — The sticky composer strip is only a bgcolor block — no top border, shadow, or fade — so message bubbles scroll flush into/under it and visually collide with the input. Also the surface widths are inconsistent across sibling messaging screens: inbox 640px, thread 720px, admin thread 820px.
|
||||
- evidence: lines 100-107 `position:'sticky', bottom:0, bgcolor:'var(--bal-bg-default)'` and nothing else; TicketInboxScreen.tsx:41 maxWidth 640 vs TicketThreadScreen.tsx:45 maxWidth 720
|
||||
- **[low]** `client/src/components/notifications/NotificationCenter.tsx` — The error empty-state icon is `error` → MUI `Dangerous` (a filled hazard glyph) for a mundane "couldn't load" state — over-severe and one of several filled icons (Info, Star, Dangerous) mixed into an otherwise Outlined icon set on these surfaces, reinforcing the default-MUI look. Load-more also just grows `limit` and refetches the whole list from offset 0 (O(n) payload growth per click).
|
||||
- evidence: line 79 `<AppIcon icon="error" …/>`; config.ts:21 `DangerousIcon`; line 111 `setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)`
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx` — Admin notifications is still a PlaceholderScreen while the admin nav links to it — a dead-end "coming soon" page in a shipped backoffice.
|
||||
- evidence: lines 4-8 return `<PlaceholderScreen icon="notifications" …/>`
|
||||
|
||||
## Opportunities (9)
|
||||
|
||||
- **Deliver REQ-028 and rebuild the ticket inbox as a real messaging inbox** (impact: high, effort: medium) — Land the contract fields (unreadCount, lastMessageAt, plus a lastMessagePreview snippet and author role of the last message) and redesign TicketListCard around them: bold subject + one-line last-message snippet, unread pill, relative last-activity time, ordering by activity. Add status filter chips (باز/بسته) and load-more/pagination using the params useMyTickets already accepts. This turns a static ticket registry into an inbox users check willingly.
|
||||
- **Make the thread live: poll-while-mounted + scroll orchestration** (impact: high, effort: medium) — Give the ticket detail query a refetchInterval while the thread screen is mounted (the seam already exists; SSE can replace it later), auto-scroll to the newest message on open and on send/receive, and show a floating "پیام جدید" pill when the user has scrolled up. Pair with a subtle enter animation for new bubbles. This single change is the biggest step toward messaging-app quality on the only communication channel the platform allows.
|
||||
- **Chat-grade thread typography: date separators, grouping, time-only stamps, system event lines** (impact: high, effort: medium) — Insert centered Shamsi date separators (امروز / دیروز / ۲۵ تیر), collapse consecutive same-author messages into a group with one author label, show hh:mm-only inside bubbles (full date on long-press/hover), and render authorRole 'system' as centered chip-style event lines (e.g. "تیکت هماهنگی ایجاد شد"). Give the support author a terracotta-tinted label/avatar dot so "a human from Balinyaar" reads warm and distinct — a direct trust cue in a healthcare product.
|
||||
- **Desktop notification popover + bell micro-interaction** (impact: medium, effort: medium) — On the nurse/admin desktop shells, make the bell open a Popover with the 5 most recent notifications, mark-all-read, and a "view all" link to the full center; keep the direct navigation on the mobile customer shell. Add a one-shot badge pulse/ring animation when the polled count increases so arriving activity is felt, and a document.title/favicon unread hint since polling is 60s.
|
||||
- **Notification center: day grouping, relative time, per-kind visual identity** (impact: medium, effort: small) — Group rows under امروز / دیروز / این هفته headers, use relative timestamps ("۵ دقیقه پیش") that decay into Shamsi dates, and give each kind a soft tinted icon container (booking teal, payout success-green, ticket terracotta, refund info) instead of the uniform primary icon — the list gains scannable hierarchy without new data. Add a trailing chevron only on rows that deep-link.
|
||||
- **Right-size the emergency affordance per surface** (impact: medium, effort: small) — Keep the full red banner with tel: only where the phone exists (nurse booking detail, post-confirmation). In the ticket inboxes, replace the permanent alarm banner with a compact, neutral "موارد اضطراری" help row that expands to the playbook copy — and on the customer side rewrite the copy so it doesn't instruct calling a number the customer can never see. Reduces alarm fatigue while keeping the safety path one tap away.
|
||||
- **Ticket lifecycle + trust affordances** (impact: medium, effort: medium) — Add user-side close/reopen actions, a post-resolution satisfaction prompt, and — most valuable for trust — an expected-response-time promise in the thread and after ticket creation ("پشتیبانی معمولاً ظرف ۲ ساعت پاسخ میدهد") plus a "support has seen this" state when staff first views. In a marketplace where disputes are money-adjacent, telling families when to expect an answer is a product feature, not copy.
|
||||
- **Composer upgrades: photo attachments, retry-in-place, mobile keyboard behavior** (impact: medium, effort: large) — Refund and coordination tickets routinely need evidence (receipts, care-situation photos); add an attachment affordance to the composer (the object-storage seam already exists server-side for verification docs). Convert send failure into a failed bubble with an inline "تلاش مجدد" chip instead of the vanish-and-caption pattern, add aria-live, and make Enter insert a newline on touch devices.
|
||||
- **Build the admin notifications surface or remove the nav entry** (impact: low, effort: small) — Either implement a minimal admin alert feed (b1 alert facade already exists) behind the placeholder route, or drop the nav item until it exists — a "coming soon" page in a staff backoffice erodes confidence in the rest of the console.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The optimistic-send architecture is genuinely excellent: clientMessageId reconciliation (never a double bubble), draft cleared only on server confirm so a failure never loses typed text, and the composer remount-keyed per ticketId so drafts/in-flight state never leak across threads (TicketThreadScreen.tsx:117-119, MessageComposer.tsx:42).
|
||||
- RTL discipline via logical properties throughout: borderStartEndRadius/borderStartStartRadius bubble tails, marginInlineStart:'auto', borderInlineStart accent on EmergencyBanner, textAlign:'start' on ButtonBase cards, justifyContent flex-end/flex-start for mine/theirs that mirrors automatically in fa.
|
||||
- Everything colors from --bal-* semantic tokens with both schemes defined — zero hard-coded hexes in any of these components, so dark mode holds up by construction (StatusChip even documents 'Never hard-code a hex here').
|
||||
- Every list surface (notification center, ticket inbox, thread) ships all four states — skeleton (with alternating bubble-shaped skeletons in threads), empty with title+body copy, error with retry, populated.
|
||||
- The is_internal boundary is airtight in the UI layer: user-side types never model it, MessageBubble/TicketMessageList never render it, and the admin surface renders internal notes unmistakably (dashed warning border + badge + distinct bg) so staff can't confuse a note with a reply.
|
||||
- referenceCode treated as the support currency: shown prominently in the inbox card, thread header, and creation-success dialog, correctly forced LTR for the Latin code.
|
||||
- Polite, well-factored polling: only the unread count polls (60s, auth-gated, stale-while-revalidate) and only the tiny bell container re-renders on count change — the shell never does.
|
||||
- Role-aware deep-linking centralized in notificationDeepLink with null-safe fallbacks (a notification that doesn't apply to the role is non-navigable, never a broken route), shared by bell and center.
|
||||
- Emergency contact is tel:-only, gated to the nurse post-confirmation care read, with no VoIP or phone directory anywhere — the product's anti-disintermediation rule is faithfully enforced in the UI.
|
||||
- Persian copy in messages/fa.json is human and warm ('بهروز هستید', 'هنوز پیامی نیست، هماهنگی را شروع کنید'), with proper ICU plurals on the bell aria-label; Shamsi dates render via the Intl Persian calendar with no date library.
|
||||
@@ -0,0 +1,65 @@
|
||||
# UI microcopy quality (fa + en message catalogs)
|
||||
|
||||
## Current state
|
||||
|
||||
All UI copy lives in two flat-namespace catalogs, client/messages/fa.json and client/messages/en.json (~1,582 lines each, 28 namespaces, full key parity — no missing namespaces or keys on either side). Coverage is unusually complete for a young product: every namespace ships loading/empty/error strings (e.g. booking.list_error, payouts.history_empty_body, tickets.thread_empty_body), confirm-dialog bodies, toasts, and field-level hints. The Persian is clearly hand-written, not machine-translated: it consistently uses the formal شما register with polite imperatives (کنید), uses Persian digits in literals (۲۴ ساعت, ۷ تا ۱۰ روز کاری), and gets culturally sensitive register right — خانم/آقا for caregiver gender in search/booking vs the clinical مرد/زن for patient gender in onboarding. The English catalog is idiomatic and often better than the Persian ("Home care you can trust", "the detail a nurse needs to find the door", "Queue clear — nothing to review").
|
||||
|
||||
The trust-critical copy — the product's core — is mostly strong: payment.escrow_notice explains escrow in plain language, payouts.explainer_point_1–3 explain the weekly batch / 72-hour dispute window / BNPL-fee-never-deducted invariant to nurses, refunds uses calming status language ("در راه" / "On its way", failed → "نیازمند بررسی" / "Needs attention"), and verification failure reasons (reason_shared_sim, reason_blurry_scan) tell the nurse exactly what to fix. The weaknesses are not tone but craft: inconsistent Persian orthography (hamza, ZWNJ, even the brand name itself is spelled two ways), two genuine grammar bugs that read as nonsense ("ورود بازی", "هشدار بازی"), one broken help-text sentence, a typo on a trust chip, missing ICU plural/zero handling on the fa side where en has it, one wrong-direction arrow baked into an en string, and policy numbers (24h/72h/7–10 days) hardcoded into copy that the admin config panel can change. Namespaces most needing a copy pass, in order: booking (largest at 183 keys, contains the grammar bug and arrow bug), verification (heaviest تایید/تأیید mixing), payment/auth/common (brand spelling split), bnpl (jargon), search (hardcoded city suggestions, missing plurals), and admin (second grammar bug).
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/messages/fa.json` — The brand name itself is spelled two different ways: 'بالین یار' (space) in 5 keys vs 'بالینیار' (ZWNJ) in 17 keys. Users see one spelling on the login screen and another on the payment/escrow/refund screens — for a trust-first brand, an unstable brand mark in the money copy is the worst place to be inconsistent.
|
||||
- evidence: common.brand + auth.customer_title/select_role_title + verification.start_body use 'بالین یار'; payment.row_commission, payment.escrow_notice, refunds.admin_approval_explainer, payouts.balance_owed_label and 13 more use 'بالینیار'
|
||||
- **[high]** `client/messages/fa.json` — Grammar bug that reads as nonsense: 'ورود بازی برای ثبت خروج وجود ندارد' — the indefinite ی attached to باز makes it read as 'there is no game-entrance'. Nurse-facing EVV error. Should be e.g. 'ورودِ ثبتشدهای برای خروج وجود ندارد؛ ابتدا ورود را ثبت کنید.' The same bug appears in admin.alert_empty: 'هشدار بازی وجود ندارد' ('no game alert') — better: 'هشداری برای رسیدگی نیست.'
|
||||
- evidence: line 542 booking.evv_no_open_check_in and line 1360 admin.alert_empty
|
||||
- **[high]** `client/messages/fa.json` — Broken sentence in customer-facing address help text: 'جزئیاتی که پرستار برای یافتن در نیاز دارد' — a word-for-word translation of the en 'find the door' where 'در' (door) collides with the preposition 'در', so it reads as 'details the nurse needs for finding in'. Should be e.g. 'هر جزئیاتی که پرستار برای پیدا کردن منزل شما لازم دارد.'
|
||||
- evidence: line 228 address.line_hint
|
||||
- **[high]** `client/messages/fa.json` — Typo on a trust-status chip: bank verification success chip reads 'تاییدشد' (missing final ه) instead of 'تأییدشده'. This is the chip a nurse stares at while waiting for IBAN ownership verification — a typo exactly where the product is asserting correctness.
|
||||
- evidence: line 180 bank.status_verified_chip: "تاییدشد"
|
||||
- **[high]** `client/messages/fa.json` — The word تأیید (confirm/verify) — the single most frequent word in a verification product — is written without hamza ('تایید', 38 occurrences) and with hamza ('تأیید', 25 occurrences) with no pattern, often inside the same namespace (verification.status_passed 'تاییدشده' vs verification.identity_title 'تأیید هویت'; booking uses no-hamza, admin uses hamza). Same-status labels also diverge across namespaces (verification.status_failed 'رد شد' vs admin.step_failed 'ناموفق'). Pick one orthography (recommend the hamza form) and one status vocabulary.
|
||||
- evidence: 38 no-hamza vs 25 hamza occurrences (grep count); e.g. bank.status_verified_title vs admin.ver_pass
|
||||
- **[medium]** `client/messages/en.json` — Wrong-direction arrow baked into an English string: booking.continue_payment is 'Continue to payment ←' (left arrow in LTR English) while the sibling key payment.cta_pay is 'Continue to payment →'. The fa file mirrors arrows manually per-string — directional glyphs inside translatable copy is exactly how this bug happens; arrows belong in the component as mirrored icons.
|
||||
- evidence: en.json line 445 booking.continue_payment: "Continue to payment ←" vs line 577 payment.cta_pay: "Continue to payment →"
|
||||
- **[medium]** `client/messages/fa.json` — fa lacks ICU plural/zero handling where en has it, so Persian users get degenerate strings: search.cta_view_results is 'مشاهده {count} پرستار' → the primary search CTA can render 'مشاهده ۰ پرستار'; search.results_count, search.reviews_count and booking.session_count have the same gap. en handles =0 ('no nurses') — though en's own =0 case produces the odd button label 'View no nurses', so both sides need a proper zero-state ('پرستاری یافت نشد' / 'No nurses found').
|
||||
- evidence: fa line 350 cta_view_results, 352 results_count, 364 reviews_count, 515 session_count vs en ICU-plural equivalents
|
||||
- **[medium]** `client/messages/fa.json` — BNPL trust copy is written from the platform's perspective with banking jargon: bnpl.ownership_note tells the customer 'ریسک نکول مشتری کاملاً با اوست' ('the customer's default risk is entirely the provider's') — 'نکول' is credit-desk vocabulary, and framing the reader as 'the customer' in third person is cold and confusing. Rewrite reader-first: 'قسطها را مستقیماً به {provider} میپردازید؛ بالینیار مبلغ کامل را همان ابتدا دریافت میکند و پرستار شما تحت تأثیر قرار نمیگیرد.'
|
||||
- evidence: line 857 bnpl.ownership_note
|
||||
- **[medium]** `client/messages/fa.json` — Policy numbers are hardcoded into trust-critical copy while the admin config panel (cfg_group_deadlines, cfg_group_cancellation) can change them: the 72-hour dispute window (payouts.explainer_point_2), the 24-hour cancellation tiers (refunds.lead_gt_24h/lead_lt_24h), and the 7–10 business-day refund ETA (refunds.eta_business_days). One config edit silently makes the UI copy lie — these should be interpolated ({hours}, {days}) from server-served config.
|
||||
- evidence: fa lines 958, 789–790, 850; same hardcodes in en.json
|
||||
- **[medium]** `client/messages/fa.json` — Search empty-state suggests hardcoded cities regardless of where the user searched: 'شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را امتحان کنید' — Mashhad, Isfahan and Shiraz are ~900km apart and are nonsense advice for a Tehran user (the launch market). Replace with a location-neutral suggestion or interpolate actual nearby covered cities.
|
||||
- evidence: line 361 search.empty_suggest_city (en line 361 identical pattern)
|
||||
- **[medium]** `client/messages/fa.json` — 'احراز هویت' is overloaded: it names the entire 7-step verification pipeline (nav.verification, verification.title) AND one specific step inside it (step_identity_kyc 'احراز هویت (ثبت احوال)', admin.step_identity_kyc 'احراز هویت'). A nurse who completed the KYC step but sees 'احراز هویت' still incomplete in nav gets contradictory signals. Name the pipeline differently, e.g. 'تأیید صلاحیت'.
|
||||
- evidence: fa lines 13, 658 vs 676 and 1470
|
||||
- **[medium]** `client/messages/fa.json` — Domain-term drift: 'مددجو' (care recipient) appears exactly once as a parenthetical — booking.patient_label 'بیمار (مددجو)' — while every other surface says 'بیمار'. Either adopt مددجو consistently (it is the softer, industry-standard term for home care) or drop the one-off. Similarly 'جستجو' (9 keys) vs 'جستوجو' (2 keys) are mixed.
|
||||
- evidence: line 398 booking.patient_label; جستوجو in coverage.empty_warning and booking.missing_nurse_body only
|
||||
- **[low]** `client/messages/fa.json` — The EVV acronym is exposed to nurses untranslated and never explained: 'ثبت ورود (EVV)', 'ویزیتهای امروز… با EVV ثبت کنید'. First occurrence should introduce it — 'ثبت حضور الکترونیکی (EVV)' — then short-form thereafter; a Latin acronym as the only name for a core nurse workflow is alienating in a fa-default product.
|
||||
- evidence: lines 530–533 booking.evv_* keys; admin.cfg_group_evv does gloss it as 'ثبت حضور (EVV)'
|
||||
- **[low]** `client/messages/en.json` — en catalog mixes British and American conventions: 'licence' (auth.nurse_subtitle) against American 'center' (21 occurrences: 'Partner centers', nav.partner_home 'Center'); admin namespace also uses curly apostrophes ('don’t', 'Couldn’t') where the rest of the file uses straight ones. Pick American throughout.
|
||||
- evidence: en line 628 'Nursing Council licence' vs nav.partners 'Partner centers'; en line 1199 'don’t'
|
||||
- **[low]** `client/messages/fa.json` — Register slips into bureaucratic officialese in a few money strings: refunds.confirm_restate ends 'کسر میگردد' (archaic میگردد) while the rest of the catalog uses 'میشود'; tickets.thread_empty_body is a comma splice ('هنوز پیامی نیست، هماهنگی را شروع کنید.'). Small, but the cancellation-confirm sentence is a high-anxiety moment where stiff prose reads as fine print.
|
||||
- evidence: line 814 refunds.confirm_restate; line 1141 tickets.thread_empty_body
|
||||
- **[low]** `client/messages/fa.json` — The four app shells are named with four different metaphors: 'اپلیکیشن خانواده' (app), 'نمای پرستار' (view), 'کنسول مدیریت' (console), 'پرتال همکار' (portal). Harmless individually but signals no naming system; pick one pattern per audience.
|
||||
- evidence: lines 57–61 shell namespace
|
||||
|
||||
## Opportunities (7)
|
||||
|
||||
- **Check in a Persian style guide + terminology glossary and lint the catalogs against it** (impact: high, effort: small) — One page in the repo (e.g. client/messages/STYLE.md) fixing: brand = 'بالینیار' (ZWNJ), hamza form 'تأیید', 'جستوجو' or 'جستجو' (pick one), ZWNJ rules for می/ها, the domain glossary (پرستار، مددجو vs بیمار، ویزیت، رزرو، نوبت، شبا), and one status vocabulary shared by nurse-facing and admin-facing keys. Then a 30-line node script in CI that greps fa.json for the banned variants (the space-brand, no-hamza تایید, 'بازی' word-boundary traps). This converts today's 60+ scattered orthography findings into a one-time fix that can never regress.
|
||||
- **Serve policy numbers into copy instead of hardcoding them** (impact: high, effort: medium) — The dispute-window hours, cancellation tiers/percentages, and refund ETA days already live in server config (admin cfg_ keys exist to edit them). Change the message keys to take parameters ('پس از بستهشدن پنجرهٔ {hours} ساعته اعتراض…') and feed them from the same endpoint that serves fees. This keeps the product's most legally-sensitive copy permanently truthful and unlocks per-tier cancellation copy on the C-side cancel screen.
|
||||
- **Add fa plural/zero variants + Persian digit formatting policy** (impact: medium, effort: small) — Give every {count} key an ICU form with a designed =0 case (fa: 'پرستاری یافت نشد' instead of '۰ پرستار'; fix en's 'View no nurses' button while there). Decide once whether interpolated numbers render as Persian digits ({count, number} with fa locale) — currently literals use Persian digits but interpolations will render Latin, so a card can read '۷۲ ساعت' next to '3 visits'.
|
||||
- **Trust-moments copy pass: write the reassurance where the money moves** (impact: high, effort: small) — The catalogs explain escrow and payouts well, but three anxiety peaks still have thin copy: (1) the OTP screen says nothing about why a family should trust the platform (auth.customer_subtitle is just 'sign in with your mobile'); (2) the checkout escrow notice is one sentence with no link to how disputes work; (3) the nurse 'accept request' screen never states the payout amount protection ('پرداخت خانواده نزد بالینیار امانت میماند'). Add one warm trust line per moment — this is copy, not UI work, and it is the cheapest trust lever the product has.
|
||||
- **Introduce EVV in Persian once, then abbreviate** (impact: medium, effort: small) — Add a first-run explainer key ('ثبت حضور الکترونیکی (EVV) — ورود و خروج شما موقعیتسنجی میشود تا ویزیت بدون اختلاف تأیید شود') shown on the nurse's first visit day, and keep the short chips thereafter. Turns an alienating acronym into a selling point (EVV is why the nurse gets paid without arguments).
|
||||
- **Move directional arrows out of strings into mirrored icon components** (impact: low, effort: small) — Five keys embed ←/→ literally and one (en booking.continue_payment) already points the wrong way. Replace with an end-icon in the button component that auto-mirrors with dir; also fixes admin.cfg_history_change ('{old} ← {new}') which relies on translators hand-mirroring.
|
||||
- **Disambiguate the verification pipeline name and rename status vocabulary once** (impact: medium, effort: medium) — Rename the pipeline (nav + page titles) to 'تأیید صلاحیت' while the KYC step keeps 'احراز هویت'; simultaneously unify the failed/rejected status words (رد شد vs ناموفق vs ردشده) into one nurse-facing and one admin-facing set. Do it as a single sweep because the terms cross-reference each other.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- payment.escrow_notice — plain-language escrow explanation at checkout ('مبلغ بهصورت امانی نزد بالینیار میماند و پس از پایان ویزیت آزاد میشود'); exactly the right sentence at the right moment
|
||||
- payouts.explainer_point_1–3 — the three-bullet 'how payouts work' copy, especially point 3 guaranteeing the nurse the BNPL provider fee is never deducted from her; this is model trust writing
|
||||
- Calming money-status vocabulary: refund steps 'ثبتشده → در راه → انجامشده' and failed states softened to 'نیازمند بررسی'/'Needs attention' with 'you don't need to do anything else' reassurance (refunds.failed_body, payouts.failure_hint)
|
||||
- Two-stage disclosure copy is precise on both sides: booking.notes_hint tells the family exactly what the nurse sees pre-acceptance, and booking.disclosure_note tells the nurse exactly what unlocks after accepting
|
||||
- Culturally-tuned gender copy: خانم/آقا (polite) for caregiver preference vs مرد/زن (clinical) for patient gender, plus search.gender_hint explaining same-gender preference for bodily care without awkwardness
|
||||
- Verification failure reasons are specific and actionable (reason_shared_sim tells the nurse the SIM isn't in her name and what to do; reason_blurry_scan asks for a sharper copy) — no generic 'verification failed' anywhere
|
||||
- Consistent formal شما register across the entire fa catalog with warm touches where appropriate ('بهروز هستید' empty notifications state, 'طولی نمیکشد' during BNPL settling)
|
||||
- The en catalog is genuinely idiomatic hand-written English, not a translation dump — 'Queue clear — nothing to review', 'What stood out', 'Home care you can trust'
|
||||
- Every namespace ships complete loading/empty/error/confirm/toast strings — the string coverage for states is already better than most mature products
|
||||
- Admin confirm-dialog bodies state consequences honestly ('Money moves to nurses. This is protected by an idempotency key — a double-click can't pay a booking twice.') — keep this operational candor
|
||||
@@ -0,0 +1,73 @@
|
||||
# Nurse trust & operations — verification, request inbox, visits/EVV, earnings & payouts
|
||||
|
||||
## Current state
|
||||
|
||||
The nurse side lives under client/src/app/[locale]/(private-routes)/nurse/ inside NurseLayout (client/src/layout/NurseLayout.tsx), a 10-item flat sidebar + fixed TopBar shell inherited from the starter (TopBarAndSideBarLayout.tsx). Verification is a hub-and-spoke: verification/page.tsx (B3 hub) renders VerificationChecklist.tsx (an "X از Y" LinearProgress meter + data-driven step rows via verificationSteps.ts, reusing the shared StatusChip) with a single "continue" CTA; identity/page.tsx (B4) collects national ID + two local DocumentUpload captures; credentials/page.tsx (B5) renders one DocumentUpload per manual step plus INO number, specialty Chips and native type="date" registry dates; review/page.tsx (B6) is a second view of the same cached query. B4/B5/B6 carry a bare default-MUI StepperHeader (3 macro steps) alongside the hub's 7-step checklist. DocumentUpload owns a full idle→uploading(progress %)→success(preview)→error state machine plus a rejected variant with reason + re-upload.
|
||||
|
||||
The request inbox (requests/page.tsx) polls every 15s and lists pending-only cards (patient name, Shamsi time, required-gender chip, notes preview, per-card CountdownTimer) with an "open detail" button; requests/[id]/page.tsx shows the masked city·district location, stage-1 notes only, and 50/50 accept / reject-with-reason-dialog buttons plus 409-stale handling. Visits (visits/page.tsx) is a "today's sessions" list of shared SessionCard components with terracotta EVV check-in/out buttons driven by useEvvController (GPS via an ILocationProvider seam that never rejects — denied GPS still checks in, advisory) and EvvStatusBanner (success/warning/info tokens, mismatch is never an error); visits/[id]/page.tsx composes the both-roles BookingDetailView (timeline, sessions with EVV, money summary, gated CareInstructionsCard) + NurseVisitNotesPanel (append-only note + task checklist). Earnings (earnings/page.tsx) shows EarningsBalanceHeader (signed net balance with an explicit "owed back" negative state + four token-coded buckets), a collapsible explainer, state-filter Tabs and EarningsRow items (PriceBreakdown gross−commission=payout, per-state affordances, dispute-window countdown); payouts/page.tsx and payouts/[id]/page.tsx render PayoutHistoryRow / batch reconciliation with masked IBAN, transfer reference, and read-only failure banners. Styling throughout is flat bordered Paper cards with borderInlineStart accent strips, --bal-* CSS variables (mirrored dark scheme in src/theme/tokens.css), Persian digits via Intl fa-IR, and Shamsi dates via formatShamsiDate. Notably, the nurse landing page /nurse (nurse/page.tsx) is still a PlaceholderScreen.
|
||||
|
||||
## Problems (19)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/page.tsx` — The nurse landing page after login is a bare PlaceholderScreen — there is no operational home tying together today's visits, pending requests (with deadlines), verification progress, and earnings. Every session starts at a dead end and the sidebar is the only wayfinding.
|
||||
- evidence: line 7: `return <PlaceholderScreen icon="dashboard" title={t('dashboard')} ... />`
|
||||
- **[high]** `client/src/components/booking/BookingDetailView/BookingDetailView.tsx` — The nurse day-of flow contains no service address, no family/patient contact, and no navigate/call affordance anywhere — not on the visits day list, not on the booking detail. The header renders only patient + nurse names; the DTO's addressSnapshotJson is never rendered, and the bookings mock even nulls it for the nurse view (mockApi.ts:358). A field nurse cannot find where to go or reach the family from the app, even on a confirmed (post-payment, stage-2) booking.
|
||||
- evidence: lines 90-93 render only HeaderFact(patient) + HeaderFact(nurse); `addressSnapshotJson` has zero render-site references in src/
|
||||
- **[high]** `client/src/components/DocumentUpload/DocumentUpload.tsx` — Rejected-step recovery loses all upload feedback: the `rejected` branch of the render ternary takes precedence over `state === 'uploading'`, and the prop only flips after the status query invalidates. So when a nurse re-uploads a rejected document, the card stays frozen on the red 'rejected' state for the whole upload (no progress bar, no success flash) and the re-upload button stays enabled mid-flight, inviting double submissions on the single most anxiety-laden path.
|
||||
- evidence: line 155 `{rejected ? (` … precedes line 193 `: state === 'uploading' ? (`; re-upload AppButton (lines 182-191) is only disabled by the `disabled` prop
|
||||
- **[high]** `client/src/components/booking/SessionCard/SessionCard.tsx` — The EVV check-in/check-out CTA — the most important tap of a nurse's day, done on a phone at a doorstep — is a small start-aligned button (`alignSelf: 'flex-start', py: 1`) visually equal to tertiary links around it. No full-width layout, no large touch target, no sticky positioning, no visual weight distinguishing it from 'view booking'.
|
||||
- evidence: lines 117-141: both EVV buttons use `sx={{ m: 0, alignSelf: 'flex-start', py: 1 }}`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx` — The credentials form does not survive re-entry: INO number, specialties and registry fields start empty every visit (never hydrated from server status), and submit is disabled unless a document was uploaded in this session (`anyUploaded` reads only local `uploadedSteps` state) — so a returning nurse whose docs are already in_review sees blank fields and a dead submit button with no explanation.
|
||||
- evidence: line 106 `const anyUploaded = Object.values(uploadedSteps).some(Boolean)` + line 255 `disabled={submitCredentials.isPending || !anyUploaded}`; state initialised to '' / [] at lines 37-44
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx` — License issue/expiry dates use native Gregorian `type="date"` inputs in a Persian-default UI — Iranian nurses read their license dates in Shamsi; forcing a Gregorian browser picker on a trust-critical form invites wrong dates (which feed credential-expiry logic).
|
||||
- evidence: lines 220-235: two `<TextField type="date" ...>` for issued_at / expires_at
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx` — Inbox cards omit the requested service/variant and its price — the decision-critical facts. A nurse sees patient name, time, gender chip and a notes preview but must open each detail to learn what job is being requested and what it pays.
|
||||
- evidence: InboxCard (lines 57-115) renders counterpartyName, whenLabel, gender chip, customerNotes only; variantLabel/variantPrice appear only in [id]/page.tsx
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx` — Pending-only, page-1-only inbox: the page hardcodes the hook's default status ('pending_nurse_response') with no tabs for answered/expired history (no way to review past decisions or learn from expirations), and no pagination UI even though the API pages at 20 — a 21st pending request is unreachable.
|
||||
- evidence: line 19 `useNurseRequestInbox()` called with no status/page args; no Pager rendered; BOOKING_REQUEST_PAGE_SIZE = 20
|
||||
- **[medium]** `client/src/components/CountdownTimer/CountdownTimer.tsx` — No urgency escalation: the response-deadline countdown stays calm teal from 24h down to 00:00:01 (the `urgent` prop is static and unused by the inbox), and on inbox cards it renders without a label — bare ticking digits floating at the card corner. Also ambiguous formats: under an hour it drops to MM:SS which reads like HH:MM, and multi-day dispute windows render as raw hour counts like ۱۲۶:۴۴:۰۲.
|
||||
- evidence: line 66 `const accent = urgent ? ... : 'var(--bal-primary)'`; lines 88-90 drop the hours segment when 0; requests/page.tsx:79 passes no `label`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx` — Two competing progress metaphors in one journey: the B3 hub counts 7 granular checklist steps ("X از Y" + LinearProgress, inflated by the synthetic mobile step) while B4/B5/B6 show an unrelated default-MUI 3-step Stepper — the nurse gets two different answers to 'how far along am I'. StepperHeader itself is an unstyled starter Stepper (default numbered circles).
|
||||
- evidence: VerificationChecklist ProgressMeter vs StepperHeader.tsx lines 20-28 (bare `<Stepper>` wrap); verificationSteps.ts MOBILE_STEP id 0 always 'passed'
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx` — ExplainerCard's collapse header is a clickable Stack with no button semantics (no role, tabIndex, aria-expanded — keyboard users can't open the 'how payouts work' copy), and it uses eye icons (visibilityon/visibilityoff) as an expand/collapse affordance instead of a chevron, even though an 'expand' icon exists in the registry.
|
||||
- evidence: lines 127-139: `<Stack ... sx={{ cursor: 'pointer' }} onClick={onToggle}>` + `<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} .../>`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx` — Raw vendor strings shown to nurses: failed payouts print the bank rail's `failureReason` verbatim (LTR English/bank codes) in a Persian UI, in both the payout detail and PayoutHistoryRow; likewise VerificationChecklist falls back to the raw snake_case failure code (e.g. 'blurry_scan') when an i18n key is missing.
|
||||
- evidence: payouts/[id]/page.tsx lines 127-131 `{t('failure_reason_label')}: {data.failureReason}` dir="ltr"; PayoutHistoryRow.tsx 101-105; VerificationChecklist.tsx 75-79 `: step.failureReason`
|
||||
- **[medium]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Starter-grade shell hazards directly affecting these flows: physical `paddingLeft`/`paddingRight` + physically 'left'-anchored desktop drawer put the nurse nav on the trailing side in RTL fa (mobile anchor is 'right' — inconsistent, leftover starter comments in config.ts); the logo icon doubles as the sidebar opener with a hard-coded English 'Open Sidebar' tooltip; the main content gutter is a fixed 8px at all breakpoints.
|
||||
- evidence: lines 53-58 physical paddingLeft/Right keyed on `anchor?.includes('left')`; line 71 `title={... : 'Open Sidebar'}`; line 102 `paddingLeft: 1, paddingRight: 1`; config.ts `SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx` — Page-width chaos across adjacent nurse screens: verification (620) / requests / visits (640) cap maxWidth without mx:'auto' so content hugs the start edge beside a vast empty area on desktop; earnings and payout history have no maxWidth so money rows stretch the full viewport; BookingDetailView centers with mx:'auto'. Three different page shapes in one shell.
|
||||
- evidence: earnings/page.tsx:48 `sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}` (no cap) vs verification/page.tsx:47 `maxWidth: 620` (no mx) vs BookingDetailView.tsx:62 `maxWidth: 640, mx: 'auto'`
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx` — Accept fires on a single tap with no confirmation, summary of consequence ('the family will be asked to pay; a booking will be created'), or undo — while sitting flex:1 directly beside Reject at equal width. A mis-tap is materially consequential and irreversible from this UI.
|
||||
- evidence: lines 198-219: accept/reject both `sx={{ m: 0, flex: 1, py: 1.25 }}`, `onClick={handleAccept}` mutates immediately
|
||||
- **[low]** `client/src/components/common/AppButton/AppButton.tsx` — AppButton ships a starter default `margin: 1`, so virtually every call site in this area fights it with `sx={{ m: 0 }}` (30+ occurrences across the audited pages); any forgotten override produces phantom spacing.
|
||||
- evidence: lines 9-11 `DEFAULT_SX_VALUES = { margin: 1, ... }`
|
||||
- **[low]** `client/src/components/booking/SessionCard/SessionCard.tsx` — Terracotta — the brand's 'single sparing accent' — is spread across the nurse surface: EVV contained+outlined buttons, the per-session payout amount text, the 'نمای پرستار' chip, and the notes-panel border/icon/submit all use secondary at once, diluting its urgency value.
|
||||
- evidence: SessionCard line 112 `color: 'var(--bal-secondary-dark)'` + lines 119/133 `color="secondary"`; BookingDetailView 71/83; NurseVisitNotesPanel 73/77/120
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx` — The day surface has no date anchor (no 'امروز، ۲۴ تیر' header), the cards don't say which service the visit is for (patient name + session index only), and useTodaySessions has no refetchInterval — a same-day schedule change won't appear without re-navigation, unlike the polled inbox.
|
||||
- evidence: page title is static `t('evv_visits_title')`; SessionCard receives no service name; useTodaySessions.ts sets only staleTime
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx` — The under-review screen gives a text ETA but no submitted-timestamp or 'what happens next' timeline, and unlike its sibling pages it has no page h1/subtitle above the stepper — the heading lives inside the status card, breaking the header rhythm established by every other nurse page.
|
||||
- evidence: lines 26-29: page opens directly with StepperHeader; h1 is the Typography inside the Paper (line 47)
|
||||
|
||||
## Opportunities (9)
|
||||
|
||||
- **Nurse 'Today' home — replace the placeholder dashboard** (impact: high, effort: medium) — Build /nurse as an operational hub: (1) next visit card with a check-in shortcut and time-until, (2) pending requests strip with the most urgent countdown and an inline accept path, (3) a verification-progress card (reusing the cached status query) until approved, (4) this-week earnings snapshot with the next-payout date. All four data sources already exist as cached queries — this is composition, not new plumbing, and it is the single highest-leverage screen for making the product feel alive and trustworthy to nurses.
|
||||
- **Visit workspace: address, contact, and an in-visit mode** (impact: high, effort: large) — On a confirmed booking, surface the stage-2 disclosure the product already promises: render addressSnapshotJson as an address card with a map deep-link (Neshan/Balad/Google via geo: URI) and a tel: 'call family' action; make check-in a full-width sticky-bottom hero button; after check-in switch the screen into an 'in-visit' mode (elapsed timer, task checklist promoted from the notes panel, check-out CTA); after check-out show a payout confirmation moment ('این ویزیت X تومان به درآمد شما اضافه شد'). This is the flow nurses live in daily and today it's a stack of generic cards.
|
||||
- **Mobile bottom navigation for the nurse shell** (impact: high, effort: medium) — Field nurses are on phones; the current pattern (tap the logo to open a right-anchored drawer with 10 flat items) hides everything. A 4-5 item bottom nav (امروز / درخواستها with unread-count badge / درآمد / پروفایل) plus overflow drawer would transform day-of usability. The BottomBar component already exists in the layout folder, disabled by starter config.
|
||||
- **Decision-first inbox card redesign + urgency system** (impact: high, effort: medium) — Reshape InboxCard around the accept decision: service name + price as the headline, an urgency-tinted countdown pill (teal >2h → amber <2h → terracotta <30min, aria-live), patient/time/gender as secondary facts, and inline accept/decline directly on the card (dialog only for reject reason). Add tabs (در انتظار / پاسخداده / منقضی), a pager, and a sidebar badge for pending count. Pair with a 'fast responses win more bookings' trust nudge in the empty state.
|
||||
- **Unified verification journey with the trust payoff visible** (impact: high, effort: medium) — Merge the two progress metaphors into one vertical journey page: a hero showing a live TrustBadge preview ('این نشان را خانوادهها میبینند') that fills in as steps pass, grouped step cards (identity / credentials / bank) replacing both the 3-step Stepper and the flat 7-row list, per-step ETA chips for in_review items (the 24-48h promise, today only on B6), a submitted-at timestamp, and a celebratory approved state that leads into publish. Verification is the product's core trust ritual and currently reads as a settings checklist.
|
||||
- **Shamsi date picker component** (impact: medium, effort: medium) — A reusable Jalali date field (wheel or calendar) to replace native type="date" in credentials — and later everywhere dates are entered. Directly removes a correctness risk on license expiry data that feeds the credential-expiry sweep.
|
||||
- **'Next payout' forecast on earnings** (impact: medium, effort: small) — Add a single server-provided line above the tabs: next batch date (holiday-shifted) + the eligible amount expected in it ('پرداخت بعدی: شنبه — ۲٬۴۵۰٬۰۰۰ تومان'). Converts the four abstract buckets into the one answer nurses actually seek ('when do I get paid, how much'), and replace the raw HH:MM:SS dispute-window countdown with day-granular copy ('۲ روز تا آزادسازی').
|
||||
- **New-request notifications beyond the 15s poll** (impact: high, effort: large) — The 2h response deadline is only survivable if the nurse happens to have the tab open. Add a web-push opt-in banner on the inbox (service worker + the existing notifications service), falling back to SMS via the backend's Kavenegar rail for accepted-critical events. Deadline-driven marketplaces live or die on this.
|
||||
- **Upload polish: fix rejected-state machine and add capture guidance** (impact: medium, effort: small) — Beyond the precedence bug fix, elevate DocumentUpload for the trust flow: show a frame overlay/illustration for the ID-card capture, image-too-dark/blurry client hints, and keep the rejected reason visible above (not instead of) the progress UI during re-upload so nurses see their recovery succeeding.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Semantic token discipline: every audited component colors via --bal-* CSS variables (StatusChip, TrustBadge, EvvStatusBanner, EarningsBalanceHeader) with explicit 'never hard-code a hex' comments and a mirrored dark scheme in src/theme/tokens.css — no raw hexes found in this area.
|
||||
- Complete loading/empty/error coverage: every list and detail screen (verification hub, inbox, request detail, visits, earnings, payout history, payout detail) has a real skeleton, a designed dashed-border empty state with icon+copy, and an error panel with retry.
|
||||
- The EVV advisory philosophy in code: GPS denial/timeout never blocks a check-in (locationProvider never rejects; controller submits null coords with a warning toast), out-of-range renders warning-toned never error (EvvStatusBanner), and per-session busy state isolates the acting card.
|
||||
- CountdownTimer correctness: server-frozen deadlines only, an isolated 1s tick that re-renders nothing around it, Persian digits forced dir=ltr so HH:MM:SS order survives RTL, and a single onElapsed refetch hook.
|
||||
- Money honesty in EarningsBalanceHeader/EarningsRow/PriceBreakdown: negative net renders as an explicit error-toned 'owed back' card (never a bare minus), gross − commission = payout is reconciled visually, clawbacks get their own explanatory breakdown, and all money is BigInt-safe display-only strings.
|
||||
- Two-stage disclosure enforced in the UI layer: request detail renders only customerNotes + coarse city·district with an explanatory disclosure note; the care-instructions query is enabled only for the nurse on a confirmed+ booking (never fired for customers).
|
||||
- Rejected-step recovery paths exist everywhere and are data-driven: checklist rows surface the failure reason with a 'fix' CTA routed by step code, DocumentUpload has a dedicated rejected variant with reason + re-upload, and the Shahkar shared-SIM failure gets deliberately non-accusatory warning copy.
|
||||
- RTL-aware physical CSS throughout content components: borderInlineStart accent strips, textAlign 'start'/'end' (no left/right), and dir="ltr" islands on IBANs, transfer references, national-ID input, and clock strings.
|
||||
- Locale-correct numerals and dates: Intl NumberFormat fa-IR for all digits (progress counts, pagers, countdown) and formatShamsiDate for every date — no Gregorian date strings leak into the fa UI (except the native date-input problem flagged separately).
|
||||
- The 'honesty constraint' pattern in verification: only genuinely automated checks advertise استعلام خودکار, manual-review copy never claims an authority check, and TrustBadge 'verified' renders only from the approved aggregate with expired visually distinct from unverified.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Nurse-side workspace — dashboard, profile, service pricing (variant builder), coverage, bank
|
||||
|
||||
## Current state
|
||||
|
||||
The nurse workspace lives under client/src/app/[locale]/(private-routes)/nurse/, wrapped by RoleGuard + NurseLayout (client/src/layout/NurseLayout.tsx), which renders the starter-derived TopBarAndSideBarLayout: fixed top bar + a 240px persistent desktop sidebar with a flat 10-item nav (dashboard, requests, profile, services, coverage, bank, verification, visits, earnings, support). The nurse home (nurse/page.tsx) is literally PlaceholderScreen — an icon, the nav title, and generic "coming later" copy — so the landing surface of the entire nurse business is empty while every ingredient of a real dashboard already exists as a cached hook elsewhere (useNurseRequestInbox, useNurseEarningsBalance, useVerificationStatus, bank/coverage/variant queries).
|
||||
|
||||
The functional pages are competent, narrow form columns (each sets its own maxWidth 560–640 and hugs the start edge of the wide shell). Profile (nurse/profile/page.tsx) edits only avatar + bio + years, shows the TrustBadge and a blocked-until-verified banner, and passes education/specialization fields through untouched. Services (nurse/services/page.tsx) switches in-page between MyServicesList (VariantCard rows with soft deactivate/reactivate, skeletons, a good dashed empty state, and the PublishGate verification banner) and VariantBuilder — a 3-step create stepper (CategoryTile grid → option-group ToggleButtonGroups with required badges → Toman price entry with a live PriceDisplay estimate and auto-generated display name); edit mode locks category/options and edits price only. Coverage (nurse/coverage/page.tsx) renders areas as chips, an add card with a whole-city/districts scope toggle plus CascadingRegionSelect (province→city→district, aggressively cached, loading adornments), inline duplicate blocking mirrored to the server 409, and a confirm dialog on remove. Bank (nurse/bank/page.tsx) renders each account through BankStatusPanel's three semantic states (pending/verified/mismatch) with masked LTR IBAN, a make-primary action, and a re-enter path on mismatch.
|
||||
|
||||
Styling is token-disciplined: zero hard-coded hexes in the whole nurse tree (grep-verified), all color through --bal-* semantic tokens, accents via RTL-safe borderInlineStart. But the composition is default-MUI: plain bordered Papers, MUI Stepper header, ToggleButtonGroups, h5+body2 page headers repeated by hand, and the AppButton starter component whose built-in margin every call site cancels with sx={{ m: 0 }}. The systemic behavioral gap is error handling: most queries destructure only { data, isLoading }, so a failed request renders the *empty* state, and several mutations have no onError at all.
|
||||
|
||||
## Problems (16)
|
||||
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/page.tsx` — The nurse dashboard — the landing page of the whole workspace — is a PlaceholderScreen with generic 'placeholder_body' copy. A nurse running their business here gets no today's visits, no pending-request count, no earnings snapshot, no verification/setup status, even though every one of those hooks already exists (useNurseRequestInbox, useNurseEarningsBalance, useVerificationStatus, useMyVariants, useServiceAreas, useNurseBankAccounts).
|
||||
- evidence: line 7: `return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx` — The requests inbox has no error state: it destructures only { data, isLoading }, so a failed useNurseRequestInbox query renders the 'no incoming requests' empty state. A nurse can silently miss paid work while requests are actually pending — with per-request response deadlines ticking. Income-critical false negative.
|
||||
- evidence: line 19 `const { data, isLoading } = useNurseRequestInbox();` + lines 33–45: only `isLoading ? skeleton : items.length === 0 ? empty : list`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx` — Once an account is verified there is no way to add another bank account or change IBAN: the form only appears when accounts.length === 0 or after a mismatch re-enter (setShowForm(true) exists only in the mismatch branch). Yet 'make primary' (line 85) implies multiple accounts are supported. A nurse who switches banks is dead-ended on the money path. Additionally, a failed useNurseBankAccounts query renders the 'no account yet' empty state + open form, inviting a duplicate IBAN submission.
|
||||
- evidence: line 31 `const showFormNow = !isLoading && (accounts.length === 0 || showForm);` — showForm set only at line 81 `onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx` — The 'publish' primary CTA is a no-op that fires a success snackbar — nothing is published, but the UI claims completion. On a trust-first platform a fake success on the go-live action is a product-integrity bug, and the panel occupies prime space on every visit to the services list even when approved.
|
||||
- evidence: line 64: `onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}`
|
||||
- **[high]** `client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx` — Profile save and avatar upload fail silently: both mutations pass only onSuccess (lines 44, 63), and the hooks (services/profiles/hooks/useUpsertNurseProfile.ts, useUploadAvatar.ts) define no onError — a failed save shows nothing, and a nurse may leave believing their trust-critical profile is saved. Also the uploaded avatar is only staged in local state; leaving without pressing 'save' discards it with no warning.
|
||||
- evidence: line 44 `uploadAvatar.mutate(file, { onSuccess: ... })` and lines 54–64 `upsert.mutate(..., { onSuccess: () => enqueueSnackbar(...) })` — no onError anywhere
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx` — Same error→false-empty pattern: a failed useMyVariants query renders the 'create your first service' empty state (isEmpty = !isLoading && variants.length === 0), telling an established nurse their offerings are gone / never existed.
|
||||
- evidence: lines 37, 41–42: `const { data, isLoading } = useMyVariants(); ... const isEmpty = !isLoading && variants.length === 0;`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — Step 2 treats a failed option-groups query as 'this category needs no options' (only isLoading and groups.length === 0 are handled), letting the nurse advance and submit a variant missing required option groups, which then dies with a generic create_error toast. The categories step handles isError with retry (lines 348–356) but the options step does not.
|
||||
- evidence: lines 384–389: `optionGroupsQuery.isLoading ? <AppLoading /> : groups.length === 0 ? t('options_none') : ...` — no isError branch
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx` — The whole-city choice is encoded twice and contradicts itself: the scope ToggleButtonGroup (lines 198–210) selects whole-city vs districts, but when 'specific districts' is chosen, CascadingRegionSelect's district dropdown still offers its own 'whole city' empty MenuItem (CascadingRegionSelect.tsx line 152) — picking it then trips the 'district required' error on add. Also removeArea.mutate has no onError (lines 124–126): a failed removal leaves the chip with no feedback.
|
||||
- evidence: coverage handleAdd line 89: `const districtInvalid = effectiveScope === 'districts' && region.districtId == null;` vs CascadingRegionSelect.tsx line 152: `<MenuItem value="">{t('whole_city')}</MenuItem>`
|
||||
- **[medium]** `client/src/layout/config.ts` — Sidebar anchoring is physical, not logical, and inconsistent per breakpoint: desktop drawer anchors 'left' while mobile anchors 'right', regardless of locale — in the default fa/RTL app the desktop nav sits on the trailing edge (unconventional for RTL) and switches sides between mobile and desktop. TopBarAndSideBarLayout offsets content with physical paddingLeft/paddingRight keyed to the anchor string (lines 53–60). Leftover starter comments ('// 'right';') confirm this was never decided for RTL.
|
||||
- evidence: lines 8–9: `export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';` / `export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';`
|
||||
- **[medium]** `client/src/components/common/AppButton/AppButton.tsx` — Starter-grade AppButton ships a default 8px margin on all sides (DEFAULT_SX_VALUES = { margin: 1 }), which every nurse-workspace call site individually fights with sx={{ m: 0 }} (profile, coverage, bank, services, builder — ~15 occurrences). Any forgotten override yields off-grid spacing; spacing should come from layout gaps, not the button.
|
||||
- evidence: lines 9–11: `const DEFAULT_SX_VALUES = { margin: 1, ... }`
|
||||
- **[medium]** `client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx` — Education level/field and specializations — trust-relevant credentials in a healthcare marketplace — exist in the data model but are not editable anywhere: the form silently round-trips initial values, so nurses can never present their qualifications. The page even shows a 'deferred_services' caption admitting the gap.
|
||||
- evidence: lines 58–60: `educationLevel: initial?.educationLevel ?? '', educationField: initial?.educationField ?? '', specializationsJson: initial?.specializationsJson ?? '[]'`
|
||||
- **[medium]** `client/src/layout/TopBarAndSideBarLayout.tsx` — The content area gets a flat 8px gutter (paddingLeft/Right/Top: 1) and no container system, while every nurse page sets its own maxWidth (560 on profile/coverage/bank/builder, 640 on services list, none on earnings) and start-aligns — so on desktop the workspace reads as a narrow form column stuck in the corner of a mostly-empty page. Classic starter-dashboard composition, not a designed workspace.
|
||||
- evidence: line 102: `sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}` vs MyServicesList.tsx line 73 `maxWidth: 640` and coverage/page.tsx line 130 `maxWidth: 560`
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — Option values render in a ToggleButtonGroup forced to wrap (sx={{ flexWrap: 'wrap' }}); MUI's grouped-button styling (collapsed borders/negative margins, first/last corner rounding) is designed for a single row, so wrapped rows show missing side borders and squared corners on mid-row buttons once a group has many values.
|
||||
- evidence: line 424: `sx={{ flexWrap: 'wrap' }}` on ToggleButtonGroup
|
||||
- **[low]** `client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx` — The duplicate-listing warning sets body text color to var(--bal-warning) (amber) on the paper background — likely failing WCAG contrast in light mode; warning tokens elsewhere are used as chip backgrounds with a dedicated -contrast foreground. The price field also shows raw ungrouped digits (up to 12) while typing — a mis-typed extra zero is a 10× price error; grouping only appears in the estimate panel below.
|
||||
- evidence: line 263: `<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 600 }}>`
|
||||
- **[low]** `client/src/components/PlaceholderScreen/PlaceholderScreen.tsx` — The single sparing terracotta accent (--bal-secondary) is spent on placeholder icons for unfinished screens — the brand accent's most prominent appearance in the nurse workspace is on an empty page, inverting its purpose.
|
||||
- evidence: line 24: `<AppIcon icon={icon} size={48} color="var(--bal-secondary)" />`
|
||||
- **[low]** `client/src/components/StepperHeader/StepperHeader.tsx` — The builder's progress header is a bare default MUI Stepper (default connector, default dot/check icons) — the most default-MUI element in the nurse flow, on the screen a nurse uses to define their core business offering. Page headers likewise are hand-repeated h5+body2 blocks on all five pages with no shared PageHeader component.
|
||||
- evidence: lines 21–27: unstyled `<Stepper activeStep={...} alternativeLabel>`
|
||||
|
||||
## Opportunities (8)
|
||||
|
||||
- **Build the real nurse dashboard ('Today') — pure assembly, all data hooks exist** (impact: high, effort: medium) — Replace the placeholder with a working day-runner: (1) greeting header with name + TrustBadge; (2) 'needs your response' strip — pending requests from useNurseRequestInbox with per-card CountdownTimer, the single most time-critical thing a nurse can miss; (3) today's visits from the bookings/sessions queries with a check-in CTA (EVV lives at /nurse/visits already); (4) an earnings snapshot card from useNurseEarningsBalance (net payable + next weekly payout day) deep-linking to /nurse/earnings; (5) a verification/publish status card when not yet approved. Every widget is a read of an already-cached query — no new services needed.
|
||||
- **'Go live' setup checklist replacing the scattered warnings** (impact: high, effort: medium) — Verification banner (profile), PublishGate (services), empty-coverage warning (coverage), and bank empty state are four disconnected nags for one journey. Build one activation tracker — verified ✓, profile complete ✓, ≥1 active service ✓, ≥1 coverage area ✓, verified primary IBAN ✓ — with a progress meter, shown on the dashboard until complete and linked from each page's banner. This converts anxiety ('why am I not bookable?') into a guided funnel and directly drives supply-side activation.
|
||||
- **Public-profile preview: 'how families see you'** (impact: high, effort: small) — Nurses can't see their own listing as customers do. Add a preview mode composing the existing C3 public-profile pieces (avatar, TrustBadge, bio, ServicePriceRow list, coverage chips) from the nurse's own data, reachable from profile and services pages. On a trust-first marketplace this is both a confidence tool and the strongest motivator to complete bio/photo/credentials.
|
||||
- **Shared query error/empty boundary to kill the error→false-empty pattern** (impact: high, effort: small) — Earnings already has local ErrorPanel/EmptyPanel/retry (nurse/earnings/page.tsx lines 153–181). Extract them into shared components (or a small QueryStateGate wrapper) and apply across requests inbox, services list, bank, coverage, and the builder's options step. One small primitive fixes five misleading states at once and standardizes the retry affordance.
|
||||
- **Variant builder: live listing preview + smarter duplicate handling** (impact: medium, effort: small) — In step 3, render the actual VariantCard as a live 'this is what appears in search' preview (name, category, PriceDisplay) instead of only the price paper — the nurse is composing a listing, show the listing. On a 409 duplicate, offer 'edit the existing listing' (the list already knows it) rather than a dead-end warning. Consider chips instead of wrapped ToggleButtonGroups for option values, which also fixes the grouped-border artifact.
|
||||
- **Coverage map visualization** (impact: medium, effort: medium) — Coverage is text chips only, yet AddressMapPicker/Neshan tiles already exist in components/geography. Rendering covered city/district shapes (or even pins) on a small map makes 'where will I appear in search' tangible and catches mistakes (wrong city, forgotten district) instantly. Also collapse the double whole-city affordance: let the district dropdown's 'whole city' option BE the choice and drop the separate scope toggle.
|
||||
- **Mobile bottom navigation for the daily loop** (impact: medium, effort: medium) — Nurses on shift are on phones; today the 10-item nav hides behind a hamburger in a drawer that opens from the opposite side than on desktop. Give the nurse app a 4–5 tab bottom bar (Today, Requests, Visits, Earnings, More) and demote setup pages (profile/services/coverage/bank/verification) to the 'More' sheet — daily ops one thumb-tap away.
|
||||
- **Bank: add-account and change-IBAN flow** (impact: medium, effort: small) — Beyond the missing 'add another account' button, design the state properly: an 'accounts' section with a persistent add CTA, the pending poll surfaced as an explicit 'we are checking ownership, usually takes X' timeline, and a guarded flow for replacing the primary IBAN (new account → verify → make primary → optionally remove old). This is the nurse's paycheck; it should feel like a bank settings page, not a one-shot form.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- Token discipline is genuinely excellent: zero hard-coded hexes anywhere in the nurse tree (grep-verified); all color flows through --bal-* semantic tokens with -contrast pairs (StatusChip, TrustBadge, BankStatusPanel, PublishGate), so dark mode switches for free.
|
||||
- RTL-safe logical properties for status accents — borderInlineStartWidth/borderInlineStartColor on every banner/panel (profile banner, coverage warning, BankStatusPanel, PublishGate, duplicate warning) — and LTR-pinned numeric inputs (IBAN, years, price) with textAlign:'start'.
|
||||
- Money correctness in the UI: PriceDisplay computes totals integer-safe via BigInt, never shows a total from rate alone, Toman entry converts to IRR at the field boundary, and the builder shows a live grouped-Toman estimate panel.
|
||||
- BankStatusPanel's three-state design (pending/verified/mismatch) with semantic accent edge, masked dir="ltr" IBAN, non-accusatory mismatch copy, and re-enter as the only action — exactly right for a money-trust surface.
|
||||
- TrustBadge honesty-by-construction (verified only when the aggregate is approved; expired visually distinct from never-verified) and its reuse from own-profile to search results.
|
||||
- Two-stage disclosure honored in the requests inbox (notes preview only, never address/clinical data) plus server-frozen CountdownTimer — the privacy model is visible in the UI code.
|
||||
- Soft deactivate semantics on VariantCard: no delete affordance at all, confirm dialog only for the destructive direction, instant reactivate, dimmed + neutral chip + 'can't be booked' hint on inactive rows.
|
||||
- Edit mode of the variant builder correctly locks identity fields (category + option set) with an explanatory caption — EAV identity semantics surfaced honestly instead of letting an edit silently create a different listing.
|
||||
- CascadingRegionSelect is production-grade: cached geo queries, out-of-range prefill guard against MUI Select warnings, per-level loading adornments, whole-city-only cities force the right affordance instead of dead-ending.
|
||||
- Duplicate coverage handling both belt (client-side areaExists pre-check) and braces (server 409 mapped to the same inline message).
|
||||
- Empty states with dashed border + icon + CTA (services list, bank, requests) and rounded skeletons on most lists — the vocabulary exists; it just needs error-state siblings.
|
||||
- Locale-aware digits everywhere (Intl fa-IR for counts/pagers) and Shamsi dates in the inbox.
|
||||
@@ -0,0 +1,75 @@
|
||||
# App shell — header, sidebar, bottom bar, layouts, navigation
|
||||
|
||||
## Current state
|
||||
|
||||
The shell is a two-tier system. The root layout (client/src/app/[locale]/layout.tsx) is genuinely well-built: it owns <html lang/dir>, conditionally loads the Mikhak Persian font only on fa routes, seeds the color scheme from a cookie (no flash), and pairs direction-keyed themes (APP_THEME_RTL/LTR) with a stylis-plugin-rtl Emotion cache. Below it, route groups map to per-actor shells: (public-routes) → PublicLayout, (private-routes) → useSessionRoleSync + a pass-through PrivateLayout, then (customer) → RoleGuard+CustomerLayout, nurse/ → NurseLayout, admin/ → AdminLayout, partner/ → PartnerLayout. RoleGuard (client/src/components/auth/RoleGuard.tsx) is solid: brand splash while /me resolves, explicit error recovery, toast+redirect on role mismatch.
|
||||
|
||||
The chrome itself is split. CustomerLayout (client/src/layout/CustomerLayout.tsx) is bespoke and closest to right: fixed TopBar (static "اپلیکیشن خانواده" title, support icon start, NotificationBell + dark toggle end), an 800px reading column with inner scroll, and a 5-tab BottomBar (Home/Bookings/Patients/Wallet/Profile). Everything else — NurseLayout (10 flat sidebar items), AdminLayout (12 capability-gated items via useAdminCapabilities), PartnerLayout (4 items), and PublicLayout (zero items) — reuses TopBarAndSideBarLayout.tsx, which is the untouched react-starter-kit engine: a stock MUI AppBar with a centered static app-label title, a 240px Drawer (persistent on desktop, temporary on mobile) whose toggle button is the starter's multicolor Twemoji PENCIL icon registered as `logo`, a SideBar containing a永-placeholder UserInfo card ("Current User" / "Loading..."), a default ListItemButton nav list, a dark-mode switch, and a logout icon. Nav items are defined inline in each *Layout.tsx via translated LinkToPage arrays; ROUTES constants live in client/src/constants/routes.ts.
|
||||
|
||||
The "old MUI starter" complaint has a precise root cause: client/src/theme/theme.ts contains palette, typography, and shape only — there is not a single `components` override in the theme, so the AppBar, Drawer, ListItemButton selected state, Toolbar, and BottomNavigation all render stock MUI. On top of that, sidebar navigation is functionally degraded: SideBarNavItem compares unprefixed paths against the locale-prefixed pathname so the active item never highlights, links push unprefixed hrefs through raw next/link (middleware redirect hop), and the SSR mobile-first useIsMobile causes a 240px desktop layout jump after hydration. There is no brand mark, no page-title wayfinding, no back affordance, no locale switcher, and no way for a dual customer+nurse account to switch apps.
|
||||
|
||||
## Problems (20)
|
||||
|
||||
- **[high]** `client/src/components/common/AppIcon/config.ts` — The brand 'logo' is the starter kit's multicolor emoji-style pencil (client/src/components/common/AppIcon/icons/PencilIcon.tsx with hard-coded fills #EA596E, #FFCC4D, #D99E82). It is the brand mark on the auth screens (BrandMark.tsx:21 passes color="var(--bal-primary)" which is ignored because every path has its own fill) and the sidebar-toggle button in every sidebar shell (TopBarAndSideBarLayout.tsx:68-75). A trust-first healthcare product presents a cartoon pencil as its identity.
|
||||
- evidence: config.ts:115 `logo: PencilIcon`; PencilIcon.tsx `fill="#EA596E"` / `fill="#FFCC4D"`
|
||||
- **[high]** `client/src/theme/theme.ts` — createAppTheme defines only cssVariables/colorSchemes/typography/shape/direction — zero `components` overrides. Every piece of chrome (AppBar shadow+solid primary, Drawer paper, grey ListItemButton selected state, BottomNavigation, Toolbar density) is stock MUI. This is the structural root cause of the 'default-MUI starter' look; no amount of per-layout tweaking fixes it without a theme components pass.
|
||||
- evidence: theme.ts:20-35 — createTheme call has no `components` key
|
||||
- **[high]** `client/src/layout/components/SideBarNavItem.tsx` — Sidebar active-item highlight never triggers. `pathname` from next/navigation is locale-prefixed ('/fa/nurse/requests') while nav paths are unprefixed ('/nurse/requests'); defineRouting (client/src/i18n/routing.ts) uses the default localePrefix 'always', so startsWith always fails. Nurse, admin, and partner users get no 'where am I' signal in the sidebar. AppLink's activeClassName (AppLinkNextNavigation.tsx:95 `pathname == currentPath`) has the same bug.
|
||||
- evidence: SideBarNavItem.tsx:28 `(path && path.length > 1 && pathname.startsWith(path))`
|
||||
- **[high]** `client/src/layout/components/SideBar.tsx` — The sidebar identity card renders a permanent placeholder: `<UserInfo showAvatar />` is never given a user, so every nurse/admin/partner sees an empty avatar, hard-coded English 'Current User', and an eternal 'Loading...' (UserInfo.tsx:34-36) — in the chrome of a product whose entire premise is verified identity. AuthState (context/auth/types.ts) has at least phone+roles, and profile services have name/avatar, but nothing is wired.
|
||||
- evidence: SideBar.tsx:56 `<UserInfo showAvatar />`; UserInfo.tsx:34 `{fullName || 'Current User'}`
|
||||
- **[high]** `client/src/layout/PublicLayout.tsx` — The login/first-impression screen carries starter junk: the TopBar title is the hard-coded English 'Unauthorized - Balinyaar' (line 9) shown even on the fa default locale; SIDE_BAR_ITEMS is [] yet the pencil button still opens a drawer containing only a dark-mode switch; and on mobile an EMPTY BottomBar (BOTTOM_BAR_ITEMS = [], line 19) renders as a bare elevated strip at the bottom of the login page (line 34).
|
||||
- evidence: PublicLayout.tsx:9 `const TITLE_PUBLIC = 'Unauthorized - Balinyaar'`; line 34 `{bottomBarVisible && <BottomBar items={BOTTOM_BAR_ITEMS} />}`
|
||||
- **[high]** `client/src/hooks/layout.ts` — SERVER_SIDE_MOBILE_FIRST = true makes every desktop SSR paint the mobile shell first (no sidebar, 56px top bar), then TopBarAndSideBarLayout's stackStyles flip after hydration and content jumps 240px sideways on every nurse/admin/partner desktop load — visible CLS on the daily-driver backoffice screens.
|
||||
- evidence: layout.ts:8 `SERVER_SIDE_MOBILE_FIRST = true`; TopBarAndSideBarLayout.tsx:49-63 paddings keyed on `onMobile`/`sidebarProps`
|
||||
- **[medium]** `client/src/layout/components/SideBarNavItem.tsx` — Sidebar links navigate via raw next/link with unprefixed paths (`to={path}`), forcing a middleware redirect hop on every click and risking a locale flip for /en users (cookie/accept-language re-detection). BottomBar and NotificationBell manually prefix with `/${locale}` instead. Three different locale-handling strategies exist in the chrome and there is no next-intl createNavigation wrapper.
|
||||
- evidence: SideBarNavItem.tsx:34 `to={path}` vs BottomBar.tsx:14 `withLocale(locale, path)`
|
||||
- **[medium]** `client/src/layout/components/TopBar.tsx` — The header is a wasted, static surface: a centered app-label title ('نمای پرستار', 'اپلیکیشن خانواده') with whiteSpace:'nowrap' (overflow risk between icon groups on small screens), no page title, no breadcrumbs, no user identity/avatar, plus leftover starter comment '// boxShadow: none // Uncomment to hide shadow'. Users get zero wayfinding from the chrome on every screen.
|
||||
- evidence: TopBar.tsx:28-38 centered nowrap Typography; line 20 commented starter code
|
||||
- **[medium]** `client/src/layout/AdminLayout.tsx` — Admin and Partner shells pass no headerActions: no notification bell (admin notifications is only a buried 12th sidebar item; partner has none at all), no admin identity or fine-grained-role chip in the header, no environment indicator. For a backoffice where a 'finance' vs 'support' admin see different consoles, the chrome never says who you are.
|
||||
- evidence: AdminLayout.tsx:39-44 and PartnerLayout.tsx:29-36 — TopBarAndSideBarLayout called without headerActions
|
||||
- **[medium]** `client/src/layout/NurseLayout.tsx` — The nurse sidebar is a flat, ungrouped list of 10 items (dashboard, requests, profile, services, coverage, bank, verification, visits, earnings, support) in default ListItemText styling — no sections separating daily work (requests/visits) from setup (services/coverage/bank/verification) from money, and no verification-status cue in nav even though an unverified nurse's single most important task is finishing verification.
|
||||
- evidence: NurseLayout.tsx:19-33 — one flat useMemo array
|
||||
- **[medium]** `client/src/layout/components/BottomBar.tsx` — No iOS safe-area handling — the Paper/BottomNavigation has no env(safe-area-inset-bottom) padding, so on iPhones the home indicator overlaps the tab labels of the customer app's primary navigation. The customer shell is explicitly the mobile-first primary experience.
|
||||
- evidence: BottomBar.tsx:51-62 — sx only sets borderTop; no safe-area padding anywhere in globals.css either
|
||||
- **[medium]** `client/src/layout/CustomerLayout.tsx` — The shell offers no back affordance or contextual title for detail screens (nurse profile, booking detail, checkout): the TopBar startNode is always the support icon and the title is always 'Family app'. Only one page in the whole app (bookings/[id]/review/page.tsx:98) renders a back button, and there is no 'back'/'arrow' icon in the AppIcon registry at all — mobile users must rely on browser chrome mid-funnel.
|
||||
- evidence: CustomerLayout.tsx:45-61 static TopBar; AppIcon/config.ts has no back/arrow icon; only review/page.tsx uses router.back()
|
||||
- **[medium]** `client/src/layout/components/SideBar.tsx` — The drawer-close handler is attached to the whole content Stack (onClick={handleAfterLinkClick} on line 52), so on mobile ANY tap inside the temporary drawer closes it — including toggling the dark-mode switch or a mis-tap on the divider, not just nav-link clicks.
|
||||
- evidence: SideBar.tsx:49-53 `<Stack … onClick={handleAfterLinkClick}>` wrapping UserInfo, nav list, and DarkModeFormSwitch
|
||||
- **[medium]** `client/src/layout/CustomerLayout.tsx` — The BottomBar renders unconditionally, so desktop customers get a full-width mobile tab bar pinned to the bottom of a wide viewport with a lone 800px column above it — a 'phone app stretched to desktop' effect with no desktop nav alternative (BOTTOM_BAR_DESKTOP_VISIBLE in config.ts is dead — only PublicLayout reads it).
|
||||
- evidence: CustomerLayout.tsx:81 `<BottomBar items={bottomNavItems} />` with no breakpoint gate
|
||||
- **[medium]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Chrome strings hard-coded in English on a Persian-default product: 'Open Sidebar' tooltip (line 71), 'Logout Current User' (SideBar.tsx:76), 'Current User'/'Loading...' (UserInfo.tsx). Every other shell string goes through next-intl; these leak English into fa tooltips/labels.
|
||||
- evidence: TopBarAndSideBarLayout.tsx:71 `title={sidebarProps.open ? undefined : 'Open Sidebar'}`
|
||||
- **[low]** `client/src/layout/TopBarAndSideBarLayout.tsx` — RTL correctness rests on a fragile double-flip coincidence: content offset uses physical paddingLeft/paddingRight keyed to the physical anchor string (lines 53-60), which only aligns with the drawer because MUI flips Drawer anchor under theme.direction='rtl' AND stylis-plugin-rtl flips the generated padding CSS. Any future inline style, non-Emotion CSS, or plugin removal silently breaks the fa desktop layout. Logical properties (marginInlineStart / paddingInlineStart) would make it robust.
|
||||
- evidence: TopBarAndSideBarLayout.tsx:53-60 `paddingLeft: … anchor?.includes('left') ? SIDE_BAR_WIDTH : undefined`
|
||||
- **[low]** `client/src/layout/config.ts` — Starter residue: commented-out alternates ('right'; // 'right';) on the anchor constants and the dead BOTTOM_BAR_DESKTOP_VISIBLE=false // true; flag — config that documents the starter's indecision rather than Balinyaar's design.
|
||||
- evidence: config.ts:8-9, 21
|
||||
- **[low]** `client/src/components/UserInfo/UserInfo.tsx` — Starter-typed component: `user?: any`, name/email fallback logic for a phone-OTP product with Persian names, 64px avatar with fontSize '3rem' initials. Needs replacing, not patching, when the sidebar identity card is wired to real profile data.
|
||||
- evidence: UserInfo.tsx:6 `user?: any`; line 18 `user?.phone || (user?.email as string)`
|
||||
- **[low]** `client/src/app/globals.css` — Starter CSS reset sets max-height:100vh + overflow-x:hidden on html/body while the app runs two different scroll models (window scroll in TopBarAndSideBarLayout, inner overflowY:auto <main> in CustomerLayout). The inner-scroll model also defeats Next.js scroll restoration — back-navigating from a nurse profile to search results loses the list position.
|
||||
- evidence: globals.css `max-height: 100vh` on html,body; CustomerLayout.tsx:68 `overflowY: 'auto'` on main
|
||||
- **[low]** `client/src/layout/CustomerLayout.tsx` — No locale switcher exists anywhere in any shell (fa/en are both shipped), and a dual customer+nurse session — explicitly supported by RoleGuard ('passes either shell's guard and can move freely') — has no UI anywhere to switch between the family app and the nurse view, including the profile hub page.
|
||||
- evidence: grep for LocaleSwitch/setLocale returns nothing; resolveRoleDestination used only in auth guards; (customer)/profile/page.tsx has no /nurse link
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **Design a real brand mark and put identity into the chrome** (impact: high, effort: medium) — Replace the Twemoji pencil with a proper Balinyaar logomark (teal/cream SVG that respects currentColor so dark mode works), register it as `logo` in AppIcon, and add a brand lockup to the chrome: wordmark in the customer Home header, a compact brand header at the top of the nurse/admin/partner sidebars, and a proper favicon/manifest icon. Auth screens (BrandMark) fix themselves for free since they already reference icon="logo".
|
||||
- **One theme `components` pass to de-starter every shell at once** (impact: high, effort: medium) — Add component overrides in theme.ts using existing --bal-* tokens: AppBar → cream/paper surface with a hairline divider instead of solid-teal + default shadow (calm, clinical-warm); Drawer paper → bg-default with inset border; ListItemButton → rounded 'pill' selected state using --bal-primary-soft with teal text/icon; BottomNavigation → teal selected color, medium label weight; Toolbar → consistent gutters. Because all four shells render through these primitives, one file transforms the entire chrome without touching layout logic.
|
||||
- **Contextual customer header: page title + back on detail routes** (impact: high, effort: medium) — Turn the customer TopBar from a static 'Family app' label into a contextual header: brand lockup on the 5 root tabs, and (title + back chevron) on pushed routes (nurse profile, booking detail, checkout steps, ticket thread). Implement via a tiny header context or a route-segment→title map; add a direction-aware back icon to the AppIcon registry. This is the single biggest mobile-UX upgrade available — the whole booking funnel currently has no in-app way back.
|
||||
- **Nurse workspace shell with grouped nav and a real identity card** (impact: high, effort: medium) — Restructure the nurse sidebar into labeled sections — امروز (dashboard, requests, visits), حرفه من (services, coverage, verification), مالی (earnings, bank), پشتیبانی — with subheaders and dividers; replace the placeholder UserInfo with a real card: avatar, name, TrustBadge verification state (the existing f5 component), and a small 'complete your verification' progress affordance for unverified nurses. Verification status in the chrome directly serves the trust-first premise.
|
||||
- **Dense admin backoffice chrome** (impact: medium, effort: large) — Give /admin real ops-console chrome: sectioned sidebar (Trust: verification/reviews · Money: payouts/refunds · Support: tickets/alerts · System: config/holidays/audit/roles/partners), a page-title + breadcrumb bar under the AppBar, the admin's fine-grained role chip (super_admin/finance/…) and a bell in the header, denser list typography, and full-width content (drop the 8px-gutter Stack for a proper content frame). Keep useAdminCapabilities gating exactly as is.
|
||||
- **App switcher for dual-role users + locale switcher** (impact: high, effort: small) — Add a compact actor switcher ('نمای پرستار ⇄ اپلیکیشن خانواده') to the customer profile hub and the nurse sidebar for sessions holding both roles (roles already live in AuthContext), and a fa/en locale switcher in the sidebar/profile. Also gives nurses-who-are-also-family a discoverable path that currently does not exist at all.
|
||||
- **Unify locale-aware navigation on next-intl createNavigation** (impact: high, effort: small) — Create src/i18n/navigation.ts (createNavigation from next-intl) and route ALL chrome navigation through its Link/usePathname/useRouter. This simultaneously fixes the never-highlighting sidebar selected state, the middleware redirect hop, the manual `/${locale}` prefixing scattered across BottomBar/NotificationBell/RoleGuard, and future-proofs against localePrefix changes.
|
||||
- **Mobile-native polish for the customer shell** (impact: medium, effort: medium) — Safe-area padding (env(safe-area-inset-bottom)) on the BottomBar, a max-width phone-frame or top-nav variant for desktop customers instead of a full-width bottom tab bar, hide-on-scroll app bar for long lists, and a slim route-transition progress indicator. Consider making the search results list restore scroll on back (window-scroll model or manual restoration) since it is the discovery workhorse.
|
||||
- **Trust cues woven into the chrome itself** (impact: medium, effort: small) — Beyond screens: a persistent 'پرداخت امن نزد بالینیار' escrow microcopy chip in the wallet tab header, verified-nurse badge treatment wherever a nurse identity appears in chrome, and an always-reachable emergency/support affordance in the nurse visit context (the customer support icon exists — mirror the guarantee on the nurse side). In a healthcare marketplace the shell, not just content, should keep signaling safety.
|
||||
- **Fix desktop SSR flash with a CSS-first responsive shell** (impact: medium, effort: medium) — Replace the JS useIsMobile branching in the shells with CSS breakpoints (sx display/breakpoint props render both variants and let media queries pick), or read a UA hint server-side, so desktop first paint already includes the persistent sidebar and correct paddings — eliminating the 240px post-hydration jump on every nurse/admin/partner load.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The design-token architecture: client/src/theme/tokens.css (--bal-* custom properties, light + dark schemes keyed on data-mui-color-scheme) mirrored 1:1 by colors.ts BRAND/LIGHT_PALETTE/DARK_PALETTE — the palette itself (deep teal, sparing terracotta, cream) is on-brand and already dark-mode-complete.
|
||||
- The root [locale] layout: correct lang/dir per locale, conditional Mikhak font loading only on fa routes, cookie-seeded color scheme with no flash, direction-keyed theme pair + stylis-plugin-rtl Emotion cache — this RTL/theming foundation is better than most production apps and must not be regressed.
|
||||
- The per-actor shell architecture: separate CustomerLayout / NurseLayout / AdminLayout / PartnerLayout mapped 1:1 to route groups, with RoleGuard's resolved-vs-pending hydration (brand splash, explicit /me error recovery, mismatch redirect with toast — never the wrong shell as a stand-in). Restyle the chrome, keep this structure.
|
||||
- AdminLayout's capability-gated sidebar via useAdminCapabilities — nav items filtered by fine-grained role codes with server-side enforcement acknowledged in comments; exactly the right display-convenience pattern.
|
||||
- The customer 5-tab bottom-nav IA (Home/Bookings/Patients/Wallet/Profile) and BottomBar's implementation details: locale-prefixed navigation and longest-prefix active matching so /patients/123 still highlights the Patients tab — the one nav component that handles locale correctly.
|
||||
- Performance-conscious chrome composition: DarkModeToggleButton/DarkModeFormSwitch are the only useColorScheme subscribers and NotificationBell isolates the polling unread count, so theme flips and bell updates never re-render the shells.
|
||||
- CONTENT_MAX_WIDTH reading-column constraint (800px) for customer content, ErrorBoundary wrapping every shell's main content, and the RTL typography setup (Mikhak across headings+body for full Persian glyph coverage, button textTransform:'none').
|
||||
@@ -0,0 +1,66 @@
|
||||
# Design tokens, theme, typography, brand execution
|
||||
|
||||
## Current state
|
||||
|
||||
The theme lives in client/src/theme/ as a deliberate two-layer token system: tokens.css defines ~30 `--bal-*` CSS variables (primary/secondary + light/dark/contrast, soft tints, surfaces, text, divider, and four semantic feedback pairs) keyed on `[data-mui-color-scheme='light'|'dark']`, mirrored by colors.ts (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) which feeds a MUI v9 cssVariables theme built in theme.ts (`createAppTheme` produces APP_THEME_LTR/APP_THEME_RTL once at module load). ThemeProvider.tsx wires a direction-aware Emotion cache with stylis-plugin-rtl, CssBaseline enableColorScheme, and a cookie sync so the server (app/[locale]/layout.tsx + lib/cookies/server.ts getThemeMode) stamps `data-mui-color-scheme` on <html> before first paint for returning visitors. Fonts are loaded per-locale in app/[locale]/layout.tsx: Mikhak (400/500/700 woff2 via next/font/local, preload:false) attached only on fa routes; typography.ts declares a Space Grotesk variable for EN that is explicitly "not currently wired to a font loader". Adoption of the tokens in feature code is exceptionally disciplined — 331 `var(--bal-*)` usages across 103 component/page files, with raw hexes existing only in theme/colors.ts, one starter SVG, and one test.
|
||||
|
||||
What the theme does NOT do is the story: createTheme receives only palette, typography (family+weight only), `shape.borderRadius: 10`, and direction — there is zero `components` key anywhere in src (the only styleOverrides grep hit is ErrorBoundary's unrelated text). Every Button, Card, TextField, Chip, Dialog, Table, Tab, and Alert renders stock Material Design with recolored primaries: default elevations, default densities, default focus treatment, Roboto-tuned type metrics. The MUI palette also omits success/error/warning/info, so the 29 files using `<Alert severity>`/`color="success"` show stock MUI greens/reds while toasts (lib/toast/NotistackProvider.tsx) use the brand-harmonized `--bal-*` feedback colors — two visibly different feedback systems. Persian typography inherits MUI's Latin defaults wholesale (rem scale, tight heading line-heights, non-zero letter-spacing that is wrong for joined Persian script), and the "logo" registered in components/common/AppIcon/config.ts is still the starter's multicolor Twemoji pencil SVG, rendered at 56px on the auth screens by components/auth/BrandMark.tsx and as the TopBar logo button. The brand seed deck the tokens were extracted from (product/balinyaar.html) is referenced in tokens.css and .claude/skills/frontend-designer/SKILL.md but no longer exists in the repo. Starter residue persists: dead legacy themes light.ts/dark.ts (built on the deprecated LTR-only TYPOGRAPHY export, exported from theme/index.ts including `LIGHT_THEME as default` — never imported by the app), a raw starter globals.css, and starter-era comments in components/config.ts.
|
||||
|
||||
## Problems (15)
|
||||
|
||||
- **[high]** `client/src/theme/theme.ts` — The MUI theme has NO `components` customization at all — createTheme gets only cssVariables/colorSchemes/typography/shape/direction. No styleOverrides or defaultProps for Button, Card, Paper, TextField, Chip, Dialog, Table, Tabs, Skeleton, etc., so every surface renders stock Material Design (default elevations/shadows, ripple, densities, focus states) with recolored primaries. This is the single biggest reason the app reads as a default-MUI starter instead of the calm/warm brand.
|
||||
- evidence: Lines 19-36: the entire createTheme call — a repo-wide grep for `styleOverrides|defaultProps` finds only an unrelated hit in ErrorBoundary.tsx
|
||||
- **[high]** `client/src/components/common/AppIcon/config.ts` — The registered brand `logo` is still the starter's Twemoji multicolor pencil SVG (icons/PencilIcon.tsx with hard-coded fills #D99E82/#EA596E/#FFCC4D/#CCD6DD). It is the logo button in the TopBar shell and is rendered at 56px on every auth screen by components/auth/BrandMark.tsx, where the passed `color="var(--bal-primary)"` prop is silently ignored by the hard-coded fills. The seed-deck logo (deep-teal square, cream glyph, terracotta dot) was never implemented — first-impression brand execution is a cartoon pencil.
|
||||
- evidence: config.ts line 115: `logo: PencilIcon,`; BrandMark.tsx line 21: `<AppIcon icon="logo" size={56} color="var(--bal-primary)" />`
|
||||
- **[high]** `client/src/theme/colors.ts` — LIGHT_PALETTE/DARK_PALETTE never define success/error/warning/info, so all MUI severity surfaces (Alert, Chip color=success, etc. — used in 29 files including checkout, refund status, verification) render stock MUI #2e7d32 green / #d32f2f red, while notistack toasts use the brand-harmonized --bal-success #1f6b50 / --bal-error #a8392a. The same semantic state shows two different color systems depending on whether it arrives as a toast or an inline alert — directly against the skill rule 'Need success/error/warning/info → use --bal-* tokens, not MUI defaults'.
|
||||
- evidence: colors.ts lines 29-75 define only primary/secondary/background/text/divider; AppAlert.tsx defaults `severity='error' variant='filled'` to stock MUI error red
|
||||
- **[high]** `client/src/theme/typography.ts` — TYPOGRAPHY_RTL sets only fontFamily and weights — the entire Persian type scale is MUI's Roboto-tuned Latin defaults: rem sizes, tight heading line-heights (e.g. h4 1.235) that clip Persian ascenders/descenders, and non-zero letterSpacing on body1/body2/button/caption/overline, which is typographically wrong for joined (cursive) Persian script — letter-spacing visually breaks glyph connections in Mikhak. There is no fa-specific size, line-height, or letter-spacing tuning anywhere, and no responsive heading sizes.
|
||||
- evidence: Lines 43-52: TYPOGRAPHY_RTL contains only fontFamily + fontWeight per variant; no fontSize/lineHeight/letterSpacing overrides exist
|
||||
- **[medium]** `client/src/theme/typography.ts` — The theme requests fontWeight 600 (h6, button) but the Mikhak loader in app/[locale]/layout.tsx ships only 400/500/700 — CSS font-matching resolves 600 upward to 700, so all intended 'semibold' text renders full Bold in Persian. The same 600-vs-loaded-weights mismatch is repeated in ~68 `fontWeight: 600` sx usages across 42 component/page files, collapsing the weight hierarchy to regular-vs-bold; the loaded Medium 500 is barely used.
|
||||
- evidence: typography.ts lines 38/51 (`fontWeight: 600`), layout.tsx lines 40-44 (weights 400/500/700 only); grep `fontWeight: 600` = 68 hits in 42 files
|
||||
- **[medium]** `client/src/theme/typography.ts` — The English brand font is vaporware: BRAND_FONT_VARIABLE_EN '--font-space-grotesk' is declared with a comment admitting it is 'Not currently wired to a font loader; the LTR stack falls back to the system fonts'. /en pages have no brand typeface at all — headings render in Segoe UI/Roboto, so the secondary locale has zero typographic identity.
|
||||
- evidence: Lines 3-5 comment + line 22 DISPLAY_FONT_LTR referencing the never-populated variable
|
||||
- **[medium]** `client/src/theme/tokens.css` — The token system covers colors only. There are no spacing-scale tokens, no radius steps beyond the single shape.borderRadius:10, no elevation/shadow tokens (all shadows are MUI's default neutral-black stack — cold and grey against the warm cream surfaces), no motion/duration/easing tokens, and no focus-ring token. Focus styling exists exactly once in the whole app, hand-rolled in NurseResultCard.tsx (`'&:focus-visible': { outline: '2px solid var(--bal-primary)' }`) — keyboard focus everywhere else is MUI's faint default, a real accessibility + polish gap.
|
||||
- evidence: tokens.css lines 20-99 define only color variables; grep `focus-visible|outline:` yields a single hit at NurseResultCard.tsx:67
|
||||
- **[medium]** `client/src/theme/index.ts` — Dead starter theme code is still exported as the public API: light.ts/dark.ts build legacy ThemeOptions on the deprecated LTR-only TYPOGRAPHY (system font, no colorSchemes, no cssVariables, no direction) and index.ts exports them plus `LIGHT_THEME as default` — importing the package default yields a broken, unbranded theme. Only ThemeProvider and getDirection are actually consumed (single import in app/[locale]/layout.tsx). Violates the repo's own no-dead-code rule and is a trap for future contributors.
|
||||
- evidence: index.ts lines 8-17 export APP_THEME/LIGHT_THEME/DARK_THEME/`LIGHT_THEME as default`; grep shows the only consumer imports `{ ThemeProvider, getDirection }`
|
||||
- **[medium]** `client/src/lib/cookies/server.ts` — First-visit dark-mode flash: when no color-scheme cookie exists, getThemeMode returns `colorScheme: 'light'`, so SSR stamps data-mui-color-scheme="light" on <html>; an OS-dark first-time visitor paints the full light theme, then MUI flips to dark client-side (no InitColorSchemeScript/inline pre-paint script). On a cream-vs-deep-teal palette this flash is stark.
|
||||
- evidence: Line 46: `return { colorScheme: 'light', defaultMode: 'system' };` combined with layout.tsx line 89 `data-mui-color-scheme={colorScheme}`
|
||||
- **[medium]** `client/src/theme/tokens.css` — The brand source of truth is missing: tokens.css says the palette was 'extracted from the seed-deck proposal (balinyaar.html)' and the frontend-designer skill describes the logo from 'product/balinyaar.html', but that file does not exist anywhere in the repo — the intended identity (logo lockup, imagery, tone) is unrecoverable from the codebase, so gaps between deck and implementation cannot even be checked.
|
||||
- evidence: tokens.css line 4 references balinyaar.html; Glob `**/balinyaar*.html` across the repo returns no files
|
||||
- **[low]** `client/src/app/globals.css` — globals.css is an untouched starter reset with hazards: `max-width: 100vw; overflow-x: hidden; max-height: 100vh` on html/body (100vw invites scrollbar-width overflow that the hidden overflow then silently masks; max-height:100vh is fragile on mobile browsers vs dvh and makes body scrolling work only by accident) plus a global `a { color: inherit; text-decoration: none }` that strips native link affordance app-wide. Nothing brandful (selection color, scrollbar, focus) lives here.
|
||||
- evidence: Lines 7-12 and 14-17 — the entire 17-line file
|
||||
- **[low]** `client/src/app/[locale]/layout.tsx` — Brand metadata is placeholder-grade and not locale-aware: a single static title 'Balinyaar | بالینیار' and description 'Balinyaar web application' serve both /fa and /en (no generateMetadata per locale), and viewport.themeColor is hard-coded to light teal #1d4a40 with no dark-scheme media entry, so dark-mode users get a light-teal browser chrome over a #0f1c19 page.
|
||||
- evidence: Lines 50-58: `themeColor: BRAND.teal` and `description: 'Balinyaar web application'`
|
||||
- **[low]** `client/src/components/config.ts` — Starter residue in the component-defaults file: the comment `CONTENT_MIN_WIDTH = 320; // CONTENT_MAX_WIDTH - Sidebar width` is factually wrong (800 − 240 = 560) and the whole file keeps the starter's commented-out-alternatives style ('error' // 'error' | 'info' ...), signaling copy-paste config rather than owned design decisions.
|
||||
- evidence: Line 5 comment; lines 10-35
|
||||
- **[low]** `client/src/components/common/AppIcon/config.ts` — The icon registry (~90 icons) mixes filled and outlined Material styles with no system: legacy starter entries are filled (Home, Settings, Star, AccountCircle, Dashboard, CheckCircle, Cancel, MedicalServices...) while everything added later is deliberately Outlined — adjacent nav/status icons visibly differ in visual weight, reinforcing the default-MUI feel the owner already dislikes. No custom/brand icon set exists (the only custom SVG is the Twemoji pencil).
|
||||
- evidence: Lines 4-33 filled imports vs lines 35-98 `*Outlined` imports registered side-by-side in ICONS
|
||||
- **[low]** `client/src/layout/TopBarAndSideBarLayout.tsx` — Physical direction props in the shell (`paddingLeft`/`paddingRight` gated on `anchor.includes('left')`) instead of logical padding — currently rescued at runtime by the stylis-plugin-rtl Emotion cache flipping them, but it contradicts the skill's own RTL rule and couples shell correctness to the RTL cache implementation.
|
||||
- evidence: Lines 53-60 and line 102
|
||||
|
||||
## Opportunities (10)
|
||||
|
||||
- **A single MUI components theming pass — the highest-leverage move in the whole app** (impact: high, effort: large) — Add a `components` block to createAppTheme encoding the brand once: Button (disableElevation, weight, comfortable padding), Paper/Card (hairline divider border + soft teal-tinted shadow instead of grey elevation), TextField (calmer outline, cream-tinted filled variant), Chip (soft --bal-primary-soft/secondary-soft fills), Dialog/Drawer (radius 16, cream surfaces), Tabs (thicker indicator), Table (relaxed density, tinted header), Skeleton (warm tint), Alert (severity colors mapped to --bal-* tokens). Every screen upgrades simultaneously with zero per-page edits — this is the escape hatch from the 'default-MUI starter' look.
|
||||
- **Design a real Persian type scale** (impact: high, effort: medium) — Replace inherited Roboto metrics in TYPOGRAPHY_RTL with an owned fa scale: letterSpacing: 0 on every variant (joined script), body line-height ≥ 1.7 and heading line-heights ~1.4-1.5 for Persian ascender/descender room, explicit responsive heading sizes (MUI's default 6rem h1 is unusable, which is why no page uses h1/h2 today — grep confirms zero usages), and a weight system built on the actually-loaded 400/500/700 (retire the phantom 600). Wire Space Grotesk via next/font for /en so the secondary locale gets its brand voice.
|
||||
- **Implement the real brand mark and kill the pencil** (impact: high, effort: medium) — Build the seed-deck logo (deep-teal rounded square, cream lowercase glyph, single terracotta dot) as a theme-aware SVG component with sizes for TopBar, auth lockup (BrandMark), favicon, and webmanifest/PWA icons. Since product/balinyaar.html is gone, first re-establish the brand source of truth as a product/brand.md (palette, logo construction, tone words, do/don'ts) so design decisions stop living only in a skill file.
|
||||
- **Unify semantic feedback into the MUI palette** (impact: high, effort: small) — Add success/error/warning/info (from the existing --bal-* values) into LIGHT_PALETTE/DARK_PALETTE so Alert, Chip, Badge, LinearProgress color props are automatically brand-harmonized, then delete per-component semantic styling. One feedback language across toasts, inline alerts, and status chips — important in a product where refund/verification/payment states are the emotional core.
|
||||
- **Codify a trust-signal design language at token level** (impact: high, effort: medium) — Trust IS the product, yet 'verified' has no dedicated visual identity — TrustBadge and verification chips borrow generic primary/success styling. Introduce a `--bal-trust`/`--bal-trust-soft` token pair (both schemes), a consistent shield/checkmark mark, and a defined 'verified nurse' card treatment (badge placement, tinted ring on avatar, tooltip explaining WHAT was verified: identity, license, Shahkar). This turns the platform's core differentiator into a recognizable, repeatable visual asset instead of an ad-hoc green chip.
|
||||
- **Extend tokens beyond color: shadows, radii, motion, focus** (impact: medium, effort: medium) — Add teal-tinted elevation tokens (e.g. shadows built on rgba(29,74,64,α) for light / black-teal for dark), a radius scale (4/10/16) documented next to shape.borderRadius, motion tokens (durations + easings) for consistent transitions, and a global :focus-visible ring (2px --bal-primary, offset 2) applied via theme so keyboard accessibility is uniform instead of existing in exactly one card.
|
||||
- **No-flash color-scheme boot + dark browser chrome** (impact: medium, effort: small) — Render MUI's InitColorSchemeScript (or a 3-line inline script) before paint so cookie-less OS-dark visitors never see the light flash, and switch viewport.themeColor to the media-query array form ({ media: '(prefers-color-scheme: dark)', color: BRAND.tealDeep }) so the browser UI matches the page in both schemes.
|
||||
- **Delete starter residue from the theme layer** (impact: medium, effort: small) — Remove light.ts, dark.ts, the deprecated TYPOGRAPHY export, and the APP_THEME/LIGHT_THEME/default exports from theme/index.ts (nothing imports them); rewrite globals.css as an intentional base (logical-property-safe reset, ::selection in brand teal/cream, dvh-safe heights, focus-visible fallback); fix the false CONTENT_MIN_WIDTH comment and prune the option-menu comments in components/config.ts.
|
||||
- **Commit to one icon style and consider a warmer set** (impact: medium, effort: medium) — Normalize the registry to a single style (the Outlined majority) by swapping the ~12 legacy filled starter icons, or go further and adopt a rounded/duotone set (e.g. Material Symbols Rounded or Phosphor) rendered through the existing AppIcon registry — rounded strokes read warmer and less 'admin dashboard', matching clinical-but-human. The name-registry architecture makes this a config-file-only swap.
|
||||
- **Systematize Persian numerals as a component** (impact: medium, effort: small) — The Intl plumbing (money.ts, date.ts, booking/format.ts) is correct but every call site must remember the locale parameter; add a tiny <LocalizedNumber>/<Money>/<ShamsiDate> component family (or a useFormatters() hook) so counts, pagination, phone numbers, and durations can never accidentally render Latin digits on fa — and typographic details like the Toman unit label and IRR→Toman display stay consistent.
|
||||
|
||||
## Keep (do not regress)
|
||||
|
||||
- The two-layer token architecture (tokens.css --bal-* variables scheme-keyed on data-mui-color-scheme, mirrored in colors.ts) with the sync rule documented in both file headers — a genuinely well-designed system, keep it as the foundation for any restyle.
|
||||
- Outstanding token discipline in feature code: 331 var(--bal-*) usages across 103 files and effectively zero hard-coded hexes outside the theme layer (only the starter PencilIcon and one test) — do not let a design pass reintroduce literals.
|
||||
- Correct MUI v9 RTL setup: dual prebuilt themes (APP_THEME_LTR/APP_THEME_RTL), direction-aware Emotion cache with stylis-plugin-rtl in ThemeProvider.tsx, and lang/dir sourced from the [locale] layout with the documented reasoning for why <html> lives there.
|
||||
- Cookie-SSR color-scheme sync (lib/cookies/server.ts getThemeMode + data-mui-color-scheme stamped server-side) — returning visitors get zero dark-mode flash; also the documented colorSchemeSelector fix in theme.ts.
|
||||
- Per-locale font loading done right: Mikhak via next/font/local with preload:false and a conditional className so Persian woff2 never ships to /en, and the skill rule that fonts load only in the locale layout.
|
||||
- Brand-harmonized feedback tokens plus NotistackProvider styling toasts entirely from --bal-* variables, so toasts track scheme and direction for free through the portal.
|
||||
- The Persian correctness utility layer: money.ts (BigInt IRR, fa-IR digit grouping, Toman-at-the-boundary), date.ts (fa-IR-u-ca-persian Shamsi via Intl, no date library), text.ts toEnglishDigits for Persian-keyboard input, booking/format.ts locale clocks — this is rare-quality i18n plumbing.
|
||||
- The few global theme decisions already made are the right ones: shape.borderRadius 10, button textTransform 'none', dark palette that lifts teal to #6fc0ac on deep-teal surfaces instead of inverting to grey.
|
||||
- The frontend-designer skill (.claude/skills/frontend-designer/SKILL.md) as a written, enforceable design contract — extend it with whatever the design pass adds rather than replacing it.
|
||||
- The AppIcon name-registry pattern — exactly what makes a future icon-set swap a one-file change.
|
||||
Reference in New Issue
Block a user