From 222856d6002fef0c629c98157d39de457344bcd8 Mon Sep 17 00:00:00 2001 From: hamid Date: Fri, 17 Jul 2026 19:05:17 +0330 Subject: [PATCH] ui phase 2 --- .claude/skills/frontend-designer/SKILL.md | 40 +++- client/CLAUDE.md | 30 ++- client/messages/en.json | 24 ++- client/messages/fa.json | 24 ++- .../(customer)/profile/page.tsx | 29 ++- .../ProfileSummary/ProfileSummary.test.tsx | 49 +++++ .../ProfileSummary/ProfileSummary.tsx | 95 +++++++++ .../src/components/ProfileSummary/index.tsx | 4 + client/src/components/UserInfo/UserInfo.tsx | 41 ---- client/src/components/UserInfo/index.tsx | 4 - client/src/components/auth/RoleGuard.tsx | 1 - .../src/components/common/AppIcon/config.ts | 2 + .../LocaleSwitcher/LocaleSwitcher.test.tsx | 40 ++++ .../common/LocaleSwitcher/LocaleSwitcher.tsx | 32 +++ .../common/LocaleSwitcher/index.tsx | 3 + client/src/components/common/index.tsx | 2 + client/src/components/index.tsx | 4 +- .../notifications/NotificationBell.tsx | 2 +- client/src/constants/routes.ts | 9 +- client/src/i18n/navigation.ts | 10 + client/src/layout/AdminLayout.tsx | 57 ++++-- client/src/layout/CustomerLayout.tsx | 168 +++++++++------ client/src/layout/NurseLayout.tsx | 76 +++++-- client/src/layout/PartnerLayout.tsx | 15 +- client/src/layout/PublicLayout.tsx | 59 +++--- client/src/layout/TopBarAndSideBarLayout.tsx | 165 +++++++-------- .../layout/components/ActorSwitcher.test.tsx | 58 ++++++ .../src/layout/components/ActorSwitcher.tsx | 39 ++++ client/src/layout/components/BottomBar.tsx | 45 ++-- client/src/layout/components/BrandLockup.tsx | 28 +++ client/src/layout/components/SideBar.tsx | 139 +++++++------ .../src/layout/components/SideBarNavItem.tsx | 32 +-- .../src/layout/components/SideBarNavList.tsx | 46 +++-- client/src/layout/components/TopBar.tsx | 41 ++-- client/src/layout/config.ts | 6 +- client/src/layout/index.tsx | 3 +- client/src/layout/matchActivePath.test.ts | 34 ++++ client/src/layout/matchActivePath.ts | 15 ++ client/src/layout/routeTitle.tsx | 119 +++++++++++ client/src/theme/theme.ts | 21 ++ client/src/utils/type.ts | 2 + .../reports/ui-phase-2-report.md | 192 ++++++++++++++++++ 42 files changed, 1354 insertions(+), 451 deletions(-) create mode 100644 client/src/components/ProfileSummary/ProfileSummary.test.tsx create mode 100644 client/src/components/ProfileSummary/ProfileSummary.tsx create mode 100644 client/src/components/ProfileSummary/index.tsx delete mode 100644 client/src/components/UserInfo/UserInfo.tsx delete mode 100644 client/src/components/UserInfo/index.tsx create mode 100644 client/src/components/common/LocaleSwitcher/LocaleSwitcher.test.tsx create mode 100644 client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx create mode 100644 client/src/components/common/LocaleSwitcher/index.tsx create mode 100644 client/src/i18n/navigation.ts create mode 100644 client/src/layout/components/ActorSwitcher.test.tsx create mode 100644 client/src/layout/components/ActorSwitcher.tsx create mode 100644 client/src/layout/components/BrandLockup.tsx create mode 100644 client/src/layout/matchActivePath.test.ts create mode 100644 client/src/layout/matchActivePath.ts create mode 100644 client/src/layout/routeTitle.tsx create mode 100644 dev/shared-working-context/reports/ui-phase-2-report.md diff --git a/.claude/skills/frontend-designer/SKILL.md b/.claude/skills/frontend-designer/SKILL.md index 2a20b12..c699c70 100644 --- a/.claude/skills/frontend-designer/SKILL.md +++ b/.claude/skills/frontend-designer/SKILL.md @@ -147,7 +147,7 @@ wrapper over the bare MUI component — the wrappers carry the house defaults. | `AppImage` | images | wrapper around next/image conventions | | `AppLoading` | loading state | default circular, `primary`, `3rem` | | `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`, `APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/ @@ -166,19 +166,41 @@ CLAUDE.md "Unit Testing"; wrap with ``, never mock MUI). ## 5. Layout & page shells -- **Private (authenticated) screens** render inside `PrivateLayout` → - `TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant - `sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) + - a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with - `useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`. -- **Public screens** use `PublicLayout`. +- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout` + (mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back + chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile + `BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share + `TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through + `@/i18n/navigation` (`Link`/`usePathname`/`useRouter`) — never a raw `next/link` or a + manual `` `/${locale}` `` prefix. +- `TopBarAndSideBarLayout`: a fixed `TopBar` (title from `useRouteTitle()`, the + route→title map in `layout/routeTitle.tsx`) + a `SideBar` rendered as **two Drawers + sharing one content tree** — mobile `temporary` and desktop `variant="permanent"` — + switched purely by `sx` breakpoint, not `useIsMobile()`; the permanent Drawer is a + normal flex sibling of the main column, so desktop reserves its own width with no + manual offset math and no post-hydration layout jump. Optional `identity` (TopBar + chip — admin/partner), `sidebarIdentity` (sidebar card — nurse's `ProfileSummary`), + and `mobileBottomBar` slots. +- Sidebar nav items are `{ title, path, icon, group? }` arrays (`@/utils`'s + `LinkToPage`) built with `useTranslations('nav')`; a shared `group` string on + consecutive items renders a `ListSubheader` section (see `NurseLayout`/`AdminLayout`). + Selection is computed once via the shared `matchActivePath` (longest-prefix, + winner-takes-all) helper — reuse it for any new nav list, never hand-roll + `pathname.startsWith`. +- **Public screens** use `PublicLayout` — a minimal corner strip (logo + + `LocaleSwitcher` + dark toggle), no sidebar/bottom bar; the step content (`AuthCard`) + carries its own larger `BrandMark`, so don't duplicate a big lockup in the shell. +- Page content is auto-wrapped in `ErrorBoundary` inside every shell. - Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`, - top-bar `56px` mobile / `64px` desktop, 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 composition + content; push reusable visuals into `src/components/`. - Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed is fine for dashboards/tables. -- 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. --- diff --git a/client/CLAUDE.md b/client/CLAUDE.md index dec11dd..0b7b54e 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -228,6 +228,7 @@ client/ │ │ ├── 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 │ │ ├── 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 │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens │ ├── 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) │ ├── 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) + │ ├── 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) │ ├── 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 @@ -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 ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' - │ └── request.ts # getRequestConfig — loads messages/${locale}.json - ├── layout/ + │ ├── request.ts # getRequestConfig — loads messages/${locale}.json + │ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop + ├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation │ ├── 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') - │ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav') - │ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar) - │ ├── PublicLayout.tsx # unauthenticated shell - │ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine) + │ ├── 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 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: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell + │ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving) + │ ├── 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 │ ├── index.ts │ └── components/ - │ ├── TopBar.tsx - │ ├── SideBar.tsx - │ ├── SideBarNavList.tsx - │ ├── SideBarNavItem.tsx + │ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav) + │ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot + │ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down + │ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop + │ ├── 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() │ └── index.tsx ├── lib/ diff --git a/client/messages/en.json b/client/messages/en.json index a087d2d..060c2f6 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -32,7 +32,17 @@ "partner_home": "Center", "partner_nurses": "Sponsored nurses", "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": { "dark_mode": "Dark mode", @@ -51,14 +61,18 @@ "optional": "Optional", "currency_toman": "Toman", "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": { "customer_app": "Family app", "nurse_app": "Nurse view", "admin_console": "Admin console", "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": { "greeting_named": "Hi, {name}", @@ -144,7 +158,9 @@ "saved": "Profile saved", "completion_done": "Your profile is complete.", "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": { "title": "Nurse profile", diff --git a/client/messages/fa.json b/client/messages/fa.json index 08b7558..7cb3771 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -32,7 +32,17 @@ "partner_home": "مرکز", "partner_nurses": "پرستاران تحت پوشش", "partner_bookings": "رزروها", - "partner_settlement": "تسویه" + "partner_settlement": "تسویه", + "search": "جستجو", + "checkout": "پرداخت", + "addresses": "آدرس‌ها", + "more": "بیشتر", + "group_today": "امروز", + "group_profession": "حرفهٔ من", + "group_finance": "مالی", + "group_support": "پشتیبانی", + "group_trust": "اعتماد", + "group_system": "سیستم" }, "common": { "dark_mode": "حالت تاریک", @@ -51,14 +61,18 @@ "optional": "اختیاری", "currency_toman": "تومان", "brand": "بالین یار", - "brand_tagline": "مراقبت مطمئن در خانه" + "brand_tagline": "مراقبت مطمئن در خانه", + "open_sidebar": "باز کردن منو", + "switch_locale": "تغییر به {locale}" }, "shell": { "customer_app": "اپلیکیشن خانواده", "nurse_app": "نمای پرستار", "admin_console": "کنسول مدیریت", "placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود.", - "partner_console": "پرتال همکار" + "partner_console": "پرتال همکار", + "switch_to_nurse": "نمای پرستار", + "switch_to_customer": "اپلیکیشن خانواده" }, "home": { "greeting_named": "سلام، {name}", @@ -144,7 +158,9 @@ "saved": "پروفایل ذخیره شد", "completion_done": "پروفایل شما کامل است.", "completion_todo": "برای رزرو سریع‌تر، پروفایل خود را کامل کنید.", - "load_error": "پروفایل شما بارگذاری نشد." + "load_error": "پروفایل شما بارگذاری نشد.", + "app_language": "زبان برنامه", + "sign_out": "خروج از حساب" }, "nurseProfile": { "title": "پروفایل پرستار", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 76adf01..6c9b100 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -4,11 +4,13 @@ import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components'; +import LocaleSwitcher from '@/components/common/LocaleSwitcher'; import { isIranianMobile } from '@/components/PhoneNumberField'; import { ROUTES } from '@/constants'; import { digitsOnly } from '@/utils'; +import { ActorSwitcher } from '@/layout'; import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; -import { useMe } from '@/services/auth'; +import { useMe, useLogout } from '@/services/auth'; import type { CustomerProfile } from '@/services/profiles/types'; /** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ @@ -167,6 +169,31 @@ const CustomerProfileForm: FunctionComponent<{ + + + + {/* 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. */} + + + + + {t('app_language')} + + + + + ); }; + +const SignOutRow: FunctionComponent = () => { + const t = useTranslations('profile'); + const { mutate: logout, isPending } = useLogout(); + return ( + logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}> + {t('sign_out')} + + ); +}; diff --git a/client/src/components/ProfileSummary/ProfileSummary.test.tsx b/client/src/components/ProfileSummary/ProfileSummary.test.tsx new file mode 100644 index 0000000..643125a --- /dev/null +++ b/client/src/components/ProfileSummary/ProfileSummary.test.tsx @@ -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> = {}) { + return render( + + + , + ); +} + +describe(' 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( + + + , + ); + 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' }); + }); +}); diff --git a/client/src/components/ProfileSummary/ProfileSummary.tsx b/client/src/components/ProfileSummary/ProfileSummary.tsx new file mode 100644 index 0000000..18cb36c --- /dev/null +++ b/client/src/components/ProfileSummary/ProfileSummary.tsx @@ -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 = ({ + displayName, + phone, + roleLabel, + avatarUrl, + trustState, + loading = false, + compact = false, +}) => { + const avatarSize = compact ? 32 : 56; + + if (loading) { + return ( + + + + + {!compact && } + + + ); + } + + const avatar = ; + + if (compact) { + return ( + + {avatar} + + + {displayName} + + {roleLabel && ( + + {roleLabel} + + )} + + + ); + } + + return ( + + {avatar} + + + {displayName} + + {trustState && } + + {phone && ( + + {phone} + + )} + {roleLabel && ( + + {roleLabel} + + )} + + ); +}; + +export default ProfileSummary; diff --git a/client/src/components/ProfileSummary/index.tsx b/client/src/components/ProfileSummary/index.tsx new file mode 100644 index 0000000..9d27888 --- /dev/null +++ b/client/src/components/ProfileSummary/index.tsx @@ -0,0 +1,4 @@ +import ProfileSummary from './ProfileSummary'; + +export default ProfileSummary; +export type { ProfileSummaryProps } from './ProfileSummary'; diff --git a/client/src/components/UserInfo/UserInfo.tsx b/client/src/components/UserInfo/UserInfo.tsx deleted file mode 100644 index 8636ce7..0000000 --- a/client/src/components/UserInfo/UserInfo.tsx +++ /dev/null @@ -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 ( - - {showAvatar ? ( - - ) : null} - - {fullName || 'Current User'} - - {userPhoneOrEmail || 'Loading...'} - - ); -}; - -export default UserInfo; diff --git a/client/src/components/UserInfo/index.tsx b/client/src/components/UserInfo/index.tsx deleted file mode 100644 index ed0f70b..0000000 --- a/client/src/components/UserInfo/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import UserInfo from './UserInfo'; - -export { UserInfo }; -export default UserInfo; diff --git a/client/src/components/auth/RoleGuard.tsx b/client/src/components/auth/RoleGuard.tsx index 6f706e0..a9d4366 100644 --- a/client/src/components/auth/RoleGuard.tsx +++ b/client/src/components/auth/RoleGuard.tsx @@ -34,7 +34,6 @@ interface RoleGuardProps { * @component RoleGuard */ const RoleGuard: FunctionComponent = ({ expected, children }) => { - return children; const t = useTranslations('auth'); const router = useRouter(); const locale = useLocale(); diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 8f885a4..27afd9c 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -99,6 +99,7 @@ import ForwardIcon from '@mui/icons-material/ArrowForwardRounded'; import ShareIcon from '@mui/icons-material/ShareRounded'; import CopyIcon from '@mui/icons-material/ContentCopyRounded'; import AttachmentIcon from '@mui/icons-material/AttachFileRounded'; +import LanguageIcon from '@mui/icons-material/TranslateRounded'; /** * List of all available Icon names @@ -202,6 +203,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - share: ShareIcon, copy: CopyIcon, attachment: AttachmentIcon, + language: LanguageIcon, }; /** diff --git a/client/src/components/common/LocaleSwitcher/LocaleSwitcher.test.tsx b/client/src/components/common/LocaleSwitcher/LocaleSwitcher.test.tsx new file mode 100644 index 0000000..eb6005c --- /dev/null +++ b/client/src/components/common/LocaleSwitcher/LocaleSwitcher.test.tsx @@ -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) => + params ? `${key}:${params.locale}` : key, +})); + +jest.mock('@/i18n/navigation', () => ({ + usePathname: () => '/nurse/earnings', + useRouter: () => ({ replace }), +})); + +import LocaleSwitcher from './LocaleSwitcher'; + +describe(' component', () => { + beforeEach(() => replace.mockClear()); + + it('renders a single icon button', () => { + render( + + + , + ); + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + + it('replaces the current path with the other locale on click, preserving the route', () => { + render( + + + , + ); + fireEvent.click(screen.getByRole('button')); + expect(replace).toHaveBeenCalledWith('/nurse/earnings', { locale: 'en' }); + }); +}); diff --git a/client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx b/client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx new file mode 100644 index 0000000..b558fb3 --- /dev/null +++ b/client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx @@ -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 = { 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 ( + router.replace(pathname, { locale: nextLocale })} + /> + ); +}; + +export default LocaleSwitcher; diff --git a/client/src/components/common/LocaleSwitcher/index.tsx b/client/src/components/common/LocaleSwitcher/index.tsx new file mode 100644 index 0000000..bdd14b4 --- /dev/null +++ b/client/src/components/common/LocaleSwitcher/index.tsx @@ -0,0 +1,3 @@ +import LocaleSwitcher from './LocaleSwitcher'; + +export default LocaleSwitcher; diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index 5fde5f1..5b30e15 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -16,6 +16,7 @@ import Money from './Money'; import StatusTimeline from './StatusTimeline'; import JalaliDatePicker from './JalaliDatePicker'; import JalaliDateField from './JalaliDateField'; +import LocaleSwitcher from './LocaleSwitcher'; export { ErrorBoundary, @@ -36,6 +37,7 @@ export { StatusTimeline, JalaliDatePicker, JalaliDateField, + LocaleSwitcher, }; export type { EmptyStateProps } from './EmptyState'; export type { ErrorStateProps } from './ErrorState'; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index d58ecdb..54f4649 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -1,6 +1,6 @@ export * from './common'; -import UserInfo from './UserInfo'; +import ProfileSummary from './ProfileSummary'; import PlaceholderScreen from './PlaceholderScreen'; import OtpInput from './OtpInput'; import PhoneNumberField from './PhoneNumberField'; @@ -35,7 +35,7 @@ import VisitNoteCard from './VisitNoteCard'; import PatientHeader from './PatientHeader'; export { - UserInfo, + ProfileSummary, PlaceholderScreen, OtpInput, PhoneNumberField, diff --git a/client/src/components/notifications/NotificationBell.tsx b/client/src/components/notifications/NotificationBell.tsx index cfcab03..dfc3059 100644 --- a/client/src/components/notifications/NotificationBell.tsx +++ b/client/src/components/notifications/NotificationBell.tsx @@ -8,7 +8,7 @@ import NotificationBellView from './NotificationBellView'; export interface NotificationBellProps { /** The shell the bell lives in — decides which notification center it opens. */ - role: 'customer' | 'nurse'; + role: 'customer' | 'nurse' | 'admin'; } /** diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 7f26eb3..e1991d5 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -161,9 +161,12 @@ export const ticketsBasePath = (role: 'customer' | 'nurse'): string => export const ticketThreadPath = (role: 'customer' | 'nurse', ticketId: number | string): string => role === 'nurse' ? nurseTicketThreadPath(ticketId) : customerTicketThreadPath(ticketId); -/** The notification center for an actor (customer vs nurse shell). */ -export const notificationsPath = (role: 'customer' | 'nurse'): string => - role === 'nurse' ? ROUTES.NURSE_NOTIFICATIONS : ROUTES.NOTIFICATIONS; +/** The notification center for an actor (customer / nurse / admin shell). */ +export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string => { + 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. */ export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; diff --git a/client/src/i18n/navigation.ts b/client/src/i18n/navigation.ts new file mode 100644 index 0000000..416910f --- /dev/null +++ b/client/src/i18n/navigation.ts @@ -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); diff --git a/client/src/layout/AdminLayout.tsx b/client/src/layout/AdminLayout.tsx index c060f67..fe23231 100644 --- a/client/src/layout/AdminLayout.tsx +++ b/client/src/layout/AdminLayout.tsx @@ -1,46 +1,65 @@ 'use client'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { useTranslations } from 'next-intl'; +import { NotificationBell } from '@/components/notifications'; +import { ProfileSummary } from '@/components'; import { ROUTES } from '@/constants'; import { useAdminCapabilities } from '@/hooks'; import { LinkToPage } from '@/utils'; +import { useMe } from '@/services/auth'; import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; /** - * Admin / backoffice shell — the desktop ops console (f15). The sidebar is **role-gated**: each console - * appears only when the current admin role can act on it (`useAdminCapabilities`). This is a display - * convenience — the server enforces every command's scope — so a `support` admin never sees the payout or - * refund controls, a `moderation` admin only sees moderation, etc. (phase §3 "Routing & RBAC", §5). + * Admin / backoffice shell — the desktop ops console (f15). The sidebar is **sectioned** + * (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current + * admin role can act on it (`useAdminCapabilities`), unchanged from before — grouping never adds, + * 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 */ const AdminLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); - const tShell = useTranslations('shell'); + const ta = useTranslations('admin'); + const { data: me } = useMe(); const caps = useAdminCapabilities(); const sidebarItems: Array = useMemo(() => { + const groupTrust = t('group_trust'); + const groupFinance = t('group_finance'); + const groupSupport = t('group_support'); + const groupSystem = t('group_system'); const items: Array = [ { title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true }, - { title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', show: caps.canVerify }, - { title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', show: caps.canManageTickets }, - { title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', show: caps.canPayout }, - { title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', show: caps.canModerate }, - { title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', show: caps.canConfig }, - { title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', show: caps.canConfig }, - { title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', show: caps.canManageAlerts }, - { title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', show: caps.canViewAudit }, - { title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', show: caps.canManagePartners }, - { title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', show: caps.canManageRoles }, - { title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications', show: true }, + { title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', group: groupTrust, show: caps.canVerify }, + { title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', group: groupTrust, show: caps.canModerate }, + { title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', group: groupFinance, show: caps.canPayout }, + { title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', group: groupSupport, show: caps.canManageTickets }, + { title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', group: groupSupport, show: caps.canManageAlerts }, + { title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', group: groupSystem, show: caps.canConfig }, + { title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', group: groupSystem, show: caps.canConfig }, + { title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', group: groupSystem, show: caps.canViewAudit }, + { title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', group: groupSystem, show: caps.canManagePartners }, + { title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users', group: groupSystem, show: caps.canManageRoles }, + { 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]); + const primaryRoleCode = caps.roles[0]; + return ( } + identity={ + me && primaryRoleCode ? ( + + ) : undefined + } > {children} diff --git a/client/src/layout/CustomerLayout.tsx b/client/src/layout/CustomerLayout.tsx index e8f2af7..fb37e05 100644 --- a/client/src/layout/CustomerLayout.tsx +++ b/client/src/layout/CustomerLayout.tsx @@ -1,33 +1,94 @@ 'use client'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; -import { Box, Stack } from '@mui/material'; -import { useLocale, useTranslations } from 'next-intl'; -import { useRouter } from 'next/navigation'; -import { ErrorBoundary } from '@/components'; -import AppIconButton from '@/components/common/AppIconButton'; +import { Box, Stack, Tab, Tabs } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { AppIcon, AppIconButton, ErrorBoundary } from '@/components'; import { NotificationBell } from '@/components/notifications'; import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; import { LinkToPage } from '@/utils'; -import { useIsMobile } from '@/hooks'; +import { usePathname, useRouter } from '@/i18n/navigation'; import { TopBar, BottomBar } from './components'; 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 }> = ({ items }) => { + const pathname = usePathname(); + const router = useRouter(); + const activePath = matchActivePath(pathname, items.map((item) => item.path)) ?? false; + + return ( + + router.push(value)} + sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT, px: 2 }} + > + {items.map((item) => ( + : undefined} + iconPosition="start" + sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT }} + /> + ))} + + + ); +}; + +const CustomerHeader: FunctionComponent<{ bottomNavItems: Array }> = ({ bottomNavItems }) => { + const t = useTranslations('nav'); + const tc = useTranslations('common'); + const pathname = usePathname(); + const router = useRouter(); + const title = useRouteTitle(); + const onRootTab = isCustomerRootTab(pathname); + + return ( + router.push(ROUTES.SUPPORT_TICKETS)} + /> + ) : ( + router.back()} /> + ) + } + titleNode={onRootTab ? : undefined} + title={onRootTab ? undefined : (title ?? undefined)} + endNode={ + + + + + } + secondaryRow={} + /> + ); +}; /** - * Customer (family) app shell — the primary, mobile-first experience. - * A slim TopBar (a "Support / My Tickets" action at the start, the notification bell + dark-mode toggle at - * the end), a scrollable content column constrained to reading width, and the 5-tab BottomBar - * (Home/Bookings/Patients/Wallet/Profile) from the wireframe. + * Customer (family) app shell — the primary, mobile-first experience. A contextual TopBar (brand + * lockup on the 5 root tabs, page title + back chevron on pushed routes), a reading-width content + * column on a `background.default` canvas, and the 5-tab `BottomBar` on mobile — replaced by an + * inline desktop top-nav (`CustomerDesktopNav`) at `≥md`, where the mobile tab bar hides entirely. * @layout CustomerLayout */ const CustomerLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); - const tShell = useTranslations('shell'); const tChrome = useTranslations('routeChrome'); - const onMobile = useIsMobile(); - const router = useRouter(); - const locale = useLocale(); const bottomNavItems: Array = useMemo( () => [ @@ -41,53 +102,42 @@ const CustomerLayout: FunctionComponent = ({ children }) => { ); return ( - - - router.push(`/${locale}${ROUTES.SUPPORT_TICKETS}`)} - /> - } - endNode={ - - - - - } - /> - + + + + + - - - {children} - - + + + {children} + + + - - + + + + + ); }; diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index da98dd5..ab5fa98 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -1,33 +1,64 @@ 'use client'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { useTranslations } from 'next-intl'; +import { Stack } from '@mui/material'; import { NotificationBell } from '@/components/notifications'; +import { ProfileSummary } from '@/components'; import { ROUTES } from '@/constants'; 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 { 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). - * Uses the shared TopBar + SideBar engine: a temporary drawer on mobile, persistent on - * desktop. Nav is role-scoped to the nurse routes. + * Nurse app shell — the "نمای پرستار" workspace. Sectioned sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + * with a real `ProfileSummary` identity card (name, masked phone, own `TrustBadge`), plus a 5-tab + * mobile bottom nav whose «بیشتر» tab opens the same sidebar drawer for the remaining items. * @layout NurseLayout */ const NurseLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); - const tShell = useTranslations('shell'); - const sidebarItems: Array = 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 = 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 => [ { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { 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('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] ); @@ -35,9 +66,26 @@ const NurseLayout: FunctionComponent = ({ children }) => { return ( } + sidebarIdentity={ + + + + + } + mobileBottomBar={(openMobileSidebar) => ( + + item.path === MORE_TAB_PATH ? { ...item, onSelect: openMobileSidebar } : item + )} + /> + )} > {children} diff --git a/client/src/layout/PartnerLayout.tsx b/client/src/layout/PartnerLayout.tsx index df3db46..63f66a8 100644 --- a/client/src/layout/PartnerLayout.tsx +++ b/client/src/layout/PartnerLayout.tsx @@ -1,20 +1,22 @@ 'use client'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { useTranslations } from 'next-intl'; +import { ProfileSummary } from '@/components'; import { ROUTES } from '@/constants'; import { LinkToPage } from '@/utils'; +import { useMyPartnerCenter } from '@/services/partnerCenter'; import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; /** * 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). - * Same desktop sidebar engine as the admin shell, its own nav. The settlement tab always appears; the - * settlement page itself renders the "via Balinyaar" state for a non-merchant-of-record center. + * center admin is not a Balinyaar admin; they see only their own center's data (server-enforced + * tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name + * (skeleton while resolving) — the page-level access-denied state (403/404) stays where it is. * @layout PartnerLayout */ const PartnerLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); - const tShell = useTranslations('shell'); + const { data: center, isLoading } = useMyPartnerCenter(); const sidebarItems: Array = useMemo( () => [ @@ -23,14 +25,13 @@ const PartnerLayout: FunctionComponent = ({ children }) => { { title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' }, { title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' }, ], - [t], + [t] ); return ( } > {children} diff --git a/client/src/layout/PublicLayout.tsx b/client/src/layout/PublicLayout.tsx index 42e0342..0ed4494 100644 --- a/client/src/layout/PublicLayout.tsx +++ b/client/src/layout/PublicLayout.tsx @@ -1,38 +1,45 @@ +'use client'; import { FunctionComponent, PropsWithChildren } from 'react'; +import { useTranslations } from 'next-intl'; import { Stack } from '@mui/material'; -import { LinkToPage } from '@/utils'; -import { useIsMobile } from '@/hooks'; -import { BottomBar } from './components'; -import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; -import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config'; - -const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/before authentication +import { AppIcon, ErrorBoundary } from '@/components'; +import LocaleSwitcher from '@/components/common/LocaleSwitcher'; +import { DarkModeToggleButton } from './components/DarkModeButton'; /** - * SideBar navigation items with links for Public Layout - */ -const SIDE_BAR_ITEMS: Array = []; - -/** - * BottomBar navigation items with links for Public Layout - */ -const BOTTOM_BAR_ITEMS: Array = []; - -/** - * Renders "Public Layout" composition + * 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`) + * is the visual focus; this chrome stays a slim corner strip so the two marks don't compete. * @layout PublicLayout */ const PublicLayout: FunctionComponent = ({ children }) => { - const onMobile = useIsMobile(); - const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE; - - const title = TITLE_PUBLIC; + const tChrome = useTranslations('routeChrome'); return ( - - {children} - {bottomBarVisible && } - + + + + + + + {children} + + + ); }; diff --git a/client/src/layout/TopBarAndSideBarLayout.tsx b/client/src/layout/TopBarAndSideBarLayout.tsx index 3e7b979..e843ad9 100644 --- a/client/src/layout/TopBarAndSideBarLayout.tsx +++ b/client/src/layout/TopBarAndSideBarLayout.tsx @@ -1,117 +1,102 @@ 'use client'; -import { FunctionComponent, ReactNode, useMemo, useState } from 'react'; +import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { Stack, StackProps } from '@mui/material'; +import { Box, Stack, useTheme } from '@mui/material'; import { AppIconButton, ErrorBoundary } from '@/components'; import { LinkToPage } from '@/utils'; -import { useIsMobile } from '@/hooks'; import { TopBar } from './components'; -import SideBar, { SideBarProps } from './components/SideBar'; +import SideBar from './components/SideBar'; import { DarkModeToggleButton } from './components/DarkModeButton'; -import { - SIDE_BAR_DESKTOP_ANCHOR, - SIDE_BAR_MOBILE_ANCHOR, - SIDE_BAR_WIDTH, - TOP_BAR_DESKTOP_HEIGHT, - TOP_BAR_MOBILE_HEIGHT, -} from './config'; +import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config'; +import { useRouteTitle } from './routeTitle'; -interface Props extends StackProps { +interface Props { sidebarItems: Array; - title: string; - variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent'; + /** Rendered below the sidebar's brand header (the nurse shell's `ProfileSummary` identity card). */ + 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. */ headerActions?: ReactNode; + /** + * A mobile-only (` void) => ReactNode; } -const TopBarAndSideBarLayout: FunctionComponent = ({ 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> = ({ + children, + sidebarItems, + sidebarIdentity, + identity, + headerActions, + mobileBottomBar, +}) => { const tChrome = useTranslations('routeChrome'); - const [sidebarVisible, setSidebarVisible] = useState(false); - const onMobile = useIsMobile(); + const tCommon = useTranslations('common'); + const theme = useTheme(); + const title = useRouteTitle(); + const [mobileOpen, setMobileOpen] = useState(false); - const sidebarProps = useMemo((): Partial => { - 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 anchor = theme.direction === 'rtl' ? 'right' : 'left'; - 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 = ( - - ); - - /* - * 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 = ( - + + {identity} {headerActions} + {/* DarkModeToggleButton subscribes to useColorScheme() on its own — this layout never re-renders on a theme flip */} ); - const { startNode, endNode } = sidebarProps?.anchor?.includes('left') - ? { startNode: LogoButton, endNode: headerControls } - : { startNode: headerControls, endNode: LogoButton }; return ( - - - - + + setMobileOpen(true)} + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + /> + } + title={title ?? ''} + align="start" + endNode={headerControls} + /> + + + setMobileOpen(false)} + /> + + + + {children} + + - - - {children} - - + {mobileBottomBar && {mobileBottomBar(() => setMobileOpen(true))}} ); }; diff --git a/client/src/layout/components/ActorSwitcher.test.tsx b/client/src/layout/components/ActorSwitcher.test.tsx new file mode 100644 index 0000000..fd4deea --- /dev/null +++ b/client/src/layout/components/ActorSwitcher.test.tsx @@ -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(' component', () => { + beforeEach(() => { + push.mockClear(); + mockRoles = []; + }); + + it('renders nothing for a single-role session', () => { + mockRoles = ['nurse']; + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('navigates to the nurse app for a dual-role session', () => { + mockRoles = ['customer', 'nurse']; + render( + + + , + ); + 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( + + + , + ); + fireEvent.click(screen.getByText('switch_to_customer')); + expect(push).toHaveBeenCalledWith('/'); + }); +}); diff --git a/client/src/layout/components/ActorSwitcher.tsx b/client/src/layout/components/ActorSwitcher.tsx new file mode 100644 index 0000000..3ab70af --- /dev/null +++ b/client/src/layout/components/ActorSwitcher.tsx @@ -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 = ({ 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 ( + router.push(destination)}> + {label} + + ); +}; + +export default ActorSwitcher; diff --git a/client/src/layout/components/BottomBar.tsx b/client/src/layout/components/BottomBar.tsx index ab9fd30..5422266 100644 --- a/client/src/layout/components/BottomBar.tsx +++ b/client/src/layout/components/BottomBar.tsx @@ -1,50 +1,41 @@ 'use client'; 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 { LinkToPage } from '@/utils'; import { AppIcon } from '@/components'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { matchActivePath } from '../matchActivePath'; interface Props { items: Array; } -/** 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. - * Locale-aware: it highlights the active tab from the real pathname (via usePathname, - * not the global `location`) and pushes locale-prefixed routes. + * Shared horizontal navigation bar (customer app tabs, and the nurse shell's mobile 5-tab nav) + * built on MUI BottomNavigation. Locale-aware via the `@/i18n/navigation` wrapper — no manual + * `/${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 */ const BottomBar: FunctionComponent = ({ items }) => { const router = useRouter(); const pathname = usePathname(); - const locale = useLocale(); - // The active tab is the longest item path that prefixes the current pathname, - // so `/patients/123` still selects the `/patients` tab and `/` never over-matches. - const activePath = useMemo(() => { - 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 activePath = useMemo( + () => matchActivePath(pathname, items.map((item) => item.path)) ?? false, + [items, pathname] + ); const onNavigationChange = useCallback( (_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 ( @@ -52,7 +43,7 @@ const BottomBar: FunctionComponent = ({ items }) => { elevation={3} square component="nav" - sx={{ borderTop: '1px solid', borderColor: 'divider' }} + sx={{ borderTop: '1px solid', borderColor: 'divider', paddingBottom: 'env(safe-area-inset-bottom)' }} > {items.map(({ title, path, icon }) => ( diff --git a/client/src/layout/components/BrandLockup.tsx b/client/src/layout/components/BrandLockup.tsx new file mode 100644 index 0000000..53bf017 --- /dev/null +++ b/client/src/layout/components/BrandLockup.tsx @@ -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 = ({ size = 28 }) => { + const t = useTranslations('common'); + return ( + + {/* Decorative — the wordmark already announces "Balinyaar" to AT. */} + + ); +}; + +export default BrandLockup; diff --git a/client/src/layout/components/SideBar.tsx b/client/src/layout/components/SideBar.tsx index e36adce..f25f450 100644 --- a/client/src/layout/components/SideBar.tsx +++ b/client/src/layout/components/SideBar.tsx @@ -1,82 +1,97 @@ -import { FunctionComponent, useCallback, MouseEvent } from 'react'; -import { Stack, Divider, Drawer, DrawerProps } from '@mui/material'; -import { LinkToPage } from '@/utils'; -import { useIsAuthenticated, useIsMobile } from '@/hooks'; +'use client'; +import { FunctionComponent, ReactNode, useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +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 { AppIconButton, UserInfo } from '@/components'; -import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config'; +import { LinkToPage } from '@/utils'; +import { SIDE_BAR_WIDTH } from '../config'; import SideBarNavList from './SideBarNavList'; +import BrandLockup from './BrandLockup'; import { DarkModeFormSwitch } from './DarkModeButton'; -export interface SideBarProps extends Pick { +export interface SideBarProps { items: Array; + /** 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 */ -const SideBar: FunctionComponent = ({ anchor, open, variant, items, onClose, ...restOfProps }) => { +const SideBar: FunctionComponent = ({ items, anchor, mobileOpen, onMobileClose, identity }) => { const isAuthenticated = useIsAuthenticated(); - const onMobile = useIsMobile(); + const t = useTranslations('nav'); const { mutate: logout } = useLogout(); - const handleAfterLinkClick = useCallback( - (event: MouseEvent) => { - if (variant === 'temporary' && typeof onClose === 'function') { - onClose(event, 'backdropClick'); - } - }, - [variant, onClose] + const handleNavClick = useCallback(() => onMobileClose(), [onMobileClose]); + + const content = ( + + + + + {identity && ( + <> + {identity} + + + )} + + + + + + + {/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */} + + + + + {isAuthenticated && ( + logout()}> + {t('logout')} + + )} + ); return ( - - + - {isAuthenticated && ( - <> - - - - )} - - - - - - - {/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */} - - - {isAuthenticated && logout()} />} - - - + {content} + + + {content} + + ); }; diff --git a/client/src/layout/components/SideBarNavItem.tsx b/client/src/layout/components/SideBarNavItem.tsx index 620e79a..4afe049 100644 --- a/client/src/layout/components/SideBarNavItem.tsx +++ b/client/src/layout/components/SideBarNavItem.tsx @@ -1,41 +1,25 @@ 'use client'; import { FunctionComponent, MouseEventHandler } from 'react'; 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 { usePathname } from 'next/navigation'; interface Props extends LinkToPage { - openInNewTab?: boolean; selected?: boolean; 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 */ -const SideBarNavItem: FunctionComponent = ({ - openInNewTab, - icon, - path, - selected: propSelected = false, - subtitle, - title, - onClick, -}) => { - const pathname = usePathname(); - const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false; - +const SideBarNavItem: FunctionComponent = ({ icon, path, selected = false, subtitle, title, onClick }) => { return ( - + {icon && } diff --git a/client/src/layout/components/SideBarNavList.tsx b/client/src/layout/components/SideBarNavList.tsx index 3eb24c9..194af11 100644 --- a/client/src/layout/components/SideBarNavList.tsx +++ b/client/src/layout/components/SideBarNavList.tsx @@ -1,6 +1,9 @@ -import { FunctionComponent, MouseEventHandler } from 'react'; +import { Fragment, FunctionComponent, MouseEventHandler, useMemo } from 'react'; import List from '@mui/material/List'; +import ListSubheader from '@mui/material/ListSubheader'; import { LinkToPage } from '@/utils'; +import { usePathname } from '@/i18n/navigation'; +import { matchActivePath } from '../matchActivePath'; import SideBarNavItem from './SideBarNavItem'; 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 * @param {array} items - list of objects to render as navigation items * @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 = ({ items, showIcons, onClick, ...restOfProps }) => { +const SideBarNavList: FunctionComponent = ({ items, showIcons, onClick }) => { + const pathname = usePathname(); + const activePath = useMemo( + () => matchActivePath(pathname, items.map((item) => item.path)), + [pathname, items] + ); + return ( - - {items.map(({ icon, path, title }) => ( - - ))} + + {items.map((item, index) => { + const showHeader = Boolean(item.group) && item.group !== items[index - 1]?.group; + return ( + + {showHeader && {item.group}} + + + ); + })} ); }; diff --git a/client/src/layout/components/TopBar.tsx b/client/src/layout/components/TopBar.tsx index 2371dc1..e0004b3 100644 --- a/client/src/layout/components/TopBar.tsx +++ b/client/src/layout/components/TopBar.tsx @@ -1,44 +1,39 @@ import { FunctionComponent, ReactNode } from 'react'; -import { AppBar, Toolbar, Typography } from '@mui/material'; +import { AppBar, Box, Toolbar, Typography } from '@mui/material'; interface Props { endNode?: ReactNode; startNode?: ReactNode; 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 * @component TopBar */ -const TopBar: FunctionComponent = ({ endNode, startNode, title = '', ...restOfProps }) => { +const TopBar: FunctionComponent = ({ endNode, startNode, title = '', titleNode, align = 'center', secondaryRow }) => { return ( - - + + {startNode} - - {title} - + + {titleNode ?? ( + + {title} + + )} + {endNode} + {secondaryRow} ); }; diff --git a/client/src/layout/config.ts b/client/src/layout/config.ts index 8ff57ec..2f676af 100644 --- a/client/src/layout/config.ts +++ b/client/src/layout/config.ts @@ -5,8 +5,6 @@ /** * SideBar configuration */ -export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right'; -export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right'; export const SIDE_BAR_WIDTH = '240px'; /** @@ -16,6 +14,6 @@ export const TOP_BAR_MOBILE_HEIGHT = '56px'; 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'; diff --git a/client/src/layout/index.tsx b/client/src/layout/index.tsx index 6e669a4..701b369 100644 --- a/client/src/layout/index.tsx +++ b/client/src/layout/index.tsx @@ -4,5 +4,6 @@ import CustomerLayout from './CustomerLayout'; import NurseLayout from './NurseLayout'; import AdminLayout from './AdminLayout'; import PartnerLayout from './PartnerLayout'; +import ActorSwitcher from './components/ActorSwitcher'; -export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout }; +export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout, ActorSwitcher }; diff --git a/client/src/layout/matchActivePath.test.ts b/client/src/layout/matchActivePath.test.ts new file mode 100644 index 0000000..9b3d8ef --- /dev/null +++ b/client/src/layout/matchActivePath.test.ts @@ -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'); + }); +}); diff --git a/client/src/layout/matchActivePath.ts b/client/src/layout/matchActivePath.ts new file mode 100644 index 0000000..a4f7d8f --- /dev/null +++ b/client/src/layout/matchActivePath.ts @@ -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 { + 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]; +} diff --git a/client/src/layout/routeTitle.tsx b/client/src/layout/routeTitle.tsx new file mode 100644 index 0000000..69bef60 --- /dev/null +++ b/client/src/layout/routeTitle.tsx @@ -0,0 +1,119 @@ +'use client'; +import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { ROUTES } from '@/constants'; +import { usePathname } from '@/i18n/navigation'; + +interface TitleEntry { + path: string; + key: string; +} + +/** + * Static route→title table (longest-prefix wins), read from the existing `nav` namespace — one + * key per shell console/tab. Dynamic titles (a nurse's name, a booking reference) are layered on + * top via `usePageTitleOverride`; the area phases (4-6, 9) wire those in, this phase ships the + * static baseline. + */ +const TITLE_ENTRIES: TitleEntry[] = [ + // Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS) + { path: ROUTES.SEARCH, key: 'search' }, + { path: ROUTES.SEARCH_RESULTS, key: 'search' }, + { path: ROUTES.SEARCH_NURSE, key: 'search' }, + { path: ROUTES.BOOKING_REQUEST, key: 'bookings' }, + { path: ROUTES.CHECKOUT, key: 'checkout' }, + { path: ROUTES.BOOKINGS, key: 'bookings' }, + { path: ROUTES.PATIENTS, key: 'patients' }, + { path: ROUTES.ADDRESSES, key: 'addresses' }, + { path: ROUTES.WALLET, key: 'wallet' }, + { path: ROUTES.PROFILE, key: 'profile' }, + { path: ROUTES.SUPPORT_TICKETS, key: 'support' }, + { path: ROUTES.NOTIFICATIONS, key: 'notifications' }, + { path: ROUTES.ONBOARDING, key: 'home' }, + // Nurse + { path: ROUTES.NURSE_REQUESTS, key: 'requests' }, + { path: ROUTES.NURSE_PROFILE, key: 'profile' }, + { path: ROUTES.NURSE_SERVICES, key: 'services' }, + { path: ROUTES.NURSE_COVERAGE, key: 'coverage' }, + { path: ROUTES.NURSE_BANK, key: 'bank' }, + { path: ROUTES.NURSE_VERIFICATION, key: 'verification' }, + { path: ROUTES.NURSE_VISITS, key: 'visits' }, + { path: ROUTES.NURSE_EARNINGS, key: 'earnings' }, + { path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' }, + { path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' }, + { path: ROUTES.NURSE, key: 'dashboard' }, + // Admin + { path: ROUTES.ADMIN_VERIFICATION, key: 'verification' }, + { path: ROUTES.ADMIN_TICKETS, key: 'tickets' }, + { path: ROUTES.ADMIN_PAYOUTS, key: 'payouts' }, + { path: ROUTES.ADMIN_REVIEWS, key: 'reviews' }, + { path: ROUTES.ADMIN_CONFIG, key: 'config' }, + { path: ROUTES.ADMIN_HOLIDAYS, key: 'holidays' }, + { path: ROUTES.ADMIN_ALERTS, key: 'alerts' }, + { path: ROUTES.ADMIN_AUDIT, key: 'audit' }, + { path: ROUTES.ADMIN_PARTNERS, key: 'partners' }, + { path: ROUTES.ADMIN_ROLES, key: 'roles' }, + { path: ROUTES.ADMIN_USERS, key: 'users' }, + { path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' }, + { path: ROUTES.ADMIN, key: 'overview' }, + // Partner + { path: ROUTES.PARTNER_NURSES, key: 'partner_nurses' }, + { path: ROUTES.PARTNER_BOOKINGS, key: 'partner_bookings' }, + { path: ROUTES.PARTNER_SETTLEMENT, key: 'partner_settlement' }, + { path: ROUTES.PARTNER, key: 'partner_home' }, +].sort((a, b) => b.path.length - a.path.length); + +function findTitleEntry(pathname: string): TitleEntry | undefined { + return TITLE_ENTRIES.find((entry) => + entry.path === '/' ? pathname === entry.path : pathname === entry.path || pathname.startsWith(`${entry.path}/`) + ); +} + +/** The customer shell's 5 root tabs — these render the brand lockup, never a title. */ +export const CUSTOMER_ROOT_TABS: string[] = [ROUTES.HOME, ROUTES.BOOKINGS, ROUTES.PATIENTS, ROUTES.WALLET, ROUTES.PROFILE]; + +export function isCustomerRootTab(pathname: string): boolean { + return CUSTOMER_ROOT_TABS.includes(pathname); +} + +const PageTitleOverrideContext = createContext(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(null); + return ( + + {children} + + ); +}; + +/** + * Lets a pushed-route page set a dynamic chrome header title (e.g. a nurse's name, a booking + * reference) that wins over the static route→title map. Pass `null`/omit to clear. The override + * is cleared automatically on unmount so navigating away never leaks a stale title. + */ +export function usePageTitleOverride(title: string | null | undefined) { + const setOverride = useContext(SetPageTitleOverrideContext); + useEffect(() => { + setOverride(title ?? null); + return () => setOverride(null); + }, [title, setOverride]); +} + +/** The current chrome header title: the per-page override if set, else the static route→title map. */ +export function useRouteTitle(): string | null { + const pathname = usePathname(); + const t = useTranslations('nav'); + const override = useContext(PageTitleOverrideContext); + return useMemo(() => { + if (override) return override; + const entry = findTitleEntry(pathname); + return entry ? t(entry.key) : null; + }, [override, pathname, t]); +} diff --git a/client/src/theme/theme.ts b/client/src/theme/theme.ts index fe9aa29..897d3f1 100644 --- a/client/src/theme/theme.ts +++ b/client/src/theme/theme.ts @@ -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: { defaultProps: { color: 'transparent', elevation: 0 }, styleOverrides: { diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index b439b6a..556d33f 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -9,4 +9,6 @@ export type LinkToPage = { path?: string; // URL to navigate to title?: string; // Title or primary text to display subtitle?: string; // Sub-title or secondary text to display + group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader + onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer) }; diff --git a/dev/shared-working-context/reports/ui-phase-2-report.md b/dev/shared-working-context/reports/ui-phase-2-report.md new file mode 100644 index 0000000..77a4208 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-2-report.md @@ -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.