manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
+45 -44
View File
@@ -1,69 +1,70 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
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';
import MobileShell from './MobileShell';
/**
* 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 and a compact identity chip showing
* the admin's fine-grained role. **No notification bell** (ui-phase-10): admin notifications have no
* real feed yet (phase 11 owns that decision) and the bell was the only entry into the dead
* `admin/notifications` placeholder — re-add it once phase 11 ships a feed, reusing
* `NotificationBellPopover` (already exported for that handoff).
* Admin / backoffice shell. The sidebar's four sections (اعتماد/مالی/پشتیبانی/سیستم) became four
* bottom-nav destinations plus the overview, each a real group-root page listing the consoles in
* it. Gating is unchanged and still per-console (`useAdminCapabilities`) — a group tab simply
* disappears when the current admin role can act on nothing inside it, and the server still
* enforces every call regardless. «سیستم» is always present: it carries settings and sign-out.
* @layout AdminLayout
*/
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const ta = useTranslations('admin');
const { data: me } = useMe();
const caps = useAdminCapabilities();
const sidebarItems: Array<LinkToPage> = useMemo(() => {
const groupTrust = t('group_trust');
const groupFinance = t('group_finance');
const groupSupport = t('group_support');
const groupSystem = t('group_system');
const tabs: Array<LinkToPage> = useMemo(() => {
const items: Array<LinkToPage & { show: boolean }> = [
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', 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 },
{
title: t('group_trust'),
path: ROUTES.ADMIN_TRUST,
icon: 'verification',
matchPaths: [ROUTES.ADMIN_VERIFICATION, ROUTES.ADMIN_REVIEWS],
show: caps.canVerify || caps.canModerate,
},
{
title: t('group_finance'),
path: ROUTES.ADMIN_FINANCE,
icon: 'earnings',
matchPaths: [ROUTES.ADMIN_PAYOUTS],
show: caps.canPayout,
},
{
title: t('group_support'),
path: ROUTES.ADMIN_SUPPORT,
icon: 'support',
matchPaths: [ROUTES.ADMIN_TICKETS, ROUTES.ADMIN_ALERTS],
show: caps.canManageTickets || caps.canManageAlerts,
},
{
title: t('group_system'),
path: ROUTES.ADMIN_SYSTEM,
icon: 'config',
matchPaths: [
ROUTES.ADMIN_CONFIG,
ROUTES.ADMIN_HOLIDAYS,
ROUTES.ADMIN_AUDIT,
ROUTES.ADMIN_PARTNERS,
ROUTES.ADMIN_USERS,
ROUTES.ADMIN_ROLES,
ROUTES.ADMIN_NOTIFICATIONS,
],
show: true,
},
];
return items.filter((item) => item.show).map(({ show: _show, ...rest }) => rest);
}, [t, caps]);
const primaryRoleCode = caps.roles[0];
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
identity={
me && primaryRoleCode ? (
<ProfileSummary
compact
displayName={[me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone}
roleLabel={ta(`role_${primaryRoleCode}`)}
/>
) : undefined
}
>
<MobileShell name="Admin" tabs={tabs}>
{children}
</TopBarAndSideBarLayout>
</MobileShell>
);
};
+45
View File
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../theme';
import AppFrame from './AppFrame';
import { APP_FRAME_MAX_WIDTH } from './config';
function renderFrame(props: Parameters<typeof AppFrame>[0]) {
return render(
<ThemeProvider>
<AppFrame {...props} />
</ThemeProvider>
);
}
describe('<AppFrame/> layout', () => {
it('renders header, content and footer as landmarks in order', () => {
renderFrame({
header: <span>chrome-top</span>,
footer: <span>chrome-bottom</span>,
children: <span>page body</span>,
});
expect(screen.getByRole('banner')).toHaveTextContent('chrome-top');
expect(screen.getByRole('main')).toHaveTextContent('page body');
expect(screen.getByRole('contentinfo')).toHaveTextContent('chrome-bottom');
});
it('omits the header and footer slots when unused (the chrome-free auth shell)', () => {
renderFrame({ children: <span>page body</span> });
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).not.toBeInTheDocument();
});
it('caps the app column at the phone-frame width', () => {
renderFrame({ children: <span>page body</span> });
// The whole point of the frame: a wide window gets more canvas, never a wider app.
expect(screen.getByRole('main').parentElement).toHaveStyle(`max-width: ${APP_FRAME_MAX_WIDTH}px`);
});
it('scrolls vertically inside main and never horizontally', () => {
renderFrame({ children: <span>page body</span> });
const main = screen.getByRole('main');
expect(main).toHaveStyle('overflow-y: auto');
// Guards the reported bug: an over-wide child must clip, not drag the whole app sideways.
expect(main).toHaveStyle('overflow-x: hidden');
});
});
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Box, Stack } from '@mui/material';
import { APP_FRAME_MAX_WIDTH } from './config';
interface AppFrameProps {
/** Pinned to the top of the frame; never scrolls with the content. */
header?: ReactNode;
/** Pinned to the bottom of the frame (the bottom nav); never scrolls with the content. */
footer?: ReactNode;
children: ReactNode;
}
/**
* The one device frame every shell renders inside — a centered, phone-width column on a canvas
* that absorbs whatever extra viewport there is. Three structural jobs, and it is the only place
* any of them are solved:
*
* 1. **Width is capped everywhere.** No shell stretches a header, a nav bar, or a content column
* across a desktop monitor; a wide window gets more canvas, not a wider app.
* 2. **The frame owns the scroll, not the document.** Header and footer are flex siblings of a
* single scrolling `<main>`, so the top bar needs no `position: fixed` and no page needs a
* matching top offset (the source of the old per-shell `pt: TOP_BAR_*` math).
* 3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the
* column means an over-wide child clips instead of dragging the whole app sideways; anything
* genuinely wide (a data table) scrolls inside its own container.
* @layout AppFrame
*/
const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }) => (
<Box
sx={{
height: '100dvh',
display: 'flex',
justifyContent: 'center',
bgcolor: 'var(--bal-frame-canvas)',
overflow: 'hidden',
}}
>
<Stack
sx={{
width: '100%',
maxWidth: APP_FRAME_MAX_WIDTH,
minWidth: 0,
height: '100%',
bgcolor: 'background.default',
// Reads as a hairline seam between app and canvas on a wide window; invisible on a phone,
// where the frame already fills the viewport edge to edge.
borderInline: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{header ? (
<Box component="header" sx={{ flexShrink: 0, minWidth: 0 }}>
{header}
</Box>
) : null}
<Box
component="main"
sx={{
flexGrow: 1,
minHeight: 0,
minWidth: 0,
overflowY: 'auto',
overflowX: 'hidden',
// Keeps a rubber-band scroll at the end of a list from chaining out to the canvas.
overscrollBehaviorY: 'contain',
}}
>
{children}
</Box>
{footer ? (
<Box component="footer" sx={{ flexShrink: 0, minWidth: 0 }}>
{footer}
</Box>
) : null}
</Stack>
</Box>
);
export default AppFrame;
+14 -128
View File
@@ -1,153 +1,39 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { Badge, Box, Stack, Tab, Tabs } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AppIcon, AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
import { NotificationBell } from '@/components/notifications';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useSupportUnreadTotal } from '@/services/tickets';
import { LinkToPage } from '@/utils';
import { usePathname, useRouter } from '@/i18n/navigation';
import { TopBar, BottomBar } from './components';
import { DarkModeToggleButton } from './components/DarkModeButton';
import BrandLockup from './components/BrandLockup';
import { matchActivePath } from './matchActivePath';
import { PageTitleProvider, isCustomerRootTab, useRouteTitle } from './routeTitle';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT, TOP_NAV_DESKTOP_HEIGHT } from './config';
/** The ≥md replacement for the mobile BottomBar — the same 5 tabs, inline in the header. */
const CustomerDesktopNav: FunctionComponent<{ items: Array<LinkToPage> }> = ({ items }) => {
const pathname = usePathname();
const router = useRouter();
const activePath = matchActivePath(pathname, items.map((item) => item.path)) ?? false;
return (
<Box sx={{ display: { xs: 'none', md: 'block' }, borderTop: '1px solid', borderColor: 'divider' }}>
<Tabs
value={activePath}
onChange={(_event, value: string) => router.push(value)}
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT, px: 2 }}
>
{items.map((item) => (
<Tab
key={item.path}
value={item.path}
label={item.title}
icon={item.icon ? <AppIcon icon={item.icon} size={18} /> : undefined}
iconPosition="start"
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT }}
/>
))}
</Tabs>
</Box>
);
};
const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> = ({ bottomNavItems }) => {
const t = useTranslations('nav');
const tc = useTranslations('common');
const pathname = usePathname();
const router = useRouter();
const title = useRouteTitle();
const onRootTab = isCustomerRootTab(pathname);
const supportUnreadTotal = useSupportUnreadTotal();
return (
<TopBar
align={onRootTab ? 'center' : 'start'}
startNode={
onRootTab ? (
<Badge
badgeContent={supportUnreadTotal ?? 0}
max={99}
overlap="circular"
invisible={!supportUnreadTotal}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
/>
</Badge>
) : (
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
)
}
titleNode={onRootTab ? <BrandLockup /> : undefined}
title={onRootTab ? undefined : (title ?? undefined)}
endNode={
<Stack direction="row" sx={{ alignItems: 'center' }}>
<NotificationBell role="customer" />
<DarkModeToggleButton />
</Stack>
}
secondaryRow={<CustomerDesktopNav items={bottomNavItems} />}
/>
);
};
import MobileShell from './MobileShell';
/**
* 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.
* Customer (family) app shell — five bottom-nav destinations, with «پروفایل» doubling as the
* account hub that now owns appearance/language settings (they used to sit in the top bar).
* @layout CustomerLayout
*/
const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tChrome = useTranslations('routeChrome');
const bottomNavItems: Array<LinkToPage> = useMemo(
const tabs: Array<LinkToPage> = useMemo(
() => [
{ title: t('home'), path: ROUTES.HOME, icon: 'home' },
{ title: t('home'), path: ROUTES.HOME, icon: 'home', matchPaths: [ROUTES.SEARCH] },
{ title: t('bookings'), path: ROUTES.BOOKINGS, icon: 'bookings' },
{ title: t('patients'), path: ROUTES.PATIENTS, icon: 'patients' },
{ title: t('wallet'), path: ROUTES.WALLET, icon: 'wallet' },
{ title: t('profile'), path: ROUTES.PROFILE, icon: 'profile' },
{
title: t('profile'),
path: ROUTES.PROFILE,
icon: 'account',
matchPaths: [ROUTES.ADDRESSES, ROUTES.SUPPORT_TICKETS, ROUTES.NOTIFICATIONS],
},
],
[t]
);
return (
<PageTitleProvider>
<Stack sx={{ height: '100dvh', bgcolor: 'background.default' }}>
<Stack component="header">
<CustomerHeader bottomNavItems={bottomNavItems} />
</Stack>
<Box
component="main"
sx={{
flexGrow: 1,
overflowY: 'auto',
width: '100%',
// AppBar is position: fixed — offset content by the top-bar (+ desktop top-nav) height.
pt: {
xs: `calc(${TOP_BAR_MOBILE_HEIGHT} + 8px)`,
md: `calc(${TOP_BAR_DESKTOP_HEIGHT} + ${TOP_NAV_DESKTOP_HEIGHT} + 16px)`,
},
}}
>
<Box sx={{ maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', px: 2, pb: 2 }}>
<ErrorBoundary
name="Customer"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
</ErrorBoundary>
</Box>
</Box>
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<BottomBar items={bottomNavItems} />
</Box>
</Stack>
</PageTitleProvider>
<MobileShell name="Customer" tabs={tabs} headerActions={<NotificationBell role="customer" />}>
{children}
</MobileShell>
);
};
+13 -10
View File
@@ -3,34 +3,37 @@ import { FunctionComponent, PropsWithChildren } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { AppIcon, ErrorBoundary, RouteFadeIn } from '@/components';
import AppFrame from './AppFrame';
/**
* Chrome-free shell for a focused, can't-tab-away flow — today only first-run onboarding
* (ui-phase-3 §3.5). No bottom nav, no bell, no sidebar: a slim logo strip and the step content,
* in the same spirit as `AuthCard`/`PublicLayout`. `RoleGuard` still gates the route group above
* this — a chrome-free shell is a UX choice, not a security boundary.
* (ui-phase-3 §3.5). No bottom nav, no bell: a slim logo strip and the step content, in the same
* spirit as `AuthCard`/`PublicLayout`. `RoleGuard` still gates the route group above this — a
* chrome-free shell is a UX choice, not a security boundary.
* @layout FocusedLayout
*/
const FocusedLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const tChrome = useTranslations('routeChrome');
return (
<Stack sx={{ minHeight: '100vh' }}>
<Stack component="header" direction="row" sx={{ alignItems: 'center', justifyContent: 'center', paddingBlock: 2 }}>
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
</Stack>
<AppFrame>
<Stack sx={{ minHeight: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'center', paddingBlock: 2 }}>
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
</Stack>
<Stack component="main" sx={{ flexGrow: 1, px: 2, pb: 4 }}>
<ErrorBoundary
name="Focused"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
<RouteFadeIn fill>
<Stack sx={{ flexGrow: 1, px: 2, pb: 4 }}>{children}</Stack>
</RouteFadeIn>
</ErrorBoundary>
</Stack>
</Stack>
</AppFrame>
);
};
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { FunctionComponent, ReactNode, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack } from '@mui/material';
import { AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
import { LinkToPage } from '@/utils';
import { usePathname, useRouter } from '@/i18n/navigation';
import AppFrame from './AppFrame';
import { BottomBar, TopBar } from './components';
import BrandLockup from './components/BrandLockup';
import { PageTitleProvider, useRouteTitle } from './routeTitle';
interface MobileShellProps {
/** Bottom-nav destinations, 35 of them. By convention the last is the settings/«بیشتر» hub. */
tabs: Array<LinkToPage>;
/** Chrome actions for the top bar's end slot (the notification bell). Never a theme/locale toggle. */
headerActions?: ReactNode;
/** Distinguishes this shell's error boundary in logs (`Customer`, `Nurse`, …). */
name: string;
children: ReactNode;
}
/**
* A tab's own path counts as "root" — the shell shows the brand lockup there and a back button
* on anything deeper, which is the only navigational affordance a phone-width app needs.
*/
function useIsRootTab(tabs: Array<LinkToPage>): boolean {
const pathname = usePathname();
return useMemo(() => tabs.some((tab) => tab.path === pathname), [tabs, pathname]);
}
const ShellChrome: FunctionComponent<MobileShellProps> = ({ tabs, headerActions, name, children }) => {
const tc = useTranslations('common');
const tChrome = useTranslations('routeChrome');
const router = useRouter();
const title = useRouteTitle();
const onRootTab = useIsRootTab(tabs);
return (
<AppFrame
header={
<TopBar
align={onRootTab ? 'center' : 'start'}
startNode={onRootTab ? undefined : <AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />}
titleNode={onRootTab ? <BrandLockup /> : undefined}
title={onRootTab ? undefined : (title ?? '')}
endNode={headerActions ? <Stack direction="row" sx={{ alignItems: 'center' }}>{headerActions}</Stack> : undefined}
/>
}
footer={<BottomBar items={tabs} />}
>
<Box sx={{ px: 2, pt: 2, pb: 3 }}>
<ErrorBoundary
name={name}
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
</ErrorBoundary>
</Box>
</AppFrame>
);
};
/**
* The one authenticated shell behind every actor app (customer, nurse, admin, partner). Each
* actor supplies its own tabs and header actions; the chrome, the frame, the error boundary, and
* the route-title plumbing are identical, which is the point — four hand-rolled shells is how the
* app ended up with a drawer here, a tab strip there, and a theme toggle in three top bars.
* @layout MobileShell
*/
const MobileShell: FunctionComponent<MobileShellProps> = (props) => (
<PageTitleProvider>
<ShellChrome {...props} />
</PageTitleProvider>
);
export default MobileShell;
+36 -76
View File
@@ -1,102 +1,62 @@
'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 { useSupportUnreadTotal } from '@/services/tickets';
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';
import MobileShell from './MobileShell';
/**
* 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.
* Nurse app shell — the "نمای پرستار" workspace. Four bottom-nav destinations, one per group the
* sidebar used to hide behind a hamburger: امروز (the operational home), حرفهٔ من, مالی, and a
* بیشتر hub carrying support, notifications and settings. The identity card that lived in the
* drawer header now opens from that hub, where it can be read rather than glanced at.
* @layout NurseLayout
*/
const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const identityLoading = !me;
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const sidebarItems: Array<LinkToPage> = useMemo(() => {
const groupToday = t('group_today');
const groupProfession = t('group_profession');
const groupFinance = t('group_finance');
const groupSupport = t('group_support');
return [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard', group: groupToday },
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests', group: groupToday },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits', group: groupToday },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile', group: groupProfession },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services', group: groupProfession },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage', group: groupProfession },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance },
const tabs: Array<LinkToPage> = useMemo(
() => [
{
title: t('support'),
path: ROUTES.NURSE_SUPPORT_TICKETS,
icon: 'support',
group: groupSupport,
title: t('group_today'),
path: ROUTES.NURSE,
icon: 'today',
},
{
title: t('group_profession'),
path: ROUTES.NURSE_PRACTICE,
icon: 'practice',
matchPaths: [
ROUTES.NURSE_PROFILE,
ROUTES.NURSE_SERVICES,
ROUTES.NURSE_COVERAGE,
ROUTES.NURSE_VERIFICATION,
],
},
{
title: t('group_finance'),
path: ROUTES.NURSE_FINANCE,
icon: 'earnings',
matchPaths: [ROUTES.NURSE_EARNINGS, ROUTES.NURSE_BANK],
},
{
title: t('more'),
path: ROUTES.NURSE_MORE,
icon: 'more',
matchPaths: [ROUTES.NURSE_SUPPORT_TICKETS, ROUTES.NURSE_NOTIFICATIONS],
badgeCount: supportUnreadTotal ?? undefined,
},
];
}, [t, supportUnreadTotal]);
const mobileTabs = useMemo(
(): Array<LinkToPage> => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
{ title: t('more'), path: MORE_TAB_PATH, icon: 'menu' },
],
[t]
[t, supportUnreadTotal]
);
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
headerActions={<NotificationBell role="nurse" />}
sidebarIdentity={
<Stack sx={{ gap: 1.5 }}>
<ProfileSummary
displayName={displayName}
phone={me?.phone}
avatarUrl={nurseProfile?.avatarUrl}
trustState={ownBadgeState(verification)}
loading={identityLoading}
/>
<ActorSwitcher target="customer" />
</Stack>
}
mobileBottomBar={(openMobileSidebar) => (
<BottomBar
items={mobileTabs.map((item) =>
item.path === MORE_TAB_PATH ? { ...item, onSelect: openMobileSidebar } : item
)}
/>
)}
>
<MobileShell name="Nurse" tabs={tabs} headerActions={<NotificationBell role="nurse" />}>
{children}
</TopBarAndSideBarLayout>
</MobileShell>
);
};
+9 -29
View File
@@ -1,55 +1,35 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { ProfileSummary, StatusChip } from '@/components';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useMyPartnerCenter } from '@/services/partnerCenter';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
import MobileShell from './MobileShell';
/**
* 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 engine as the admin shell; the TopBar identity slot shows the center's own name
* (skeleton while resolving) **plus a compact merchant-of-record indicator** (ui-phase-11 — previously
* only the home page's own header showed this, so it disappeared once a center admin navigated away) —
* the page-level access-denied state (403/404) stays where it is.
* 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, plus each page's own `useMyPartnerCenter` access-denied state). The center's identity
* and merchant-of-record status moved from the top bar into the «بیشتر» hub, alongside settings.
* @layout PartnerLayout
*/
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter();
const sidebarItems: Array<LinkToPage> = useMemo(
const tabs: Array<LinkToPage> = useMemo(
() => [
{ title: t('partner_home'), path: ROUTES.PARTNER, icon: 'partners' },
{ title: t('partner_nurses'), path: ROUTES.PARTNER_NURSES, icon: 'patients' },
{ title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' },
{ title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' },
{ title: t('more'), path: ROUTES.PARTNER_MORE, icon: 'more' },
],
[t]
);
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
identity={
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
sx={{ height: 20, fontSize: '0.6875rem', display: { xs: 'none', sm: 'inline-flex' } }}
/>
) : null}
</Stack>
}
>
<MobileShell name="Partner" tabs={tabs}>
{children}
</TopBarAndSideBarLayout>
</MobileShell>
);
};
+11 -22
View File
@@ -2,44 +2,33 @@
import { FunctionComponent, PropsWithChildren } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { AppIcon, ErrorBoundary, RouteFadeIn } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { DarkModeToggleButton } from './components/DarkModeButton';
import { ErrorBoundary, RouteFadeIn } from '@/components';
import AppFrame from './AppFrame';
/**
* 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.
* Unauthenticated shell — the frame and nothing else. There is deliberately **no top bar**: the
* only thing on a login screen is the login card, which carries its own `BrandMark`, and the
* locale/theme controls that used to sit up here are settings, not front-door decisions (they now
* live in each actor's settings hub). A second brand glyph in a strip above the card was competing
* with the card's own.
* @layout PublicLayout
*/
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const tChrome = useTranslations('routeChrome');
return (
<Stack sx={{ minHeight: '100vh' }}>
<Stack
component="header"
direction="row"
sx={{ alignItems: 'center', justifyContent: 'space-between', paddingInline: 2, paddingBlock: 1.5 }}
>
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
<Stack direction="row" sx={{ alignItems: 'center' }}>
<LocaleSwitcher />
<DarkModeToggleButton />
</Stack>
</Stack>
<Stack component="main" sx={{ flexGrow: 1 }}>
<AppFrame>
<Stack sx={{ minHeight: '100%' }}>
<ErrorBoundary
name="Public"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
<RouteFadeIn fill>{children}</RouteFadeIn>
</ErrorBoundary>
</Stack>
</Stack>
</AppFrame>
);
};
@@ -1,104 +0,0 @@
'use client';
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack, useTheme } from '@mui/material';
import { AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
import { LinkToPage } from '@/utils';
import { TopBar } from './components';
import SideBar from './components/SideBar';
import { DarkModeToggleButton } from './components/DarkModeButton';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
import { useRouteTitle } from './routeTitle';
interface Props {
sidebarItems: Array<LinkToPage>;
/** 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 (`<md`) bottom nav rendered under the content column (the nurse shell's 5-tab
* bar). Receives a callback that opens the same mobile drawer the TopBar's menu button opens, so
* a "more" tab can reveal the rest of the sidebar without a second drawer.
*/
mobileBottomBar?: (openMobileSidebar: () => void) => ReactNode;
}
/**
* The shared engine behind the nurse/admin/partner shells: a fixed `TopBar` showing the current
* page title (`useRouteTitle`, longest-prefix over `ROUTES.*`) plus a `SideBar` that renders as a
* mobile overlay and a desktop in-flow column at once (see `SideBar` for why that kills the SSR
* flash). The sidebar and the main column are flex siblings in a row — native RTL flexbox puts the
* sidebar at the reading-start side with no manual offset math.
* @layout TopBarAndSideBarLayout
*/
const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
children,
sidebarItems,
sidebarIdentity,
identity,
headerActions,
mobileBottomBar,
}) => {
const tChrome = useTranslations('routeChrome');
const tCommon = useTranslations('common');
const theme = useTheme();
const title = useRouteTitle();
const [mobileOpen, setMobileOpen] = useState(false);
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
const headerControls = (
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
{identity}
{headerActions}
{/* DarkModeToggleButton subscribes to useColorScheme() on its own — this layout never re-renders on a theme flip */}
<DarkModeToggleButton />
</Stack>
);
return (
<Stack sx={{ minHeight: '100vh' }}>
<TopBar
startNode={
<AppIconButton
icon="menu"
title={tCommon('open_sidebar')}
onClick={() => setMobileOpen(true)}
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
/>
}
title={title ?? ''}
align="start"
endNode={headerControls}
/>
<Stack direction="row" sx={{ flexGrow: 1, pt: { xs: TOP_BAR_MOBILE_HEIGHT, md: TOP_BAR_DESKTOP_HEIGHT } }}>
<SideBar
items={sidebarItems}
identity={sidebarIdentity}
anchor={anchor}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<Stack component="main" sx={{ flexGrow: 1, minWidth: 0, padding: 2 }}>
<ErrorBoundary
name="Content"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
</ErrorBoundary>
</Stack>
</Stack>
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
</Stack>
);
};
export default TopBarAndSideBarLayout;
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import type { LinkToPage } from '@/utils';
const mockPush = jest.fn();
let pathname = '/nurse';
jest.mock('@/i18n/navigation', () => ({
usePathname: () => pathname,
useRouter: () => ({ push: mockPush }),
}));
import BottomBar from './BottomBar';
const TABS: Array<LinkToPage> = [
{ title: 'Today', path: '/nurse', icon: 'today' },
{ title: 'Practice', path: '/nurse/practice', icon: 'practice', matchPaths: ['/nurse/profile', '/nurse/services'] },
{ title: 'Money', path: '/nurse/finance', icon: 'earnings', matchPaths: ['/nurse/earnings', '/nurse/bank'] },
{ title: 'More', path: '/nurse/more', icon: 'more', badgeCount: 2 },
];
function renderBar(at: string) {
pathname = at;
return render(
<ThemeProvider>
<BottomBar items={TABS} />
</ThemeProvider>
);
}
/** The single active tab, read off the aria-current the bar stamps on it. */
function activeTabName(): string | undefined {
return screen.queryByRole('button', { current: 'page' })?.textContent ?? undefined;
}
describe('<BottomBar/> component', () => {
beforeEach(() => mockPush.mockReset());
it('renders one tab per item', () => {
renderBar('/nurse');
expect(screen.getAllByRole('button')).toHaveLength(TABS.length);
});
it('marks the exact tab as active', () => {
renderBar('/nurse');
expect(activeTabName()).toBe('Today');
});
it('keeps a nested route on its own tab (longest prefix wins over the shell root)', () => {
renderBar('/nurse/practice');
expect(activeTabName()).toBe('Practice');
});
it('lights up the group tab for a destination outside its subtree (matchPaths)', () => {
// The regression this guards: `/nurse/earnings` is not under `/nurse/finance`, so a plain
// longest-prefix match would fall back to the `/nurse` root tab and highlight «Today» instead.
renderBar('/nurse/earnings/payouts');
expect(activeTabName()).toBe('Money');
});
it('navigates on tap', async () => {
renderBar('/nurse');
await userEvent.click(screen.getByRole('button', { name: /Money/ }));
expect(mockPush).toHaveBeenCalledWith('/nurse/finance');
});
it('shows a badge only on the tab that has a count', () => {
renderBar('/nurse');
expect(screen.getByText('2')).toBeInTheDocument();
});
it('floats as a rounded, elevated bar rather than an edge-to-edge slab', () => {
renderBar('/nurse');
const bar = screen.getByRole('navigation').firstElementChild as HTMLElement;
expect(bar).toHaveStyle('border-radius: var(--bal-radius-pill)');
expect(bar).toHaveStyle('box-shadow: var(--bal-shadow-2)');
});
});
+114 -25
View File
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, useCallback, useMemo } from 'react';
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
import { Badge, Box, ButtonBase, Stack, Typography } from '@mui/material';
import { LinkToPage } from '@/utils';
import { AppIcon } from '@/components';
import { usePathname, useRouter } from '@/i18n/navigation';
@@ -10,47 +10,136 @@ interface Props {
items: Array<LinkToPage>;
}
const ICON_SIZE = 22;
/** The active pill behind the icon — wide enough to read as a target, short enough to stay a pill. */
const PILL_WIDTH = 52;
const PILL_HEIGHT = 28;
/**
* 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.
* The app's only navigation surface: a bottom tab bar carrying one destination per top-level
* area. It replaced the drawer + hamburger outright — on a phone-width frame a drawer hides the
* whole information architecture behind a tap and gives the top bar a job it doesn't need.
*
* Each tab is a `ButtonBase` (not MUI's `BottomNavigation`) so the active state can be a pill
* that fills in behind the icon rather than a color-only swap: the shape change is what makes
* the selection legible at a glance. The transition reads through `--bal-motion-fast`, so the
* app-wide reduced-motion gate (globals.css) already collapses it — no local branch.
*
* Locale-aware via the `@/i18n/navigation` wrapper (no manual `/${locale}` prefixing), with the
* shared `matchActivePath` longest-prefix helper deciding the active tab, so a nested route
* (`/patients/123`) still lights up its parent.
* @component BottomBar
*/
const BottomBar: FunctionComponent<Props> = ({ items }) => {
const router = useRouter();
const pathname = usePathname();
const activePath = useMemo(
() => matchActivePath(pathname, items.map((item) => item.path)) ?? false,
[items, pathname]
);
// A tab can claim paths outside its own subtree (`matchPaths`), so the longest-prefix match runs
// over every claimed path and the winner is mapped back to the tab that claimed it.
const activePath = useMemo(() => {
const claims = items.flatMap((item) =>
!item.path ? [] : [item.path, ...(item.matchPaths ?? [])].map((claimed) => ({ claimed, tab: item.path as string }))
);
const winner = matchActivePath(pathname, claims.map((claim) => claim.claimed));
return claims.find((claim) => claim.claimed === winner)?.tab;
}, [items, pathname]);
const onNavigationChange = useCallback(
(_event: unknown, newValue: string) => {
const item = items.find((candidate) => candidate.path === newValue);
if (item?.onSelect) {
const onSelect = useCallback(
(item: LinkToPage) => {
if (item.onSelect) {
item.onSelect();
return;
}
router.push(newValue);
if (item.path) router.push(item.path);
},
[items, router]
[router]
);
return (
<Paper
elevation={3}
square
// The bar floats: it is inset from the frame edges and fully rounded, so the page background
// runs behind and around it instead of the bar sealing off the bottom of the screen with an
// edge-to-edge slab. It is still a flex sibling of the scrolling <main> (AppFrame), not an
// overlay — content is never hidden underneath it and no screen needs bottom padding for it.
<Box
component="nav"
sx={{ borderTop: '1px solid', borderColor: 'divider', paddingBottom: 'env(safe-area-inset-bottom)' }}
sx={{
px: 1.5,
pt: 0.5,
paddingBottom: 'calc(env(safe-area-inset-bottom) + 10px)',
}}
>
<BottomNavigation value={activePath} showLabels onChange={onNavigationChange}>
{items.map(({ title, path, icon }) => (
<BottomNavigationAction key={`${title}-${path}`} label={title} value={path} icon={<AppIcon icon={icon} />} />
))}
</BottomNavigation>
</Paper>
<Stack
direction="row"
sx={{
px: 0.75,
py: 1,
gap: 0.25,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-pill)',
boxShadow: 'var(--bal-shadow-2)',
}}
>
{items.map((item) => {
const isActive = Boolean(item.path) && item.path === activePath;
return (
<ButtonBase
key={`${item.title}-${item.path}`}
onClick={() => onSelect(item)}
aria-current={isActive ? 'page' : undefined}
sx={{
flex: 1,
minWidth: 0,
flexDirection: 'column',
gap: 0.25,
py: 0.25,
// Matches the bar's own shape so the ripple/focus ring never squares off a
// corner against the rounded container behind it.
borderRadius: 'var(--bal-radius-pill)',
}}
>
<Badge
badgeContent={item.badgeCount ?? 0}
max={99}
invisible={!item.badgeCount}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<Box
sx={{
width: PILL_WIDTH,
height: PILL_HEIGHT,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-pill)',
bgcolor: isActive ? 'var(--bal-primary-soft)' : 'transparent',
transition: 'background-color var(--bal-motion-fast) var(--bal-easing-standard)',
}}
>
<AppIcon
icon={item.icon}
size={ICON_SIZE}
color={isActive ? 'var(--bal-primary)' : 'var(--bal-text-secondary)'}
strokeWidth={isActive ? 2.1 : 1.75}
/>
</Box>
</Badge>
<Typography
variant="caption"
noWrap
sx={{
maxWidth: '100%',
fontWeight: isActive ? 700 : 400,
color: isActive ? 'var(--bal-primary)' : 'text.secondary',
}}
>
{item.title}
</Typography>
</ButtonBase>
);
})}
</Stack>
</Box>
);
};
@@ -1,42 +0,0 @@
'use client';
/*
* Tiny components that are the ONLY React nodes subscribed to useColorScheme().
* When the user flips the theme:
* 1. useColorScheme().setMode() sets data-mui-color-scheme on <html>
* 2. CSS custom properties resolve to new values → browser repaints
* 3. React re-renders ONLY these two components (icon label / switch state)
* Nothing above them in the tree is touched.
*/
import { FormControlLabel, Switch, Tooltip } from '@mui/material';
import { useColorScheme } from '@mui/material/styles';
import { useTranslations } from 'next-intl';
import { AppIconButton } from '@/components';
/** Icon button for the TopBar dark-mode toggle. */
export function DarkModeToggleButton() {
const { colorScheme, setMode } = useColorScheme();
const t = useTranslations('common');
const isDark = colorScheme === 'dark';
return (
<AppIconButton
icon={isDark ? 'day' : 'night'}
title={isDark ? t('light_mode') : t('dark_mode')}
onClick={() => setMode(isDark ? 'light' : 'dark')}
/>
);
}
/** Labeled switch for the SideBar dark-mode toggle. */
export function DarkModeFormSwitch() {
const { colorScheme, setMode } = useColorScheme();
const t = useTranslations('common');
const isDark = colorScheme === 'dark';
return (
<Tooltip title={isDark ? t('light_mode') : t('dark_mode')}>
<FormControlLabel
label={isDark ? t('dark_mode') : t('light_mode')}
control={<Switch checked={isDark} onChange={() => setMode(isDark ? 'light' : 'dark')} />}
/>
</Tooltip>
);
}
-98
View File
@@ -1,98 +0,0 @@
'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 { LinkToPage } from '@/utils';
import { SIDE_BAR_WIDTH } from '../config';
import SideBarNavList from './SideBarNavList';
import BrandLockup from './BrandLockup';
import { DarkModeFormSwitch } from './DarkModeButton';
export interface SideBarProps {
items: Array<LinkToPage>;
/** Physical edge, derived from text direction by the caller ('start' under both locales). */
anchor: 'left' | 'right';
mobileOpen: boolean;
onMobileClose: () => void;
/** Rendered below the brand header, above the nav list (the nurse shell's ProfileSummary card). */
identity?: ReactNode;
}
/**
* Renders 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<SideBarProps> = ({ items, anchor, mobileOpen, onMobileClose, identity }) => {
const isAuthenticated = useIsAuthenticated();
const t = useTranslations('nav');
const { mutate: logout } = useLogout();
const handleNavClick = useCallback(() => onMobileClose(), [onMobileClose]);
const content = (
<Stack sx={{ height: '100%', padding: 2, gap: 2, overflowY: 'auto' }}>
<BrandLockup />
<Divider />
{identity && (
<>
{identity}
<Divider />
</>
)}
<SideBarNavList items={items} showIcons onClick={handleNavClick} />
<Divider sx={{ marginTop: 'auto' }} />
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
<DarkModeFormSwitch />
<LocaleSwitcher />
</Stack>
{isAuthenticated && (
<AppButton variant="text" color="error" startIcon="logout" fullWidth onClick={() => logout()}>
{t('logout')}
</AppButton>
)}
</Stack>
);
return (
<>
<Drawer
anchor={anchor}
open={mobileOpen}
onClose={onMobileClose}
variant="temporary"
ModalProps={{ keepMounted: true }}
sx={{ display: { xs: 'block', md: 'none' } }}
slotProps={{ paper: { sx: { width: SIDE_BAR_WIDTH } } }}
>
{content}
</Drawer>
<Drawer
anchor={anchor}
variant="permanent"
sx={{ display: { xs: 'none', md: 'block' }, width: SIDE_BAR_WIDTH, flexShrink: 0 }}
slotProps={{ paper: { sx: { width: SIDE_BAR_WIDTH, position: 'relative' } } }}
>
{content}
</Drawer>
</>
);
};
export default SideBar;
@@ -1,52 +0,0 @@
'use client';
import { FunctionComponent, MouseEventHandler } from 'react';
import { Badge, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { AppIcon } from '@/components';
import { Link } from '@/i18n/navigation';
import { LinkToPage } from '@/utils';
interface Props extends LinkToPage {
selected?: boolean;
onClick?: MouseEventHandler;
}
/**
* 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. `badgeCount` renders a small unread dot on the icon (the
* nurse support entry, §3.1) — omitted entirely when falsy, never a bare "0".
* @component SideBarNavItem
*/
const SideBarNavItem: FunctionComponent<Props> = ({
icon,
path,
selected = false,
subtitle,
title,
onClick,
badgeCount,
}) => {
const iconNode = icon && <AppIcon icon={icon} />;
return (
<ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}>
<ListItemIcon>
{badgeCount ? (
<Badge
badgeContent={badgeCount}
max={99}
overlap="circular"
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
{iconNode}
</Badge>
) : (
iconNode
)}
</ListItemIcon>
<ListItemText primary={title} secondary={subtitle} />
</ListItemButton>
);
};
export default SideBarNavItem;
@@ -1,54 +0,0 @@
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 {
items: Array<LinkToPage>;
showIcons?: boolean;
onClick?: MouseEventHandler;
}
/**
* 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} [onClick] - optional callback called when some navigation item was clicked
*/
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick }) => {
const pathname = usePathname();
const activePath = useMemo(
() => matchActivePath(pathname, items.map((item) => item.path)),
[pathname, items]
);
return (
<List component="nav" sx={{ width: '100%' }}>
{items.map((item, index) => {
const showHeader = Boolean(item.group) && item.group !== items[index - 1]?.group;
return (
<Fragment key={`${item.title}-${item.path}`}>
{showHeader && <ListSubheader component="div" disableSticky>{item.group}</ListSubheader>}
<SideBarNavItem
icon={showIcons ? item.icon : undefined}
path={item.path}
title={item.title}
subtitle={item.subtitle}
selected={item.path === activePath}
onClick={onClick}
badgeCount={item.badgeCount}
/>
</Fragment>
);
})}
</List>
);
};
export default SideBarNavList;
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import TopBar from './TopBar';
function renderBar(props: Parameters<typeof TopBar>[0]) {
const { container } = render(
<ThemeProvider>
<TopBar {...props} />
</ThemeProvider>
);
return container;
}
describe('<TopBar/> component', () => {
it('renders the title as the page heading', () => {
renderBar({ title: 'Earnings' });
expect(screen.getByRole('heading', { level: 1, name: 'Earnings' })).toBeInTheDocument();
});
it('lets titleNode replace the title entirely (the brand lockup on a root tab)', () => {
renderBar({ title: 'Earnings', titleNode: <span>brand</span> });
expect(screen.getByText('brand')).toBeInTheDocument();
expect(screen.queryByText('Earnings')).not.toBeInTheDocument();
});
it('renders the start and end slots', () => {
renderBar({ title: 'Earnings', startNode: <button>back</button>, endNode: <button>bell</button> });
expect(screen.getByRole('button', { name: 'back' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'bell' })).toBeInTheDocument();
});
it('is not a filled AppBar', () => {
const container = renderBar({ title: 'Earnings' });
// The design rule this guards: on a 480px frame a solid header band is a permanent slab of
// chrome above content that is already only ~50 characters wide. The bar sits on the page
// background instead — no MUI AppBar surface, rule or elevation.
expect(container.querySelector('.MuiAppBar-root')).toBeNull();
});
});
+28 -24
View File
@@ -1,41 +1,45 @@
import { FunctionComponent, ReactNode } from 'react';
import { AppBar, Box, Toolbar, Typography } from '@mui/material';
import { Box, Stack, Typography } from '@mui/material';
import { TOP_BAR_HEIGHT } from '../config';
interface Props {
endNode?: ReactNode;
startNode?: ReactNode;
title?: string;
/** Overrides `title` with arbitrary content (e.g. the brand lockup on the customer home). */
/** Overrides `title` with arbitrary content (e.g. the brand lockup on a root tab). */
titleNode?: ReactNode;
/** 'start' for a breadcrumb-style label (sidebar shells); 'center' for the customer shell. */
/** 'start' for a breadcrumb-style label on a pushed route; 'center' for a root tab. */
align?: 'start' | 'center';
/** An optional second row under the main one (the customer shell's desktop top-nav tabs). */
secondaryRow?: ReactNode;
}
/**
* Renders TopBar composition
* The frame's top row. Deliberately **not** an `AppBar`: no filled surface, no bottom rule, no
* elevation — it sits directly on `background.default` and reads as part of the page rather than a
* bar covering the top of it. On a 480px frame a solid header band is a large, permanent slab of
* chrome above content that is already only ~50 characters wide.
*
* It is also a plain flex sibling of the scrolling `<main>` inside `AppFrame`, never a fixed
* overlay — which is what keeps it inside the phone-width column on a wide window and removes the
* per-shell top-offset math the fixed version needed.
* @component TopBar
*/
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', titleNode, align = 'center', secondaryRow }) => {
return (
<AppBar component="div">
<Toolbar disableGutters sx={{ paddingInline: 1, gap: 1 }}>
{startNode}
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', titleNode, align = 'center' }) => (
<Stack
direction="row"
sx={{ alignItems: 'center', gap: 0.5, minHeight: TOP_BAR_HEIGHT, paddingInline: 1 }}
>
{startNode}
<Box sx={{ flexGrow: 1, minWidth: 0, textAlign: align }}>
{titleNode ?? (
<Typography variant="h6" component="span" noWrap sx={{ display: 'block' }}>
{title}
</Typography>
)}
</Box>
<Box sx={{ flexGrow: 1, minWidth: 0, textAlign: align }}>
{titleNode ?? (
<Typography variant="subtitle1" component="h1" noWrap sx={{ display: 'block', fontWeight: 700 }}>
{title}
</Typography>
)}
</Box>
{endNode}
</Toolbar>
{secondaryRow}
</AppBar>
);
};
{endNode}
</Stack>
);
export default TopBar;
+2 -2
View File
@@ -1,5 +1,5 @@
import BottomBar from './BottomBar';
import SideBar from './SideBar';
import BrandLockup from './BrandLockup';
import TopBar from './TopBar';
export { BottomBar, SideBar, TopBar };
export { BottomBar, BrandLockup, TopBar };
+12 -10
View File
@@ -3,17 +3,19 @@
*/
/**
* SideBar configuration
* The app renders inside a phone-width frame at **every** viewport — there is no desktop
* layout, and deliberately so: Balinyaar's users (families arranging care, nurses between
* visits) are on phones, and one layout means one set of states to design and verify.
* A wider window gets more canvas around the frame, never a wider app.
*
* `components/config.ts`'s `CONTENT_MAX_WIDTH` mirrors this number — a page column can
* never be wider than the frame it lives in.
*/
export const SIDE_BAR_WIDTH = '240px';
export const APP_FRAME_MAX_WIDTH = 480;
/**
* TopBar configuration
* TopBar configuration — one height at every viewport (the frame never changes width, so the
* old mobile/desktop split had nothing left to switch on). The bar sits *inside* the frame as a
* normal flex row rather than `position: fixed`, so no page needs a matching top offset.
*/
export const TOP_BAR_MOBILE_HEIGHT = '56px';
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
/**
* Customer shell's desktop top-nav row height (the ≥md replacement for the mobile BottomBar).
*/
export const TOP_NAV_DESKTOP_HEIGHT = '48px';
export const TOP_BAR_HEIGHT = 56;
+9 -8
View File
@@ -16,7 +16,7 @@ interface TitleEntry {
* static baseline.
*/
const TITLE_ENTRIES: TitleEntry[] = [
// Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS)
// Customer — pushed routes only; a shell's own tab paths render the brand lockup instead (MobileShell)
{ path: ROUTES.SEARCH, key: 'search' },
{ path: ROUTES.SEARCH_RESULTS, key: 'search' },
{ path: ROUTES.SEARCH_NURSE, key: 'search' },
@@ -41,6 +41,9 @@ const TITLE_ENTRIES: TitleEntry[] = [
{ path: ROUTES.NURSE_EARNINGS, key: 'earnings' },
{ path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' },
{ path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' },
{ path: ROUTES.NURSE_PRACTICE, key: 'group_profession' },
{ path: ROUTES.NURSE_FINANCE, key: 'group_finance' },
{ path: ROUTES.NURSE_MORE, key: 'more' },
{ path: ROUTES.NURSE, key: 'dashboard' },
// Admin
{ path: ROUTES.ADMIN_VERIFICATION, key: 'verification' },
@@ -55,11 +58,16 @@ const TITLE_ENTRIES: TitleEntry[] = [
{ path: ROUTES.ADMIN_ROLES, key: 'roles' },
{ path: ROUTES.ADMIN_USERS, key: 'users' },
{ path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' },
{ path: ROUTES.ADMIN_TRUST, key: 'group_trust' },
{ path: ROUTES.ADMIN_FINANCE, key: 'group_finance' },
{ path: ROUTES.ADMIN_SUPPORT, key: 'group_support' },
{ path: ROUTES.ADMIN_SYSTEM, key: 'group_system' },
{ path: ROUTES.ADMIN, key: 'overview' },
// Partner
{ path: ROUTES.PARTNER_NURSES, key: 'partner_nurses' },
{ path: ROUTES.PARTNER_BOOKINGS, key: 'partner_bookings' },
{ path: ROUTES.PARTNER_SETTLEMENT, key: 'partner_settlement' },
{ path: ROUTES.PARTNER_MORE, key: 'more' },
{ path: ROUTES.PARTNER, key: 'partner_home' },
].sort((a, b) => b.path.length - a.path.length);
@@ -69,13 +77,6 @@ function findTitleEntry(pathname: string): TitleEntry | undefined {
);
}
/** The customer shell's 5 root tabs — these render the brand lockup, never a title. */
export const CUSTOMER_ROOT_TABS: string[] = [ROUTES.HOME, ROUTES.BOOKINGS, ROUTES.PATIENTS, ROUTES.WALLET, ROUTES.PROFILE];
export function isCustomerRootTab(pathname: string): boolean {
return CUSTOMER_ROOT_TABS.includes(pathname);
}
const PageTitleOverrideContext = createContext<string | null>(null);
const SetPageTitleOverrideContext = createContext<(title: string | null) => void>(() => {});