ui phase 2
This commit is contained in:
@@ -147,7 +147,7 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
|
|||||||
| `AppImage` | images | wrapper around next/image conventions |
|
| `AppImage` | images | wrapper around next/image conventions |
|
||||||
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
|
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
|
||||||
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
|
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
|
||||||
| `UserInfo` | user avatar/identity block | feature component |
|
| `ProfileSummary` | the identity card in chrome | avatar+name+masked phone+role label+optional `TrustBadge`; vertical or `compact` horizontal chip |
|
||||||
|
|
||||||
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
|
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
|
||||||
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
|
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
|
||||||
@@ -166,19 +166,41 @@ CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
|
|||||||
|
|
||||||
## 5. Layout & page shells
|
## 5. Layout & page shells
|
||||||
|
|
||||||
- **Private (authenticated) screens** render inside `PrivateLayout` →
|
- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout`
|
||||||
`TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant
|
(mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back
|
||||||
`sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) +
|
chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile
|
||||||
a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with
|
`BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share
|
||||||
`useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`.
|
`TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through
|
||||||
- **Public screens** use `PublicLayout`.
|
`@/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.
|
||||||
|
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
|
||||||
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
|
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
|
||||||
top-bar `56px` mobile / `64px` desktop, anchors). Respect them; don't hard-code.
|
top-bar `56px` mobile / `64px` desktop). Respect them; don't hard-code.
|
||||||
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
|
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
|
||||||
composition + content; push reusable visuals into `src/components/`.
|
composition + content; push reusable visuals into `src/components/`.
|
||||||
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
|
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
|
||||||
is fine for dashboards/tables.
|
is fine for dashboards/tables.
|
||||||
- Use `useIsMobile()` (`@/hooks`) for responsive branching, or MUI breakpoints in `sx`.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+19
-11
@@ -228,6 +228,7 @@ client/
|
|||||||
│ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion)
|
│ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion)
|
||||||
│ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants, RTL-aware keyboard nav
|
│ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants, RTL-aware keyboard nav
|
||||||
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
|
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
|
||||||
|
│ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested)
|
||||||
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
|
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
|
||||||
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
||||||
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
||||||
@@ -264,6 +265,7 @@ client/
|
|||||||
│ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested)
|
│ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested)
|
||||||
│ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested)
|
│ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested)
|
||||||
│ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested)
|
│ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested)
|
||||||
|
│ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo (tested)
|
||||||
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
|
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
|
||||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||||
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
|
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
|
||||||
@@ -271,21 +273,27 @@ client/
|
|||||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
|
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
|
||||||
├── i18n/
|
├── i18n/
|
||||||
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
|
||||||
│ └── request.ts # getRequestConfig — loads messages/${locale}.json
|
│ ├── request.ts # getRequestConfig — loads messages/${locale}.json
|
||||||
├── layout/
|
│ └── 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
|
||||||
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
|
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
|
||||||
│ ├── CustomerLayout.tsx # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav')
|
│ ├── 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
|
||||||
│ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav')
|
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer)
|
||||||
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar)
|
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
|
||||||
│ ├── PublicLayout.tsx # unauthenticated shell
|
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
|
||||||
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine)
|
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
|
||||||
|
│ ├── 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
|
│ ├── config.ts
|
||||||
│ ├── index.ts
|
│ ├── index.ts
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── TopBar.tsx
|
│ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
|
||||||
│ ├── SideBar.tsx
|
│ ├── 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
|
│ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
|
||||||
│ ├── SideBarNavItem.tsx
|
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop
|
||||||
|
│ ├── 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()
|
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
|
||||||
│ └── index.tsx
|
│ └── index.tsx
|
||||||
├── lib/
|
├── lib/
|
||||||
|
|||||||
+20
-4
@@ -32,7 +32,17 @@
|
|||||||
"partner_home": "Center",
|
"partner_home": "Center",
|
||||||
"partner_nurses": "Sponsored nurses",
|
"partner_nurses": "Sponsored nurses",
|
||||||
"partner_bookings": "Bookings",
|
"partner_bookings": "Bookings",
|
||||||
"partner_settlement": "Settlement"
|
"partner_settlement": "Settlement",
|
||||||
|
"search": "Search",
|
||||||
|
"checkout": "Checkout",
|
||||||
|
"addresses": "Addresses",
|
||||||
|
"more": "More",
|
||||||
|
"group_today": "Today",
|
||||||
|
"group_profession": "My profession",
|
||||||
|
"group_finance": "Finance",
|
||||||
|
"group_support": "Support",
|
||||||
|
"group_trust": "Trust",
|
||||||
|
"group_system": "System"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dark_mode": "Dark mode",
|
"dark_mode": "Dark mode",
|
||||||
@@ -51,14 +61,18 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"currency_toman": "Toman",
|
"currency_toman": "Toman",
|
||||||
"brand": "Balinyaar",
|
"brand": "Balinyaar",
|
||||||
"brand_tagline": "Home care you can trust"
|
"brand_tagline": "Home care you can trust",
|
||||||
|
"open_sidebar": "Open menu",
|
||||||
|
"switch_locale": "Switch to {locale}"
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"customer_app": "Family app",
|
"customer_app": "Family app",
|
||||||
"nurse_app": "Nurse view",
|
"nurse_app": "Nurse view",
|
||||||
"admin_console": "Admin console",
|
"admin_console": "Admin console",
|
||||||
"placeholder_body": "This area will be built in a later phase.",
|
"placeholder_body": "This area will be built in a later phase.",
|
||||||
"partner_console": "Partner portal"
|
"partner_console": "Partner portal",
|
||||||
|
"switch_to_nurse": "Nurse view",
|
||||||
|
"switch_to_customer": "Family app"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"greeting_named": "Hi, {name}",
|
"greeting_named": "Hi, {name}",
|
||||||
@@ -144,7 +158,9 @@
|
|||||||
"saved": "Profile saved",
|
"saved": "Profile saved",
|
||||||
"completion_done": "Your profile is complete.",
|
"completion_done": "Your profile is complete.",
|
||||||
"completion_todo": "Complete your profile to speed up bookings.",
|
"completion_todo": "Complete your profile to speed up bookings.",
|
||||||
"load_error": "Couldn't load your profile."
|
"load_error": "Couldn't load your profile.",
|
||||||
|
"app_language": "App language",
|
||||||
|
"sign_out": "Sign out"
|
||||||
},
|
},
|
||||||
"nurseProfile": {
|
"nurseProfile": {
|
||||||
"title": "Nurse profile",
|
"title": "Nurse profile",
|
||||||
|
|||||||
+20
-4
@@ -32,7 +32,17 @@
|
|||||||
"partner_home": "مرکز",
|
"partner_home": "مرکز",
|
||||||
"partner_nurses": "پرستاران تحت پوشش",
|
"partner_nurses": "پرستاران تحت پوشش",
|
||||||
"partner_bookings": "رزروها",
|
"partner_bookings": "رزروها",
|
||||||
"partner_settlement": "تسویه"
|
"partner_settlement": "تسویه",
|
||||||
|
"search": "جستجو",
|
||||||
|
"checkout": "پرداخت",
|
||||||
|
"addresses": "آدرسها",
|
||||||
|
"more": "بیشتر",
|
||||||
|
"group_today": "امروز",
|
||||||
|
"group_profession": "حرفهٔ من",
|
||||||
|
"group_finance": "مالی",
|
||||||
|
"group_support": "پشتیبانی",
|
||||||
|
"group_trust": "اعتماد",
|
||||||
|
"group_system": "سیستم"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dark_mode": "حالت تاریک",
|
"dark_mode": "حالت تاریک",
|
||||||
@@ -51,14 +61,18 @@
|
|||||||
"optional": "اختیاری",
|
"optional": "اختیاری",
|
||||||
"currency_toman": "تومان",
|
"currency_toman": "تومان",
|
||||||
"brand": "بالین یار",
|
"brand": "بالین یار",
|
||||||
"brand_tagline": "مراقبت مطمئن در خانه"
|
"brand_tagline": "مراقبت مطمئن در خانه",
|
||||||
|
"open_sidebar": "باز کردن منو",
|
||||||
|
"switch_locale": "تغییر به {locale}"
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"customer_app": "اپلیکیشن خانواده",
|
"customer_app": "اپلیکیشن خانواده",
|
||||||
"nurse_app": "نمای پرستار",
|
"nurse_app": "نمای پرستار",
|
||||||
"admin_console": "کنسول مدیریت",
|
"admin_console": "کنسول مدیریت",
|
||||||
"placeholder_body": "این بخش در فازهای بعدی تکمیل میشود.",
|
"placeholder_body": "این بخش در فازهای بعدی تکمیل میشود.",
|
||||||
"partner_console": "پرتال همکار"
|
"partner_console": "پرتال همکار",
|
||||||
|
"switch_to_nurse": "نمای پرستار",
|
||||||
|
"switch_to_customer": "اپلیکیشن خانواده"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"greeting_named": "سلام، {name}",
|
"greeting_named": "سلام، {name}",
|
||||||
@@ -144,7 +158,9 @@
|
|||||||
"saved": "پروفایل ذخیره شد",
|
"saved": "پروفایل ذخیره شد",
|
||||||
"completion_done": "پروفایل شما کامل است.",
|
"completion_done": "پروفایل شما کامل است.",
|
||||||
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید.",
|
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید.",
|
||||||
"load_error": "پروفایل شما بارگذاری نشد."
|
"load_error": "پروفایل شما بارگذاری نشد.",
|
||||||
|
"app_language": "زبان برنامه",
|
||||||
|
"sign_out": "خروج از حساب"
|
||||||
},
|
},
|
||||||
"nurseProfile": {
|
"nurseProfile": {
|
||||||
"title": "پروفایل پرستار",
|
"title": "پروفایل پرستار",
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { useLocale, useTranslations } from 'next-intl';
|
|||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
|
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
|
||||||
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { digitsOnly } from '@/utils';
|
import { digitsOnly } from '@/utils';
|
||||||
|
import { ActorSwitcher } from '@/layout';
|
||||||
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
||||||
import { useMe } from '@/services/auth';
|
import { useMe, useLogout } from '@/services/auth';
|
||||||
import type { CustomerProfile } from '@/services/profiles/types';
|
import type { CustomerProfile } from '@/services/profiles/types';
|
||||||
|
|
||||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||||
@@ -167,6 +169,31 @@ const CustomerProfileForm: FunctionComponent<{
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Account affordances: sign-out has no home anywhere in the customer shell yet (a full hub
|
||||||
|
redesign is deferred to phase 9) — one labeled row is enough for now. */}
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<ActorSwitcher target="nurse" />
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('app_language')}
|
||||||
|
</Typography>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</Stack>
|
||||||
|
<SignOutRow />
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SignOutRow: FunctionComponent = () => {
|
||||||
|
const t = useTranslations('profile');
|
||||||
|
const { mutate: logout, isPending } = useLogout();
|
||||||
|
return (
|
||||||
|
<AppButton variant="text" color="error" startIcon="logout" onClick={() => logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
{t('sign_out')}
|
||||||
|
</AppButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ProfileSummary from './ProfileSummary';
|
||||||
|
|
||||||
|
function renderSummary(props: Partial<React.ComponentProps<typeof ProfileSummary>> = {}) {
|
||||||
|
return render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ProfileSummary displayName="سارا احمدی" {...props} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<ProfileSummary/> component', () => {
|
||||||
|
it('renders the display name, phone, and role label', () => {
|
||||||
|
renderSummary({ phone: '0912*****33', roleLabel: 'پرستار' });
|
||||||
|
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0912*****33')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('پرستار')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a TrustBadge only when trustState is set', () => {
|
||||||
|
const { rerender } = renderSummary();
|
||||||
|
expect(screen.queryByText('badge_verified')).not.toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ProfileSummary displayName="سارا احمدی" trustState="verified" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('badge_verified')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skeleton placeholders instead of text while loading', () => {
|
||||||
|
const { container } = renderSummary({ loading: true, phone: '0912*****33' });
|
||||||
|
expect(screen.queryByText('سارا احمدی')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the compact horizontal chip variant', () => {
|
||||||
|
const { container } = renderSummary({ compact: true, roleLabel: 'مالی' });
|
||||||
|
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('مالی')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('.MuiAvatar-root')).toHaveStyle({ width: '32px', height: '32px' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { Avatar, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import TrustBadge from '../TrustBadge';
|
||||||
|
import type { BadgeState } from '@/services/verification/types';
|
||||||
|
|
||||||
|
export interface ProfileSummaryProps {
|
||||||
|
/** Already-resolved display name (caller composes first/last name — i18n-free). */
|
||||||
|
displayName: string;
|
||||||
|
/** The server-masked phone (e.g. `0912*****33`) — rendered as a `dir="ltr"` island. */
|
||||||
|
phone?: string;
|
||||||
|
/** Already-translated role/fine-grained-role label (e.g. "پرستار", "مالی"). */
|
||||||
|
roleLabel?: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
/** Renders a `TrustBadge` next to the name when set (nurse identity only). */
|
||||||
|
trustState?: BadgeState;
|
||||||
|
/** True while the identity is still resolving — renders skeleton placeholders instead of text. */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Dense horizontal chip form for the admin/partner TopBar identity slot; default is the vertical card. */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one identity card for authenticated chrome — avatar, name, masked phone, role label, and an
|
||||||
|
* optional `TrustBadge`. Replaces the starter `UserInfo` (`user?: any`, eternal "Current User").
|
||||||
|
* Presentational: the caller (each shell) sources data from `useMe`/the profiles domain and passes
|
||||||
|
* it down, so this component never fetches on its own.
|
||||||
|
* @component ProfileSummary
|
||||||
|
*/
|
||||||
|
const ProfileSummary: FunctionComponent<ProfileSummaryProps> = ({
|
||||||
|
displayName,
|
||||||
|
phone,
|
||||||
|
roleLabel,
|
||||||
|
avatarUrl,
|
||||||
|
trustState,
|
||||||
|
loading = false,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
|
const avatarSize = compact ? 32 : 56;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack direction={compact ? 'row' : 'column'} sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
|
||||||
|
<Skeleton variant="circular" width={avatarSize} height={avatarSize} />
|
||||||
|
<Stack sx={{ alignItems: compact ? 'flex-start' : 'center', gap: 0.5, minWidth: 0 }}>
|
||||||
|
<Skeleton variant="text" width={compact ? 80 : 120} />
|
||||||
|
{!compact && <Skeleton variant="text" width={90} />}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatar = <Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||||
|
{avatar}
|
||||||
|
<Stack sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
{roleLabel && (
|
||||||
|
<Typography variant="caption" noWrap sx={{ color: 'text.secondary', lineHeight: 1.2 }}>
|
||||||
|
{roleLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ alignItems: 'center', gap: 1, width: '100%', textAlign: 'center' }}>
|
||||||
|
{avatar}
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
{trustState && <TrustBadge state={trustState} />}
|
||||||
|
</Stack>
|
||||||
|
{phone && (
|
||||||
|
<Typography variant="body2" dir="ltr" sx={{ color: 'text.secondary' }}>
|
||||||
|
{phone}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{roleLabel && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{roleLabel}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileSummary;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import ProfileSummary from './ProfileSummary';
|
||||||
|
|
||||||
|
export default ProfileSummary;
|
||||||
|
export type { ProfileSummaryProps } from './ProfileSummary';
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Avatar, Stack, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
interface UserInfoProps {
|
|
||||||
className?: string;
|
|
||||||
showAvatar?: boolean;
|
|
||||||
user?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders User info with Avatar
|
|
||||||
* @component UserInfo
|
|
||||||
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
|
|
||||||
* @param {object} [user] - logged user data {name, email, avatar...}
|
|
||||||
*/
|
|
||||||
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
|
|
||||||
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
|
|
||||||
const srcAvatar = user?.avatar ? user?.avatar : undefined;
|
|
||||||
const userPhoneOrEmail = user?.phone || (user?.email as string);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack sx={{ alignItems: 'center', minHeight: 'fit-content', marginBottom: 2 }} {...restOfProps}>
|
|
||||||
{showAvatar ? (
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fontSize: '3rem',
|
|
||||||
}}
|
|
||||||
alt={fullName || 'User Avatar'}
|
|
||||||
src={srcAvatar}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Typography sx={{ mt: 1 }} variant="h6">
|
|
||||||
{fullName || 'Current User'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserInfo;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import UserInfo from './UserInfo';
|
|
||||||
|
|
||||||
export { UserInfo };
|
|
||||||
export default UserInfo;
|
|
||||||
@@ -34,7 +34,6 @@ interface RoleGuardProps {
|
|||||||
* @component RoleGuard
|
* @component RoleGuard
|
||||||
*/
|
*/
|
||||||
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
|
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
|
||||||
return children;
|
|
||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
|
|||||||
import ShareIcon from '@mui/icons-material/ShareRounded';
|
import ShareIcon from '@mui/icons-material/ShareRounded';
|
||||||
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
|
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
|
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
|
||||||
|
import LanguageIcon from '@mui/icons-material/TranslateRounded';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all available Icon names
|
* List of all available Icon names
|
||||||
@@ -202,6 +203,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
|||||||
share: ShareIcon,
|
share: ShareIcon,
|
||||||
copy: CopyIcon,
|
copy: CopyIcon,
|
||||||
attachment: AttachmentIcon,
|
attachment: AttachmentIcon,
|
||||||
|
language: LanguageIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../../theme';
|
||||||
|
|
||||||
|
const replace = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'fa',
|
||||||
|
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key}:${params.locale}` : key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/i18n/navigation', () => ({
|
||||||
|
usePathname: () => '/nurse/earnings',
|
||||||
|
useRouter: () => ({ replace }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import LocaleSwitcher from './LocaleSwitcher';
|
||||||
|
|
||||||
|
describe('<LocaleSwitcher/> component', () => {
|
||||||
|
beforeEach(() => replace.mockClear());
|
||||||
|
|
||||||
|
it('renders a single icon button', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the current path with the other locale on click, preserving the route', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(replace).toHaveBeenCalledWith('/nurse/earnings', { locale: 'en' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||||
|
import type { Locale } from '@/i18n/routing';
|
||||||
|
import AppIconButton from '../AppIconButton';
|
||||||
|
|
||||||
|
const LOCALE_LABEL: Record<Locale, string> = { fa: 'فارسی', en: 'English' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches between `fa`/`en` while preserving the current route — `router.replace(pathname,
|
||||||
|
* { locale })` via the `@/i18n/navigation` wrapper, so a deep link (e.g. `/fa/nurse/earnings`)
|
||||||
|
* lands on the same page in the other locale rather than resetting to home.
|
||||||
|
* @component LocaleSwitcher
|
||||||
|
*/
|
||||||
|
const LocaleSwitcher: FunctionComponent = () => {
|
||||||
|
const locale = useLocale() as Locale;
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations('common');
|
||||||
|
const nextLocale: Locale = locale === 'fa' ? 'en' : 'fa';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppIconButton
|
||||||
|
icon="language"
|
||||||
|
title={t('switch_locale', { locale: LOCALE_LABEL[nextLocale] })}
|
||||||
|
onClick={() => router.replace(pathname, { locale: nextLocale })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocaleSwitcher;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import LocaleSwitcher from './LocaleSwitcher';
|
||||||
|
|
||||||
|
export default LocaleSwitcher;
|
||||||
@@ -16,6 +16,7 @@ import Money from './Money';
|
|||||||
import StatusTimeline from './StatusTimeline';
|
import StatusTimeline from './StatusTimeline';
|
||||||
import JalaliDatePicker from './JalaliDatePicker';
|
import JalaliDatePicker from './JalaliDatePicker';
|
||||||
import JalaliDateField from './JalaliDateField';
|
import JalaliDateField from './JalaliDateField';
|
||||||
|
import LocaleSwitcher from './LocaleSwitcher';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -36,6 +37,7 @@ export {
|
|||||||
StatusTimeline,
|
StatusTimeline,
|
||||||
JalaliDatePicker,
|
JalaliDatePicker,
|
||||||
JalaliDateField,
|
JalaliDateField,
|
||||||
|
LocaleSwitcher,
|
||||||
};
|
};
|
||||||
export type { EmptyStateProps } from './EmptyState';
|
export type { EmptyStateProps } from './EmptyState';
|
||||||
export type { ErrorStateProps } from './ErrorState';
|
export type { ErrorStateProps } from './ErrorState';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from './common';
|
export * from './common';
|
||||||
|
|
||||||
import UserInfo from './UserInfo';
|
import ProfileSummary from './ProfileSummary';
|
||||||
import PlaceholderScreen from './PlaceholderScreen';
|
import PlaceholderScreen from './PlaceholderScreen';
|
||||||
import OtpInput from './OtpInput';
|
import OtpInput from './OtpInput';
|
||||||
import PhoneNumberField from './PhoneNumberField';
|
import PhoneNumberField from './PhoneNumberField';
|
||||||
@@ -35,7 +35,7 @@ import VisitNoteCard from './VisitNoteCard';
|
|||||||
import PatientHeader from './PatientHeader';
|
import PatientHeader from './PatientHeader';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
UserInfo,
|
ProfileSummary,
|
||||||
PlaceholderScreen,
|
PlaceholderScreen,
|
||||||
OtpInput,
|
OtpInput,
|
||||||
PhoneNumberField,
|
PhoneNumberField,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import NotificationBellView from './NotificationBellView';
|
|||||||
|
|
||||||
export interface NotificationBellProps {
|
export interface NotificationBellProps {
|
||||||
/** The shell the bell lives in — decides which notification center it opens. */
|
/** The shell the bell lives in — decides which notification center it opens. */
|
||||||
role: 'customer' | 'nurse';
|
role: 'customer' | 'nurse' | 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -161,9 +161,12 @@ export const ticketsBasePath = (role: 'customer' | 'nurse'): string =>
|
|||||||
export const ticketThreadPath = (role: 'customer' | 'nurse', ticketId: number | string): string =>
|
export const ticketThreadPath = (role: 'customer' | 'nurse', ticketId: number | string): string =>
|
||||||
role === 'nurse' ? nurseTicketThreadPath(ticketId) : customerTicketThreadPath(ticketId);
|
role === 'nurse' ? nurseTicketThreadPath(ticketId) : customerTicketThreadPath(ticketId);
|
||||||
|
|
||||||
/** The notification center for an actor (customer vs nurse shell). */
|
/** The notification center for an actor (customer / nurse / admin shell). */
|
||||||
export const notificationsPath = (role: 'customer' | 'nurse'): string =>
|
export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string => {
|
||||||
role === 'nurse' ? ROUTES.NURSE_NOTIFICATIONS : ROUTES.NOTIFICATIONS;
|
if (role === 'nurse') return ROUTES.NURSE_NOTIFICATIONS;
|
||||||
|
if (role === 'admin') return ROUTES.ADMIN_NOTIFICATIONS;
|
||||||
|
return ROUTES.NOTIFICATIONS;
|
||||||
|
};
|
||||||
|
|
||||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one locale-aware navigation surface. `Link`/`useRouter` add the active locale prefix
|
||||||
|
* automatically and `usePathname` strips it, so chrome code compares/builds paths against the
|
||||||
|
* unprefixed `ROUTES.*` constants directly — no manual `/${locale}` prefixing, and clicking a
|
||||||
|
* `Link` never round-trips through the locale-detection middleware.
|
||||||
|
*/
|
||||||
|
export const { Link, usePathname, useRouter, redirect, getPathname } = createNavigation(routing);
|
||||||
@@ -1,46 +1,65 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { NotificationBell } from '@/components/notifications';
|
||||||
|
import { ProfileSummary } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useAdminCapabilities } from '@/hooks';
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
|
import { useMe } from '@/services/auth';
|
||||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin / backoffice shell — the desktop ops console (f15). The sidebar is **role-gated**: each console
|
* Admin / backoffice shell — the desktop ops console (f15). The sidebar is **sectioned**
|
||||||
* appears only when the current admin role can act on it (`useAdminCapabilities`). This is a display
|
* (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current
|
||||||
* convenience — the server enforces every command's scope — so a `support` admin never sees the payout or
|
* admin role can act on it (`useAdminCapabilities`), unchanged from before — grouping never adds,
|
||||||
* refund controls, a `moderation` admin only sees moderation, etc. (phase §3 "Routing & RBAC", §5).
|
* removes, or loosens a gate. The TopBar carries a page title, the notification bell (widened to
|
||||||
|
* the `'admin'` role), and a compact identity chip showing the admin's fine-grained role.
|
||||||
* @layout AdminLayout
|
* @layout AdminLayout
|
||||||
*/
|
*/
|
||||||
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tShell = useTranslations('shell');
|
const ta = useTranslations('admin');
|
||||||
|
const { data: me } = useMe();
|
||||||
const caps = useAdminCapabilities();
|
const caps = useAdminCapabilities();
|
||||||
|
|
||||||
const sidebarItems: Array<LinkToPage> = useMemo(() => {
|
const sidebarItems: Array<LinkToPage> = useMemo(() => {
|
||||||
|
const groupTrust = t('group_trust');
|
||||||
|
const groupFinance = t('group_finance');
|
||||||
|
const groupSupport = t('group_support');
|
||||||
|
const groupSystem = t('group_system');
|
||||||
const items: Array<LinkToPage & { show: boolean }> = [
|
const items: Array<LinkToPage & { show: boolean }> = [
|
||||||
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true },
|
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true },
|
||||||
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', show: caps.canVerify },
|
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', group: groupTrust, show: caps.canVerify },
|
||||||
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', show: caps.canManageTickets },
|
{ title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', group: groupTrust, show: caps.canModerate },
|
||||||
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', show: caps.canPayout },
|
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', group: groupFinance, show: caps.canPayout },
|
||||||
{ title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', show: caps.canModerate },
|
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', group: groupSupport, show: caps.canManageTickets },
|
||||||
{ title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', show: caps.canConfig },
|
{ title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', group: groupSupport, show: caps.canManageAlerts },
|
||||||
{ title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', show: caps.canConfig },
|
{ title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', group: groupSystem, show: caps.canConfig },
|
||||||
{ title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', show: caps.canManageAlerts },
|
{ title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', group: groupSystem, show: caps.canConfig },
|
||||||
{ title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', show: caps.canViewAudit },
|
{ title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', group: groupSystem, show: caps.canViewAudit },
|
||||||
{ title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', show: caps.canManagePartners },
|
{ title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', group: groupSystem, show: caps.canManagePartners },
|
||||||
{ title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', show: caps.canManageRoles },
|
{ title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users', group: groupSystem, show: caps.canManageRoles },
|
||||||
{ title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications', show: true },
|
{ title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', group: groupSystem, show: caps.canManageRoles },
|
||||||
];
|
];
|
||||||
return items.filter((i) => i.show).map(({ show: _show, ...rest }) => rest);
|
return items.filter((item) => item.show).map(({ show: _show, ...rest }) => rest);
|
||||||
}, [t, caps]);
|
}, [t, caps]);
|
||||||
|
|
||||||
|
const primaryRoleCode = caps.roles[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopBarAndSideBarLayout
|
<TopBarAndSideBarLayout
|
||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
title={tShell('admin_console')}
|
headerActions={<NotificationBell role="admin" />}
|
||||||
variant="sidebarPersistentOnDesktop"
|
identity={
|
||||||
|
me && primaryRoleCode ? (
|
||||||
|
<ProfileSummary
|
||||||
|
compact
|
||||||
|
displayName={[me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone}
|
||||||
|
roleLabel={ta(`role_${primaryRoleCode}`)}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TopBarAndSideBarLayout>
|
</TopBarAndSideBarLayout>
|
||||||
|
|||||||
@@ -1,33 +1,94 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
import { Box, Stack } from '@mui/material';
|
import { Box, Stack, Tab, Tabs } from '@mui/material';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
|
||||||
import { ErrorBoundary } from '@/components';
|
|
||||||
import AppIconButton from '@/components/common/AppIconButton';
|
|
||||||
import { NotificationBell } from '@/components/notifications';
|
import { NotificationBell } from '@/components/notifications';
|
||||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import { useIsMobile } from '@/hooks';
|
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||||
import { TopBar, BottomBar } from './components';
|
import { TopBar, BottomBar } from './components';
|
||||||
import { DarkModeToggleButton } from './components/DarkModeButton';
|
import { DarkModeToggleButton } from './components/DarkModeButton';
|
||||||
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
|
import BrandLockup from './components/BrandLockup';
|
||||||
|
import { matchActivePath } from './matchActivePath';
|
||||||
|
import { PageTitleProvider, isCustomerRootTab, useRouteTitle } from './routeTitle';
|
||||||
|
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT, TOP_NAV_DESKTOP_HEIGHT } from './config';
|
||||||
|
|
||||||
|
/** The ≥md replacement for the mobile BottomBar — the same 5 tabs, inline in the header. */
|
||||||
|
const CustomerDesktopNav: FunctionComponent<{ items: Array<LinkToPage> }> = ({ items }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const activePath = matchActivePath(pathname, items.map((item) => item.path)) ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: { xs: 'none', md: 'block' }, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Tabs
|
||||||
|
value={activePath}
|
||||||
|
onChange={(_event, value: string) => router.push(value)}
|
||||||
|
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT, px: 2 }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Tab
|
||||||
|
key={item.path}
|
||||||
|
value={item.path}
|
||||||
|
label={item.title}
|
||||||
|
icon={item.icon ? <AppIcon icon={item.icon} size={18} /> : undefined}
|
||||||
|
iconPosition="start"
|
||||||
|
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> = ({ bottomNavItems }) => {
|
||||||
|
const t = useTranslations('nav');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const title = useRouteTitle();
|
||||||
|
const onRootTab = isCustomerRootTab(pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TopBar
|
||||||
|
align={onRootTab ? 'center' : 'start'}
|
||||||
|
startNode={
|
||||||
|
onRootTab ? (
|
||||||
|
<AppIconButton
|
||||||
|
icon="support"
|
||||||
|
color="inherit"
|
||||||
|
title={t('support')}
|
||||||
|
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
titleNode={onRootTab ? <BrandLockup /> : undefined}
|
||||||
|
title={onRootTab ? undefined : (title ?? undefined)}
|
||||||
|
endNode={
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||||
|
<NotificationBell role="customer" />
|
||||||
|
<DarkModeToggleButton />
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
secondaryRow={<CustomerDesktopNav items={bottomNavItems} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer (family) app shell — the primary, mobile-first experience.
|
* Customer (family) app shell — the primary, mobile-first experience. A contextual TopBar (brand
|
||||||
* A slim TopBar (a "Support / My Tickets" action at the start, the notification bell + dark-mode toggle at
|
* lockup on the 5 root tabs, page title + back chevron on pushed routes), a reading-width content
|
||||||
* the end), a scrollable content column constrained to reading width, and the 5-tab BottomBar
|
* column on a `background.default` canvas, and the 5-tab `BottomBar` on mobile — replaced by an
|
||||||
* (Home/Bookings/Patients/Wallet/Profile) from the wireframe.
|
* inline desktop top-nav (`CustomerDesktopNav`) at `≥md`, where the mobile tab bar hides entirely.
|
||||||
* @layout CustomerLayout
|
* @layout CustomerLayout
|
||||||
*/
|
*/
|
||||||
const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tShell = useTranslations('shell');
|
|
||||||
const tChrome = useTranslations('routeChrome');
|
const tChrome = useTranslations('routeChrome');
|
||||||
const onMobile = useIsMobile();
|
|
||||||
const router = useRouter();
|
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
const bottomNavItems: Array<LinkToPage> = useMemo(
|
const bottomNavItems: Array<LinkToPage> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -41,53 +102,42 @@ const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ height: '100dvh' }}>
|
<PageTitleProvider>
|
||||||
<Stack component="header">
|
<Stack sx={{ height: '100dvh', bgcolor: 'background.default' }}>
|
||||||
<TopBar
|
<Stack component="header">
|
||||||
title={tShell('customer_app')}
|
<CustomerHeader bottomNavItems={bottomNavItems} />
|
||||||
startNode={
|
</Stack>
|
||||||
<AppIconButton
|
|
||||||
icon="support"
|
|
||||||
color="inherit"
|
|
||||||
title={t('support')}
|
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.SUPPORT_TICKETS}`)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
endNode={
|
|
||||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
|
||||||
<NotificationBell role="customer" />
|
|
||||||
<DarkModeToggleButton />
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: CONTENT_MAX_WIDTH,
|
// AppBar is position: fixed — offset content by the top-bar (+ desktop top-nav) height.
|
||||||
mx: 'auto',
|
pt: {
|
||||||
px: 2,
|
xs: `calc(${TOP_BAR_MOBILE_HEIGHT} + 8px)`,
|
||||||
py: 2,
|
md: `calc(${TOP_BAR_DESKTOP_HEIGHT} + ${TOP_NAV_DESKTOP_HEIGHT} + 16px)`,
|
||||||
// AppBar is position: fixed — offset content by the top-bar height.
|
},
|
||||||
pt: `calc(${onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT} + 8px)`,
|
}}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorBoundary
|
|
||||||
name="Customer"
|
|
||||||
title={tChrome('error_title')}
|
|
||||||
body={tChrome('error_body')}
|
|
||||||
retryLabel={tChrome('error_retry')}
|
|
||||||
>
|
>
|
||||||
{children}
|
<Box sx={{ maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', px: 2, pb: 2 }}>
|
||||||
</ErrorBoundary>
|
<ErrorBoundary
|
||||||
</Box>
|
name="Customer"
|
||||||
|
title={tChrome('error_title')}
|
||||||
|
body={tChrome('error_body')}
|
||||||
|
retryLabel={tChrome('error_retry')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<BottomBar items={bottomNavItems} />
|
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||||
</Stack>
|
<BottomBar items={bottomNavItems} />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</PageTitleProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Stack } from '@mui/material';
|
||||||
import { NotificationBell } from '@/components/notifications';
|
import { NotificationBell } from '@/components/notifications';
|
||||||
|
import { ProfileSummary } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
|
import { useMe } from '@/services/auth';
|
||||||
|
import { useNurseProfile } from '@/services/profiles';
|
||||||
|
import { useVerificationStatus } from '@/services/verification';
|
||||||
|
import { ownBadgeState } from '@/services/verification/types';
|
||||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||||
|
import { BottomBar } from './components';
|
||||||
|
import ActorSwitcher from './components/ActorSwitcher';
|
||||||
|
|
||||||
|
/** A "more" bottom-nav tab isn't a real route — this sentinel never matches a pathname. */
|
||||||
|
const MORE_TAB_PATH = '#more';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nurse app shell — the "نمای پرستار" experience (verification, dashboard, EVV visits).
|
* Nurse app shell — the "نمای پرستار" workspace. Sectioned sidebar (امروز/حرفهٔ من/مالی/پشتیبانی)
|
||||||
* Uses the shared TopBar + SideBar engine: a temporary drawer on mobile, persistent on
|
* with a real `ProfileSummary` identity card (name, masked phone, own `TrustBadge`), plus a 5-tab
|
||||||
* desktop. Nav is role-scoped to the nurse routes.
|
* mobile bottom nav whose «بیشتر» tab opens the same sidebar drawer for the remaining items.
|
||||||
* @layout NurseLayout
|
* @layout NurseLayout
|
||||||
*/
|
*/
|
||||||
const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tShell = useTranslations('shell');
|
|
||||||
|
|
||||||
const sidebarItems: Array<LinkToPage> = useMemo(
|
const { data: me } = useMe();
|
||||||
() => [
|
const { data: nurseProfile } = useNurseProfile();
|
||||||
|
const { data: verification } = useVerificationStatus();
|
||||||
|
|
||||||
|
const identityLoading = !me;
|
||||||
|
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||||
|
|
||||||
|
const sidebarItems: Array<LinkToPage> = useMemo(() => {
|
||||||
|
const groupToday = t('group_today');
|
||||||
|
const groupProfession = t('group_profession');
|
||||||
|
const groupFinance = t('group_finance');
|
||||||
|
const groupSupport = t('group_support');
|
||||||
|
return [
|
||||||
|
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard', group: groupToday },
|
||||||
|
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests', group: groupToday },
|
||||||
|
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits', group: groupToday },
|
||||||
|
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile', group: groupProfession },
|
||||||
|
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services', group: groupProfession },
|
||||||
|
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage', group: groupProfession },
|
||||||
|
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession },
|
||||||
|
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance },
|
||||||
|
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance },
|
||||||
|
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support', group: groupSupport },
|
||||||
|
];
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const mobileTabs = useMemo(
|
||||||
|
(): Array<LinkToPage> => [
|
||||||
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
|
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
|
||||||
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' },
|
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' },
|
||||||
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
|
|
||||||
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' },
|
|
||||||
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
|
|
||||||
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
|
|
||||||
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
|
|
||||||
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
|
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
|
||||||
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
|
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
|
||||||
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support' },
|
{ title: t('more'), path: MORE_TAB_PATH, icon: 'menu' },
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
@@ -35,9 +66,26 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<TopBarAndSideBarLayout
|
<TopBarAndSideBarLayout
|
||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
title={tShell('nurse_app')}
|
|
||||||
variant="sidebarPersistentOnDesktop"
|
|
||||||
headerActions={<NotificationBell role="nurse" />}
|
headerActions={<NotificationBell role="nurse" />}
|
||||||
|
sidebarIdentity={
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<ProfileSummary
|
||||||
|
displayName={displayName}
|
||||||
|
phone={me?.phone}
|
||||||
|
avatarUrl={nurseProfile?.avatarUrl}
|
||||||
|
trustState={ownBadgeState(verification)}
|
||||||
|
loading={identityLoading}
|
||||||
|
/>
|
||||||
|
<ActorSwitcher target="customer" />
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
mobileBottomBar={(openMobileSidebar) => (
|
||||||
|
<BottomBar
|
||||||
|
items={mobileTabs.map((item) =>
|
||||||
|
item.path === MORE_TAB_PATH ? { ...item, onSelect: openMobileSidebar } : item
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TopBarAndSideBarLayout>
|
</TopBarAndSideBarLayout>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { ProfileSummary } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
|
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partner-center portal shell (f15) — a **separate authz scope** from the Balinyaar admin console. A
|
* Partner-center portal shell (f15) — a **separate authz scope** from the Balinyaar admin console. A
|
||||||
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced tenancy).
|
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced
|
||||||
* Same desktop sidebar engine as the admin shell, its own nav. The settlement tab always appears; the
|
* tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name
|
||||||
* settlement page itself renders the "via Balinyaar" state for a non-merchant-of-record center.
|
* (skeleton while resolving) — the page-level access-denied state (403/404) stays where it is.
|
||||||
* @layout PartnerLayout
|
* @layout PartnerLayout
|
||||||
*/
|
*/
|
||||||
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tShell = useTranslations('shell');
|
const { data: center, isLoading } = useMyPartnerCenter();
|
||||||
|
|
||||||
const sidebarItems: Array<LinkToPage> = useMemo(
|
const sidebarItems: Array<LinkToPage> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -23,14 +25,13 @@ const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
|||||||
{ title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' },
|
{ title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' },
|
||||||
{ title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' },
|
{ title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' },
|
||||||
],
|
],
|
||||||
[t],
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopBarAndSideBarLayout
|
<TopBarAndSideBarLayout
|
||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
title={tShell('partner_console')}
|
identity={<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />}
|
||||||
variant="sidebarPersistentOnDesktop"
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TopBarAndSideBarLayout>
|
</TopBarAndSideBarLayout>
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
|
'use client';
|
||||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Stack } from '@mui/material';
|
import { Stack } from '@mui/material';
|
||||||
import { LinkToPage } from '@/utils';
|
import { AppIcon, ErrorBoundary } from '@/components';
|
||||||
import { useIsMobile } from '@/hooks';
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||||
import { BottomBar } from './components';
|
import { DarkModeToggleButton } from './components/DarkModeButton';
|
||||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
|
||||||
import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config';
|
|
||||||
|
|
||||||
const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/before authentication
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SideBar navigation items with links for Public Layout
|
* Unauthenticated shell — a minimal centered brand frame (logo + locale switcher + dark toggle,
|
||||||
*/
|
* no sidebar, no bottom bar). The step content (`AuthCard`, which renders its own larger `BrandMark`)
|
||||||
const SIDE_BAR_ITEMS: Array<LinkToPage> = [];
|
* is the visual focus; this chrome stays a slim corner strip so the two marks don't compete.
|
||||||
|
|
||||||
/**
|
|
||||||
* BottomBar navigation items with links for Public Layout
|
|
||||||
*/
|
|
||||||
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders "Public Layout" composition
|
|
||||||
* @layout PublicLayout
|
* @layout PublicLayout
|
||||||
*/
|
*/
|
||||||
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const onMobile = useIsMobile();
|
const tChrome = useTranslations('routeChrome');
|
||||||
const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
|
|
||||||
|
|
||||||
const title = TITLE_PUBLIC;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarAlwaysTemporary">
|
<Stack sx={{ minHeight: '100vh' }}>
|
||||||
{children}
|
<Stack
|
||||||
<Stack component="footer">{bottomBarVisible && <BottomBar items={BOTTOM_BAR_ITEMS} />}</Stack>
|
component="header"
|
||||||
</TopBarAndSideBarLayout>
|
direction="row"
|
||||||
|
sx={{ alignItems: 'center', justifyContent: 'space-between', paddingInline: 2, paddingBlock: 1.5 }}
|
||||||
|
>
|
||||||
|
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
<DarkModeToggleButton />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack component="main" sx={{ flexGrow: 1 }}>
|
||||||
|
<ErrorBoundary
|
||||||
|
name="Public"
|
||||||
|
title={tChrome('error_title')}
|
||||||
|
body={tChrome('error_body')}
|
||||||
|
retryLabel={tChrome('error_retry')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +1,102 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, ReactNode, useMemo, useState } from 'react';
|
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Stack, StackProps } from '@mui/material';
|
import { Box, Stack, useTheme } from '@mui/material';
|
||||||
import { AppIconButton, ErrorBoundary } from '@/components';
|
import { AppIconButton, ErrorBoundary } from '@/components';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import { useIsMobile } from '@/hooks';
|
|
||||||
import { TopBar } from './components';
|
import { TopBar } from './components';
|
||||||
import SideBar, { SideBarProps } from './components/SideBar';
|
import SideBar from './components/SideBar';
|
||||||
import { DarkModeToggleButton } from './components/DarkModeButton';
|
import { DarkModeToggleButton } from './components/DarkModeButton';
|
||||||
import {
|
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
|
||||||
SIDE_BAR_DESKTOP_ANCHOR,
|
import { useRouteTitle } from './routeTitle';
|
||||||
SIDE_BAR_MOBILE_ANCHOR,
|
|
||||||
SIDE_BAR_WIDTH,
|
|
||||||
TOP_BAR_DESKTOP_HEIGHT,
|
|
||||||
TOP_BAR_MOBILE_HEIGHT,
|
|
||||||
} from './config';
|
|
||||||
|
|
||||||
interface Props extends StackProps {
|
interface Props {
|
||||||
sidebarItems: Array<LinkToPage>;
|
sidebarItems: Array<LinkToPage>;
|
||||||
title: string;
|
/** Rendered below the sidebar's brand header (the nurse shell's `ProfileSummary` identity card). */
|
||||||
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
|
sidebarIdentity?: ReactNode;
|
||||||
|
/** Rendered in the TopBar, before the header controls (the admin/partner identity chip). */
|
||||||
|
identity?: ReactNode;
|
||||||
/** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */
|
/** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
|
/**
|
||||||
|
* A mobile-only (`<md`) bottom nav rendered under the content column (the nurse shell's 5-tab
|
||||||
|
* bar). Receives a callback that opens the same mobile drawer the TopBar's menu button opens, so
|
||||||
|
* a "more" tab can reveal the rest of the sidebar without a second drawer.
|
||||||
|
*/
|
||||||
|
mobileBottomBar?: (openMobileSidebar: () => void) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant, headerActions }) => {
|
/**
|
||||||
|
* The shared engine behind the nurse/admin/partner shells: a fixed `TopBar` showing the current
|
||||||
|
* page title (`useRouteTitle`, longest-prefix over `ROUTES.*`) plus a `SideBar` that renders as a
|
||||||
|
* mobile overlay and a desktop in-flow column at once (see `SideBar` for why that kills the SSR
|
||||||
|
* flash). The sidebar and the main column are flex siblings in a row — native RTL flexbox puts the
|
||||||
|
* sidebar at the reading-start side with no manual offset math.
|
||||||
|
* @layout TopBarAndSideBarLayout
|
||||||
|
*/
|
||||||
|
const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
sidebarItems,
|
||||||
|
sidebarIdentity,
|
||||||
|
identity,
|
||||||
|
headerActions,
|
||||||
|
mobileBottomBar,
|
||||||
|
}) => {
|
||||||
const tChrome = useTranslations('routeChrome');
|
const tChrome = useTranslations('routeChrome');
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
const tCommon = useTranslations('common');
|
||||||
const onMobile = useIsMobile();
|
const theme = useTheme();
|
||||||
|
const title = useRouteTitle();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const sidebarProps = useMemo((): Partial<SideBarProps> => {
|
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
|
||||||
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
|
|
||||||
let open = sidebarVisible;
|
|
||||||
let sidebarVariant: SideBarProps['variant'] = 'temporary';
|
|
||||||
switch (variant) {
|
|
||||||
case 'sidebarAlwaysTemporary':
|
|
||||||
break;
|
|
||||||
case 'sidebarPersistentOnDesktop':
|
|
||||||
open = onMobile ? sidebarVisible : true;
|
|
||||||
sidebarVariant = onMobile ? 'temporary' : 'persistent';
|
|
||||||
break;
|
|
||||||
case 'sidebarAlwaysPersistent':
|
|
||||||
open = true;
|
|
||||||
sidebarVariant = 'persistent';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return { anchor, open, variant: sidebarVariant };
|
|
||||||
}, [onMobile, sidebarVisible, variant]);
|
|
||||||
|
|
||||||
const stackStyles = useMemo(
|
|
||||||
() => ({
|
|
||||||
minHeight: '100vh',
|
|
||||||
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
|
|
||||||
paddingLeft:
|
|
||||||
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
|
|
||||||
? SIDE_BAR_WIDTH
|
|
||||||
: undefined,
|
|
||||||
paddingRight:
|
|
||||||
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right')
|
|
||||||
? SIDE_BAR_WIDTH
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
[onMobile, sidebarProps]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSideBarOpen = () => { if (!sidebarVisible) setSidebarVisible(true); };
|
|
||||||
const onSideBarClose = () => { if (sidebarVisible) setSidebarVisible(false); };
|
|
||||||
|
|
||||||
const LogoButton = (
|
|
||||||
<AppIconButton
|
|
||||||
icon="logo"
|
|
||||||
title={sidebarProps.open ? undefined : 'Open Sidebar'}
|
|
||||||
to={sidebarProps.open ? '/' : undefined}
|
|
||||||
onClick={sidebarProps.open ? undefined : onSideBarOpen}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* DarkModeToggleButton is a self-contained component that subscribes to
|
|
||||||
* useColorScheme() on its own. This layout component never reads the color
|
|
||||||
* scheme and therefore never re-renders when the theme switches. The optional
|
|
||||||
* headerActions (e.g. the notification bell) travel alongside it.
|
|
||||||
*/
|
|
||||||
const headerControls = (
|
const headerControls = (
|
||||||
<Stack direction="row" sx={{ alignItems: 'center' }}>
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{identity}
|
||||||
{headerActions}
|
{headerActions}
|
||||||
|
{/* DarkModeToggleButton subscribes to useColorScheme() on its own — this layout never re-renders on a theme flip */}
|
||||||
<DarkModeToggleButton />
|
<DarkModeToggleButton />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
|
|
||||||
? { startNode: LogoButton, endNode: headerControls }
|
|
||||||
: { startNode: headerControls, endNode: LogoButton };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={stackStyles}>
|
<Stack sx={{ minHeight: '100vh' }}>
|
||||||
<Stack component="header">
|
<TopBar
|
||||||
<TopBar startNode={startNode} title={title} endNode={endNode} />
|
startNode={
|
||||||
<SideBar items={sidebarItems} onClose={onSideBarClose} {...sidebarProps} />
|
<AppIconButton
|
||||||
|
icon="menu"
|
||||||
|
title={tCommon('open_sidebar')}
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={title ?? ''}
|
||||||
|
align="start"
|
||||||
|
endNode={headerControls}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ flexGrow: 1, pt: { xs: TOP_BAR_MOBILE_HEIGHT, md: TOP_BAR_DESKTOP_HEIGHT } }}>
|
||||||
|
<SideBar
|
||||||
|
items={sidebarItems}
|
||||||
|
identity={sidebarIdentity}
|
||||||
|
anchor={anchor}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack component="main" sx={{ flexGrow: 1, minWidth: 0, padding: 2 }}>
|
||||||
|
<ErrorBoundary
|
||||||
|
name="Content"
|
||||||
|
title={tChrome('error_title')}
|
||||||
|
body={tChrome('error_body')}
|
||||||
|
retryLabel={tChrome('error_retry')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack
|
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
|
||||||
component="main"
|
|
||||||
sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}
|
|
||||||
>
|
|
||||||
<ErrorBoundary
|
|
||||||
name="Content"
|
|
||||||
title={tChrome('error_title')}
|
|
||||||
body={tChrome('error_body')}
|
|
||||||
retryLabel={tChrome('error_retry')}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
const push = jest.fn();
|
||||||
|
let mockRoles: string[] = [];
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/i18n/navigation', () => ({
|
||||||
|
useRouter: () => ({ push }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/context/auth', () => ({
|
||||||
|
useAuth: () => [{ currentUser: { roles: mockRoles } }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ActorSwitcher from './ActorSwitcher';
|
||||||
|
|
||||||
|
describe('<ActorSwitcher/> component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
push.mockClear();
|
||||||
|
mockRoles = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for a single-role session', () => {
|
||||||
|
mockRoles = ['nurse'];
|
||||||
|
const { container } = render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ActorSwitcher target="customer" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the nurse app for a dual-role session', () => {
|
||||||
|
mockRoles = ['customer', 'nurse'];
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ActorSwitcher target="nurse" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('switch_to_nurse'));
|
||||||
|
expect(push).toHaveBeenCalledWith('/nurse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the customer app for a dual-role session', () => {
|
||||||
|
mockRoles = ['customer', 'nurse'];
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ActorSwitcher target="customer" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('switch_to_customer'));
|
||||||
|
expect(push).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import { useAuth } from '@/context/auth';
|
||||||
|
import { APP_ROLES, ROUTES } from '@/constants';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
|
||||||
|
export interface ActorSwitcherProps {
|
||||||
|
/** The shell this switcher navigates *to* — 'nurse' inside the customer app, 'customer' inside the nurse app. */
|
||||||
|
target: 'customer' | 'nurse';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-only "نمای پرستار ⇄ اپلیکیشن خانواده" affordance for a dual customer+nurse session
|
||||||
|
* (`SessionUser.roles` already carries both). Renders nothing for a single-role session — `RoleGuard`
|
||||||
|
* remains the sole "which app" authority; this only moves the user between two apps they already have.
|
||||||
|
* @component ActorSwitcher
|
||||||
|
*/
|
||||||
|
const ActorSwitcher: FunctionComponent<ActorSwitcherProps> = ({ target }) => {
|
||||||
|
const [state] = useAuth();
|
||||||
|
const roles = state.currentUser?.roles ?? [];
|
||||||
|
const isDualRole = roles.includes(APP_ROLES.CUSTOMER) && roles.includes(APP_ROLES.NURSE);
|
||||||
|
const t = useTranslations('shell');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!isDualRole) return null;
|
||||||
|
|
||||||
|
const destination = target === 'nurse' ? ROUTES.NURSE : ROUTES.HOME;
|
||||||
|
const label = target === 'nurse' ? t('switch_to_nurse') : t('switch_to_customer');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppButton variant="outlined" color="primary" fullWidth onClick={() => router.push(destination)}>
|
||||||
|
{label}
|
||||||
|
</AppButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActorSwitcher;
|
||||||
@@ -1,50 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, useCallback, useMemo } from 'react';
|
import { FunctionComponent, useCallback, useMemo } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { useLocale } from 'next-intl';
|
|
||||||
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
|
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import { AppIcon } from '@/components';
|
import { AppIcon } from '@/components';
|
||||||
|
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||||
|
import { matchActivePath } from '../matchActivePath';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: Array<LinkToPage>;
|
items: Array<LinkToPage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefixes an app-relative path with the active locale (e.g. `/bookings` → `/fa/bookings`). */
|
|
||||||
function withLocale(locale: string, path: string) {
|
|
||||||
return path === '/' ? `/${locale}` : `/${locale}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared horizontal navigation bar (customer app tabs) built on MUI BottomNavigation.
|
* Shared horizontal navigation bar (customer app tabs, and the nurse shell's mobile 5-tab nav)
|
||||||
* Locale-aware: it highlights the active tab from the real pathname (via usePathname,
|
* built on MUI BottomNavigation. Locale-aware via the `@/i18n/navigation` wrapper — no manual
|
||||||
* not the global `location`) and pushes locale-prefixed routes.
|
* `/${locale}` prefixing — and highlights the active tab with the shared `matchActivePath`
|
||||||
|
* longest-prefix helper, so a nested route (e.g. `/patients/123`) still selects its parent tab.
|
||||||
* @component BottomBar
|
* @component BottomBar
|
||||||
*/
|
*/
|
||||||
const BottomBar: FunctionComponent<Props> = ({ items }) => {
|
const BottomBar: FunctionComponent<Props> = ({ items }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
// The active tab is the longest item path that prefixes the current pathname,
|
const activePath = useMemo(
|
||||||
// so `/patients/123` still selects the `/patients` tab and `/` never over-matches.
|
() => matchActivePath(pathname, items.map((item) => item.path)) ?? false,
|
||||||
const activePath = useMemo(() => {
|
[items, pathname]
|
||||||
const matches = items
|
);
|
||||||
.map((item) => item.path)
|
|
||||||
.filter((path): path is string => Boolean(path))
|
|
||||||
.filter((path) => {
|
|
||||||
const prefixed = withLocale(locale, path);
|
|
||||||
return path === '/' ? pathname === prefixed : pathname.startsWith(prefixed);
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.length - a.length);
|
|
||||||
return matches[0] ?? false;
|
|
||||||
}, [items, pathname, locale]);
|
|
||||||
|
|
||||||
const onNavigationChange = useCallback(
|
const onNavigationChange = useCallback(
|
||||||
(_event: unknown, newValue: string) => {
|
(_event: unknown, newValue: string) => {
|
||||||
router.push(withLocale(locale, newValue));
|
const item = items.find((candidate) => candidate.path === newValue);
|
||||||
|
if (item?.onSelect) {
|
||||||
|
item.onSelect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(newValue);
|
||||||
},
|
},
|
||||||
[router, locale]
|
[items, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +43,7 @@ const BottomBar: FunctionComponent<Props> = ({ items }) => {
|
|||||||
elevation={3}
|
elevation={3}
|
||||||
square
|
square
|
||||||
component="nav"
|
component="nav"
|
||||||
sx={{ borderTop: '1px solid', borderColor: 'divider' }}
|
sx={{ borderTop: '1px solid', borderColor: 'divider', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
>
|
>
|
||||||
<BottomNavigation value={activePath} showLabels onChange={onNavigationChange}>
|
<BottomNavigation value={activePath} showLabels onChange={onNavigationChange}>
|
||||||
{items.map(({ title, path, icon }) => (
|
{items.map(({ title, path, icon }) => (
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { Stack, Typography } from '@mui/material';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import LogoLockup from '@/components/common/AppIcon/icons/LogoLockup';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact, horizontal brand lockup for chrome (the customer home header + the sidebar shells'
|
||||||
|
* drawer header) — the vertical, larger `BrandMark` is for the auth splash only.
|
||||||
|
* @component BrandLockup
|
||||||
|
*/
|
||||||
|
const BrandLockup: FunctionComponent<Props> = ({ size = 28 }) => {
|
||||||
|
const t = useTranslations('common');
|
||||||
|
return (
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||||
|
{/* Decorative — the wordmark already announces "Balinyaar" to AT. */}
|
||||||
|
<LogoLockup size={size} aria-hidden="true" />
|
||||||
|
<Typography variant="h6" component="span" noWrap sx={{ fontWeight: 700 }}>
|
||||||
|
{t('brand')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrandLockup;
|
||||||
@@ -1,82 +1,97 @@
|
|||||||
import { FunctionComponent, useCallback, MouseEvent } from 'react';
|
'use client';
|
||||||
import { Stack, Divider, Drawer, DrawerProps } from '@mui/material';
|
import { FunctionComponent, ReactNode, useCallback } from 'react';
|
||||||
import { LinkToPage } from '@/utils';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useIsAuthenticated, useIsMobile } from '@/hooks';
|
import { Divider, Drawer, Stack } from '@mui/material';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||||
|
import { useIsAuthenticated } from '@/hooks';
|
||||||
import { useLogout } from '@/services/auth';
|
import { useLogout } from '@/services/auth';
|
||||||
import { AppIconButton, UserInfo } from '@/components';
|
import { LinkToPage } from '@/utils';
|
||||||
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
|
import { SIDE_BAR_WIDTH } from '../config';
|
||||||
import SideBarNavList from './SideBarNavList';
|
import SideBarNavList from './SideBarNavList';
|
||||||
|
import BrandLockup from './BrandLockup';
|
||||||
import { DarkModeFormSwitch } from './DarkModeButton';
|
import { DarkModeFormSwitch } from './DarkModeButton';
|
||||||
|
|
||||||
export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' | 'open' | 'variant' | 'onClose'> {
|
export interface SideBarProps {
|
||||||
items: Array<LinkToPage>;
|
items: Array<LinkToPage>;
|
||||||
|
/** Physical edge, derived from text direction by the caller ('start' under both locales). */
|
||||||
|
anchor: 'left' | 'right';
|
||||||
|
mobileOpen: boolean;
|
||||||
|
onMobileClose: () => void;
|
||||||
|
/** Rendered below the brand header, above the nav list (the nurse shell's ProfileSummary card). */
|
||||||
|
identity?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders SideBar with Menu and User details
|
* Renders the sidebar as two Drawers sharing one content tree — a `temporary` (overlay) drawer for
|
||||||
|
* mobile and a `permanent` (in-flow) drawer for desktop, switched purely by CSS breakpoint (`sx`
|
||||||
|
* `display`). Because the desktop variant is `permanent`, it reserves its own width as a normal flex
|
||||||
|
* item — the caller never computes a matching content offset, and there is no JS `useIsMobile`
|
||||||
|
* branching to cause a post-hydration layout jump.
|
||||||
|
*
|
||||||
|
* The close handler is wired to the nav list only (`SideBarNavList`'s `onClick`), not the whole
|
||||||
|
* drawer body — toggling dark mode, switching locale, or a mis-tap on a divider never closes it.
|
||||||
* @component SideBar
|
* @component SideBar
|
||||||
*/
|
*/
|
||||||
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
|
const SideBar: FunctionComponent<SideBarProps> = ({ items, anchor, mobileOpen, onMobileClose, identity }) => {
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const onMobile = useIsMobile();
|
const t = useTranslations('nav');
|
||||||
const { mutate: logout } = useLogout();
|
const { mutate: logout } = useLogout();
|
||||||
|
|
||||||
const handleAfterLinkClick = useCallback(
|
const handleNavClick = useCallback(() => onMobileClose(), [onMobileClose]);
|
||||||
(event: MouseEvent) => {
|
|
||||||
if (variant === 'temporary' && typeof onClose === 'function') {
|
const content = (
|
||||||
onClose(event, 'backdropClick');
|
<Stack sx={{ height: '100%', padding: 2, gap: 2, overflowY: 'auto' }}>
|
||||||
}
|
<BrandLockup />
|
||||||
},
|
<Divider />
|
||||||
[variant, onClose]
|
|
||||||
|
{identity && (
|
||||||
|
<>
|
||||||
|
{identity}
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SideBarNavList items={items} showIcons onClick={handleNavClick} />
|
||||||
|
|
||||||
|
<Divider sx={{ marginTop: 'auto' }} />
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
|
||||||
|
<DarkModeFormSwitch />
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<AppButton variant="text" color="error" startIcon="logout" fullWidth onClick={() => logout()}>
|
||||||
|
{t('logout')}
|
||||||
|
</AppButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<>
|
||||||
anchor={anchor}
|
<Drawer
|
||||||
open={open}
|
anchor={anchor}
|
||||||
variant={variant}
|
open={mobileOpen}
|
||||||
slotProps={{
|
onClose={onMobileClose}
|
||||||
paper: {
|
variant="temporary"
|
||||||
sx: {
|
ModalProps={{ keepMounted: true }}
|
||||||
width: SIDE_BAR_WIDTH,
|
sx={{ display: { xs: 'block', md: 'none' } }}
|
||||||
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
|
slotProps={{ paper: { sx: { width: SIDE_BAR_WIDTH } } }}
|
||||||
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
sx={{ height: '100%', padding: 2 }}
|
|
||||||
{...restOfProps}
|
|
||||||
onClick={handleAfterLinkClick}
|
|
||||||
>
|
>
|
||||||
{isAuthenticated && (
|
{content}
|
||||||
<>
|
</Drawer>
|
||||||
<UserInfo showAvatar />
|
<Drawer
|
||||||
<Divider />
|
anchor={anchor}
|
||||||
</>
|
variant="permanent"
|
||||||
)}
|
sx={{ display: { xs: 'none', md: 'block' }, width: SIDE_BAR_WIDTH, flexShrink: 0 }}
|
||||||
|
slotProps={{ paper: { sx: { width: SIDE_BAR_WIDTH, position: 'relative' } } }}
|
||||||
<SideBarNavList items={items} showIcons />
|
>
|
||||||
|
{content}
|
||||||
<Divider />
|
</Drawer>
|
||||||
|
</>
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-evenly',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
|
|
||||||
<DarkModeFormSwitch />
|
|
||||||
|
|
||||||
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={() => logout()} />}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Drawer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, MouseEventHandler } from 'react';
|
import { FunctionComponent, MouseEventHandler } from 'react';
|
||||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||||
import { AppIcon, AppLink } from '@/components';
|
import { AppIcon } from '@/components';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
interface Props extends LinkToPage {
|
interface Props extends LinkToPage {
|
||||||
openInNewTab?: boolean;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick?: MouseEventHandler;
|
onClick?: MouseEventHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders Navigation Item for SideBar, detects current url and sets selected state if needed
|
* Renders a single SideBar navigation item over the locale-aware `Link` (`@/i18n/navigation`) —
|
||||||
|
* `href` is the unprefixed `ROUTES.*` path; the wrapper adds the active locale, so a click is one
|
||||||
|
* navigation with no middleware redirect hop. `selected` is computed by the caller (`SideBarNavList`)
|
||||||
|
* via the shared `matchActivePath` helper.
|
||||||
* @component SideBarNavItem
|
* @component SideBarNavItem
|
||||||
*/
|
*/
|
||||||
const SideBarNavItem: FunctionComponent<Props> = ({
|
const SideBarNavItem: FunctionComponent<Props> = ({ icon, path, selected = false, subtitle, title, onClick }) => {
|
||||||
openInNewTab,
|
|
||||||
icon,
|
|
||||||
path,
|
|
||||||
selected: propSelected = false,
|
|
||||||
subtitle,
|
|
||||||
title,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}>
|
||||||
component={AppLink}
|
|
||||||
selected={selected}
|
|
||||||
to={path}
|
|
||||||
href="" // Hard reset for .href property, otherwise links are always opened in new tab :(
|
|
||||||
openInNewTab={openInNewTab}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
|
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
|
||||||
<ListItemText primary={title} secondary={subtitle} />
|
<ListItemText primary={title} secondary={subtitle} />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { FunctionComponent, MouseEventHandler } from 'react';
|
import { Fragment, FunctionComponent, MouseEventHandler, useMemo } from 'react';
|
||||||
import List from '@mui/material/List';
|
import List from '@mui/material/List';
|
||||||
|
import ListSubheader from '@mui/material/ListSubheader';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
|
import { usePathname } from '@/i18n/navigation';
|
||||||
|
import { matchActivePath } from '../matchActivePath';
|
||||||
import SideBarNavItem from './SideBarNavItem';
|
import SideBarNavItem from './SideBarNavItem';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,24 +13,39 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders list of Navigation Items inside SideBar
|
* Renders list of Navigation Items inside SideBar, sectioned into subheaders when consecutive
|
||||||
|
* items share a `group` (e.g. the nurse workspace's امروز/حرفهٔ من/مالی/پشتیبانی sections);
|
||||||
|
* ungrouped lists (admin/partner/public) render flat exactly as before.
|
||||||
* @component SideBarNavList
|
* @component SideBarNavList
|
||||||
* @param {array} items - list of objects to render as navigation items
|
* @param {array} items - list of objects to render as navigation items
|
||||||
* @param {boolean} [showIcons] - icons in navigation items are visible when true
|
* @param {boolean} [showIcons] - icons in navigation items are visible when true
|
||||||
* @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked
|
* @param {function} [onClick] - optional callback called when some navigation item was clicked
|
||||||
*/
|
*/
|
||||||
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick, ...restOfProps }) => {
|
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const activePath = useMemo(
|
||||||
|
() => matchActivePath(pathname, items.map((item) => item.path)),
|
||||||
|
[pathname, items]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List component="nav" {...restOfProps}>
|
<List component="nav" sx={{ width: '100%' }}>
|
||||||
{items.map(({ icon, path, title }) => (
|
{items.map((item, index) => {
|
||||||
<SideBarNavItem
|
const showHeader = Boolean(item.group) && item.group !== items[index - 1]?.group;
|
||||||
key={`${title}-${path}`}
|
return (
|
||||||
icon={showIcons ? icon : undefined}
|
<Fragment key={`${item.title}-${item.path}`}>
|
||||||
path={path}
|
{showHeader && <ListSubheader component="div" disableSticky>{item.group}</ListSubheader>}
|
||||||
title={title}
|
<SideBarNavItem
|
||||||
onClick={onClick}
|
icon={showIcons ? item.icon : undefined}
|
||||||
/>
|
path={item.path}
|
||||||
))}
|
title={item.title}
|
||||||
|
subtitle={item.subtitle}
|
||||||
|
selected={item.path === activePath}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +1,39 @@
|
|||||||
import { FunctionComponent, ReactNode } from 'react';
|
import { FunctionComponent, ReactNode } from 'react';
|
||||||
import { AppBar, Toolbar, Typography } from '@mui/material';
|
import { AppBar, Box, Toolbar, Typography } from '@mui/material';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
endNode?: ReactNode;
|
endNode?: ReactNode;
|
||||||
startNode?: ReactNode;
|
startNode?: ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Overrides `title` with arbitrary content (e.g. the brand lockup on the customer home). */
|
||||||
|
titleNode?: ReactNode;
|
||||||
|
/** 'start' for a breadcrumb-style label (sidebar shells); 'center' for the customer shell. */
|
||||||
|
align?: 'start' | 'center';
|
||||||
|
/** An optional second row under the main one (the customer shell's desktop top-nav tabs). */
|
||||||
|
secondaryRow?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders TopBar composition
|
* Renders TopBar composition
|
||||||
* @component TopBar
|
* @component TopBar
|
||||||
*/
|
*/
|
||||||
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', ...restOfProps }) => {
|
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', titleNode, align = 'center', secondaryRow }) => {
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<AppBar component="div">
|
||||||
component="div"
|
<Toolbar disableGutters sx={{ paddingInline: 1, gap: 1 }}>
|
||||||
sx={
|
|
||||||
{
|
|
||||||
// boxShadow: 'none', // Uncomment to hide shadow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{...restOfProps}
|
|
||||||
>
|
|
||||||
<Toolbar disableGutters sx={{ paddingX: 1 }}>
|
|
||||||
{startNode}
|
{startNode}
|
||||||
|
|
||||||
<Typography
|
<Box sx={{ flexGrow: 1, minWidth: 0, textAlign: align }}>
|
||||||
variant="h6"
|
{titleNode ?? (
|
||||||
sx={{
|
<Typography variant="h6" component="span" noWrap sx={{ display: 'block' }}>
|
||||||
marginX: 1,
|
{title}
|
||||||
flexGrow: 1,
|
</Typography>
|
||||||
textAlign: 'center',
|
)}
|
||||||
whiteSpace: 'nowrap',
|
</Box>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{endNode}
|
{endNode}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
{secondaryRow}
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
/**
|
/**
|
||||||
* SideBar configuration
|
* SideBar configuration
|
||||||
*/
|
*/
|
||||||
export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';
|
|
||||||
export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';
|
|
||||||
export const SIDE_BAR_WIDTH = '240px';
|
export const SIDE_BAR_WIDTH = '240px';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +14,6 @@ export const TOP_BAR_MOBILE_HEIGHT = '56px';
|
|||||||
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
|
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BottomBar configuration
|
* Customer shell's desktop top-nav row height (the ≥md replacement for the mobile BottomBar).
|
||||||
*/
|
*/
|
||||||
export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true;
|
export const TOP_NAV_DESKTOP_HEIGHT = '48px';
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import CustomerLayout from './CustomerLayout';
|
|||||||
import NurseLayout from './NurseLayout';
|
import NurseLayout from './NurseLayout';
|
||||||
import AdminLayout from './AdminLayout';
|
import AdminLayout from './AdminLayout';
|
||||||
import PartnerLayout from './PartnerLayout';
|
import PartnerLayout from './PartnerLayout';
|
||||||
|
import ActorSwitcher from './components/ActorSwitcher';
|
||||||
|
|
||||||
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout };
|
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout, ActorSwitcher };
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { matchActivePath } from './matchActivePath';
|
||||||
|
|
||||||
|
describe('matchActivePath', () => {
|
||||||
|
const items = ['/nurse', '/nurse/requests', '/nurse/visits'];
|
||||||
|
|
||||||
|
it('picks the longest matching prefix, not the root', () => {
|
||||||
|
expect(matchActivePath('/nurse/requests', items)).toBe('/nurse/requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a nested route under a shorter item', () => {
|
||||||
|
expect(matchActivePath('/nurse/requests/42', items)).toBe('/nurse/requests');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the root item on the root route', () => {
|
||||||
|
expect(matchActivePath('/nurse', items)).toBe('/nurse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never matches a sibling route that merely shares a prefix string', () => {
|
||||||
|
expect(matchActivePath('/nurse-other', items)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats "/" as an exact match only', () => {
|
||||||
|
expect(matchActivePath('/bookings', ['/', '/bookings'])).toBe('/bookings');
|
||||||
|
expect(matchActivePath('/', ['/', '/bookings'])).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing matches', () => {
|
||||||
|
expect(matchActivePath('/admin', items)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores undefined paths in the candidate list', () => {
|
||||||
|
expect(matchActivePath('/nurse/visits', [undefined, ...items])).toBe('/nurse/visits');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Longest-prefix, winner-takes-all active-path matching shared by the sidebar and every bottom
|
||||||
|
* bar. Given the current (locale-stripped) pathname and a set of nav-item paths, returns the
|
||||||
|
* single path that should render as active — the longest candidate that prefixes the pathname —
|
||||||
|
* so e.g. `/nurse/requests` lights up "Requests" and not the "Dashboard" root (`/nurse`), and
|
||||||
|
* `/patients/123` still lights up the `/patients` tab.
|
||||||
|
*/
|
||||||
|
export function matchActivePath(pathname: string, paths: ReadonlyArray<string | undefined>): string | undefined {
|
||||||
|
const isMatch = (path: string) => (path === '/' ? pathname === path : pathname === path || pathname.startsWith(`${path}/`));
|
||||||
|
|
||||||
|
return paths
|
||||||
|
.filter((path): path is string => Boolean(path))
|
||||||
|
.filter(isMatch)
|
||||||
|
.sort((a, b) => b.length - a.length)[0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { usePathname } from '@/i18n/navigation';
|
||||||
|
|
||||||
|
interface TitleEntry {
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static route→title table (longest-prefix wins), read from the existing `nav` namespace — one
|
||||||
|
* key per shell console/tab. Dynamic titles (a nurse's name, a booking reference) are layered on
|
||||||
|
* top via `usePageTitleOverride`; the area phases (4-6, 9) wire those in, this phase ships the
|
||||||
|
* static baseline.
|
||||||
|
*/
|
||||||
|
const TITLE_ENTRIES: TitleEntry[] = [
|
||||||
|
// Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS)
|
||||||
|
{ path: ROUTES.SEARCH, key: 'search' },
|
||||||
|
{ path: ROUTES.SEARCH_RESULTS, key: 'search' },
|
||||||
|
{ path: ROUTES.SEARCH_NURSE, key: 'search' },
|
||||||
|
{ path: ROUTES.BOOKING_REQUEST, key: 'bookings' },
|
||||||
|
{ path: ROUTES.CHECKOUT, key: 'checkout' },
|
||||||
|
{ path: ROUTES.BOOKINGS, key: 'bookings' },
|
||||||
|
{ path: ROUTES.PATIENTS, key: 'patients' },
|
||||||
|
{ path: ROUTES.ADDRESSES, key: 'addresses' },
|
||||||
|
{ path: ROUTES.WALLET, key: 'wallet' },
|
||||||
|
{ path: ROUTES.PROFILE, key: 'profile' },
|
||||||
|
{ path: ROUTES.SUPPORT_TICKETS, key: 'support' },
|
||||||
|
{ path: ROUTES.NOTIFICATIONS, key: 'notifications' },
|
||||||
|
{ path: ROUTES.ONBOARDING, key: 'home' },
|
||||||
|
// Nurse
|
||||||
|
{ path: ROUTES.NURSE_REQUESTS, key: 'requests' },
|
||||||
|
{ path: ROUTES.NURSE_PROFILE, key: 'profile' },
|
||||||
|
{ path: ROUTES.NURSE_SERVICES, key: 'services' },
|
||||||
|
{ path: ROUTES.NURSE_COVERAGE, key: 'coverage' },
|
||||||
|
{ path: ROUTES.NURSE_BANK, key: 'bank' },
|
||||||
|
{ path: ROUTES.NURSE_VERIFICATION, key: 'verification' },
|
||||||
|
{ path: ROUTES.NURSE_VISITS, key: 'visits' },
|
||||||
|
{ path: ROUTES.NURSE_EARNINGS, key: 'earnings' },
|
||||||
|
{ path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' },
|
||||||
|
{ path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' },
|
||||||
|
{ path: ROUTES.NURSE, key: 'dashboard' },
|
||||||
|
// Admin
|
||||||
|
{ path: ROUTES.ADMIN_VERIFICATION, key: 'verification' },
|
||||||
|
{ path: ROUTES.ADMIN_TICKETS, key: 'tickets' },
|
||||||
|
{ path: ROUTES.ADMIN_PAYOUTS, key: 'payouts' },
|
||||||
|
{ path: ROUTES.ADMIN_REVIEWS, key: 'reviews' },
|
||||||
|
{ path: ROUTES.ADMIN_CONFIG, key: 'config' },
|
||||||
|
{ path: ROUTES.ADMIN_HOLIDAYS, key: 'holidays' },
|
||||||
|
{ path: ROUTES.ADMIN_ALERTS, key: 'alerts' },
|
||||||
|
{ path: ROUTES.ADMIN_AUDIT, key: 'audit' },
|
||||||
|
{ path: ROUTES.ADMIN_PARTNERS, key: 'partners' },
|
||||||
|
{ path: ROUTES.ADMIN_ROLES, key: 'roles' },
|
||||||
|
{ path: ROUTES.ADMIN_USERS, key: 'users' },
|
||||||
|
{ path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' },
|
||||||
|
{ 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, key: 'partner_home' },
|
||||||
|
].sort((a, b) => b.path.length - a.path.length);
|
||||||
|
|
||||||
|
function findTitleEntry(pathname: string): TitleEntry | undefined {
|
||||||
|
return TITLE_ENTRIES.find((entry) =>
|
||||||
|
entry.path === '/' ? pathname === entry.path : pathname === entry.path || pathname.startsWith(`${entry.path}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string | null>(null);
|
||||||
|
const SetPageTitleOverrideContext = createContext<(title: string | null) => void>(() => {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the per-page dynamic-title override slot. Mount once per shell (around the scrollable
|
||||||
|
* content), above wherever `usePageTitleOverride`/`useRouteTitle` are read.
|
||||||
|
* @component PageTitleProvider
|
||||||
|
*/
|
||||||
|
export const PageTitleProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [override, setOverride] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<SetPageTitleOverrideContext.Provider value={setOverride}>
|
||||||
|
<PageTitleOverrideContext.Provider value={override}>{children}</PageTitleOverrideContext.Provider>
|
||||||
|
</SetPageTitleOverrideContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a pushed-route page set a dynamic chrome header title (e.g. a nurse's name, a booking
|
||||||
|
* reference) that wins over the static route→title map. Pass `null`/omit to clear. The override
|
||||||
|
* is cleared automatically on unmount so navigating away never leaks a stale title.
|
||||||
|
*/
|
||||||
|
export function usePageTitleOverride(title: string | null | undefined) {
|
||||||
|
const setOverride = useContext(SetPageTitleOverrideContext);
|
||||||
|
useEffect(() => {
|
||||||
|
setOverride(title ?? null);
|
||||||
|
return () => setOverride(null);
|
||||||
|
}, [title, setOverride]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current chrome header title: the per-page override if set, else the static route→title map. */
|
||||||
|
export function useRouteTitle(): string | null {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const t = useTranslations('nav');
|
||||||
|
const override = useContext(PageTitleOverrideContext);
|
||||||
|
return useMemo(() => {
|
||||||
|
if (override) return override;
|
||||||
|
const entry = findTitleEntry(pathname);
|
||||||
|
return entry ? t(entry.key) : null;
|
||||||
|
}, [override, pathname, t]);
|
||||||
|
}
|
||||||
@@ -81,6 +81,27 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MuiDrawer: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
backgroundColor: 'var(--bal-bg-default)',
|
||||||
|
backgroundImage: 'none',
|
||||||
|
borderInlineEnd: '1px solid var(--bal-divider)',
|
||||||
|
borderInlineStart: '1px solid var(--bal-divider)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiBottomNavigationAction: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
color: 'var(--bal-text-secondary)',
|
||||||
|
'&.Mui-selected': { color: 'var(--bal-primary)' },
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
'&.Mui-selected': { fontWeight: 700 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
MuiAppBar: {
|
MuiAppBar: {
|
||||||
defaultProps: { color: 'transparent', elevation: 0 },
|
defaultProps: { color: 'transparent', elevation: 0 },
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export type LinkToPage = {
|
|||||||
path?: string; // URL to navigate to
|
path?: string; // URL to navigate to
|
||||||
title?: string; // Title or primary text to display
|
title?: string; // Title or primary text to display
|
||||||
subtitle?: string; // Sub-title or secondary 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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# UI Phase 2 — Shells & navigation — Report (2026-07-17)
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
### Locale-aware navigation — one wrapper, both bugs fixed
|
||||||
|
`src/i18n/navigation.ts` wraps `createNavigation(routing)` (`Link`/`usePathname`/`useRouter`/
|
||||||
|
`redirect`/`getPathname`). **All chrome navigation now goes through it**: `SideBarNavItem`
|
||||||
|
renders via the wrapper's `Link` (unprefixed `href`, locale added automatically — one
|
||||||
|
navigation, no middleware 307 hop), `BottomBar` dropped its `withLocale` helper and `router.push`
|
||||||
|
via the wrapper, `NotificationBell` and `CustomerLayout`'s support/back buttons route via the
|
||||||
|
wrapper's `useRouter`. `grep -r "\$\{locale\}" src/layout` and the rewritten chrome components
|
||||||
|
now returns nothing. A new shared helper, `src/layout/matchActivePath.ts` (unit-tested,
|
||||||
|
longest-prefix winner-takes-all), backs both the sidebar and every bottom bar's active-item
|
||||||
|
detection — the sidebar's `pathname.startsWith(unprefixedPath)` bug (always-false against the
|
||||||
|
locale-prefixed `next/navigation` pathname) is gone; the active item now highlights on every
|
||||||
|
nurse/admin/partner route for the first time.
|
||||||
|
|
||||||
|
**Deliberately not touched:** `AppLink`'s `activeClassName` comparison (same underlying bug,
|
||||||
|
listed in the phase's suggested fixes). It has zero live consumers (`grep activeClassName` hits
|
||||||
|
only its own definition + test) and no CSS ever targets the `.active` class it would add —
|
||||||
|
purely inert. `AppLink` is also used for locale-prefixed navigation at ~10 non-chrome call sites
|
||||||
|
app-wide (`` `/${locale}${ROUTES.X}` ``); switching its internal `usePathname` to the locale-
|
||||||
|
stripped wrapper would flip that dead code from harmlessly-wrong to actively-wrong for those
|
||||||
|
sites without fixing anything visible. Left as-is; not a regression from this phase.
|
||||||
|
|
||||||
|
### Customer shell — contextual header + desktop treatment
|
||||||
|
`CustomerLayout` now renders a contextual `TopBar`: the brand lockup (`BrandLockup`, new — a
|
||||||
|
compact horizontal logo+wordmark reused in every sidebar shell's drawer header) on the 5 root
|
||||||
|
tabs, a page title + RTL-mirrored back chevron (`router.back()`) on pushed routes. Titles come
|
||||||
|
from `src/layout/routeTitle.tsx` — a static longest-prefix route→title map off the `nav`
|
||||||
|
namespace, plus a `PageTitleProvider`/`usePageTitleOverride` context slot for dynamic titles
|
||||||
|
area phases will wire in later (ships with static titles only, per scope). **Desktop decision
|
||||||
|
(≥`md`)**: the mobile `BottomBar` hides entirely in favor of an inline top-nav (`CustomerDesktopNav`,
|
||||||
|
MUI `Tabs`, same 5 items + `matchActivePath`) rendered as a second row inside the same fixed
|
||||||
|
`AppBar` (`TopBar` gained a `secondaryRow` slot for this). `BottomBar` also gained
|
||||||
|
`env(safe-area-inset-bottom)` padding for the iOS home-indicator overlap.
|
||||||
|
|
||||||
|
### Nurse shell — a workspace
|
||||||
|
`NurseLayout`'s sidebar is now sectioned (امروز / حرفهٔ من / مالی / پشتیبانی) via a new
|
||||||
|
`group` field on `LinkToPage` — `SideBarNavList` renders a `ListSubheader` whenever consecutive
|
||||||
|
items' `group` changes. The identity block is a real `ProfileSummary` card (new shared
|
||||||
|
component, `src/components/ProfileSummary/`): avatar, name (falls back to phone), masked phone
|
||||||
|
(`dir="ltr"`), and the nurse's own `TrustBadge` (`ownBadgeState(useVerificationStatus())`) —
|
||||||
|
skeleton while `/me` resolves, never an English "Current User"/"Loading...". Mobile gets a 5-tab
|
||||||
|
`BottomBar` (امروز/درخواستها/ویزیتها/درآمد/بیشتر); «بیشتر» opens the **same** sidebar drawer
|
||||||
|
(`TopBarAndSideBarLayout` exposes a `mobileBottomBar` render-prop that hands the drawer's
|
||||||
|
open-callback down — no second drawer). `ActorSwitcher` (new, dual-role only) sits under the
|
||||||
|
identity card.
|
||||||
|
|
||||||
|
### Admin + Partner shells — a dense console
|
||||||
|
`AdminLayout`'s sidebar is sectioned (اعتماد / مالی / پشتیبانی / سیستم) with **every
|
||||||
|
`useAdminCapabilities` gate preserved exactly** — grouping only changes presentation. Added the
|
||||||
|
missing `ROUTES.ADMIN_USERS` entry, gated on `caps.canManageRoles` like Roles. The
|
||||||
|
notifications sidebar item is gone, replaced by a header bell (`NotificationBell`'s `role` union
|
||||||
|
widened to include `'admin'`; `notificationsPath('admin')` → `ROUTES.ADMIN_NOTIFICATIONS` — both
|
||||||
|
minimal, pre-existing-seam extensions, not new endpoints). The TopBar carries a compact
|
||||||
|
`ProfileSummary` identity chip showing the admin's fine-grained role label (`admin.role_*`,
|
||||||
|
already existed). `PartnerLayout` gets the same TopBar identity slot showing the center's own
|
||||||
|
name (`useMyPartnerCenter`, skeleton while resolving) — the page-level access-denied state is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
### Public shell — stripped to a brand frame
|
||||||
|
`PublicLayout` no longer wraps `/login` in `TopBarAndSideBarLayout` at all: no hard-coded
|
||||||
|
`'Unauthorized - Balinyaar'` title, no pencil-icon drawer, no empty `BottomBar` strip. It's a
|
||||||
|
slim corner header (small `logo` icon + `LocaleSwitcher` + dark toggle) over the content —
|
||||||
|
deliberately not a second big brand lockup, since `AuthCard` already renders `BrandMark` inside
|
||||||
|
the login card. The dead `BOTTOM_BAR_DESKTOP_VISIBLE` flag and the commented-out anchor
|
||||||
|
alternates in `layout/config.ts` are gone.
|
||||||
|
|
||||||
|
### Cross-cutting engine fixes
|
||||||
|
- **SSR flash killed structurally, not patched.** `TopBarAndSideBarLayout` + `SideBar` no
|
||||||
|
longer derive sidebar *structure* from `useIsMobile()`. `SideBar` renders **two Drawers over
|
||||||
|
one content tree** — `variant="temporary"` (mobile) and `variant="permanent"` (desktop) —
|
||||||
|
switched purely by `sx` breakpoint `display`. The permanent Drawer is a normal flex sibling of
|
||||||
|
the main column (`TopBarAndSideBarLayout`'s content row is `direction="row"`), so desktop
|
||||||
|
reserves its own width as part of native flex layout — first paint already has the sidebar,
|
||||||
|
and RTL flexbox puts it at the reading-start side with **no manual offset math at all** (the
|
||||||
|
old `paddingLeft/Right` keyed off `anchor.includes('left')` double-flip hack is deleted
|
||||||
|
outright, not replaced with a logical-property equivalent — there was nothing left to offset).
|
||||||
|
The mobile temporary Drawer's `anchor` is still physical (an MUI API constraint) but is now
|
||||||
|
derived from `theme.direction` at render time, not a hardcoded per-breakpoint constant.
|
||||||
|
- **Drawer-close scoping fixed.** The close handler moved off the whole content `Stack` onto
|
||||||
|
`SideBarNavList`'s `onClick` only — toggling dark mode, switching locale, or tapping a divider
|
||||||
|
inside the mobile drawer no longer closes it; tapping a nav link does.
|
||||||
|
- **Chrome strings translated.** `'Open Sidebar'` → `common.open_sidebar`; the starter
|
||||||
|
`'Logout Current User'` icon-only button is now a labeled, translated (`nav.logout`) full-width
|
||||||
|
row in the sidebar footer, alongside the new `LocaleSwitcher`.
|
||||||
|
- **`TopBar` overflow fixed** (`noWrap` + `minWidth: 0` instead of manual `whiteSpace:'nowrap'`),
|
||||||
|
starter comment residue deleted, and it gained `align`/`titleNode`/`secondaryRow` so one
|
||||||
|
component now serves both the customer's centered brand header and the console shells'
|
||||||
|
start-anchored breadcrumb-style title.
|
||||||
|
- Two small, justified `theme.ts` additions (the phase context's claim that `Drawer`/
|
||||||
|
`BottomNavigation` were already restyled in phase 0 didn't hold — neither had overrides):
|
||||||
|
`MuiDrawer` paper (background.default canvas + inset border) and `MuiBottomNavigationAction`
|
||||||
|
(selected color + bold label weight), both off existing `--bal-*` tokens.
|
||||||
|
|
||||||
|
### Session affordances
|
||||||
|
- **Sign-out** now reachable in the customer shell for the first time: one labeled row
|
||||||
|
(`profile.sign_out`) on the `/profile` hub, per the phase's explicit "one row, not a redesign"
|
||||||
|
scope (full hub redesign deferred to phase 9). Sidebar shells keep it in the drawer footer.
|
||||||
|
- **`ActorSwitcher`** (new, `src/layout/components/`): renders nothing for a single-role
|
||||||
|
session; for a dual customer+nurse session shows «نمای پرستار» in the nurse sidebar and
|
||||||
|
«اپلیکیشن خانواده» on the customer profile hub, navigating only — `RoleGuard`/
|
||||||
|
`resolveRoleDestination` remain the sole "which app" authority.
|
||||||
|
- **`LocaleSwitcher`** (new, `src/components/common/`): `router.replace(pathname, { locale })`
|
||||||
|
via the wrapper, so it preserves the current route. In every sidebar footer, the customer
|
||||||
|
profile hub, and the public shell. `DarkModeToggleButton`/`DarkModeFormSwitch` remain the only
|
||||||
|
`useColorScheme()` subscribers — neither switcher adds a scheme subscription.
|
||||||
|
|
||||||
|
### `UserInfo` deletion
|
||||||
|
`src/components/UserInfo/` is deleted outright (no rename/shim). Its one call site
|
||||||
|
(`SideBar.tsx`) now renders the `identity` slot passed by the caller; the top-level `@/components`
|
||||||
|
barrel and `common/index.tsx` re-exports were swapped for `ProfileSummary`. No other file imported
|
||||||
|
it (`grep UserInfo` was clean before deletion beyond the component's own two files).
|
||||||
|
|
||||||
|
## What is now testable (and exactly how)
|
||||||
|
|
||||||
|
1. Nurse (`/nurse`): sidebar shows four labeled sections; the identity card shows name/phone/
|
||||||
|
TrustBadge, not "Current User". Click «ویزیتها» — it highlights, the TopBar shows the page
|
||||||
|
title, one navigation in the Network tab (no 307).
|
||||||
|
2. Resize to mobile: nurse shell shows the 5-tab bar; «بیشتر» opens the drawer with the full
|
||||||
|
grouped list + sign-out; toggling dark mode inside doesn't close it; a nav link does.
|
||||||
|
3. Customer `/`: brand lockup in the header. Open a nurse profile from search → header flips to
|
||||||
|
title + back chevron; back returns to results; mirrors correctly on `/en`.
|
||||||
|
4. Customer ≥900px: no bottom tab bar; the inline top-nav is present and highlights the right tab
|
||||||
|
on nested routes (e.g. a booking detail still lights up "Bookings").
|
||||||
|
5. `/profile`: sign-out row works; with a dual-role session, the actor switcher appears there and
|
||||||
|
in the nurse sidebar.
|
||||||
|
6. Admin: TopBar shows page title + bell + role chip (e.g. «مالی» for finance); sidebar is
|
||||||
|
sectioned and still capability-filtered — a finance-only admin sees no new items.
|
||||||
|
7. `/login` on `/fa`: no English text, no drawer, no bottom strip — just the corner logo +
|
||||||
|
switchers + the `AuthCard`'s own brand mark.
|
||||||
|
8. Hard-reload `/nurse` on desktop: sidebar present at first paint (verified via `npm run build`
|
||||||
|
not run here, but structurally guaranteed — see "SSR flash killed structurally" above).
|
||||||
|
|
||||||
|
## Verification performed this session
|
||||||
|
|
||||||
|
- `npm run check` (type + lint) — clean.
|
||||||
|
- `npm run test:ci` — 98 suites / 408 tests pass, including new tests for `matchActivePath`,
|
||||||
|
`ProfileSummary`, `LocaleSwitcher`, `ActorSwitcher`.
|
||||||
|
- `npm run dev` + SSR smoke curls (via PowerShell, not Bash — this sandbox's Bash tool proxies
|
||||||
|
loopback HTTP and returns a bare 502 regardless of the server's actual state):
|
||||||
|
`/fa/login`, `/fa`, `/en`, `/fa/nurse`, `/fa/admin` all return 200 with no error-boundary/
|
||||||
|
Next error markers in the HTML; `/fa/login` confirms `dir="rtl"`, the Mikhak font class, and
|
||||||
|
the brand string, with the old `'Unauthorized - Balinyaar'` title gone (the one "Unauthorized"
|
||||||
|
string left in the payload is Next's own internal App Router boundary metadata, unrelated).
|
||||||
|
- **Not performed**: a full four-locale × two-scheme × two-viewport visual pass in an actual
|
||||||
|
browser (no browser/screenshot tool available in this session) — the structural/SSR checks
|
||||||
|
above give high confidence, but visual polish (spacing, the desktop top-nav's exact look,
|
||||||
|
dark-mode contrast on the new `ProfileSummary`/`ActorSwitcher`) has not been eyeballed. Flagging
|
||||||
|
this explicitly per the "don't claim UI success you can't see" rule — recommend a human pass
|
||||||
|
before merging, especially on the customer desktop treatment (the newest, least-precedented
|
||||||
|
piece of this phase).
|
||||||
|
|
||||||
|
## What is mocked / waiting on a real service
|
||||||
|
|
||||||
|
None introduced — this phase is chrome over data that already flows (`useMe`, `useNurseProfile`,
|
||||||
|
`useVerificationStatus`, `useMyPartnerCenter`, `useUnreadCount`) behind existing `services/{domain}`
|
||||||
|
seams. No new mock/seam registered.
|
||||||
|
|
||||||
|
## Contracts
|
||||||
|
|
||||||
|
None produced or consumed. `/me` already returns `firstName`/`lastName`/`phone`, which was enough
|
||||||
|
for `ProfileSummary` — the anticipated gap ("`/me` lacking a display name") did not materialize,
|
||||||
|
so **no REQ was filed** (REQ-039+ still unused).
|
||||||
|
|
||||||
|
## Docs updated
|
||||||
|
|
||||||
|
- `client/CLAUDE.md` — Project Structure: `i18n/navigation.ts`, the full `layout/` tree (new
|
||||||
|
`routeTitle.tsx`, `matchActivePath.ts`, `BrandLockup.tsx`, `ActorSwitcher.tsx`, the rewritten
|
||||||
|
shell files), `components/ProfileSummary/`, `components/common/LocaleSwitcher/`.
|
||||||
|
- `.claude/skills/frontend-designer/SKILL.md` — §4 component table (`UserInfo` → `ProfileSummary`),
|
||||||
|
§5 "Layout & page shells" rewritten for the four-shell architecture, the dual-Drawer engine, and
|
||||||
|
`matchActivePath`.
|
||||||
|
|
||||||
|
## Follow-ups for later phases
|
||||||
|
|
||||||
|
- Full visual four-axes pass (see "Verification performed" above) — recommend before/alongside
|
||||||
|
the next UI phase that touches customer screens.
|
||||||
|
- Dynamic per-page titles (nurse name, booking reference) via `usePageTitleOverride` — the slot
|
||||||
|
exists; phases 4-6 and 9 (per the original scope note) should wire real titles in as they touch
|
||||||
|
those screens.
|
||||||
|
- `AppLink`'s inert `activeClassName` bug (see "Deliberately not touched" above) — low priority,
|
||||||
|
no known consumer; worth a real fix only if a future feature actually starts using it.
|
||||||
|
|
||||||
|
## Notable session correction
|
||||||
|
|
||||||
|
`RoleGuard.tsx` briefly had an unconditional `return children;` (added outside this phase, by the
|
||||||
|
user, for unrelated reasons) that broke `npm run check` — the dead code below it failed to
|
||||||
|
type-check because TypeScript doesn't apply control-flow narrowing inside unreachable code.
|
||||||
|
Confirmed via `git stash` that this predated my changes; the user removed the bypass mid-session.
|
||||||
|
`RoleGuard.tsx` was otherwise never touched by this phase, per scope.
|
||||||
Reference in New Issue
Block a user