manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
+89 -58
View File
@@ -83,9 +83,15 @@ Colors exist in **two mirrored places** that must stay in sync. Pick the right o
**Beyond color**`tokens.css` also defines non-palette tokens (`colors.ts` never needs
these; they're define-only in CSS):
- **Radius** — `--bal-radius-sm` (4px, controls: buttons/inputs), `--bal-radius-md`
(10px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(16px: dialogs). Reference the token/constant, never invent a new radius.
- **Radius** — `--bal-radius-sm` (6px, controls: buttons/inputs), `--bal-radius-md`
(8px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
(12px: dialogs). Reference the token, **never a numeric `sx={{ borderRadius: n }}`**
that multiplies the shape unit, which is how the login card once ended up a 30px pill.
`MuiPaper` pins the md step so a Paper can't drift past it. `--bal-radius-pill` (999px)
is for shapes that genuinely *are* pills — the floating bottom nav, a segmented
control's active chip — never for a card.
- **Frame canvas** — `--bal-frame-canvas`, the backdrop `AppFrame` paints *outside* the
phone-width app column. Never a surface a component draws on.
- **Elevation** — `--bal-shadow-1/2/3`, teal-tinted (black-teal in dark mode) shadow
steps that back `theme.ts`'s `shadows` array — every MUI elevation (Paper, Dialog,
Menu, Popover, AppBar) resolves through these, never MUI's default grey stack.
@@ -105,9 +111,9 @@ these; they're define-only in CSS):
## 3. Typography & fonts
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius
- `shape.borderRadius: 8` (set in `src/theme/theme.ts`) — the house corner radius
(= `--bal-radius-md`). Don't override per-component unless deliberate; the radius
*scale* is `--bal-radius-sm` (4, controls) / `-md` (10, cards) / `-lg` (16, dialogs).
*scale* is `--bal-radius-sm` (6, controls) / `-md` (8, cards) / `-lg` (12, dialogs).
- **Weight system — never write `fontWeight: 600`.** Mikhak and Space Grotesk both load
only 400/500/700 (no 600 face), so a requested 600 silently renders full Bold. Use
**700** for headings (`h1``h6`) and buttons/strong emphasis, **500** for lighter
@@ -166,74 +172,99 @@ CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
## 5. Layout & page shells
- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout`
(mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back
chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile
`BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share
`TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through
`@/i18n/navigation` (`Link`/`usePathname`/`useRouter`) — never a raw `next/link` or a
manual `` `/${locale}` `` prefix.
- `TopBarAndSideBarLayout`: a fixed `TopBar` (title from `useRouteTitle()`, the
route→title map in `layout/routeTitle.tsx`) + a `SideBar` rendered as **two Drawers
sharing one content tree** — mobile `temporary` and desktop `variant="permanent"` —
switched purely by `sx` breakpoint, not `useIsMobile()`; the permanent Drawer is a
normal flex sibling of the main column, so desktop reserves its own width with no
manual offset math and no post-hydration layout jump. Optional `identity` (TopBar
chip — admin/partner), `sidebarIdentity` (sidebar card — nurse's `ProfileSummary`),
and `mobileBottomBar` slots.
- Sidebar nav items are `{ title, path, icon, group? }` arrays (`@/utils`'s
`LinkToPage`) built with `useTranslations('nav')`; a shared `group` string on
consecutive items renders a `ListSubheader` section (see `NurseLayout`/`AdminLayout`).
Selection is computed once via the shared `matchActivePath` (longest-prefix,
winner-takes-all) helper — reuse it for any new nav list, never hand-roll
`pathname.startsWith`.
- **Public screens** use `PublicLayout` — a minimal corner strip (logo +
`LocaleSwitcher` + dark toggle), no sidebar/bottom bar; the step content (`AuthCard`)
carries its own larger `BrandMark`, so don't duplicate a big lockup in the shell.
**There is one layout: a phone.** `AppFrame` (`src/layout/AppFrame.tsx`) renders every
screen inside a centered `APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas`
backdrop, at **every viewport**. A wider window gets more canvas, never a wider app —
design one set of states, verify one set of states. Do not add a `≥md` branch that widens
a shell, restores a sidebar, or lays a screen out in columns.
- `AppFrame` owns three structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (header /
`<main>` / footer are flex siblings, so a top bar is `position: static` and no page
needs a top offset); and `overflowX: hidden` + `minWidth: 0`, so an over-wide child
clips rather than dragging the app sideways. Genuinely wide content (a data table)
scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
/ `NurseLayout` / `AdminLayout` / `PartnerLayout`, each wrapped in `RoleGuard`) supply
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation; it sits on the page background. The bottom bar *floats*:
inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated. Neither should
read as a slab sealing off an edge of a 480px screen.
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
(longest-prefix, winner-takes-all) over each tab's own path **plus its `matchPaths`
claims — use `matchPaths` when a tab owns a destination outside its own URL subtree
(`/nurse/finance` owning `/nurse/earnings`). Never hand-roll `pathname.startsWith`.
- **A nav group's root is a real page**, not a drawer section: a short summary of that
domain (read only off queries that already answer it — never a fabricated figure) over a
`NavHubList` of its destinations. See `/nurse/practice`, `/nurse/finance`,
`/admin/trust`, `/admin/system`.
- **Chrome carries no preferences.** Language and appearance live in `SettingsPanel`
(`@/components/settings`), mounted in each actor's settings hub and nowhere else. The
top bar is for identity, the page title, and at most a notification bell. Appearance is a
three-way segmented control (light/dark/**system**) — never a boolean switch, which cannot
express the app's own default.
- **Public screens** use `PublicLayout` — the frame and nothing else, **no top bar**; the
step content (`AuthCard`) carries the only brand mark on screen. `FocusedLayout` is the
framed chrome-free shell for can't-tab-away flows (onboarding, `/select-role`).
- All chrome navigation goes through `@/i18n/navigation` (`Link`/`usePathname`/
`useRouter`) — never a raw `next/link` or a manual `` `/${locale}` `` prefix. (Inside a
*page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.)
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
top-bar `56px` mobile / `64px` desktop). Respect them; don't hard-code.
- Shell dimensions are constants in `src/layout/config.ts` (`APP_FRAME_MAX_WIDTH`,
`TOP_BAR_HEIGHT`). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
is fine for dashboards/tables.
- Prefer MUI breakpoints in `sx` (`{ xs: …, md: … }`) for responsive branching over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and is what caused the
desktop SSR flash `TopBarAndSideBarLayout` now avoids; reach for it only for genuinely
non-structural, JS-only behavior.
- `CONTENT_MAX_WIDTH` mirrors the frame width — a page column can never be wider than the
frame containing it.
- Prefer MUI breakpoints in `sx` for the little responsive branching that remains over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and caused a real SSR
flash; reach for it only for genuinely non-structural, JS-only behavior.
---
## 6. Icons
Icons are a **name registry**, not free imports. `src/components/common/AppIcon/config.ts`
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
the name to `AppButton`/`AppIconButton` (`icon="search"`).
maps lowercase names → components. Render with `<AppIcon icon="home" />` or pass the name
to `AppButton`/`AppIconButton` (`icon="search"`).
**One visual family: MUI `*Rounded`.** Every registered icon is the `Rounded` variant of
`@mui/icons-material` (warmer, softer strokes than the old filled/outlined mix — fits
"clinical-but-human"). When adding an icon, import the `*Rounded` version; don't mix in a
Filled/Outlined/Sharp/TwoTone icon next to it. ~90 names are registered today, spanning
navigation, catalog, verification, booking, payments, admin, and messaging — read
`AppIcon/config.ts` directly for the full list rather than duplicating it here (it drifts
too fast for a skill doc to track reliably); the two structural rules below don't.
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a
contemporary outline family on a 24px grid with round caps/joins, which reads far lighter
than the filled glyphs this registry used to carry at the small sizes a phone-width app
actually uses. `@mui/icons-material` is **no longer a dependency**; never reintroduce it.
The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which
competes with Mikhak's lighter Persian strokes).
**`size` actually resizes now.** `AppIcon` drives size via `style.fontSize` (the basis for
MUI SvgIcon's internal `1em` sizing) instead of `width`/`height` attributes, which MUI's
own CSS used to beat. `<AppIcon icon="verified" size={48} />` renders 48px — no more
silent 24px flattening.
**The mapping is semantic, not incidental.** A name describes the domain concept
("verification", "earnings", "coverage") and the glyph depicts *that*, so swapping the
underlying glyph never leaks into call sites. Related concepts share a visual root on
purpose: trust/verification names are shields, money names are coins or cards, clinical
names are a pulse or a cross. ~110 names are registered — read `AppIcon/config.ts` for the
list rather than duplicating it here; the structural rules below are what won't drift.
**`size` drives real `width`/`height`.** Lucide sizes off SVG attributes, so
`<AppIcon icon="verified" size={48} />` is 48px with no `fontSize`/`1em` indirection.
Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was the one
layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
RTL (`back`, `chevron_start`) are registered in `AppIcon/config.ts`'s `DIRECTIONAL_ICONS`
set. `AppIcon` stamps `data-icon-directional` on those, and one CSS rule
(`app/globals.css`) does `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`.
Adding a new directional icon is a one-line registry addition — never hand-roll a
per-component flip.
RTL (`back`, `chevron_start`, `chevron_end`) are registered in `AppIcon/config.ts`'s
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
icon is a one-line registry addition — never hand-roll a per-component flip.
**Need a new icon:** import the `*Rounded` version into `config.ts`, add a **lowercase**
**Need a new icon:** import it from `lucide-react` into `config.ts`, add a **lowercase**
key to `ICONS`, then reference by that name. Custom SVGs (the brand mark) go in
`AppIcon/icons/`. An unregistered name logs a dev-only warning and falls back to
`default` — never pass a raw MUI icon where a name is expected.
`AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls
back to `default` — never pass a raw icon component where a name is expected.
---
+50 -31
View File
@@ -19,6 +19,8 @@ i18n, cookies, and the rules every change must follow.
- **React 19** + **TypeScript** (`strict`).
- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via
`stylis-plugin-rtl`).
- **Lucide** (`lucide-react`) for icons, behind the `AppIcon` name registry.
`@mui/icons-material` was removed — never reintroduce it.
- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`.
- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer,
`src/context/auth/`, seeded with server-read auth state) for auth/session state.
@@ -121,8 +123,8 @@ client/
│ ├── [...rest]/page.tsx # Catch-all — calls notFound() so any unmatched path under a locale renders not-found.tsx (next-intl's recommended 404 pattern)
│ ├── (private-routes)/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
│ │ ├── _chrome/SidebarShellSkeleton.tsx # Private (`_`-prefixed, not a route) shared loading.tsx skeleton for the 3 sidebar shells (nurse/admin/partner)
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
│ │ ├── _chrome/ShellContentSkeleton.tsx # Private (`_`-prefixed, not a route) shared loading.tsx skeleton for nurse/admin/partner — shapes the content area only; MobileShell chrome is already rendered by the enclosing layout
│ │ ├── select-role/ # /select-role — first-use role picker (no public role yet); role router lands here. Its own layout.tsx wraps FocusedLayout: outside every actor group (no role to tab for) but still inside the phone-width frame
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
│ │ │ ├── loading.tsx # Route-group loading skeleton (header + search bar + category-tile row + card stack)
@@ -163,10 +165,13 @@ client/
│ │ │ ├── profile/page.tsx # /profile — ui-phase-9 rebuild into the customer's account hub (still the single route — sub-sections are `FormDialogShell` sheets, not sub-routes): `ProfileSummary` identity header (avatar/initials + name + server-masked phone), grouped tappable rows (اطلاعات شخصی/نشانی‌ها/زبان/اعلان‌ها/پشتیبانی/خروج — the زبان row owns the server-stored `preferredLanguage` and hosts the phase-2 `LocaleSwitcher`, never a second locale mechanism), an emergency-contact status card (tel:-only link when complete, a warm nudge otherwise), `ActorSwitcher` preserved; sign-out goes through `useLogout()` only. No national-ID.
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ ├── nurse/ # Nurse app (/nurse/…) — 4-tab bottom nav, one tab per group root
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
│ │ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton
│ │ │ ├── page.tsx # /nurse (the «امروز» tab) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
│ │ │ ├── practice/page.tsx # /nurse/practice — «حرفهٔ من» group root (NursePracticeScreen): a listing-status card (own TrustBadge + the real accepting-bookings state) over links to profile/services/coverage/verification, each with a count read off its already-cached query (omitted, never faked, while one is in flight)
│ │ │ ├── finance/page.tsx # /nurse/finance — «مالی» group root (NurseFinanceScreen): the SIGNED net payable balance (an owed-back reads as an error tone, never clamped) over links to earnings/payout history/bank
│ │ │ ├── more/page.tsx # /nurse/more — «بیشتر» group root (NurseMoreScreen): ProfileSummary + ActorSwitcher, support/notifications links with unread badges, SettingsPanel, SignOutRow. Everything the sidebar drawer header and footer used to carry
│ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout
│ │ │ ├── DashboardActivationSlot.tsx # ui-phase-8 — the named composition point from ui-phase-7's hand-off, now filled with the shared `ActivationChecklist` (same component as `/nurse/services`) instead of the old single-row verification banner
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail)
@@ -187,11 +192,16 @@ client/
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `<Suspense>` wrapper.
│ │ ├── admin/ # Admin/backoffice (/admin/…) — 5-tab bottom nav over four group roots (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `<Suspense>` wrapper.
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: every permitted console as a NavHubList, grouped by the same four sections the bottom nav carries (the old 3-column card grid collapsed to one column of icon-and-a-word tiles inside the frame)
│ │ │ ├── _hub/AdminGroupHub.tsx # Private (`_`-prefixed, not a route) body every admin group root shares: header + capability-filtered NavHubList + an optional tail; an all-denied group renders an explicit no-access state, never an empty card
│ │ │ ├── trust/page.tsx # /admin/trust — «اعتماد» group root (verification queue + review moderation)
│ │ │ ├── finance/page.tsx # /admin/finance — «مالی» group root (the weekly payout run)
│ │ │ ├── support/page.tsx # /admin/support — «پشتیبانی» group root (ticket queue + internal alerts)
│ │ │ ├── system/page.tsx # /admin/system — «سیستم» group root (config/holidays/audit/partners/users/roles) plus the admin identity chip, SettingsPanel and SignOutRow that used to live in the top bar. Always reachable: it is the only way out of the app
│ │ │ ├── verification/ # /admin/verification — ui-phase-11 rebuild: page.tsx = status **Tabs** with server counts (when served, REQ-062) + a name/phone search behind the draft-vs-applied Apply/Clear pattern + a client-computed SLA-colored waiting-time column (`WAITING_TIME_WARNING_HOURS`/`_ALARM_HOURS`) ↔ [nurseId]/page.tsx per-nurse case (unchanged DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry with `JalaliDateField` issued/expires, Approve enabled only when all steps pass) + «پرونده بعدی/قبلی» next/prev case nav (re-derives the queue's cached page via `queueFilters.ts`) + arrow-key bindings — client never writes is_verified
│ │ │ ├── tickets/ # /admin/tickets — ui-phase-11: page.tsx adds an activity column + a results footer ↔ [id]/page.tsx admin thread gains close/reopen/assign-to-me (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, gated behind `TICKET_LIFECYCLE_ENABLED` + `canManageTickets` — REQ-063, no live route yet), opens scrolled to the newest message (`useThreadScroll`), and the composer turns amber (`--bal-warning-soft`) + relabels its send button in internal-note mode so a note can't be posted publicly by mistake; AdminMessageBubble still renders isInternal notes distinctly; RefundPanel opens from a refund ticket
│ │ │ ├── payouts/ # /admin/payouts — ui-phase-11: page.tsx fixes the local-midnight UTC off-by-one on the window default, adopts `JalaliDateField` for the period inputs, and the run-confirm shows the batch total/count/date (from the already-fetched preview) behind `ConfirmDialog`'s new typed-confirmation gate (type «تایید» or the amount) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile, unified onto `PageHeader`
@@ -206,11 +216,12 @@ client/
│ │ │ └── notifications/page.tsx # /admin/notifications — still a placeholder; NOT in `AdminLayout`'s nav (no real feed yet, ui-phase-10/11) so it satisfies "no placeholder reachable from admin nav"
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
│ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton
│ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen
│ │ ├── PartnerHomeScreen.tsx # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
│ │ ├── bookings/ # /partner/bookings — ui-phase-11: page.tsx localizes the 7 booking-status codes onto `StatusChip` (was raw English wire codes, e.g. `pending_payment`) in both the table and filter, adopts `useAdminListState`, and rows link to ↔ [id]/page.tsx (new) — a scoped read-only detail (dates, status timeline via the shared `StatusTimeline`, patient display name only — no clinical data; REQ-064, mock-backed)
│ │ ├── more/page.tsx # /partner/more — «بیشتر» group root: the center identity + merchant-of-record status (previously squeezed into the top bar and hidden below `sm`), SettingsPanel, SignOutRow
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state; ui-phase-11 adds a client-side «خروجی CSV» export (`utils/toCsv.ts`, UTF-8 BOM + CRLF for Excel) of the current result set
│ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
│ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
@@ -250,6 +261,7 @@ client/
│ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested)
│ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested)
│ │ ├── FormDialogShell/ # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested)
│ │ ├── NavHubList/ # the grouped list of destinations a group-root («hub») page is built from — the surface that replaced the sidebar: icon chip + title + one line of orientation + optional badge/meta + chevron, navigating through the locale-aware `@/i18n/navigation` Link (tested)
│ │ ├── RouteFadeIn/ # ui-phase-12 — the one route-content fade/slide primitive (motion pass): wraps `{children}`, keyed on the locale-stripped pathname so it remounts (replays the CSS `bal-fade-in` keyframe, globals.css) on navigation but never on an in-place re-render; mounted inside the `ErrorBoundary` in all five shells (tested)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
│ ├── admin/ # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id
@@ -298,32 +310,31 @@ client/
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging), ui-phase-10 messaging-app rebuild. Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen (status filter chips + load-more, EmergencyPlaybookRow instead of a permanent banner), TicketThreadScreen (+ TicketConversationPanel, key={ticketId} — owns the one usePostMessage/draft both TicketMessageList and MessageComposer share, so retry/discard and the composer are one pipeline), TicketMessageList (date separators/author-grouped bubbles/centered system events via useThreadScroll — opens at the newest message, "new message" pill), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (controlled; pointer-aware Enter semantics, attachment affordance gated behind `TICKETS_ATTACHMENTS_ENABLED`), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch; still mounts the full alarm-red EmergencyBanner, untouched). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, hh:mm-only, failed-send retry-in-place + discard, `role="alert"` on failure), TicketListCard (prominent referenceCode + unread pill + last-message preview + relative time, mock-tolerant when the enrichment fields are absent), EmergencyBanner (nurse post-confirmation tel: playbook only), EmergencyPlaybookRow (the inbox's compact neutral emergency row). Helpers: statusKind.ts, authorLabel.ts, clientMessageId.ts, useThreadScroll (reusable scroll-orchestration hook, exported for phase 11's admin thread)
│ ├── notifications/ # f14 notification composites (import from @/components/notifications), ui-phase-10 pass. NotificationBell (chrome container — subscribes to the polling count so only it re-renders; opens NotificationBellPopover on the nurse desktop shell instead of navigating, everywhere else still navigates) → NotificationBellView (pure, tested, ref-forwarding so the container can anchor the popover), NotificationBellPopover (5-recent preview, fetches on open, exported for phase 11's admin shell once it has a feed), NotificationRow (pure, tested: per-kind tinted icon container, navigable rows get a trailing chevron, non-navigable rows render as a plain non-rippling surface), NotificationCenter (shared page body: unread-first, day-grouped امروز/دیروز/این‌هفته with relative timestamps, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts (+ notificationTint). Admin's bell entry is hidden (no real feed yet, `AdminLayout.tsx`) until phase 11 ships one.
── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment)
── settings/ # The app's one appearance-and-language surface (import from @/components/settings): SettingsPanel (language row + the appearance block, tested), ThemeModeSetting (a three-way روشن/تیره/سیستم segmented control, and the ONLY subscriber to useColorScheme() left in the app — an on/off Switch could not express `system`, which is the app's actual default), SignOutRow (always through useLogout()). Mounted in each actor's settings hub — /nurse/more, /admin/system, /partner/more and the customer profile hub — and nowhere else
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard (brand mark → step form → divider → TrustBullets on ONE surface; the facts used to float under the card, and the desktop side-illustration went away with the frame), BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested — also what makes the locale root role-aware), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), TrustBullets
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ ├── request.ts # getRequestConfig — loads messages/${locale}.json
│ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop
├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation
├── layout/ # the one mobile app shell — per-actor tabs over a shared phone-width frame
│ ├── AppFrame.tsx # 'use client' — THE device frame every shell renders inside: a centered `APP_FRAME_MAX_WIDTH` column on a `--bal-frame-canvas` backdrop, header/`<main>`/footer as flex siblings so the frame (not the document) owns the scroll, and `overflowX: hidden` + `minWidth: 0` so an over-wide child clips instead of dragging the app sideways. No shell computes a top offset any more (tested)
│ ├── MobileShell.tsx # 'use client' — the ONE authenticated shell behind all four actor apps: AppFrame + a contextual TopBar (brand lockup on a tab's own path, back chevron + route title on anything deeper) + BottomBar + ErrorBoundary + RouteFadeIn + PageTitleProvider. Actors supply only `tabs` + `headerActions`
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md; ui-phase-10 added a `useSupportUnreadTotal`-driven Badge on the root-tab support icon (renders only when a signal exists — mock-only until REQ-059)
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer); ui-phase-10 added `badgeCount` (useSupportUnreadTotal) on the support sidebar item
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role); no notification bell (ui-phase-10 — admin has no real feed yet, re-add via `NotificationBellPopover` once phase 11 ships one)
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving); ui-phase-11 adds a compact merchant-of-record `StatusChip` beside it, persistent across every portal page
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
│ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot (area phases feed real names in later) + `useRouteTitle`; `isCustomerRootTab`/`CUSTOMER_ROOT_TABS` for the customer header's brand-lockup-vs-title branch
│ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — used by SideBarNavList and BottomBar so a nested route still lights up its parent tab, never a sibling
│ ├── config.ts
│ ├── CustomerLayout.tsx # 'use client' — customer tabs: خانه (+/search) · رزروها · حلقهٔ مراقبت · کیف‌پول · پروفایل (+/addresses, /support, /notifications — the account hub that owns appearance/language)
│ ├── NurseLayout.tsx # 'use client' — nurse tabs, one per group the sidebar used to hide: امروز (/nurse) · حرفهٔ من (/nurse/practice) · مالی (/nurse/finance) · بیشتر (/nurse/more, `useSupportUnreadTotal` badge). Each group root is a real page; `matchPaths` keeps the historical destination URLs lighting up their group
│ ├── AdminLayout.tsx # 'use client' — admin tabs: نمای کلی · اعتماد · مالی · پشتیبانی · سیستم, each a group root. A group tab hides when `useAdminCapabilities` permits nothing inside it (gating unchanged and still per-console; the server still enforces); سیستم is always present because it carries settings + sign-out
│ ├── PartnerLayout.tsx # 'use client' — partner tabs: مرکز · پرستاران · رزروها · تسویه · بیشتر. The center identity + merchant-of-record indicator moved from the top bar into /partner/more
│ ├── PublicLayout.tsx # unauthenticated shell — the frame and NOTHING else: no top bar, so the login card's own BrandMark is the only mark on screen and the locale/theme toggles that used to sit up here live in settings
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free framed shell for can't-tab-away flows (onboarding, and /select-role via its own layout.tsx): a slim logo strip + content, no bottom nav; the route group above it still applies RoleGuard
│ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot + `useRouteTitle`
│ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — BottomBar runs it over each tab's own path PLUS its `matchPaths` claims, so a nested route still lights up its parent tab, never a sibling
│ ├── config.ts # APP_FRAME_MAX_WIDTH (480 — mirrored by components/config.ts's CONTENT_MAX_WIDTH) + TOP_BAR_HEIGHT
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
│ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot
│ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop; renders `LinkToPage.badgeCount` as a small Badge on the icon when > 0 (ui-phase-10, the nurse support entry)
│ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header
│ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested)
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
│ ├── TopBar.tsx # NOT an AppBar — no filled surface, no bottom rule, no elevation: it sits on `background.default` and reads as part of the page rather than a bar covering the top of it. A plain flex row inside AppFrame, never a fixed overlay: title | titleNode override, align ('start' breadcrumb-style | 'center')
│ ├── BottomBar.tsx # the app's only navigation surface (tested) — a FLOATING pill bar (inset from the frame edges, `--bal-radius-pill`, `--bal-shadow-2`) rather than an edge-to-edge slab sealing off the bottom; still a flex sibling of the scrolling main, so nothing is ever hidden under it. ButtonBase tabs with an active pill that fills in behind the icon (`--bal-motion-fast`, so the app-wide reduced-motion gate already covers it), `LinkToPage.badgeCount` badges, and `matchPaths`-aware active matching
│ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — the TopBar title on a shell's root tabs
│ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse «بیشتر» hub + customer profile hub (tested)
│ └── index.tsx
├── lib/
│ ├── api/
@@ -664,13 +675,21 @@ reduced-motion branch; extend this one rule if a new motion primitive needs the
### Toggle components
`DarkModeToggleButton` and `DarkModeFormSwitch` in `src/layout/components/DarkModeButton.tsx` are the **only** components that subscribe to `useColorScheme()`. When the user toggles:
`ThemeModeSetting` in `src/components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()`, and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`, `/admin/system`, `/partner/more`, the customer profile hub) and nowhere else — the old top-bar toggle spent a permanent slot of chrome in three shells on a preference set once. When the user picks a mode:
1. `setMode('dark')` is called
2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously
3. MUI sets `data-mui-color-scheme="dark"` on `<html>`
4. CSS variables resolve → browser repaints. No React re-render above the button.
4. CSS variables resolve → browser repaints. No React re-render above the control.
Use `colorScheme` (not `mode`) for the `isDark` check — `mode` can be `'system'` even when dark is active.
**It is a three-way segmented control (light / dark / system), never a boolean switch.** `system` is
the app's real default (`ThemeProvider`'s `defaultMode` on a cookie-less first visit), so an on/off
control cannot represent the current state and would silently misreport it.
Use `colorScheme` (not `mode`) for an `isDark` check — `mode` can be `'system'` even when dark is
active. The one exception is the control itself, which must read `mode`: that is the user's *choice*,
while `colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it
(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render and
pre-mount state then all agree, so there is no hydration mismatch and no flash of "nothing selected".
---
+104 -19
View File
@@ -62,14 +62,18 @@
"currency_toman": "Toman",
"brand": "Balinyaar",
"brand_tagline": "Home care you can trust",
"open_sidebar": "Open menu",
"switch_locale": "Switch to {locale}",
"page_prev": "Previous",
"page_next": "Next",
"page_indicator": "Page {page} of {total}",
"discard_title": "Discard changes?",
"discard_body": "Your unsaved changes will be lost.",
"discard_confirm": "Discard"
"discard_confirm": "Discard",
"language": "Language",
"appearance": "Appearance",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
},
"shell": {
"customer_app": "Family app",
@@ -839,12 +843,12 @@
"customer_switch": "Are you a family? Family sign in",
"rate_limited": "Too many attempts — please try again shortly",
"otp_title": "Enter the verification code",
"otp_sent_to": "Code sent to {phone}",
"otp_sent_to": "Code sent to <phone></phone>",
"otp_verify_customer": "Verify and continue",
"otp_verify_nurse": "Verify and sign in",
"otp_invalid": "The code is incorrect or has expired",
"otp_locked": "Too many attempts. Request a new code to continue.",
"resend_in": "Resend code in {time}",
"resend_in": "Resend code in <time></time>",
"resend": "Resend code",
"change_number": "Change number",
"routing_title": "Signing you in…",
@@ -1951,24 +1955,69 @@
"draft_banner": "This is placeholder legal copy — it has not yet been reviewed by counsel and must not be relied on before launch.",
"terms_intro": "These draft terms describe how Balinyaar connects families with independent, verified home-nursing professionals. By creating an account you agree to the terms below.",
"terms_sections": [
{ "title": "The service", "body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform." },
{ "title": "Bookings and payment", "body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes." },
{ "title": "Cancellations and refunds", "body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider." },
{ "title": "Nurse verification", "body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile." },
{ "title": "Your responsibilities", "body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully." },
{ "title": "Liability", "body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system." },
{ "title": "Changes to these terms", "body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about these terms can be sent through the support ticket system in the app." }
{
"title": "The service",
"body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform."
},
{
"title": "Bookings and payment",
"body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes."
},
{
"title": "Cancellations and refunds",
"body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider."
},
{
"title": "Nurse verification",
"body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile."
},
{
"title": "Your responsibilities",
"body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully."
},
{
"title": "Liability",
"body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system."
},
{
"title": "Changes to these terms",
"body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect."
},
{
"title": "Contact",
"body": "Questions about these terms can be sent through the support ticket system in the app."
}
],
"privacy_intro": "This draft policy explains what personal data Balinyaar collects to run the service, and how it is used.",
"privacy_sections": [
{ "title": "Information we collect", "body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider." },
{ "title": "How we use it", "body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support." },
{ "title": "Who we share it with", "body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more." },
{ "title": "Data security", "body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse." },
{ "title": "Your rights", "body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data." },
{ "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." }
{
"title": "Information we collect",
"body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider."
},
{
"title": "How we use it",
"body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support."
},
{
"title": "Who we share it with",
"body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more."
},
{
"title": "Data security",
"body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse."
},
{
"title": "Your rights",
"body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data."
},
{
"title": "Changes to this policy",
"body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect."
},
{
"title": "Contact",
"body": "Questions about this policy can be sent through the support ticket system in the app."
}
]
},
"welcome": {
@@ -2005,5 +2054,41 @@
"footer_contact_title": "Support",
"footer_contact_body": "For any questions, reach us through the in-app support ticket system after you sign in.",
"footer_copyright": "© {year, number} Balinyaar"
},
"hub": {
"practice_subtitle": "Your profile, services and credentials in one place",
"practice_profile_sub": "Name, photo, specialisations and education",
"practice_services_sub": "The services and prices you offer",
"practice_coverage_sub": "The cities and districts you travel to",
"practice_verification_sub": "Identity, professional credentials and your trust badge",
"practice_status_title": "Your listing status",
"practice_accepting_on": "Accepting bookings",
"practice_accepting_off": "Bookings paused",
"practice_accepting_manage": "Manage",
"finance_subtitle": "Earnings, payouts and your bank account",
"finance_earnings_sub": "Completed visits and your share",
"finance_payouts_sub": "Weekly transfer history",
"finance_bank_sub": "The IBAN payouts are sent to",
"finance_balance_error": "Your balance could not be loaded.",
"more_title": "Support & settings",
"more_support_sub": "Message the Balinyaar team",
"more_notifications_sub": "Recent activity on your account",
"admin_group_empty_title": "No access",
"admin_group_empty_body": "Your current role can't act on any console in this section.",
"admin_trust_subtitle": "Nurse verification and review moderation",
"admin_finance_subtitle": "The weekly nurse payout run",
"admin_support_subtitle": "User tickets and internal alerts",
"admin_system_subtitle": "Platform configuration and your account",
"admin_verification_sub": "Cases waiting for review",
"admin_reviews_sub": "Publish, hide or reject reviews",
"admin_payouts_sub": "Preview and run a payout batch",
"admin_tickets_sub": "The global ticket queue",
"admin_alerts_sub": "The internal alert worklist",
"admin_config_sub": "Config keys and their change history",
"admin_holidays_sub": "The bank-holiday calendar",
"admin_audit_sub": "Read-only record of every change",
"admin_partners_sub": "Partner centers and sponsored nurses",
"admin_users_sub": "Search users by name or phone",
"admin_roles_sub": "Grant and revoke roles"
}
}
+104 -19
View File
@@ -62,14 +62,18 @@
"currency_toman": "تومان",
"brand": "بالین‌یار",
"brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}",
"discard_title": "تغییرات نادیده گرفته شود؟",
"discard_body": "تغییراتی که ذخیره نشده از دست می‌رود.",
"discard_confirm": "بله، نادیده بگیر"
"discard_confirm": "بله، نادیده بگیر",
"language": "زبان",
"appearance": "نمایش",
"theme_light": "روشن",
"theme_dark": "تیره",
"theme_system": "سیستم"
},
"shell": {
"customer_app": "اپلیکیشن خانواده",
@@ -839,12 +843,12 @@
"customer_switch": "خانواده هستید؟ ورود خانواده‌ها",
"rate_limited": "به‌دلیل تلاش زیاد، کمی بعد دوباره تلاش کنید",
"otp_title": "کد تأیید را وارد کنید",
"otp_sent_to": "کد به شماره {phone} ارسال شد",
"otp_sent_to": "کد به شماره <phone></phone> ارسال شد",
"otp_verify_customer": "تأیید و ادامه",
"otp_verify_nurse": "تأیید و ورود",
"otp_invalid": "کد وارد شده نادرست یا منقضی شده است",
"otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.",
"resend_in": "ارسال مجدد کد تا {time}",
"resend_in": "ارسال مجدد کد تا <time></time>",
"resend": "ارسال مجدد کد",
"change_number": "تغییر شماره",
"routing_title": "در حال ورود…",
@@ -1951,24 +1955,69 @@
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تأییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." },
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
{
"title": "ماهیت خدمت",
"body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جستجو، رزرو و پرداخت انجام می‌دهند."
},
{
"title": "رزرو و پرداخت",
"body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تأیید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود."
},
{
"title": "لغو و بازگشت وجه",
"body": "لغو یک رزرو تأییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تأیید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تأییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد."
},
{
"title": "احراز هویت پرستاران",
"body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تأیید شده در پروفایل او نمایش داده می‌شود."
},
{
"title": "مسئولیت‌های شما",
"body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید."
},
{
"title": "مسئولیت‌پذیری",
"body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود."
},
{
"title": "تغییر این شرایط",
"body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
],
"privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.",
"privacy_sections": [
{ "title": "اطلاعاتی که جمع‌آوری می‌کنیم", "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت." },
{ "title": "نحوه استفاده", "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی." },
{ "title": "اشتراک‌گذاری اطلاعات", "body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند." },
{ "title": "امنیت اطلاعات", "body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است." },
{ "title": "حقوق شما", "body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید." },
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
{
"title": "اطلاعاتی که جمع‌آوری می‌کنیم",
"body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت."
},
{
"title": "نحوه استفاده",
"body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی."
},
{
"title": "اشتراک‌گذاری اطلاعات",
"body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند."
},
{
"title": "امنیت اطلاعات",
"body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است."
},
{
"title": "حقوق شما",
"body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید."
},
{
"title": "تغییر این سیاست",
"body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود."
},
{
"title": "تماس با ما",
"body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید."
}
]
},
"welcome": {
@@ -2005,5 +2054,41 @@
"footer_contact_title": "پشتیبانی",
"footer_contact_body": "برای هر سوالی، پس از ورود از طریق سامانه تیکت پشتیبانی اپلیکیشن با ما در تماس باشید.",
"footer_copyright": "© {year, number} بالین‌یار"
},
"hub": {
"practice_subtitle": "نمایه، خدمات و مدارک شما در یک‌جا",
"practice_profile_sub": "نام، عکس، تخصص‌ها و تحصیلات",
"practice_services_sub": "خدمات و قیمت‌هایی که ارائه می‌دهید",
"practice_coverage_sub": "شهرها و مناطقی که به آن‌ها سر می‌زنید",
"practice_verification_sub": "هویت، مدارک حرفه‌ای و نشان اعتماد",
"practice_status_title": "وضعیت نمایش شما",
"practice_accepting_on": "پذیرش رزرو فعال است",
"practice_accepting_off": "پذیرش رزرو متوقف است",
"practice_accepting_manage": "مدیریت",
"finance_subtitle": "درآمد، تسویه‌ها و حساب بانکی",
"finance_earnings_sub": "ویزیت‌های تکمیل‌شده و سهم شما",
"finance_payouts_sub": "تاریخچهٔ واریزهای هفتگی",
"finance_bank_sub": "شبای مقصد واریز",
"finance_balance_error": "موجودی شما بارگذاری نشد.",
"more_title": "پشتیبانی و تنظیمات",
"more_support_sub": "گفتگو با تیم بالین‌یار",
"more_notifications_sub": "رویدادهای تازهٔ حساب شما",
"admin_group_empty_title": "دسترسی ندارید",
"admin_group_empty_body": "نقش فعلی شما به کنسول‌های این بخش دسترسی ندارد.",
"admin_trust_subtitle": "تأیید صلاحیت پرستاران و بازبینی نظرها",
"admin_finance_subtitle": "تسویهٔ هفتگی پرستاران",
"admin_support_subtitle": "تیکت‌های کاربران و هشدارهای داخلی",
"admin_system_subtitle": "پیکربندی سامانه و حساب شما",
"admin_verification_sub": "صف پرونده‌های در انتظار بررسی",
"admin_reviews_sub": "انتشار، پنهان‌سازی یا رد نظرها",
"admin_payouts_sub": "ساخت و اجرای دستهٔ تسویه",
"admin_tickets_sub": "صف سراسری تیکت‌ها",
"admin_alerts_sub": "کارتابل داخلی هشدارها",
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
"admin_holidays_sub": "تقویم تعطیلات بانکی",
"admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی",
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
"admin_users_sub": "جستجوی کاربران بر پایهٔ نام یا شماره",
"admin_roles_sub": "اعطا و لغو نقش‌ها"
}
}
+13 -31
View File
@@ -12,7 +12,6 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -23,6 +22,7 @@
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
@@ -559,9 +559,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1997,38 +1997,11 @@
"url": "https://opencollective.com/mui-org"
}
},
"node_modules/@mui/icons-material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.1.1.tgz",
"integrity": "sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@mui/material": "^9.1.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.1.1.tgz",
"integrity": "sha512-Wv+gInjrpf99l1Q0oHe0eOWGTnlbkzs5nowClX65KCT/2fyPMwcbFEEkUsOHdpcHhB5UAbz/d7jlwt5ajWVvlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^9.1.1",
@@ -9228,6 +9201,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1 -1
View File
@@ -21,7 +21,6 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.1",
"@mui/material-nextjs": "^9.1.1",
"@tanstack/react-query": "^5.101.0",
@@ -32,6 +31,7 @@
"jalaali-js": "^2.0.0",
"js-cookie": "^3.0.8",
"leaflet": "^1.9.4",
"lucide-react": "^1.27.0",
"next": "^16.2.9",
"next-intl": "^4.13.0",
"notistack": "^3.0.2",
@@ -35,7 +35,7 @@ interface NudgeCardProps {
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
@@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -132,7 +132,7 @@ export default function CancelBookingPage() {
{/* Off-ramps before the kill switch exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
@@ -211,7 +211,7 @@ export default function CancelBookingPage() {
</>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
@@ -59,7 +59,7 @@ export default function BookingInvoicePage() {
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -89,7 +89,7 @@ export default function BookingInvoicePage() {
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -166,7 +166,7 @@ export default function BookingInvoicePage() {
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
@@ -84,7 +84,7 @@ export default function LeaveReviewPage() {
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
@@ -149,7 +149,7 @@ export default function LeaveReviewPage() {
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
@@ -61,7 +61,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
@@ -190,7 +190,7 @@ function DeclinedPanel({
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
@@ -36,7 +36,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
@@ -51,7 +51,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: 'divider',
@@ -128,7 +128,7 @@ function ProviderOption({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -69,7 +69,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{shownPlan ? (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
@@ -63,7 +63,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 4, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
@@ -245,7 +245,7 @@ function CheckoutScreen() {
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
@@ -284,7 +284,7 @@ function CheckoutScreen() {
width: 300,
}}
>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
{payActions}
</Paper>
</Box>
@@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
src={summary.nurseAvatarUrl ?? undefined}
@@ -148,7 +148,7 @@ function ReturnScreen() {
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return (
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')}
@@ -213,7 +213,7 @@ export default function BookingRequestStatusPage() {
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -247,7 +247,7 @@ export default function BookingRequestStatusPage() {
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
@@ -329,7 +329,7 @@ function TerminalCard({
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
@@ -106,7 +106,7 @@ export default function PatientRecordPage() {
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
@@ -296,7 +296,7 @@ function SheetActions({
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
return (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
{onOpen ? (
<AppButton
variant="text"
@@ -724,7 +724,7 @@ function TasksTab({
) : (
<Stack sx={{ gap: 1 }}>
{data.map((task) => (
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
@@ -14,6 +14,7 @@ import {
ProfileSummary,
} from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { ThemeModeSetting } from '@/components/settings';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
@@ -143,6 +144,9 @@ const AccountHub: FunctionComponent<{
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
{/* The app's appearance control lives here (and in each other actor's settings hub) it
used to occupy a permanent slot in every top bar for a preference set once. */}
<ThemeModeSetting />
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
</Stack>
@@ -287,7 +291,7 @@ const AccountRow: FunctionComponent<{
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
'&:hover': { bgcolor: 'action.hover' },
}}
@@ -309,7 +313,7 @@ const EmergencyContactCard: FunctionComponent<{
}> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile');
return (
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
@@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -355,7 +355,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews');
const locale = useLocale();
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
@@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => {
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -71,7 +71,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
sx={{ p: 2.25, borderRadius: 'var(--bal-radius-lg)', backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
@@ -3,12 +3,12 @@ import Stack from '@mui/material/Stack';
import SurfaceCard from '@/components/common/SurfaceCard';
/**
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) the
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
* generic worklist/detail cards. A private (`_`-prefixed) folder not a route.
* The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
* (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
* so this only shapes the content area: a heading line + a short stack of generic worklist cards.
* A private (`_`-prefixed) folder not a route.
*/
export default function SidebarShellSkeleton() {
export default function ShellContentSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width={220} height={32} />
@@ -1,81 +1,63 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AdminPageHeader } from '@/components/admin';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
interface ConsoleEntry extends NavHubItem {
/** Section this console belongs to — the same four groups the bottom nav carries. */
section: 'trust' | 'finance' | 'support' | 'system';
enabled: boolean;
}
/**
* Admin overview landing (f15) the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
* Admin overview landing (f15) the backoffice index. Every worklist the current principal may
* act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
* on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
* still enforces every command's role scope): a `support` admin sees verification/tickets/alerts,
* a `finance` admin sees payouts/config, only a `super_admin` sees roles.
*
* The old 3-column card grid is gone inside a phone-width frame it collapsed to a single column
* of oversized tiles carrying nothing but an icon and one word each.
*/
export default function AdminOverviewScreen() {
const t = useTranslations('admin');
const th = useTranslations('hub');
const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label.
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
].filter((c) => c.enabled);
const consoles: ConsoleEntry[] = [
{ section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ section: 'trust', title: tNav('reviews'), subtitle: th('admin_reviews_sub'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
{ section: 'system', title: tNav('roles'), subtitle: th('admin_roles_sub'), path: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
];
const sections: Array<{ key: ConsoleEntry['section']; label: string }> = [
{ key: 'trust', label: tNav('group_trust') },
{ key: 'finance', label: tNav('group_finance') },
{ key: 'support', label: tNav('group_support') },
{ key: 'system', label: tNav('group_system') },
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Stack sx={{ gap: 2.5 }}>
<PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{consoles.map((c) => (
<AppLink
key={c.key}
to={`/${locale}${c.route}`}
color="inherit"
underline="none"
sx={{ display: 'block', height: '100%' }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1.5,
cursor: 'pointer',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
}}
>
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{tNav(c.key)}
</Typography>
</Paper>
</AppLink>
))}
</Box>
</Box>
{sections.map((section) => {
const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
if (items.length === 0) return null;
return <NavHubList key={section.key} title={section.label} items={items} />;
})}
</Stack>
);
}
@@ -0,0 +1,45 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { EmptyState, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
export interface AdminGroupConsole extends NavHubItem {
/** Capability gate for this console — a hidden row is one the current admin role can't act on. */
enabled: boolean;
}
interface Props {
title: string;
subtitle?: string;
consoles: Array<AdminGroupConsole>;
/** Rendered below the console list (the system group's settings + sign-out). */
children?: ReactNode;
}
/**
* The body every admin group-root page shares: a header, the capability-filtered consoles in that
* group, and an optional tail. Gating stays per-console and is still only a UI hint the server
* authorizes every command regardless of what the nav shows.
* A private (`_`-prefixed) folder, so this is not itself a route.
* @component AdminGroupHub
*/
const AdminGroupHub: FunctionComponent<Props> = ({ title, subtitle, consoles, children }) => {
const t = useTranslations('hub');
const permitted = consoles.filter((console_) => console_.enabled);
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={title} subtitle={subtitle} />
{permitted.length > 0 ? (
<NavHubList items={permitted} />
) : (
<EmptyState icon="lock" title={t('admin_group_empty_title')} body={t('admin_group_empty_body')} />
)}
{children}
</Stack>
);
};
export default AdminGroupHub;
@@ -71,7 +71,7 @@ function AdminAuditPageInner() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -229,7 +229,7 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
) : (
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
@@ -0,0 +1,28 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «مالی» group root — the weekly payout dashboard. */
export default function AdminFinancePage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_finance')}
subtitle={t('admin_finance_subtitle')}
consoles={[
{
title: tn('payouts'),
subtitle: t('admin_payouts_sub'),
icon: 'earnings',
path: ROUTES.ADMIN_PAYOUTS,
enabled: caps.canPayout,
},
]}
/>
);
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -123,7 +123,7 @@ export default function AdminPartnerCenterDetailPage() {
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
/>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
@@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() {
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
@@ -83,7 +83,7 @@ function AdminPayoutBatchDetailScreen() {
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
@@ -180,7 +180,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
};
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Stack
direction="row"
@@ -217,7 +217,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
<Box
sx={{
p: 1.25,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -283,7 +283,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
@@ -317,7 +317,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
@@ -166,7 +166,7 @@ function ModerationCard({
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «پشتیبانی» group root — the global ticket queue and the internal alert worklist. */
export default function AdminSupportPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_support')}
subtitle={t('admin_support_subtitle')}
consoles={[
{
title: tn('tickets'),
subtitle: t('admin_tickets_sub'),
icon: 'support',
path: ROUTES.ADMIN_TICKETS,
enabled: caps.canManageTickets,
},
{
title: tn('alerts'),
subtitle: t('admin_alerts_sub'),
icon: 'alerts',
path: ROUTES.ADMIN_ALERTS,
enabled: caps.canManageAlerts,
},
]}
/>
);
}
@@ -0,0 +1,88 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { ProfileSummary, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import { useMe } from '@/services/auth';
import AdminGroupHub from '../_hub/AdminGroupHub';
/**
* «سیستم» group root platform configuration plus the identity/appearance/sign-out block that
* used to live in the top bar and drawer footer. Always reachable (even for an admin role with no
* system console permitted), because it is the only way out of the app.
*/
export default function AdminSystemPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const ta = useTranslations('admin');
const caps = useAdminCapabilities();
const { data: me } = useMe();
const primaryRoleCode = caps.roles[0];
return (
<AdminGroupHub
title={tn('group_system')}
subtitle={t('admin_system_subtitle')}
consoles={[
{
title: tn('config'),
subtitle: t('admin_config_sub'),
icon: 'config',
path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig,
},
{
title: tn('holidays'),
subtitle: t('admin_holidays_sub'),
icon: 'calendar',
path: ROUTES.ADMIN_HOLIDAYS,
enabled: caps.canConfig,
},
{
title: tn('audit'),
subtitle: t('admin_audit_sub'),
icon: 'audit',
path: ROUTES.ADMIN_AUDIT,
enabled: caps.canViewAudit,
},
{
title: tn('partners'),
subtitle: t('admin_partners_sub'),
icon: 'partners',
path: ROUTES.ADMIN_PARTNERS,
enabled: caps.canManagePartners,
},
{
title: tn('users'),
subtitle: t('admin_users_sub'),
icon: 'users',
path: ROUTES.ADMIN_USERS,
enabled: caps.canManageRoles,
},
{
title: tn('roles'),
subtitle: t('admin_roles_sub'),
icon: 'roles',
path: ROUTES.ADMIN_ROLES,
enabled: caps.canManageRoles,
},
]}
>
<Stack sx={{ gap: 2 }}>
<SurfaceCard>
<ProfileSummary
displayName={me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''}
phone={me?.phone}
roleLabel={primaryRoleCode ? ta(`role_${primaryRoleCode}`) : undefined}
loading={!me}
/>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
</AdminGroupHub>
);
}
@@ -167,7 +167,7 @@ export default function AdminTicketThreadPage() {
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined}
@@ -202,7 +202,7 @@ export default function AdminTicketThreadPage() {
<Paper
id={REFUND_PANEL_ID}
elevation={0}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
@@ -229,7 +229,7 @@ export default function AdminTicketThreadPage() {
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
@@ -112,7 +112,7 @@ function AdminTicketsQueue() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «اعتماد» group root — the verification queue and review moderation. */
export default function AdminTrustPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_trust')}
subtitle={t('admin_trust_subtitle')}
consoles={[
{
title: tn('verification'),
subtitle: t('admin_verification_sub'),
icon: 'verification',
path: ROUTES.ADMIN_VERIFICATION,
enabled: caps.canVerify,
},
{
title: tn('reviews'),
subtitle: t('admin_reviews_sub'),
icon: 'moderation',
path: ROUTES.ADMIN_REVIEWS,
enabled: caps.canModerate,
},
]}
/>
);
}
@@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() {
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
@@ -207,7 +207,7 @@ function AdminVerificationCaseScreen() {
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
@@ -325,7 +325,7 @@ function StepCard({
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
@@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -1,14 +1,15 @@
'use client';
import { ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
InitialsAvatar,
Money,
SurfaceCard,
TrustBadge,
@@ -19,78 +20,114 @@ import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useUnreadCount } from '@/services/notifications';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
const DASHBOARD_MAX_WIDTH = 960;
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) the operational home replacing the `PlaceholderScreen`.
* Pure assembly: every widget reads an already-cached query. Order matters the pending-requests strip
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
* The nurse «امروز» home the first bottom-nav destination.
*
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
* important than anything else. This version gives the page one visual hierarchy: a quiet identity
* strip, then exactly one hero action (the next visit), then sections introduced by a plain label
* with a text link instead of a competing button.
*
* Composition only every widget reads a query that is already cached elsewhere in the shell, and
* order encodes urgency: a missed request expires, an unread earnings figure does not.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
const { data: me, isLoading: meLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{meLoading ? (
<>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={160} height={32} />
</>
) : (
<>
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
</Stack>
</>
)}
</Stack>
<Stack sx={{ gap: 2.5 }}>
<GreetingHeader />
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<RequestsSection />
<EarningsSection />
<DashboardActivationSlot />
<NotificationsEntryRow />
</Stack>
);
}
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
/**
* A section label + an optional text link. Deliberately not a button: on a 480px row a
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
*/
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
const locale = useLocale();
return (
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{actionLabel && actionTo ? (
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
{actionLabel}
</AppLink>
) : null}
</Stack>
);
}
/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */
function GreetingHeader() {
const t = useTranslations('dashboard');
const { data: me, isLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
if (isLoading) {
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={180} height={28} />
</Stack>
);
}
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
<InitialsAvatar name={displayName} size={44} />
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{verification.isLoading ? null : (
<Box>
<TrustBadge state={ownBadgeState(verification.data)} />
</Box>
)}
</Stack>
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('next_visit_title')} />
<EmptyState icon="visits" title={t('next_visit_empty')} />
</Stack>
);
}
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
@@ -98,48 +135,41 @@ function NextVisitCard() {
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<SurfaceCard data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<AccentCard tone="secondary" data-widget="next-visit">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
{t('next_visit_title')}
</Typography>
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{next.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Typography>
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
</Typography>
<MetaLine
items={[
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Box>,
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
]}
/>
</Stack>
<AppButton
variant="contained"
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
{t('next_visit_cta')}
</AppButton>
</Stack>
</SurfaceCard>
</AccentCard>
);
}
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
function RequestsStrip() {
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
function RequestsSection() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -148,76 +178,70 @@ function RequestsStrip() {
const total = data?.total ?? 0;
if (items.length === 0) {
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
<EmptyState icon="requests" title={t('requests_strip_empty')} />
</Stack>
);
}
const mostUrgent = items[0];
return (
<SurfaceCard data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('requests_strip_title', { count: total })}
</Typography>
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
<SectionHeader
title={t('requests_strip_title', { count: total })}
actionLabel={t('requests_strip_cta')}
actionTo={ROUTES.NURSE_REQUESTS}
/>
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` the old
layout let the countdown wrap under a long Persian name and collide with the date. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<Box sx={{ flexShrink: 0 }}>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Box>
</Stack>
<AppButton
variant="text"
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
fullWidth
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
>
{t('requests_strip_cta')}
{t('requests_strip_open')}
</AppButton>
</Stack>
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Stack>
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
</SurfaceCard>
</Stack>
);
}
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
function EarningsSnapshotCard() {
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
function EarningsSection() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={120} />;
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -228,69 +252,48 @@ function EarningsSnapshotCard() {
const magnitude = isOwed ? -net : net;
return (
<SurfaceCard data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('earnings_snapshot_title')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
>
{t('earnings_snapshot_cta')}
</AppButton>
</Stack>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}
</Typography>
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
</Stack>
</Box>
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
<SectionHeader
title={t('earnings_snapshot_title')}
actionLabel={t('earnings_snapshot_cta')}
actionTo={ROUTES.NURSE_EARNINGS}
/>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<StatTile
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
/>
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
</Box>
</Stack>
);
}
function StatTile({ label, value }: { label: string; value: ReactNode }) {
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{value}
</Stack>
</SurfaceCard>
);
}
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
function MetaLine({ items }: { items: Array<ReactNode> }) {
const present = items.filter(Boolean);
return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-widget="notifications-entry">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('notifications_entry_title')}
</Typography>
</Stack>
<Typography
variant="body2"
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
</Typography>
</Stack>
</SurfaceCard>
</AppLink>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{present.map((item, index) => (
<Box key={index} component="span">
{index > 0 ? ' · ' : null}
{item}
</Box>
))}
</Typography>
);
}
@@ -132,7 +132,7 @@ export default function NurseCoveragePage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -153,7 +153,7 @@ export default function NurseCoveragePage() {
</Paper>
)}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')}
@@ -157,7 +157,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -168,7 +168,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 'var(--bal-radius-sm)' }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -66,14 +66,14 @@ export default function NursePayoutDetailPage() {
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -112,7 +112,7 @@ export default function NursePayoutDetailPage() {
<Box
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -174,7 +174,7 @@ export default function NursePayoutDetailPage() {
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
@@ -0,0 +1,83 @@
'use client';
import { Skeleton, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AccentCard, ErrorState, Money, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { parseIrr } from '@/utils';
import { useNurseEarningsBalance } from '@/services/payouts';
/**
* «مالی» group root. The one number a nurse opens this tab for the signed net payable balance
* is answered before any navigation, then the three money screens are one tap away. The balance is
* **signed**: an outstanding clawback can exceed accrued earnings, and clamping it to zero would
* quietly tell a nurse they are owed nothing when they in fact owe money back.
*/
export default function NurseFinanceScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const tp = useTranslations('payouts');
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
const items: Array<NavHubItem> = [
{
title: tn('earnings'),
subtitle: t('finance_earnings_sub'),
icon: 'earnings',
path: ROUTES.NURSE_EARNINGS,
},
{
title: tn('payouts'),
subtitle: t('finance_payouts_sub'),
icon: 'history',
path: ROUTES.NURSE_EARNINGS_PAYOUTS,
},
{
title: tn('bank'),
subtitle: t('finance_bank_sub'),
icon: 'bank',
path: ROUTES.NURSE_BANK,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_finance')} subtitle={t('finance_subtitle')} />
<BalanceSummary data={data} isLoading={isLoading} isError={isError} onRetry={() => refetch()} tp={tp} t={t} />
<NavHubList items={items} />
</Stack>
);
}
interface BalanceSummaryProps {
data: ReturnType<typeof useNurseEarningsBalance>['data'];
isLoading: boolean;
isError: boolean;
onRetry: () => void;
tp: ReturnType<typeof useTranslations<'payouts'>>;
t: ReturnType<typeof useTranslations<'hub'>>;
}
function BalanceSummary({ data, isLoading, isError, onRetry, tp, t }: BalanceSummaryProps) {
if (isLoading) return <Skeleton variant="rounded" height={104} />;
if (isError) return <ErrorState message={t('finance_balance_error')} retryLabel={t('retry')} onRetry={onRetry} />;
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<AccentCard tone={isOwed ? 'error' : 'primary'}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="xl" tone={isOwed ? 'error' : 'emphasis'} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}: <Money amountIrr={data.eligibleTotalIrr} size="sm" component="span" />
</Typography>
</Stack>
</AccentCard>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseFinanceScreen from './NurseFinanceScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_finance') };
}
export default function NurseFinancePage() {
return <NurseFinanceScreen />;
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,73 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { NavHubList, PageHeader, ProfileSummary, SurfaceCard } from '@/components';
import type { NavHubItem } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { ActorSwitcher } from '@/layout';
import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles';
import { useUnreadCount } from '@/services/notifications';
import { useSupportUnreadTotal } from '@/services/tickets';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «بیشتر» group root the fourth bottom-nav destination: who you are signed in as, the two
* conversation surfaces (support, notifications), appearance/language, and sign-out. Everything
* here used to live in the sidebar drawer's header and footer, where a preference toggle sat
* permanently next to primary navigation.
*/
export default function NurseMoreScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const notificationsUnread = useUnreadCount();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const items: Array<NavHubItem> = [
{
title: tn('support'),
subtitle: t('more_support_sub'),
icon: 'support',
path: ROUTES.NURSE_SUPPORT_TICKETS,
badgeCount: supportUnreadTotal ?? undefined,
},
{
title: tn('notifications'),
subtitle: t('more_notifications_sub'),
icon: 'notifications',
path: ROUTES.NURSE_NOTIFICATIONS,
badgeCount: notificationsUnread || undefined,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
<ProfileSummary
displayName={displayName}
phone={me?.phone}
avatarUrl={nurseProfile?.avatarUrl}
trustState={ownBadgeState(verification)}
loading={!me}
/>
<ActorSwitcher target="customer" />
</Stack>
</SurfaceCard>
<NavHubList items={items} />
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseMoreScreen from './NurseMoreScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hub' });
return { title: t('more_title') };
}
export default function NurseMorePage() {
return <NurseMoreScreen />;
}
@@ -0,0 +1,98 @@
'use client';
import { Stack, Typography } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import { AccentCard, AppLink, NavHubList, PageHeader, StatusChip, TrustBadge } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseProfile } from '@/services/profiles';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «حرفهٔ من» group root the page the bottom-nav tab lands on. It answers the question the
* sidebar section never could ("is my listing actually live, and what's missing?") before handing
* off to the four screens that fix it. Every number here is read off a query that already answers
* it; nothing is derived optimistically, and a count is simply omitted until its query resolves.
*/
export default function NursePracticeScreen() {
const t = useTranslations('hub');
const locale = useLocale();
const tn = useTranslations('nav');
const { data: verification } = useVerificationStatus();
const { data: profile } = useNurseProfile();
const { data: variants } = useMyVariants();
const { data: areas } = useServiceAreas();
const activeVariants = variants?.items.filter((variant) => variant.isActive).length;
const isAccepting = profile?.isAcceptingBookings;
const items: Array<NavHubItem> = [
{
title: tn('profile'),
subtitle: t('practice_profile_sub'),
icon: 'account',
path: ROUTES.NURSE_PROFILE,
},
{
title: tn('services'),
subtitle: t('practice_services_sub'),
icon: 'services',
path: ROUTES.NURSE_SERVICES,
meta: activeVariants === undefined ? undefined : <MetaCount value={activeVariants} />,
},
{
title: tn('coverage'),
subtitle: t('practice_coverage_sub'),
icon: 'coverage',
path: ROUTES.NURSE_COVERAGE,
meta: areas === undefined ? undefined : <MetaCount value={areas.total} />,
},
{
title: tn('verification'),
subtitle: t('practice_verification_sub'),
icon: 'verification',
path: ROUTES.NURSE_VERIFICATION,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_profession')} subtitle={t('practice_subtitle')} />
<AccentCard tone={isAccepting ? 'success' : 'warning'}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('practice_status_title')}
</Typography>
<TrustBadge state={ownBadgeState(verification)} />
</Stack>
{isAccepting === undefined ? null : (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusChip
status={isAccepting ? 'active' : 'neutral'}
label={isAccepting ? t('practice_accepting_on') : t('practice_accepting_off')}
/>
<AppLink to={`/${locale}${ROUTES.NURSE_SERVICES}`} variant="caption">
{t('practice_accepting_manage')}
</AppLink>
</Stack>
)}
</Stack>
</AccentCard>
<NavHubList items={items} />
</Stack>
);
}
/** A count read straight off a resolved query — never a placeholder while one is in flight. */
function MetaCount({ value }: { value: number }) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NursePracticeScreen from './NursePracticeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_profession') };
}
export default function NursePracticePage() {
return <NursePracticeScreen />;
}
@@ -139,7 +139,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
@@ -61,7 +61,7 @@ export default function NurseRequestDetailPage() {
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
@@ -131,7 +131,7 @@ export default function NurseRequestDetailPage() {
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -171,7 +171,7 @@ export default function NurseRequestDetailPage() {
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
@@ -225,7 +225,7 @@ export default function NurseRequestDetailPage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -105,7 +105,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -22,7 +22,7 @@ const PublishGate: FunctionComponent = () => {
const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings();
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => {
@@ -309,7 +309,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
@@ -375,7 +375,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
@@ -163,7 +163,7 @@ export default function CredentialsSubmitPage() {
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
@@ -318,7 +318,7 @@ export default function CredentialsSubmitPage() {
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -119,7 +119,7 @@ export default function IdentitySubmitPage() {
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -51,9 +51,9 @@ export default function NurseVerificationPage() {
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -127,7 +127,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
elevation={0}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -70,7 +70,7 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{canAppend ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
@@ -78,7 +78,7 @@ function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const { severity, key } = banner[state];
return (
<Alert severity={severity} sx={{ borderRadius: 2 }}>
<Alert severity={severity} sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t(key)}
</Alert>
);
@@ -89,7 +89,7 @@ function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner');
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')}
</Typography>
@@ -69,7 +69,7 @@ export default function PartnerBookingDetailPage() {
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -91,7 +91,7 @@ export default function PartnerBookingDetailPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
@@ -92,7 +92,7 @@ function PartnerBookingsScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,38 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { PageHeader, ProfileSummary, StatusChip, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { useMyPartnerCenter } from '@/services/partnerCenter';
/**
* «بیشتر» group root for the partner portal the center's identity and merchant-of-record status
* (previously squeezed into the top bar, where the MoR indicator was hidden below `sm`), plus
* appearance/language and sign-out.
*/
export default function PartnerMorePage() {
const t = useTranslations('hub');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter();
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<ProfileSummary displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
/>
) : null}
</Stack>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -112,7 +112,7 @@ function PartnerSettlementScreen() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}>
<Alert severity="info" sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t('settlement_not_mor')}
</Alert>
</Box>
@@ -149,7 +149,7 @@ function PartnerSettlementScreen() {
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')}
@@ -0,0 +1,13 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
/*
* First-use role picker. It sits outside every actor route group on purpose the session has no
* public role yet, so there is no app to show tabs for but it still needs the phone-width frame
* that the actor shells provide, hence the chrome-free `FocusedLayout` (logo strip + content, no
* bottom nav). No `RoleGuard` here: gating on a role is exactly what this page exists to resolve.
*/
export default function SelectRoleLayout({ children }: { children: ReactNode }) {
return <FocusedLayout>{children}</FocusedLayout>;
}
@@ -27,7 +27,7 @@ const ActivationChecklist: FunctionComponent = () => {
const t = useTranslations('activation');
const state = useActivationChecklist();
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
}
@@ -44,7 +44,7 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountI
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2.5,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -39,7 +39,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
@@ -69,7 +69,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
@@ -55,7 +55,7 @@ const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, on
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
@@ -171,7 +171,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 'var(--bal-radius-sm)', height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
@@ -184,7 +184,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 'var(--bal-radius-sm)', flexShrink: 0 }}
/>
) : (
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
@@ -262,7 +262,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
}}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px dashed',
borderColor: 'divider',
textAlign: 'center',
@@ -54,7 +54,7 @@ const EscrowExplainer: FunctionComponent = () => {
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack
direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
@@ -65,7 +65,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
alignItems: 'flex-start',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
cursor: 'pointer',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
@@ -145,7 +145,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />
+19 -3
View File
@@ -20,7 +20,13 @@ export interface OtpInputProps {
}
const DEFAULT_LENGTH = 5;
const BOX_SIZE = 48;
/**
* The box is sized in `ch`-free absolute terms but must survive 6 boxes + gaps inside the
* ~480px app frame minus the card padding, so it shrinks rather than overflowing.
*/
const BOX_MIN_SIZE = 40;
const BOX_MAX_SIZE = 52;
const BOX_GAP = 1;
/**
* One-time-code input: `length` single-digit boxes with auto-advance, backspace-to-previous,
@@ -110,10 +116,20 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
};
return (
<Stack direction="row" spacing={1} dir="ltr" role="group" aria-label={ariaLabel} sx={{ justifyContent: 'center' }}>
// `gap`, never Stack's `spacing`: spacing compiles to a directional margin that the RTL Emotion
// cache mirrors, while this group is force-`dir="ltr"` so the code reads left-to-right. The two
// disagreed on /fa and collapsed the gap between the first two boxes while doubling it at the end.
<Stack
direction="row"
dir="ltr"
role="group"
aria-label={ariaLabel}
sx={{ justifyContent: 'center', gap: BOX_GAP, width: '100%' }}
>
{chars.map((char, index) => (
<TextField
key={index}
sx={{ flex: `1 1 ${BOX_MIN_SIZE}px`, minWidth: BOX_MIN_SIZE, maxWidth: BOX_MAX_SIZE }}
value={char}
disabled={disabled}
error={error}
@@ -130,7 +146,7 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
autoComplete: 'one-time-code',
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
style: { textAlign: 'center', fontSize: '1.25rem', padding: 12, width: '100%' },
},
}}
/>
@@ -88,7 +88,7 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
minWidth: 0,
justifyContent: 'flex-start',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1,
m: -1,
gap: 1,
@@ -29,7 +29,7 @@ const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone
elevation={0}
data-payment-state-card
aria-live="polite"
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
@@ -52,7 +52,7 @@ const RelationSelect: FunctionComponent<RelationSelectProps> = ({ options, value
gap: 2,
border: '2px solid',
borderColor: selected ? 'primary.main' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent',
'&:hover': { borderColor: selected ? 'primary.main' : 'text.secondary' },
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
@@ -86,7 +86,7 @@ function AdminDataTable<T>({
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
overflowX: 'auto',
...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
}}
@@ -38,7 +38,7 @@ const AdminMessageBubble: FunctionComponent<AdminMessageBubbleProps> = ({ messag
<Box
sx={{
p: 1.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: internal ? '1px dashed' : '1px solid',
borderColor: internal ? 'var(--bal-warning)' : 'divider',
bgcolor: internal ? 'var(--bal-secondary-soft)' : mine ? 'var(--bal-primary-soft)' : 'background.paper',
+1 -1
View File
@@ -58,7 +58,7 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel })
<Paper
elevation={0}
data-audit-id={entry.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}
>
<Stack
role={hasDetail ? 'button' : undefined}
+2 -2
View File
@@ -29,7 +29,7 @@ const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false,
<Paper
elevation={0}
data-config-key={config.key}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 0 }}>
@@ -69,7 +69,7 @@ const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false,
<Typography
variant="body2"
data-config-value
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 1 }}
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 'var(--bal-radius-sm)' }}
>
{config.value}
</Typography>
@@ -34,7 +34,7 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
return (
<Box
data-document-id={document.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 1 }}>
<AppIcon icon="document" size={18} color="var(--bal-text-secondary)" />
@@ -71,7 +71,7 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
component="img"
src={signed.data.url}
alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 1, display: 'block' }}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
/>
) : (
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab>
@@ -39,7 +39,7 @@ const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ in
<Paper
elevation={0}
data-invoice-id={invoice.id}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', mb: 1.5 }}>
<Box>
@@ -65,7 +65,7 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
data-alert-id={alert.id}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '4px solid',
+19 -35
View File
@@ -1,50 +1,34 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH, AUTH_HERO_MAX_WIDTH } from './constants';
import { Divider, Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH } from './constants';
import BrandMark from './BrandMark';
import AuthIllustration from './AuthIllustration';
import TrustBullets from './TrustBullets';
/**
* Centered branded hero that hosts each auth step (phone, OTP). The card stays readable at
* 320px; a calm illustration joins beside it once the viewport has room to breathe (desktop),
* with the platform's trust facts underneath — login is the product's only front door, so it
* carries trust evidence rather than a bare form.
* Centered branded card that hosts each auth step (phone, OTP). One surface, top to bottom:
* brand mark the step's form → a divider → the platform's trust facts. The facts used to float
* on the page *under* the card, which read as unrelated page furniture and left the card looking
* cut off; login is the product's only front door, so the evidence belongs on the same surface as
* the form it is vouching for. The desktop side-illustration is gone with it the app now renders
* inside a phone-width frame at every viewport, so it never had room to appear.
* @component AuthCard
*/
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
<Stack
direction={{ xs: 'column', md: 'row' }}
sx={{
width: '100%',
maxWidth: AUTH_HERO_MAX_WIDTH,
alignItems: 'center',
justifyContent: 'center',
gap: { xs: 0, md: 6 },
}}
<Stack sx={{ alignItems: 'center', justifyContent: 'center', flexGrow: 1, px: 2, py: 3 }}>
<Paper
elevation={0}
// Radius comes from the house token (MuiPaper). The old `borderRadius: 3` multiplied the
// shape unit into a ~30px pill — exactly the "too round" this pass is undoing.
sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, px: { xs: 2.5, sm: 3 }, py: 3 }}
>
<AuthIllustration sx={{ display: { xs: 'none', md: 'flex' } }} />
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3, flexShrink: 0 }}>
<Paper
elevation={0}
sx={{
width: '100%',
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
</Stack>
</Paper>
<Stack sx={{ gap: 2.5 }}>
<BrandMark withTagline />
{children}
<Divider flexItem />
<TrustBullets />
</Stack>
</Stack>
</Paper>
</Stack>
);
@@ -1,85 +0,0 @@
'use client';
import { FunctionComponent } from 'react';
import { Box, SxProps, Theme } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
const ILLUSTRATION_SIZE = 220;
interface AuthIllustrationProps {
sx?: SxProps<Theme>;
}
/**
* A calm, abstract hero graphic for the login front door layered soft-tint circles (brand
* tokens only, no stock photos or raster art) with a centered family glyph and a floating
* trust badge, echoing the trust bullets underneath. Purely decorative.
* @component AuthIllustration
*/
const AuthIllustration: FunctionComponent<AuthIllustrationProps> = ({ sx }) => (
<Box
aria-hidden="true"
sx={{
position: 'relative',
width: ILLUSTRATION_SIZE,
height: ILLUSTRATION_SIZE,
flexShrink: 0,
...sx,
}}
>
<Box sx={{ position: 'absolute', inset: 0, borderRadius: '50%', bgcolor: 'var(--bal-primary-soft)' }} />
<Box
sx={{
position: 'absolute',
insetInlineStart: '16%',
insetBlockEnd: '4%',
width: '46%',
height: '46%',
borderRadius: '50%',
bgcolor: 'var(--bal-secondary-soft)',
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '58%',
height: '58%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="family" size={64} color="var(--bal-primary)" />
</Box>
</Box>
<Box
sx={{
position: 'absolute',
insetInlineEnd: '8%',
insetBlockStart: '10%',
width: '30%',
height: '30%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="verified" size={30} color="var(--bal-trust)" />
</Box>
</Box>
);
export default AuthIllustration;
+5 -2
View File
@@ -102,6 +102,9 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('otp_sent_to', {
// A rich-text TAG (`<phone></phone>` in both message files), not a `{phone}` value — a
// function passed for a value placeholder is handed straight to React as a child, which
// is the "Functions are not valid as a React child" crash this replaced.
// <bdi dir="ltr"> isolates the masked number so the RTL sentence's bidi algorithm
// never reorders the digit/bullet runs around it (the classic digits-around-neutrals bug).
phone: () => (
@@ -149,8 +152,8 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
{resendDisabled && countdown.isActive && !locked ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('resend_in', {
// Same bidi-isolation reasoning as the phone echo above — keeps mm:ss reading
// left-to-right (in Persian digits on /fa) inside the RTL sentence.
// Same tag-not-value rule and bidi-isolation reasoning as the phone echo above —
// keeps mm:ss reading left-to-right (in Persian digits on /fa) inside the RTL sentence.
time: () => (
<Box component="bdi" dir="ltr">
{formatClock(countdown.seconds, locale)}
@@ -41,6 +41,14 @@ describe('<RoleGuard/>', () => {
expect(mockReplace).not.toHaveBeenCalled();
});
it('bounces to login (never an endless splash) when there is no session at all', async () => {
// `useMe` is gated on the session, so without one it never resolves — the guard must not wait.
hydration = { status: 'unauthenticated' };
renderGuard('nurse');
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
});
it('renders the account-error state (not the shell) when /me failed', () => {
hydration = { status: 'error', retry: jest.fn(), isRetrying: false };
renderGuard('nurse');
+14 -4
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { type AppRole } from '@/constants';
import { ROUTES, type AppRole } from '@/constants';
import { useRoleHydration } from '@/services/auth';
import { resolveRoleDestination } from '@/services/auth/routing';
import AuthSplash from './AuthSplash';
@@ -25,10 +25,15 @@ interface RoleGuardProps {
* The client-side role-aware navigation guard for the private shells (refinement-phase-2). It gates a shell
* on the resolved-vs-pending role state (`useRoleHydration`) so the wrong actor app never renders:
* - **loading** a brand splash while `/me` is in flight (never the customer shell as a stand-in);
* - **unauthenticated** no session at all, so `/me` will never resolve: bounce to login carrying the
* attempted path, rather than holding a splash that can never end;
* - **error** an explicit account-error recovery when `/me` failed (never a silent customer fallback);
* - **role mismatch** redirect to the caller's real app (`resolveRoleDestination`, the single source of
* "which app") with a toast, rather than rendering a shell they lack the role for.
*
* This is also what makes the locale root role-aware: `/` is the customer app's home, so a nurse landing
* there is a role mismatch and is sent to `/nurse`, a role-less account to `/select-role`.
*
* A dual customer+nurse session holds both roles, so it passes either shell's guard and can move freely
* between the family and nurse apps.
* @component RoleGuard
@@ -45,6 +50,7 @@ const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) =>
const allowed = !expected || (appRoles?.includes(expected) ?? false);
// Stable across renders for a given identity (a string), so the redirect effect fires once, not per render.
const redirectTo = me && !allowed ? `/${locale}${resolveRoleDestination(me)}` : null;
const isUnauthenticated = hydration.status === 'unauthenticated';
useEffect(() => {
if (!redirectTo) return;
@@ -52,11 +58,15 @@ const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) =>
router.replace(redirectTo);
}, [redirectTo, enqueueSnackbar, t, router]);
if (hydration.status === 'loading') return <AuthSplash message={t('routing_title')} />;
useEffect(() => {
if (!isUnauthenticated) return;
router.replace(`/${locale}${ROUTES.LOGIN}`);
}, [isUnauthenticated, router, locale]);
if (hydration.status === 'error')
return <AuthAccountError onRetry={hydration.retry} isRetrying={hydration.isRetrying} />;
// Role mismatch — hold the neutral splash while the redirect above navigates away.
if (!allowed) return <AuthSplash message={t('routing_title')} />;
// Loading, signed-out, or a role mismatch — hold the neutral splash while the effect above navigates.
if (hydration.status !== 'ready' || !allowed) return <AuthSplash message={t('routing_title')} />;
return <>{children}</>;
};
+1 -1
View File
@@ -84,7 +84,7 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
gap: 2,
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
// Selection is never color-only: the soft fill pairs with a check glyph below.
bgcolor: isSelected ? 'var(--bal-primary-soft)' : 'transparent',
transition:
+22 -11
View File
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent } from 'react';
import { Stack, Typography } from '@mui/material';
import { Box, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import AppIcon from '@/components/common/AppIcon';
@@ -10,26 +10,37 @@ const BULLETS: ReadonlyArray<{ icon: string; key: string }> = [
{ icon: 'support', key: 'trust_support' },
];
const GLYPH_BOX = 28;
/**
* The 23 trust facts under the login card what Balinyaar actually verifies and escrows
* The 23 trust facts at the foot of the login card what Balinyaar actually verifies and escrows
* (licensed + identity-verified nurses, escrow held until a confirmed check-out, support).
* Copy is scoped to features the platform implements today, never aspirational claims.
* Rendered inside `AuthCard`, so it is deliberately quiet: caption type and a soft glyph chip, not
* a second set of headings competing with the form above it.
* @component TrustBullets
*/
const TrustBullets: FunctionComponent = () => {
const t = useTranslations('auth');
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
<Stack sx={{ gap: 1.25, width: '100%' }}>
{BULLETS.map((bullet) => (
<Stack key={bullet.key} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={bullet.icon}
size={20}
color="var(--bal-primary)"
style={{ flexShrink: 0, marginTop: 2 }}
<Stack key={bullet.key} direction="row" sx={{ gap: 1.25, alignItems: 'center' }}>
<Box
aria-hidden="true"
/>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
sx={{
width: GLYPH_BOX,
height: GLYPH_BOX,
flexShrink: 0,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-sm)',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={bullet.icon} size={16} color="var(--bal-primary)" />
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t(bullet.key)}
</Typography>
</Stack>
-3
View File
@@ -1,5 +1,2 @@
/** Reading width for the branded auth screens (login, OTP, role selection). */
export const AUTH_CARD_MAX_WIDTH = 420;
/** Width of the login hero row (illustration + card) once the illustration joins on desktop. */
export const AUTH_HERO_MAX_WIDTH = 760;
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider, useTranslations } from 'next-intl';
import { FunctionComponent } from 'react';
import enMessages from '../../../messages/en.json';
import faMessages from '../../../messages/fa.json';
/*
* The auth screens' `t.rich` call sites, exercised against the REAL message catalogue and the
* REAL next-intl formatter.
*
* `OtpStep.test.tsx` stubs `t.rich` to an identity function, which is precisely how a broken
* message shipped: `otp_sent_to` carried a `{phone}` *value* placeholder while the component
* passed a *function* for it, so next-intl handed the function straight through and React was
* asked to render it "Functions are not valid as a React child". A value placeholder and a
* rich-text tag are not interchangeable, and only the real formatter can tell them apart.
*/
const MASKED_PHONE = '0912*****34';
const CLOCK = '01:59';
const PhoneEcho: FunctionComponent = () => {
const t = useTranslations('auth');
return <p>{t.rich('otp_sent_to', { phone: () => <bdi dir="ltr">{MASKED_PHONE}</bdi> })}</p>;
};
const ResendCountdown: FunctionComponent = () => {
const t = useTranslations('auth');
return <p>{t.rich('resend_in', { time: () => <bdi dir="ltr">{CLOCK}</bdi> })}</p>;
};
describe.each([
['fa', faMessages],
['en', enMessages],
])('auth rich-text messages (%s)', (locale, messages) => {
function renderWithLocale(node: React.ReactNode) {
render(
<NextIntlClientProvider locale={locale} messages={messages}>
{node}
</NextIntlClientProvider>
);
}
it('renders the masked phone as a real node inside otp_sent_to', () => {
renderWithLocale(<PhoneEcho />);
expect(screen.getByText(MASKED_PHONE)).toBeInTheDocument();
});
it('isolates the phone in an LTR <bdi> so RTL bidi never reorders the digit runs', () => {
renderWithLocale(<PhoneEcho />);
expect(screen.getByText(MASKED_PHONE).tagName.toLowerCase()).toBe('bdi');
});
it('renders the countdown as a real node inside resend_in', () => {
renderWithLocale(<ResendCountdown />);
expect(screen.getByText(CLOCK)).toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
}}
>
@@ -197,7 +197,7 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
{onSiteCheckInAt ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 2, bgcolor: 'var(--bal-success-soft)' }}
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-success-soft)' }}
>
<AppIcon icon="location" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
@@ -451,7 +451,7 @@ function StatusNote({ booking }: { booking: BookingDetailDto }) {
const color = tone === 'warning' ? 'var(--bal-secondary-dark)' : 'var(--bal-text-secondary)';
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 2, bgcolor: bg }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: bg }}>
<AppIcon icon={icon} size={18} color={color} />
<Stack sx={{ gap: 0.25 }}>
{lines.map((line) => (
@@ -499,7 +499,7 @@ function CareSection({
if (care.isLoading) return <Skeleton variant="rounded" height={180} />;
if (care.isError || !care.data) {
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('care_error')}
</Typography>
@@ -513,7 +513,7 @@ function NotFoundCard({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640, mx: 'auto' }}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: 640, mx: 'auto' }}
>
<AppIcon icon="error" size={44} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
@@ -31,7 +31,7 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
const t = useTranslations('booking');
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>
{t('money_title')}
</Typography>
@@ -55,7 +55,7 @@ const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({
{status === 'cancelled' ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)' }}
>
<AppIcon icon="rejected" size={20} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 500, color: 'text.secondary' }}>
@@ -40,7 +40,7 @@ const CareInstructionsCard: FunctionComponent<CareInstructionsCardProps> = ({ da
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-primary)',
}}
@@ -66,7 +66,7 @@ const EvvStatusBanner: FunctionComponent<EvvStatusBannerProps> = ({ checkInAtIso
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
backgroundColor: style.bg,
color: style.fg,
}}
@@ -2,7 +2,7 @@ import { FunctionComponent } from 'react';
import { render, screen, within } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AppButton, { AppButtonProps } from './AppButton';
import DefaultIcon from '@mui/icons-material/MoreHoriz';
import { Ellipsis as DefaultIcon } from 'lucide-react';
import { randomText, capitalize } from '@/utils';
/**
@@ -130,7 +130,8 @@ describe('<AppButton/> component', () => {
let text = 'button with start icon';
render(<ComponentToTest text={text} startIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-startIcon');
@@ -140,7 +141,8 @@ describe('<AppButton/> component', () => {
let text = 'button with end icon as string';
render(<ComponentToTest text={text} endIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-endIcon');

Some files were not shown because too many files have changed in this diff Show More