diff --git a/.claude/skills/frontend-designer/SKILL.md b/.claude/skills/frontend-designer/SKILL.md index c699c70..905a991 100644 --- a/.claude/skills/frontend-designer/SKILL.md +++ b/.claude/skills/frontend-designer/SKILL.md @@ -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 ``, 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 / + `
` / 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')`, 3–5 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 `` or pass -the name to `AppButton`/`AppIconButton` (`icon="search"`). +maps lowercase names → components. Render with `` 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. `` 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 +`` 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. --- diff --git a/client/CLAUDE.md b/client/CLAUDE.md index e8ea544..7f1bafc 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 `` 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 `` 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/`
`/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 `` -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". --- diff --git a/client/messages/en.json b/client/messages/en.json index bf358f2..f407c83 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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 ", "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 ", "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" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 7ca5c25..fbc8786 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "کد به شماره ارسال شد", "otp_verify_customer": "تأیید و ادامه", "otp_verify_nurse": "تأیید و ورود", "otp_invalid": "کد وارد شده نادرست یا منقضی شده است", "otp_locked": "به‌دلیل تلاش‌های زیاد، ورود موقتاً قفل شد. کد جدید دریافت کنید.", - "resend_in": "ارسال مجدد کد تا {time}", + "resend_in": "ارسال مجدد کد تا ", "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": "اعطا و لغو نقش‌ها" } } diff --git a/client/package-lock.json b/client/package-lock.json index b816f45..e6fd45c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index ee4a874..899e5e6 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx index 90717a0..143fb3a 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx @@ -35,7 +35,7 @@ interface NudgeCardProps { const NudgeCard: FunctionComponent = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => ( @@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void } {isLoading ? ( {[0, 1, 2, 3].map((key) => ( - + ))} ) : isError ? ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx index 68d6eae..0c5972c 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx @@ -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. */} - + {t('offramp_note')} @@ -211,7 +211,7 @@ export default function CancelBookingPage() { ) : ( <> - + {t('confirm_title')} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx index 71f8267..94e2032 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx @@ -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 ( - + @@ -89,7 +89,7 @@ export default function BookingInvoicePage() { if (!invoice) { const notIssued = error instanceof ApiError && error.status === 404; return ( - + @@ -166,7 +166,7 @@ export default function BookingInvoicePage() { diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx index 865124d..cabac41 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx @@ -84,7 +84,7 @@ export default function LeaveReviewPage() { {booking ? : null} - + @@ -149,7 +149,7 @@ export default function LeaveReviewPage() { {booking ? : null} {/* Moderation expectation, up front — not only after submit. */} - + {t('moderation_note')} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx index 35c3cfd..435c11a 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx @@ -61,7 +61,7 @@ const EligibilityStep: FunctionComponent = ({ 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({ diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx index 89826ab..264e5ee 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx @@ -36,7 +36,7 @@ const MethodStep: FunctionComponent = ({ {t('payable_amount')} @@ -51,7 +51,7 @@ const MethodStep: FunctionComponent = ({ 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', diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx index 42c9e24..fc4d4b2 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx @@ -69,7 +69,7 @@ const PlanStep: FunctionComponent = ({ {shownPlan ? ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/ScheduleStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/ScheduleStep.tsx index 98ae8b8..ed26a53 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/ScheduleStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/ScheduleStep.tsx @@ -63,7 +63,7 @@ const ScheduleStep: FunctionComponent = ({ // Handoff in progress — the provider redirect is being followed. if (busy) { return ( - + @@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent = ({ 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)', diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx index e72f697..2e07231 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx @@ -245,7 +245,7 @@ function CheckoutScreen() { {summary.paymentDeadlineAt ? ( - + - + {payActions} @@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l minute: '2-digit', }); return ( - + + {t('state_pending_title')} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx index bd8d171..3f6894d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx @@ -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() { ) : ( - + + {title} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx index ce9b67f..4076e13 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx @@ -106,7 +106,7 @@ export default function PatientRecordPage() { @@ -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 ( - + {onOpen ? ( {data.map((task) => ( - + (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index af5fd82..8cc3da4 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -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<{ /> goTo(ROUTES.ADDRESSES)} /> 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. */} + goTo(ROUTES.NOTIFICATIONS)} /> goTo(ROUTES.SUPPORT_TICKETS)} /> @@ -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 ( - + diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx index 9688ad0..f4b4c3b 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx @@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: ( {isLoading ? ( {[0, 1, 2, 3].map((key) => ( - + ))} ) : isError ? ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx index 3670a95..be01445 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx @@ -355,7 +355,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) { const t = useTranslations('reviews'); const locale = useLocale(); return ( - + diff --git a/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx b/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx index b2ec1be..662efcb 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx @@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => { ) : isError ? ( - + @@ -71,7 +71,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) { {/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */} {t('outstanding_balance')} diff --git a/client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx b/client/src/app/[locale]/(private-routes)/_chrome/ShellContentSkeleton.tsx similarity index 57% rename from client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx rename to client/src/app/[locale]/(private-routes)/_chrome/ShellContentSkeleton.tsx index b9347b0..e4fcb47 100644 --- a/client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx +++ b/client/src/app/[locale]/(private-routes)/_chrome/ShellContentSkeleton.tsx @@ -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 ( diff --git a/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx index a707063..e4e8ef9 100644 --- a/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx @@ -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 ( - - + + - - {consoles.map((c) => ( - - - - - {tNav(c.key)} - - - - ))} - - + {sections.map((section) => { + const items = consoles.filter((entry) => entry.section === section.key && entry.enabled); + if (items.length === 0) return null; + return ; + })} + ); } diff --git a/client/src/app/[locale]/(private-routes)/admin/_hub/AdminGroupHub.tsx b/client/src/app/[locale]/(private-routes)/admin/_hub/AdminGroupHub.tsx new file mode 100644 index 0000000..dc1b4bc --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/_hub/AdminGroupHub.tsx @@ -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; + /** 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 = ({ title, subtitle, consoles, children }) => { + const t = useTranslations('hub'); + const permitted = consoles.filter((console_) => console_.enabled); + + return ( + + + {permitted.length > 0 ? ( + + ) : ( + + )} + {children} + + ); +}; + +export default AdminGroupHub; diff --git a/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx b/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx index b1d16de..92aff72 100644 --- a/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx @@ -71,7 +71,7 @@ function AdminAuditPageInner() { {history.data?.items.map((change) => ( - + {t('cfg_history_change_old', { old: change.oldValue ?? '—' })} diff --git a/client/src/app/[locale]/(private-routes)/admin/finance/page.tsx b/client/src/app/[locale]/(private-routes)/admin/finance/page.tsx new file mode 100644 index 0000000..4c43b12 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/finance/page.tsx @@ -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 ( + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/loading.tsx b/client/src/app/[locale]/(private-routes)/admin/loading.tsx index 569f058..0eb742e 100644 --- a/client/src/app/[locale]/(private-routes)/admin/loading.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/loading.tsx @@ -1,5 +1,5 @@ -import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; +import ShellContentSkeleton from '../_chrome/ShellContentSkeleton'; export default function Loading() { - return ; + return ; } diff --git a/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx index 50154b1..ff073c8 100644 --- a/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx @@ -123,7 +123,7 @@ export default function AdminPartnerCenterDetailPage() { meta={} /> - + } sx={{ gap: 1.25 }}> {data.legalEntityType || '—'} {data.mohEstablishmentPermitNo || '—'} @@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() { {roster.isLoading ? ( ) : ( - + }> {(roster.data ?? []).map((nurse) => ( ) : ( <> - + + ) : ( - } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> + } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}> {result.eligible.map((n) => ( {t('payout_skipped')} - } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> + } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}> {result.skipped.map((n) => ( diff --git a/client/src/app/[locale]/(private-routes)/admin/support/page.tsx b/client/src/app/[locale]/(private-routes)/admin/support/page.tsx new file mode 100644 index 0000000..2e514d3 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/support/page.tsx @@ -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 ( + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/system/page.tsx b/client/src/app/[locale]/(private-routes)/admin/system/page.tsx new file mode 100644 index 0000000..7769cf6 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/system/page.tsx @@ -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 ( + + + + + + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx index 019d037..161b10a 100644 --- a/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx @@ -167,7 +167,7 @@ export default function AdminTicketThreadPage() { ) : ( <> - + @@ -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', }} > diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx index a08d47d..4a25550 100644 --- a/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx @@ -112,7 +112,7 @@ function AdminTicketsQueue() { + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx index 7a38571..85af539 100644 --- a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx @@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() { ) : ( <> - + {t('ver_identity_name')} @@ -207,7 +207,7 @@ function AdminVerificationCaseScreen() { {t('ver_credentials_title')} {data.credentials.map((cred) => ( - + + {t(`step_${step.code}`)} diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx index d96def9..35516f9 100644 --- a/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx @@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() { 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 ( - - - {meLoading ? ( - <> - - - - ) : ( - <> - - {(displayName || '؟').charAt(0)} - - - - {t('greeting', { name: displayName })} - - {!verification.isLoading ? : null} - - - )} - - + + - - + + - ); } -/** 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 + * `, endNode: }); + expect(screen.getByRole('button', { name: 'back' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'bell' })).toBeInTheDocument(); + }); + + it('is not a filled AppBar', () => { + const container = renderBar({ title: 'Earnings' }); + // The design rule this guards: on a 480px frame a solid header band is a permanent slab of + // chrome above content that is already only ~50 characters wide. The bar sits on the page + // background instead — no MUI AppBar surface, rule or elevation. + expect(container.querySelector('.MuiAppBar-root')).toBeNull(); + }); +}); diff --git a/client/src/layout/components/TopBar.tsx b/client/src/layout/components/TopBar.tsx index e0004b3..3821f4b 100644 --- a/client/src/layout/components/TopBar.tsx +++ b/client/src/layout/components/TopBar.tsx @@ -1,41 +1,45 @@ import { FunctionComponent, ReactNode } from 'react'; -import { AppBar, Box, Toolbar, Typography } from '@mui/material'; +import { Box, Stack, Typography } from '@mui/material'; +import { TOP_BAR_HEIGHT } from '../config'; interface Props { endNode?: ReactNode; startNode?: ReactNode; title?: string; - /** Overrides `title` with arbitrary content (e.g. the brand lockup on the customer home). */ + /** Overrides `title` with arbitrary content (e.g. the brand lockup on a root tab). */ titleNode?: ReactNode; - /** 'start' for a breadcrumb-style label (sidebar shells); 'center' for the customer shell. */ + /** 'start' for a breadcrumb-style label on a pushed route; 'center' for a root tab. */ align?: 'start' | 'center'; - /** An optional second row under the main one (the customer shell's desktop top-nav tabs). */ - secondaryRow?: ReactNode; } /** - * Renders TopBar composition + * The frame's top row. Deliberately **not** an `AppBar`: no filled surface, no bottom rule, no + * elevation — it sits directly on `background.default` and reads as part of the page rather than a + * bar covering the top of it. On a 480px frame a solid header band is a large, permanent slab of + * chrome above content that is already only ~50 characters wide. + * + * It is also a plain flex sibling of the scrolling `
` inside `AppFrame`, never a fixed + * overlay — which is what keeps it inside the phone-width column on a wide window and removes the + * per-shell top-offset math the fixed version needed. * @component TopBar */ -const TopBar: FunctionComponent = ({ endNode, startNode, title = '', titleNode, align = 'center', secondaryRow }) => { - return ( - - - {startNode} +const TopBar: FunctionComponent = ({ endNode, startNode, title = '', titleNode, align = 'center' }) => ( + + {startNode} - - {titleNode ?? ( - - {title} - - )} - + + {titleNode ?? ( + + {title} + + )} + - {endNode} - - {secondaryRow} - - ); -}; + {endNode} + +); export default TopBar; diff --git a/client/src/layout/components/index.tsx b/client/src/layout/components/index.tsx index 9cd972d..c244a81 100644 --- a/client/src/layout/components/index.tsx +++ b/client/src/layout/components/index.tsx @@ -1,5 +1,5 @@ import BottomBar from './BottomBar'; -import SideBar from './SideBar'; +import BrandLockup from './BrandLockup'; import TopBar from './TopBar'; -export { BottomBar, SideBar, TopBar }; +export { BottomBar, BrandLockup, TopBar }; diff --git a/client/src/layout/config.ts b/client/src/layout/config.ts index 2f676af..9e6f226 100644 --- a/client/src/layout/config.ts +++ b/client/src/layout/config.ts @@ -3,17 +3,19 @@ */ /** - * SideBar configuration + * The app renders inside a phone-width frame at **every** viewport — there is no desktop + * layout, and deliberately so: Balinyaar's users (families arranging care, nurses between + * visits) are on phones, and one layout means one set of states to design and verify. + * A wider window gets more canvas around the frame, never a wider app. + * + * `components/config.ts`'s `CONTENT_MAX_WIDTH` mirrors this number — a page column can + * never be wider than the frame it lives in. */ -export const SIDE_BAR_WIDTH = '240px'; +export const APP_FRAME_MAX_WIDTH = 480; /** - * TopBar configuration + * TopBar configuration — one height at every viewport (the frame never changes width, so the + * old mobile/desktop split had nothing left to switch on). The bar sits *inside* the frame as a + * normal flex row rather than `position: fixed`, so no page needs a matching top offset. */ -export const TOP_BAR_MOBILE_HEIGHT = '56px'; -export const TOP_BAR_DESKTOP_HEIGHT = '64px'; - -/** - * Customer shell's desktop top-nav row height (the ≥md replacement for the mobile BottomBar). - */ -export const TOP_NAV_DESKTOP_HEIGHT = '48px'; +export const TOP_BAR_HEIGHT = 56; diff --git a/client/src/layout/routeTitle.tsx b/client/src/layout/routeTitle.tsx index 69bef60..663e20f 100644 --- a/client/src/layout/routeTitle.tsx +++ b/client/src/layout/routeTitle.tsx @@ -16,7 +16,7 @@ interface TitleEntry { * static baseline. */ const TITLE_ENTRIES: TitleEntry[] = [ - // Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS) + // Customer — pushed routes only; a shell's own tab paths render the brand lockup instead (MobileShell) { path: ROUTES.SEARCH, key: 'search' }, { path: ROUTES.SEARCH_RESULTS, key: 'search' }, { path: ROUTES.SEARCH_NURSE, key: 'search' }, @@ -41,6 +41,9 @@ const TITLE_ENTRIES: TitleEntry[] = [ { path: ROUTES.NURSE_EARNINGS, key: 'earnings' }, { path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' }, { path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' }, + { path: ROUTES.NURSE_PRACTICE, key: 'group_profession' }, + { path: ROUTES.NURSE_FINANCE, key: 'group_finance' }, + { path: ROUTES.NURSE_MORE, key: 'more' }, { path: ROUTES.NURSE, key: 'dashboard' }, // Admin { path: ROUTES.ADMIN_VERIFICATION, key: 'verification' }, @@ -55,11 +58,16 @@ const TITLE_ENTRIES: TitleEntry[] = [ { path: ROUTES.ADMIN_ROLES, key: 'roles' }, { path: ROUTES.ADMIN_USERS, key: 'users' }, { path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' }, + { path: ROUTES.ADMIN_TRUST, key: 'group_trust' }, + { path: ROUTES.ADMIN_FINANCE, key: 'group_finance' }, + { path: ROUTES.ADMIN_SUPPORT, key: 'group_support' }, + { path: ROUTES.ADMIN_SYSTEM, key: 'group_system' }, { path: ROUTES.ADMIN, key: 'overview' }, // Partner { path: ROUTES.PARTNER_NURSES, key: 'partner_nurses' }, { path: ROUTES.PARTNER_BOOKINGS, key: 'partner_bookings' }, { path: ROUTES.PARTNER_SETTLEMENT, key: 'partner_settlement' }, + { path: ROUTES.PARTNER_MORE, key: 'more' }, { path: ROUTES.PARTNER, key: 'partner_home' }, ].sort((a, b) => b.path.length - a.path.length); @@ -69,13 +77,6 @@ function findTitleEntry(pathname: string): TitleEntry | undefined { ); } -/** The customer shell's 5 root tabs — these render the brand lockup, never a title. */ -export const CUSTOMER_ROOT_TABS: string[] = [ROUTES.HOME, ROUTES.BOOKINGS, ROUTES.PATIENTS, ROUTES.WALLET, ROUTES.PROFILE]; - -export function isCustomerRootTab(pathname: string): boolean { - return CUSTOMER_ROOT_TABS.includes(pathname); -} - const PageTitleOverrideContext = createContext(null); const SetPageTitleOverrideContext = createContext<(title: string | null) => void>(() => {}); diff --git a/client/src/lib/auth/token.ts b/client/src/lib/auth/token.ts index c86b422..533bf0e 100644 --- a/client/src/lib/auth/token.ts +++ b/client/src/lib/auth/token.ts @@ -32,7 +32,28 @@ export function decodeJwtPayload(token: string | undefined): JwtPayload | null { } } +/** Compact serialization part counts: a JWS is 3, a JWE is 5 (header.key.iv.ciphertext.tag). */ +const JWE_PART_COUNT = 5; + export function isTokenAlive(token: string | undefined): boolean { + if (!token) return false; + const payload = decodeJwtPayload(token); - return typeof payload?.exp === 'number' && payload.exp * 1000 > Date.now(); + if (payload) return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now(); + + /* + * The server issues an **encrypted** access token (JWE — `JwtService._createSecurityTokenAsync` + * pairs SigningCredentials with EncryptingCredentials), so its claims, `exp` included, are + * ciphertext that no client-side decode will ever read. Treating that as "not alive" is what + * made every real session look logged-out to this gate: the middleware bounced private routes + * to /login, and an authenticated hit on '/' fell through the guest-front-door branch and got + * rewritten to the marketing page — the "app just shows a loading skeleton at /fa" symptom. + * + * A well-formed but unreadable token is therefore treated as alive. That is sound here and only + * here: this is a routing/UX hint, never a security boundary (the API is the sole authority on + * the token), and the access cookie's own 15-minute `maxAge` bounds how long the optimism can + * last — an expired cookie is simply absent, which is still a clean `false`. A stale token that + * slips through gets a 401 on the first call, which the fetch layer's silent refresh handles. + */ + return token.split('.').length === JWE_PART_COUNT; } diff --git a/client/src/services/auth/hooks/useRoleHydration.ts b/client/src/services/auth/hooks/useRoleHydration.ts index ff53a0d..3bf583a 100644 --- a/client/src/services/auth/hooks/useRoleHydration.ts +++ b/client/src/services/auth/hooks/useRoleHydration.ts @@ -1,3 +1,4 @@ +import { useIsAuthenticated } from '@/hooks'; import { useMe } from './useMe'; import { toAppRoles } from '../routing'; import type { Me } from '../types'; @@ -13,16 +14,23 @@ import type { AppRole } from '@/constants'; * * `error` fires only when `/me` has no data at all; a background refetch that fails while we still hold a * cached identity keeps serving `ready` (don't downgrade a known nurse on a transient blip). + * + * `unauthenticated` closes the one state that could hang forever: `useMe` is gated on there being a + * session, so without one it never runs, never errors, and never resolves — leaving the guard on a + * splash with no exit. Saying so explicitly lets `RoleGuard` send the visitor to login instead. */ export type RoleHydration = | { status: 'loading' } + | { status: 'unauthenticated' } | { status: 'error'; retry: () => void; isRetrying: boolean } | { status: 'ready'; me: Me; appRoles: AppRole[] }; export function useRoleHydration(): RoleHydration { + const isAuthenticated = useIsAuthenticated(); const { data: me, isError, isFetching, refetch } = useMe(); if (me) return { status: 'ready', me, appRoles: toAppRoles(me.roles) }; if (isError) return { status: 'error', retry: () => void refetch(), isRetrying: isFetching }; + if (!isAuthenticated) return { status: 'unauthenticated' }; return { status: 'loading' }; } diff --git a/client/src/theme/theme.ts b/client/src/theme/theme.ts index 6dbfd5f..272cb55 100644 --- a/client/src/theme/theme.ts +++ b/client/src/theme/theme.ts @@ -49,7 +49,7 @@ function createAppTheme(direction: 'ltr' | 'rtl') { }, defaultColorScheme: 'light', typography: direction === 'rtl' ? TYPOGRAPHY_RTL : TYPOGRAPHY_LTR, - shape: { borderRadius: 10 }, // house radius — mirrors --bal-radius-md + shape: { borderRadius: 8 }, // house radius — mirrors --bal-radius-md direction, shadows: TEAL_SHADOWS, components: { @@ -78,6 +78,9 @@ function createAppTheme(direction: 'ltr' | 'rtl') { root: { backgroundImage: 'none', border: '1px solid var(--bal-divider)', + // Pinned to the token rather than left to `shape.borderRadius` so a Paper can never + // drift past the house step — the whole point of the mobile-pass radius tightening. + borderRadius: 'var(--bal-radius-md)', }, }, }, diff --git a/client/src/theme/tokens.css b/client/src/theme/tokens.css index 83de9a6..14b73a2 100644 --- a/client/src/theme/tokens.css +++ b/client/src/theme/tokens.css @@ -39,10 +39,16 @@ /* ── Scheme-independent tokens (radius scale, motion) ──────────────────── */ :root { /* Radius scale — controls / cards / dialogs. shape.borderRadius (theme.ts) - mirrors --bal-radius-md; never invent a radius per component. */ - --bal-radius-sm: 4px; - --bal-radius-md: 10px; - --bal-radius-lg: 16px; + mirrors --bal-radius-md; never invent a radius per component. + Tightened in the mobile pass: at a 480px frame width the old 10/16 steps read + as pill-soft rather than crisp, and any `sx={{ borderRadius: n }}` multiplies + the md step — which is how the login card ended up at 30px. */ + --bal-radius-sm: 6px; + --bal-radius-md: 8px; + --bal-radius-lg: 12px; + /* Fully-rounded ends. For a shape that IS a pill (the floating bottom nav, a + segmented control), never for a card — a card wants a step of the scale above. */ + --bal-radius-pill: 999px; /* Motion — durations + easing, consumed by the phase-12 motion pass */ --bal-motion-fast: 120ms; @@ -88,6 +94,10 @@ /* Surfaces */ --bal-bg-default: #faf9f5; --bal-bg-paper: #ffffff; + /* The canvas AppFrame paints outside the phone-width app column (layout/AppFrame.tsx). + Never a surface a component draws on — one step deeper than bg-default so the frame + reads as the app and the rest of a wide window reads as backdrop. */ + --bal-frame-canvas: #ebe8de; /* Text */ --bal-text-primary: #1b2521; @@ -174,6 +184,7 @@ --bal-bg-default: #0f1c19; --bal-bg-paper: #16302a; + --bal-frame-canvas: #07100e; --bal-text-primary: #f3efe9; --bal-text-secondary: #9fb0a9; @@ -244,6 +255,7 @@ /* Surfaces — deep teal */ --bal-bg-default: #0f1c19; --bal-bg-paper: #16302a; + --bal-frame-canvas: #07100e; /* Text */ --bal-text-primary: #f3efe9; diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index b98693b..415b352 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -2,14 +2,20 @@ export type ObjectPropByName = Record; /** - * Data for "Page Link" in SideBar adn other UI elements + * Data for a "Page Link" in the BottomBar and other UI elements */ export type LinkToPage = { icon?: string; // Icon name to use as path?: string; // URL to navigate to title?: string; // Title or primary text to display subtitle?: string; // Sub-title or secondary text to display - group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader - onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer) + onSelect?: () => void; // When set, BottomBar runs this instead of navigating badgeCount?: number; // Renders a small unread-count badge on the item's icon when > 0 (e.g. the support entry) + /** + * Extra locale-less path prefixes this tab owns for active-state purposes. A group root + * (`/nurse/finance`) is a real page, but the destinations it links to keep their historical + * URLs (`/nurse/earnings`, `/nurse/bank`) — without this the plain longest-prefix match would + * light up the `/nurse` tab on every one of them. + */ + matchPaths?: string[]; }; diff --git a/dev/manual-testing/image-1.png b/dev/manual-testing/iteration-1/image-1.png similarity index 100% rename from dev/manual-testing/image-1.png rename to dev/manual-testing/iteration-1/image-1.png diff --git a/dev/manual-testing/image-2.png b/dev/manual-testing/iteration-1/image-2.png similarity index 100% rename from dev/manual-testing/image-2.png rename to dev/manual-testing/iteration-1/image-2.png diff --git a/dev/manual-testing/image-3.png b/dev/manual-testing/iteration-1/image-3.png similarity index 100% rename from dev/manual-testing/image-3.png rename to dev/manual-testing/iteration-1/image-3.png diff --git a/dev/manual-testing/image-4.png b/dev/manual-testing/iteration-1/image-4.png similarity index 100% rename from dev/manual-testing/image-4.png rename to dev/manual-testing/iteration-1/image-4.png diff --git a/dev/manual-testing/image.png b/dev/manual-testing/iteration-1/image.png similarity index 100% rename from dev/manual-testing/image.png rename to dev/manual-testing/iteration-1/image.png diff --git a/dev/manual-testing/improvement-and-fix-1.md b/dev/manual-testing/iteration-1/improvement-and-fix-1.md similarity index 100% rename from dev/manual-testing/improvement-and-fix-1.md rename to dev/manual-testing/iteration-1/improvement-and-fix-1.md diff --git a/dev/manual-testing/iteration-2/image-1.png b/dev/manual-testing/iteration-2/image-1.png new file mode 100644 index 0000000..9991476 Binary files /dev/null and b/dev/manual-testing/iteration-2/image-1.png differ diff --git a/dev/manual-testing/iteration-2/image.png b/dev/manual-testing/iteration-2/image.png new file mode 100644 index 0000000..c6da19d Binary files /dev/null and b/dev/manual-testing/iteration-2/image.png differ diff --git a/dev/manual-testing/iteration-2/improvement-2.md b/dev/manual-testing/iteration-2/improvement-2.md new file mode 100644 index 0000000..db50a81 --- /dev/null +++ b/dev/manual-testing/iteration-2/improvement-2.md @@ -0,0 +1,22 @@ +![alt text](image.png) +in the image above as you can see is in "//nurse" path +the section for user info is useless, the right side borders are not good and should improved to be more modern. +the bottom nav should have a better for, the text under icons should be removed and the hover effect should be rounded, also the bottom nav in fact is in a box whihc has a box shadow, i noticed that in light mode, the whole middle box should be a very nice and independent section which floads and is sprate. + + +there is no easy access way to the see the request pages which in fact is a very important page + + +![alt text](image-1.png) +these buttons for the them has no gap; + +/nurse/verification +and +/nurse/profile +forms and flows are mess, +both ui and ux, flows are dummy , i know, but the forms all are a long list without notions and could be very much better, + +also for /nurse/services forms and flow the problem exists, improve those ui and flow, to be a more deterministic and comprehenise form. +all forms are react state and much unnecessary rerender, you have to use react hook form for them to control these rerenders, +in fact, replace, all forms with mored that 1 field in the app with react hook form. + diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs index c25343f..c359b3f 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.cs @@ -40,6 +40,20 @@ namespace Baya.Infrastructure.Persistence.Services.Seeding; /// re-running on an already-seeded DB is a no-op. Callers must gate this on IsDevelopment() and run /// it after and the payment-gateway seed. /// +/// +/// Because several of those natural keys contain a date, the run is anchored to a stable epoch (the +/// first run's instant, read back from the demo payment transactions) rather than to wall-clock now — a +/// date-relative key silently stops matching the day after it was written, which turns "idempotent" into +/// "duplicates the whole world once per day". A wipe + reseed is what moves the demo world forward in time. +/// +/// +/// It is also resumable, which is a stronger property than idempotent and is what the booking +/// scenarios actually need: each scenario spans several aggregates that can only be written in sequence +/// (a generated id feeds the next FK), so it runs inside one transaction and every step re-checks its own +/// row rather than trusting a single scenario-level guard. An interrupted run therefore leaves nothing +/// half-written, and a database already damaged by one is repaired forward on the next boot instead of +/// throwing. +/// /// internal sealed partial class DemoLifecycleSeeder( ApplicationDbContext db, @@ -69,7 +83,29 @@ internal sealed partial class DemoLifecycleSeeder( return; } - var now = clock.UtcNow.UtcDateTime; + // The whole seed is anchored to a STABLE epoch, not to wall-clock "now". + // + // Every scenario's idempotency key includes a date derived from this anchor (`Today(now, offset)`), + // so anchoring on the current time meant the key moved every calendar day: a run on the next day + // matched none of yesterday's rows, re-created all thirteen scenarios, and then collided on + // `GatewayReferenceCode` — which IS date-independent. That is how a supposed no-op re-run ended up + // throwing a duplicate-key error and leaving a scenario half-written for the next boot to trip over. + // + // Re-using the first run's instant makes every key reproduce exactly, so a re-run is a real no-op on + // any day. The anchor is read back from the demo transactions' audit stamp rather than stored + // separately — they are the one row set this seeder owns whose natural key (the reference prefix) is + // itself date-independent. A wipe + reseed is what moves the demo world forward in time. + // Ordered by Id, not CreatedAt: identity order IS insertion order (so it picks the same first row), + // it can't tie on a shared millisecond, and — the reason it matters here — the SQLite provider the + // API tests run on cannot translate a DateTimeOffset ORDER BY at all. + var seededAt = await db.Set() + .Where(t => t.GatewayReferenceCode != null + && t.GatewayReferenceCode.StartsWith(DemoLifecycleDefinitions.GatewayRefPrefix)) + .OrderBy(t => t.Id) + .Select(t => (DateTimeOffset?)t.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + var now = seededAt?.UtcDateTime ?? clock.UtcNow.UtcDateTime; var packageVariantId = await EnsurePackageVariantAsync(world, cancellationToken); var newRequests = await EnsureRequestScenariosAsync(world, now, cancellationToken); @@ -353,78 +389,116 @@ internal sealed partial class DemoLifecycleSeeder( .FirstAsync(v => v.Id == packageVariantId, cancellationToken); var requestedDate = Today(now, scenario.ScheduledInDays); - - var existing = await db.Set() - .Where(r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id - && r.VariantId == variant.Id && r.RequestedDate == requestedDate) - .Select(r => r.Id) - .FirstOrDefaultAsync(cancellationToken); - if (existing != 0) - { - var found = await db.Set().Include(b => b.Sessions) - .FirstAsync(b => b.BookingRequestId == existing, cancellationToken); - var foundTxn = await db.Set() - .FirstAsync(t => t.BookingId == found.Id && t.Status == PaymentTransactionStatus.Succeeded, cancellationToken); - result.ByKey[scenario.Key] = found; - result.TxnByKey[scenario.Key] = foundTxn; - continue; - } - var confirmedAt = now.AddDays(-scenario.ConfirmedDaysAgo); - var request = new BookingRequest + // One transaction per scenario. The saves below exist only to hand a generated id to the next + // step (request.Id → booking FK, session ids → EVV, txn.Id → ledger source ref), so they must + // never be independently observable: committing them separately is what let an interrupted run + // (a `dotnet watch` restart, a Ctrl-C, a transient DB error) leave a request with no booking, or + // a booking with no payment. Everything below is therefore all-or-nothing. + await using var scenarioTx = await db.Database.BeginTransactionAsync(cancellationToken); + + // Resume is per STEP, not per scenario. The old probe keyed the whole scenario on the request — + // the row written FIRST — then assumed the rest existed, so a half-written scenario made the + // next boot throw ("Sequence contains no elements") instead of finishing the job. Each step now + // asks whether its own row is there, so an interrupted seed is completed rather than skipped + // (which would leave an unreachable world) or fatal. + var request = await db.Set() + .FirstOrDefaultAsync( + r => r.CustomerId == customer.ProfileId && r.NurseId == nurse.Profile.Id + && r.VariantId == variant.Id && r.RequestedDate == requestedDate, + cancellationToken); + var wroteAnything = false; + + if (request is null) { - CustomerId = customer.ProfileId, - NurseId = nurse.Profile.Id, - PatientId = customer.PatientIds[0], - VariantId = variant.Id, - CustomerAddressId = customer.PrimaryAddress.Id, - RequiredCaregiverGender = nurse.Gender, - RequestedDate = requestedDate, - RequestedTimeStart = new TimeOnly(9, 0), - RequestedTimeEnd = new TimeOnly(13, 0), - CustomerNotes = "هماهنگی از طریق پیام انجام شد.", - NurseResponseDeadlineAt = confirmedAt - }; - request.Accept(paymentDeadlineAt: confirmedAt); - db.Set().Add(request); - await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK + request = new BookingRequest + { + CustomerId = customer.ProfileId, + NurseId = nurse.Profile.Id, + PatientId = customer.PatientIds[0], + VariantId = variant.Id, + CustomerAddressId = customer.PrimaryAddress.Id, + RequiredCaregiverGender = nurse.Gender, + RequestedDate = requestedDate, + RequestedTimeStart = new TimeOnly(9, 0), + RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "هماهنگی از طریق پیام انجام شد.", + NurseResponseDeadlineAt = confirmedAt + }; + request.Accept(paymentDeadlineAt: confirmedAt); + db.Set().Add(request); + await db.SaveChangesAsync(cancellationToken); // assigns request.Id for the 1:1 booking FK + wroteAnything = true; + } - var booking = BookingFactory.Create( - await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken), - rate: await GetFeeRateAsync(cancellationToken), - now: confirmedAt, - pspFeeAmount: null, - variantSerializer); - request.MarkConverted(); + var booking = await db.Set().Include(b => b.Sessions) + .FirstOrDefaultAsync(b => b.BookingRequestId == request.Id, cancellationToken); - ApplyLifecycleState(booking, scenario, now); - db.Set().Add(booking); - await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs + if (booking is null) + { + booking = BookingFactory.Create( + await BuildConversionSourceAsync(request, customer, nurse, variant, cancellationToken), + rate: await GetFeeRateAsync(cancellationToken), + now: confirmedAt, + pspFeeAmount: null, + variantSerializer); - SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now); + // Transition() fails fast on an illegal edge, and a resumed request may already be + // converted — re-driving it would turn a repairable state into a crash. + if (BookingRequestTransitions.CanTransition(request.Status, BookingRequestStatus.Converted)) + request.MarkConverted(); - if (scenario.WithCareInstructions) - db.Set().Add(BuildCareInstructions(booking.Id)); + ApplyLifecycleState(booking, scenario, now); + db.Set().Add(booking); + await db.SaveChangesAsync(cancellationToken); // assigns booking/session ids for EVV + ledger refs - // The txn commits first so the ledger legs can reference its real id (append-only source refs). - var txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence); - db.Set().Add(txn); - await db.SaveChangesAsync(cancellationToken); + SeedVisitVerifications(booking, scenario, customer.PrimaryAddress, now); - // Card bookings post the balanced capture group now; the BNPL booking posts BnplSettle in the + if (scenario.WithCareInstructions) + db.Set().Add(BuildCareInstructions(booking.Id)); + + await db.SaveChangesAsync(cancellationToken); + wroteAnything = true; + } + + var txn = await db.Set() + .FirstOrDefaultAsync( + t => t.BookingId == booking.Id && t.Status == PaymentTransactionStatus.Succeeded, + cancellationToken); + + if (txn is null) + { + // The txn commits first so the ledger legs can reference its real id (append-only source refs). + txn = BuildSucceededTransaction(booking, request, customer.ProfileId, gatewayId, scenario, sequence); + db.Set().Add(txn); + await db.SaveChangesAsync(cancellationToken); + wroteAnything = true; + } + + // Card bookings post the balanced capture group here; the BNPL booking posts BnplSettle in the // money step (its group references the bnpl_transactions row, which doesn't exist yet). if (scenario.Key != "bnpl_settled") { - db.Set().AddRange(LedgerPosting.CardCapture( - booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, - booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt)); - await db.SaveChangesAsync(cancellationToken); + var capturePosted = await db.Set().AnyAsync( + e => e.SourceRefType == LedgerSourceRefType.PaymentTransaction && e.SourceRefId == txn.Id, + cancellationToken); + + if (!capturePosted) + { + db.Set().AddRange(LedgerPosting.CardCapture( + booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, + booking.NursePayoutAmount, txn.Id, createdAt: confirmedAt)); + await db.SaveChangesAsync(cancellationToken); + wroteAnything = true; + } } + await scenarioTx.CommitAsync(cancellationToken); + result.ByKey[scenario.Key] = booking; result.TxnByKey[scenario.Key] = txn; - result.CreatedCount++; + if (wroteAnything) result.CreatedCount++; } // One deliberately-failed extra payment attempt so the wallet's payment history has a failed row. diff --git a/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs b/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs index 1ca191d..1c8c851 100644 --- a/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs +++ b/server/src/Tests/Baya.Test.Api/DemoLifecycleSeederTests.cs @@ -1,4 +1,10 @@ using System.Net; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Identity; +using Baya.Application.Contracts.Search; +using Mediator; +using Microsoft.Extensions.Logging; using Baya.Domain.Entities.Bnpl; using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Identity; @@ -248,4 +254,67 @@ public class DemoLifecycleSeederTests(BayaApiFactory factory) : IClassFixture().CountAsync()); } } + + /// A clock frozen at a chosen instant — enough to run the seeder "on a later day". + private sealed class FixedClock(DateTimeOffset now) : IDateTimeProvider + { + public DateTimeOffset UtcNow { get; } = now; + } + + /// + /// Runs the lifecycle seeder alone against a chosen clock. Built by hand rather than resolved, so the + /// clock can move without swapping a registration on the fixture every other test shares. + /// + private async Task RunLifecycleSeedAsync(IServiceProvider services, IDateTimeProvider clock) + { + var seeder = new DemoLifecycleSeeder( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + clock, + services.GetRequiredService>()); + + await seeder.SeedAsync(CancellationToken.None); + } + + [Fact] + public async Task Seed_IsIdempotent_EvenWhenTheSecondRunHappensOnALaterDay() + { + // The regression this locks down. Several scenario keys contain a date derived from "now" + // (`Today(now, offset)`), so when the run was anchored to the wall clock the key silently moved + // at midnight: the next day's boot matched none of yesterday's rows, re-created all thirteen + // scenarios, and then collided on `GatewayReferenceCode` — which is date-independent — leaving a + // half-written scenario for the boot after that to crash on. Seeding twice within one day (the + // test above) can never surface that; only advancing the clock between runs does. + await RunSeedersAsync(); + + int requests, bookings, ledger, transactions; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + requests = await db.Set().CountAsync(); + bookings = await db.Set().CountAsync(); + ledger = await db.Set().CountAsync(); + transactions = await db.Set().CountAsync(); + } + + using (var scope = factory.Services.CreateScope()) + { + var threeDaysOn = new FixedClock(DateTimeOffset.UtcNow.AddDays(3)); + await RunLifecycleSeedAsync(scope.ServiceProvider, threeDaysOn); + } + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(requests, await db.Set().CountAsync()); + Assert.Equal(bookings, await db.Set().CountAsync()); + Assert.Equal(ledger, await db.Set().CountAsync()); + Assert.Equal(transactions, await db.Set().CountAsync()); + } + } }