ui phase 2

This commit is contained in:
hamid
2026-07-17 19:05:17 +03:30
parent 370c1beefa
commit 222856d600
42 changed files with 1354 additions and 451 deletions
+31 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
-4
View File
@@ -1,4 +0,0 @@
import UserInfo from './UserInfo';
export { UserInfo };
export default UserInfo;
-1
View File
@@ -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;
+2
View File
@@ -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';
+2 -2
View File
@@ -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';
} }
/** /**
+6 -3
View File
@@ -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];
+10
View File
@@ -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);
+38 -19
View File
@@ -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>
+109 -59
View File
@@ -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>
); );
}; };
+62 -14
View File
@@ -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>
+8 -7
View File
@@ -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>
+33 -26
View File
@@ -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>
); );
}; };
+75 -90
View File
@@ -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;
+18 -27
View File
@@ -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;
+77 -62
View File
@@ -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>
+32 -14
View File
@@ -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>
); );
}; };
+18 -23
View File
@@ -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>
); );
}; };
+2 -4
View File
@@ -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';
+2 -1
View File
@@ -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 };
+34
View File
@@ -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');
});
});
+15
View File
@@ -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];
}
+119
View File
@@ -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 routetitle 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 routetitle 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]);
}
+21
View File
@@ -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: {
+2
View File
@@ -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.