manual improvement 1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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, 3–5 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>(() => {});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user