ui phase 2

This commit is contained in:
hamid
2026-07-17 19:05:17 +03:30
parent 370c1beefa
commit 222856d600
42 changed files with 1354 additions and 451 deletions
@@ -4,11 +4,13 @@ import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { ActorSwitcher } from '@/layout';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth';
import { useMe, useLogout } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
@@ -167,6 +169,31 @@ const CustomerProfileForm: FunctionComponent<{
</AppButton>
</Stack>
</Paper>
<Divider />
{/* Account affordances: sign-out has no home anywhere in the customer shell yet (a full hub
redesign is deferred to phase 9) — one labeled row is enough for now. */}
<Stack sx={{ gap: 1.5 }}>
<ActorSwitcher target="nurse" />
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('app_language')}
</Typography>
<LocaleSwitcher />
</Stack>
<SignOutRow />
</Stack>
</Box>
);
};
const SignOutRow: FunctionComponent = () => {
const t = useTranslations('profile');
const { mutate: logout, isPending } = useLogout();
return (
<AppButton variant="text" color="error" startIcon="logout" onClick={() => logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}>
{t('sign_out')}
</AppButton>
);
};
@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import ProfileSummary from './ProfileSummary';
function renderSummary(props: Partial<React.ComponentProps<typeof ProfileSummary>> = {}) {
return render(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" {...props} />
</ThemeProvider>,
);
}
describe('<ProfileSummary/> component', () => {
it('renders the display name, phone, and role label', () => {
renderSummary({ phone: '0912*****33', roleLabel: 'پرستار' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('0912*****33')).toBeInTheDocument();
expect(screen.getByText('پرستار')).toBeInTheDocument();
});
it('renders a TrustBadge only when trustState is set', () => {
const { rerender } = renderSummary();
expect(screen.queryByText('badge_verified')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" trustState="verified" />
</ThemeProvider>,
);
expect(screen.getByText('badge_verified')).toBeInTheDocument();
});
it('renders skeleton placeholders instead of text while loading', () => {
const { container } = renderSummary({ loading: true, phone: '0912*****33' });
expect(screen.queryByText('سارا احمدی')).not.toBeInTheDocument();
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
});
it('renders the compact horizontal chip variant', () => {
const { container } = renderSummary({ compact: true, roleLabel: 'مالی' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('مالی')).toBeInTheDocument();
expect(container.querySelector('.MuiAvatar-root')).toHaveStyle({ width: '32px', height: '32px' });
});
});
@@ -0,0 +1,95 @@
import { FunctionComponent } from 'react';
import { Avatar, Skeleton, Stack, Typography } from '@mui/material';
import TrustBadge from '../TrustBadge';
import type { BadgeState } from '@/services/verification/types';
export interface ProfileSummaryProps {
/** Already-resolved display name (caller composes first/last name — i18n-free). */
displayName: string;
/** The server-masked phone (e.g. `0912*****33`) — rendered as a `dir="ltr"` island. */
phone?: string;
/** Already-translated role/fine-grained-role label (e.g. "پرستار", "مالی"). */
roleLabel?: string;
avatarUrl?: string | null;
/** Renders a `TrustBadge` next to the name when set (nurse identity only). */
trustState?: BadgeState;
/** True while the identity is still resolving — renders skeleton placeholders instead of text. */
loading?: boolean;
/** Dense horizontal chip form for the admin/partner TopBar identity slot; default is the vertical card. */
compact?: boolean;
}
/**
* The one identity card for authenticated chrome — avatar, name, masked phone, role label, and an
* optional `TrustBadge`. Replaces the starter `UserInfo` (`user?: any`, eternal "Current User").
* Presentational: the caller (each shell) sources data from `useMe`/the profiles domain and passes
* it down, so this component never fetches on its own.
* @component ProfileSummary
*/
const ProfileSummary: FunctionComponent<ProfileSummaryProps> = ({
displayName,
phone,
roleLabel,
avatarUrl,
trustState,
loading = false,
compact = false,
}) => {
const avatarSize = compact ? 32 : 56;
if (loading) {
return (
<Stack direction={compact ? 'row' : 'column'} sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
<Skeleton variant="circular" width={avatarSize} height={avatarSize} />
<Stack sx={{ alignItems: compact ? 'flex-start' : 'center', gap: 0.5, minWidth: 0 }}>
<Skeleton variant="text" width={compact ? 80 : 120} />
{!compact && <Skeleton variant="text" width={90} />}
</Stack>
</Stack>
);
}
const avatar = <Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />;
if (compact) {
return (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
{avatar}
<Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{displayName}
</Typography>
{roleLabel && (
<Typography variant="caption" noWrap sx={{ color: 'text.secondary', lineHeight: 1.2 }}>
{roleLabel}
</Typography>
)}
</Stack>
</Stack>
);
}
return (
<Stack sx={{ alignItems: 'center', gap: 1, width: '100%', textAlign: 'center' }}>
{avatar}
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{displayName}
</Typography>
{trustState && <TrustBadge state={trustState} />}
</Stack>
{phone && (
<Typography variant="body2" dir="ltr" sx={{ color: 'text.secondary' }}>
{phone}
</Typography>
)}
{roleLabel && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{roleLabel}
</Typography>
)}
</Stack>
);
};
export default ProfileSummary;
@@ -0,0 +1,4 @@
import ProfileSummary from './ProfileSummary';
export default ProfileSummary;
export type { ProfileSummaryProps } from './ProfileSummary';
@@ -1,41 +0,0 @@
import { Avatar, Stack, Typography } from '@mui/material';
interface UserInfoProps {
className?: string;
showAvatar?: boolean;
user?: any;
}
/**
* Renders User info with Avatar
* @component UserInfo
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
* @param {object} [user] - logged user data {name, email, avatar...}
*/
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
const srcAvatar = user?.avatar ? user?.avatar : undefined;
const userPhoneOrEmail = user?.phone || (user?.email as string);
return (
<Stack sx={{ alignItems: 'center', minHeight: 'fit-content', marginBottom: 2 }} {...restOfProps}>
{showAvatar ? (
<Avatar
sx={{
width: 64,
height: 64,
fontSize: '3rem',
}}
alt={fullName || 'User Avatar'}
src={srcAvatar}
/>
) : null}
<Typography sx={{ mt: 1 }} variant="h6">
{fullName || 'Current User'}
</Typography>
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
</Stack>
);
};
export default UserInfo;
-4
View File
@@ -1,4 +0,0 @@
import UserInfo from './UserInfo';
export { UserInfo };
export default UserInfo;
-1
View File
@@ -34,7 +34,6 @@ interface RoleGuardProps {
* @component RoleGuard
*/
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
return children;
const t = useTranslations('auth');
const router = useRouter();
const locale = useLocale();
@@ -99,6 +99,7 @@ import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
import ShareIcon from '@mui/icons-material/ShareRounded';
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
import LanguageIcon from '@mui/icons-material/TranslateRounded';
/**
* List of all available Icon names
@@ -202,6 +203,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
share: ShareIcon,
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
};
/**
@@ -0,0 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
const replace = jest.fn();
jest.mock('next-intl', () => ({
useLocale: () => 'fa',
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${params.locale}` : key,
}));
jest.mock('@/i18n/navigation', () => ({
usePathname: () => '/nurse/earnings',
useRouter: () => ({ replace }),
}));
import LocaleSwitcher from './LocaleSwitcher';
describe('<LocaleSwitcher/> component', () => {
beforeEach(() => replace.mockClear());
it('renders a single icon button', () => {
render(
<ThemeProvider>
<LocaleSwitcher />
</ThemeProvider>,
);
expect(screen.getAllByRole('button')).toHaveLength(1);
});
it('replaces the current path with the other locale on click, preserving the route', () => {
render(
<ThemeProvider>
<LocaleSwitcher />
</ThemeProvider>,
);
fireEvent.click(screen.getByRole('button'));
expect(replace).toHaveBeenCalledWith('/nurse/earnings', { locale: 'en' });
});
});
@@ -0,0 +1,32 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import type { Locale } from '@/i18n/routing';
import AppIconButton from '../AppIconButton';
const LOCALE_LABEL: Record<Locale, string> = { fa: 'فارسی', en: 'English' };
/**
* Switches between `fa`/`en` while preserving the current route — `router.replace(pathname,
* { locale })` via the `@/i18n/navigation` wrapper, so a deep link (e.g. `/fa/nurse/earnings`)
* lands on the same page in the other locale rather than resetting to home.
* @component LocaleSwitcher
*/
const LocaleSwitcher: FunctionComponent = () => {
const locale = useLocale() as Locale;
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('common');
const nextLocale: Locale = locale === 'fa' ? 'en' : 'fa';
return (
<AppIconButton
icon="language"
title={t('switch_locale', { locale: LOCALE_LABEL[nextLocale] })}
onClick={() => router.replace(pathname, { locale: nextLocale })}
/>
);
};
export default LocaleSwitcher;
@@ -0,0 +1,3 @@
import LocaleSwitcher from './LocaleSwitcher';
export default LocaleSwitcher;
+2
View File
@@ -16,6 +16,7 @@ import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
import LocaleSwitcher from './LocaleSwitcher';
export {
ErrorBoundary,
@@ -36,6 +37,7 @@ export {
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
LocaleSwitcher,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
+2 -2
View File
@@ -1,6 +1,6 @@
export * from './common';
import UserInfo from './UserInfo';
import ProfileSummary from './ProfileSummary';
import PlaceholderScreen from './PlaceholderScreen';
import OtpInput from './OtpInput';
import PhoneNumberField from './PhoneNumberField';
@@ -35,7 +35,7 @@ import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader';
export {
UserInfo,
ProfileSummary,
PlaceholderScreen,
OtpInput,
PhoneNumberField,
@@ -8,7 +8,7 @@ import NotificationBellView from './NotificationBellView';
export interface NotificationBellProps {
/** The shell the bell lives in — decides which notification center it opens. */
role: 'customer' | 'nurse';
role: 'customer' | 'nurse' | 'admin';
}
/**
+6 -3
View File
@@ -161,9 +161,12 @@ export const ticketsBasePath = (role: 'customer' | 'nurse'): string =>
export const ticketThreadPath = (role: 'customer' | 'nurse', ticketId: number | string): string =>
role === 'nurse' ? nurseTicketThreadPath(ticketId) : customerTicketThreadPath(ticketId);
/** The notification center for an actor (customer vs nurse shell). */
export const notificationsPath = (role: 'customer' | 'nurse'): string =>
role === 'nurse' ? ROUTES.NURSE_NOTIFICATIONS : ROUTES.NOTIFICATIONS;
/** The notification center for an actor (customer / nurse / admin shell). */
export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string => {
if (role === 'nurse') return ROUTES.NURSE_NOTIFICATIONS;
if (role === 'admin') return ROUTES.ADMIN_NOTIFICATIONS;
return ROUTES.NOTIFICATIONS;
};
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
+10
View File
@@ -0,0 +1,10 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
/**
* The one locale-aware navigation surface. `Link`/`useRouter` add the active locale prefix
* automatically and `usePathname` strips it, so chrome code compares/builds paths against the
* unprefixed `ROUTES.*` constants directly no manual `/${locale}` prefixing, and clicking a
* `Link` never round-trips through the locale-detection middleware.
*/
export const { Link, usePathname, useRouter, redirect, getPathname } = createNavigation(routing);
+38 -19
View File
@@ -1,46 +1,65 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { NotificationBell } from '@/components/notifications';
import { ProfileSummary } from '@/components';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import { LinkToPage } from '@/utils';
import { useMe } from '@/services/auth';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
/**
* Admin / backoffice shell the desktop ops console (f15). The sidebar is **role-gated**: each console
* appears only when the current admin role can act on it (`useAdminCapabilities`). This is a display
* convenience the server enforces every command's scope so a `support` admin never sees the payout or
* refund controls, a `moderation` admin only sees moderation, etc. (phase §3 "Routing & RBAC", §5).
* Admin / backoffice shell the desktop ops console (f15). The sidebar is **sectioned**
* (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current
* admin role can act on it (`useAdminCapabilities`), unchanged from before grouping never adds,
* removes, or loosens a gate. The TopBar carries a page title, the notification bell (widened to
* the `'admin'` role), and a compact identity chip showing the admin's fine-grained role.
* @layout AdminLayout
*/
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
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 items: Array<LinkToPage & { show: boolean }> = [
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true },
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', show: caps.canVerify },
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', show: caps.canManageTickets },
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', show: caps.canPayout },
{ title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', show: caps.canModerate },
{ title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', show: caps.canConfig },
{ title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', show: caps.canConfig },
{ title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', show: caps.canManageAlerts },
{ title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', show: caps.canViewAudit },
{ title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', show: caps.canManagePartners },
{ title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', show: caps.canManageRoles },
{ title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications', show: true },
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', group: groupTrust, show: caps.canVerify },
{ title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', group: groupTrust, show: caps.canModerate },
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', group: groupFinance, show: caps.canPayout },
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', group: groupSupport, show: caps.canManageTickets },
{ title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', group: groupSupport, show: caps.canManageAlerts },
{ title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', group: groupSystem, show: caps.canConfig },
{ title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', group: groupSystem, show: caps.canConfig },
{ title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', group: groupSystem, show: caps.canViewAudit },
{ title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', group: groupSystem, show: caps.canManagePartners },
{ title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users', group: groupSystem, show: caps.canManageRoles },
{ title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', group: groupSystem, show: caps.canManageRoles },
];
return items.filter((i) => i.show).map(({ show: _show, ...rest }) => rest);
return items.filter((item) => item.show).map(({ show: _show, ...rest }) => rest);
}, [t, caps]);
const primaryRoleCode = caps.roles[0];
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
title={tShell('admin_console')}
variant="sidebarPersistentOnDesktop"
headerActions={<NotificationBell role="admin" />}
identity={
me && primaryRoleCode ? (
<ProfileSummary
compact
displayName={[me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone}
roleLabel={ta(`role_${primaryRoleCode}`)}
/>
) : undefined
}
>
{children}
</TopBarAndSideBarLayout>
+109 -59
View File
@@ -1,33 +1,94 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { Box, Stack } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { ErrorBoundary } from '@/components';
import AppIconButton from '@/components/common/AppIconButton';
import { Box, Stack, Tab, Tabs } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
import { NotificationBell } from '@/components/notifications';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useIsMobile } from '@/hooks';
import { usePathname, useRouter } from '@/i18n/navigation';
import { TopBar, BottomBar } from './components';
import { DarkModeToggleButton } from './components/DarkModeButton';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
import BrandLockup from './components/BrandLockup';
import { matchActivePath } from './matchActivePath';
import { PageTitleProvider, isCustomerRootTab, useRouteTitle } from './routeTitle';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT, TOP_NAV_DESKTOP_HEIGHT } from './config';
/** The ≥md replacement for the mobile BottomBar — the same 5 tabs, inline in the header. */
const CustomerDesktopNav: FunctionComponent<{ items: Array<LinkToPage> }> = ({ items }) => {
const pathname = usePathname();
const router = useRouter();
const activePath = matchActivePath(pathname, items.map((item) => item.path)) ?? false;
return (
<Box sx={{ display: { xs: 'none', md: 'block' }, borderTop: '1px solid', borderColor: 'divider' }}>
<Tabs
value={activePath}
onChange={(_event, value: string) => router.push(value)}
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT, px: 2 }}
>
{items.map((item) => (
<Tab
key={item.path}
value={item.path}
label={item.title}
icon={item.icon ? <AppIcon icon={item.icon} size={18} /> : undefined}
iconPosition="start"
sx={{ minHeight: TOP_NAV_DESKTOP_HEIGHT }}
/>
))}
</Tabs>
</Box>
);
};
const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> = ({ bottomNavItems }) => {
const t = useTranslations('nav');
const tc = useTranslations('common');
const pathname = usePathname();
const router = useRouter();
const title = useRouteTitle();
const onRootTab = isCustomerRootTab(pathname);
return (
<TopBar
align={onRootTab ? 'center' : 'start'}
startNode={
onRootTab ? (
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
/>
) : (
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
)
}
titleNode={onRootTab ? <BrandLockup /> : undefined}
title={onRootTab ? undefined : (title ?? undefined)}
endNode={
<Stack direction="row" sx={{ alignItems: 'center' }}>
<NotificationBell role="customer" />
<DarkModeToggleButton />
</Stack>
}
secondaryRow={<CustomerDesktopNav items={bottomNavItems} />}
/>
);
};
/**
* Customer (family) app shell the primary, mobile-first experience.
* A slim TopBar (a "Support / My Tickets" action at the start, the notification bell + dark-mode toggle at
* the end), a scrollable content column constrained to reading width, and the 5-tab BottomBar
* (Home/Bookings/Patients/Wallet/Profile) from the wireframe.
* Customer (family) app shell the primary, mobile-first experience. A contextual TopBar (brand
* lockup on the 5 root tabs, page title + back chevron on pushed routes), a reading-width content
* column on a `background.default` canvas, and the 5-tab `BottomBar` on mobile replaced by an
* inline desktop top-nav (`CustomerDesktopNav`) at `≥md`, where the mobile tab bar hides entirely.
* @layout CustomerLayout
*/
const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
const tChrome = useTranslations('routeChrome');
const onMobile = useIsMobile();
const router = useRouter();
const locale = useLocale();
const bottomNavItems: Array<LinkToPage> = useMemo(
() => [
@@ -41,53 +102,42 @@ const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
);
return (
<Stack sx={{ height: '100dvh' }}>
<Stack component="header">
<TopBar
title={tShell('customer_app')}
startNode={
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(`/${locale}${ROUTES.SUPPORT_TICKETS}`)}
/>
}
endNode={
<Stack direction="row" sx={{ alignItems: 'center' }}>
<NotificationBell role="customer" />
<DarkModeToggleButton />
</Stack>
}
/>
</Stack>
<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%',
maxWidth: CONTENT_MAX_WIDTH,
mx: 'auto',
px: 2,
py: 2,
// AppBar is position: fixed — offset content by the top-bar height.
pt: `calc(${onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT} + 8px)`,
}}
>
<ErrorBoundary
name="Customer"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
<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)`,
},
}}
>
{children}
</ErrorBoundary>
</Box>
<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')}
>
{children}
</ErrorBoundary>
</Box>
</Box>
<BottomBar items={bottomNavItems} />
</Stack>
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<BottomBar items={bottomNavItems} />
</Box>
</Stack>
</PageTitleProvider>
);
};
+62 -14
View File
@@ -1,33 +1,64 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { NotificationBell } from '@/components/notifications';
import { ProfileSummary } from '@/components';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
import { BottomBar } from './components';
import ActorSwitcher from './components/ActorSwitcher';
/** A "more" bottom-nav tab isn't a real route — this sentinel never matches a pathname. */
const MORE_TAB_PATH = '#more';
/**
* Nurse app shell the "نمای پرستار" experience (verification, dashboard, EVV visits).
* Uses the shared TopBar + SideBar engine: a temporary drawer on mobile, persistent on
* desktop. Nav is role-scoped to the nurse routes.
* Nurse app shell the "نمای پرستار" workspace. Sectioned sidebar (امروز/حرفهٔ من/مالی/پشتیبانی)
* with a real `ProfileSummary` identity card (name, masked phone, own `TrustBadge`), plus a 5-tab
* mobile bottom nav whose «بیشتر» tab opens the same sidebar drawer for the remaining items.
* @layout NurseLayout
*/
const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
const sidebarItems: Array<LinkToPage> = useMemo(
() => [
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const identityLoading = !me;
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const sidebarItems: Array<LinkToPage> = useMemo(() => {
const groupToday = t('group_today');
const groupProfession = t('group_profession');
const groupFinance = t('group_finance');
const groupSupport = t('group_support');
return [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard', group: groupToday },
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests', group: groupToday },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits', group: groupToday },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile', group: groupProfession },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services', group: groupProfession },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage', group: groupProfession },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance },
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support', group: groupSupport },
];
}, [t]);
const mobileTabs = useMemo(
(): Array<LinkToPage> => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support' },
{ title: t('more'), path: MORE_TAB_PATH, icon: 'menu' },
],
[t]
);
@@ -35,9 +66,26 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
title={tShell('nurse_app')}
variant="sidebarPersistentOnDesktop"
headerActions={<NotificationBell role="nurse" />}
sidebarIdentity={
<Stack sx={{ gap: 1.5 }}>
<ProfileSummary
displayName={displayName}
phone={me?.phone}
avatarUrl={nurseProfile?.avatarUrl}
trustState={ownBadgeState(verification)}
loading={identityLoading}
/>
<ActorSwitcher target="customer" />
</Stack>
}
mobileBottomBar={(openMobileSidebar) => (
<BottomBar
items={mobileTabs.map((item) =>
item.path === MORE_TAB_PATH ? { ...item, onSelect: openMobileSidebar } : item
)}
/>
)}
>
{children}
</TopBarAndSideBarLayout>
+8 -7
View File
@@ -1,20 +1,22 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { ProfileSummary } from '@/components';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useMyPartnerCenter } from '@/services/partnerCenter';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
/**
* Partner-center portal shell (f15) a **separate authz scope** from the Balinyaar admin console. A
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced tenancy).
* Same desktop sidebar engine as the admin shell, its own nav. The settlement tab always appears; the
* settlement page itself renders the "via Balinyaar" state for a non-merchant-of-record center.
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced
* tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name
* (skeleton while resolving) the page-level access-denied state (403/404) stays where it is.
* @layout PartnerLayout
*/
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
const { data: center, isLoading } = useMyPartnerCenter();
const sidebarItems: Array<LinkToPage> = useMemo(
() => [
@@ -23,14 +25,13 @@ const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
{ title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' },
{ title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' },
],
[t],
[t]
);
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
title={tShell('partner_console')}
variant="sidebarPersistentOnDesktop"
identity={<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />}
>
{children}
</TopBarAndSideBarLayout>
+33 -26
View File
@@ -1,38 +1,45 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { LinkToPage } from '@/utils';
import { useIsMobile } from '@/hooks';
import { BottomBar } from './components';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
import { BOTTOM_BAR_DESKTOP_VISIBLE } from './config';
const TITLE_PUBLIC = 'Unauthorized - Balinyaar'; // Title for pages without/before authentication
import { AppIcon, ErrorBoundary } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { DarkModeToggleButton } from './components/DarkModeButton';
/**
* SideBar navigation items with links for Public Layout
*/
const SIDE_BAR_ITEMS: Array<LinkToPage> = [];
/**
* BottomBar navigation items with links for Public Layout
*/
const BOTTOM_BAR_ITEMS: Array<LinkToPage> = [];
/**
* Renders "Public Layout" composition
* Unauthenticated shell a minimal centered brand frame (logo + locale switcher + dark toggle,
* no sidebar, no bottom bar). The step content (`AuthCard`, which renders its own larger `BrandMark`)
* is the visual focus; this chrome stays a slim corner strip so the two marks don't compete.
* @layout PublicLayout
*/
const PublicLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const onMobile = useIsMobile();
const bottomBarVisible = onMobile || BOTTOM_BAR_DESKTOP_VISIBLE;
const title = TITLE_PUBLIC;
const tChrome = useTranslations('routeChrome');
return (
<TopBarAndSideBarLayout sidebarItems={SIDE_BAR_ITEMS} title={title} variant="sidebarAlwaysTemporary">
{children}
<Stack component="footer">{bottomBarVisible && <BottomBar items={BOTTOM_BAR_ITEMS} />}</Stack>
</TopBarAndSideBarLayout>
<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 }}>
<ErrorBoundary
name="Public"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
{children}
</ErrorBoundary>
</Stack>
</Stack>
);
};
+75 -90
View File
@@ -1,117 +1,102 @@
'use client';
import { FunctionComponent, ReactNode, useMemo, useState } from 'react';
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Stack, StackProps } from '@mui/material';
import { Box, Stack, useTheme } from '@mui/material';
import { AppIconButton, ErrorBoundary } from '@/components';
import { LinkToPage } from '@/utils';
import { useIsMobile } from '@/hooks';
import { TopBar } from './components';
import SideBar, { SideBarProps } from './components/SideBar';
import SideBar from './components/SideBar';
import { DarkModeToggleButton } from './components/DarkModeButton';
import {
SIDE_BAR_DESKTOP_ANCHOR,
SIDE_BAR_MOBILE_ANCHOR,
SIDE_BAR_WIDTH,
TOP_BAR_DESKTOP_HEIGHT,
TOP_BAR_MOBILE_HEIGHT,
} from './config';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
import { useRouteTitle } from './routeTitle';
interface Props extends StackProps {
interface Props {
sidebarItems: Array<LinkToPage>;
title: string;
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
/** Rendered below the sidebar's brand header (the nurse shell's `ProfileSummary` identity card). */
sidebarIdentity?: ReactNode;
/** Rendered in the TopBar, before the header controls (the admin/partner identity chip). */
identity?: ReactNode;
/** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */
headerActions?: ReactNode;
/**
* A mobile-only (`<md`) bottom nav rendered under the content column (the nurse shell's 5-tab
* bar). Receives a callback that opens the same mobile drawer the TopBar's menu button opens, so
* a "more" tab can reveal the rest of the sidebar without a second drawer.
*/
mobileBottomBar?: (openMobileSidebar: () => void) => ReactNode;
}
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant, headerActions }) => {
/**
* The shared engine behind the nurse/admin/partner shells: a fixed `TopBar` showing the current
* page title (`useRouteTitle`, longest-prefix over `ROUTES.*`) plus a `SideBar` that renders as a
* mobile overlay and a desktop in-flow column at once (see `SideBar` for why that kills the SSR
* flash). The sidebar and the main column are flex siblings in a row native RTL flexbox puts the
* sidebar at the reading-start side with no manual offset math.
* @layout TopBarAndSideBarLayout
*/
const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
children,
sidebarItems,
sidebarIdentity,
identity,
headerActions,
mobileBottomBar,
}) => {
const tChrome = useTranslations('routeChrome');
const [sidebarVisible, setSidebarVisible] = useState(false);
const onMobile = useIsMobile();
const tCommon = useTranslations('common');
const theme = useTheme();
const title = useRouteTitle();
const [mobileOpen, setMobileOpen] = useState(false);
const sidebarProps = useMemo((): Partial<SideBarProps> => {
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
let open = sidebarVisible;
let sidebarVariant: SideBarProps['variant'] = 'temporary';
switch (variant) {
case 'sidebarAlwaysTemporary':
break;
case 'sidebarPersistentOnDesktop':
open = onMobile ? sidebarVisible : true;
sidebarVariant = onMobile ? 'temporary' : 'persistent';
break;
case 'sidebarAlwaysPersistent':
open = true;
sidebarVariant = 'persistent';
break;
}
return { anchor, open, variant: sidebarVariant };
}, [onMobile, sidebarVisible, variant]);
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
const stackStyles = useMemo(
() => ({
minHeight: '100vh',
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
paddingLeft:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
? SIDE_BAR_WIDTH
: undefined,
paddingRight:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right')
? SIDE_BAR_WIDTH
: undefined,
}),
[onMobile, sidebarProps]
);
const onSideBarOpen = () => { if (!sidebarVisible) setSidebarVisible(true); };
const onSideBarClose = () => { if (sidebarVisible) setSidebarVisible(false); };
const LogoButton = (
<AppIconButton
icon="logo"
title={sidebarProps.open ? undefined : 'Open Sidebar'}
to={sidebarProps.open ? '/' : undefined}
onClick={sidebarProps.open ? undefined : onSideBarOpen}
/>
);
/*
* DarkModeToggleButton is a self-contained component that subscribes to
* useColorScheme() on its own. This layout component never reads the color
* scheme and therefore never re-renders when the theme switches. The optional
* headerActions (e.g. the notification bell) travel alongside it.
*/
const headerControls = (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<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>
);
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
? { startNode: LogoButton, endNode: headerControls }
: { startNode: headerControls, endNode: LogoButton };
return (
<Stack sx={stackStyles}>
<Stack component="header">
<TopBar startNode={startNode} title={title} endNode={endNode} />
<SideBar items={sidebarItems} onClose={onSideBarClose} {...sidebarProps} />
<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')}
>
{children}
</ErrorBoundary>
</Stack>
</Stack>
<Stack
component="main"
sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}
>
<ErrorBoundary
name="Content"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
{children}
</ErrorBoundary>
</Stack>
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
</Stack>
);
};
@@ -0,0 +1,58 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
const push = jest.fn();
let mockRoles: string[] = [];
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
jest.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push }),
}));
jest.mock('@/context/auth', () => ({
useAuth: () => [{ currentUser: { roles: mockRoles } }],
}));
import ActorSwitcher from './ActorSwitcher';
describe('<ActorSwitcher/> component', () => {
beforeEach(() => {
push.mockClear();
mockRoles = [];
});
it('renders nothing for a single-role session', () => {
mockRoles = ['nurse'];
const { container } = render(
<ThemeProvider>
<ActorSwitcher target="customer" />
</ThemeProvider>,
);
expect(container).toBeEmptyDOMElement();
});
it('navigates to the nurse app for a dual-role session', () => {
mockRoles = ['customer', 'nurse'];
render(
<ThemeProvider>
<ActorSwitcher target="nurse" />
</ThemeProvider>,
);
fireEvent.click(screen.getByText('switch_to_nurse'));
expect(push).toHaveBeenCalledWith('/nurse');
});
it('navigates to the customer app for a dual-role session', () => {
mockRoles = ['customer', 'nurse'];
render(
<ThemeProvider>
<ActorSwitcher target="customer" />
</ThemeProvider>,
);
fireEvent.click(screen.getByText('switch_to_customer'));
expect(push).toHaveBeenCalledWith('/');
});
});
@@ -0,0 +1,39 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { AppButton } from '@/components';
import { useAuth } from '@/context/auth';
import { APP_ROLES, ROUTES } from '@/constants';
import { useRouter } from '@/i18n/navigation';
export interface ActorSwitcherProps {
/** The shell this switcher navigates *to* — 'nurse' inside the customer app, 'customer' inside the nurse app. */
target: 'customer' | 'nurse';
}
/**
* Navigation-only "نمای پرستار ⇄ اپلیکیشن خانواده" affordance for a dual customer+nurse session
* (`SessionUser.roles` already carries both). Renders nothing for a single-role session `RoleGuard`
* remains the sole "which app" authority; this only moves the user between two apps they already have.
* @component ActorSwitcher
*/
const ActorSwitcher: FunctionComponent<ActorSwitcherProps> = ({ target }) => {
const [state] = useAuth();
const roles = state.currentUser?.roles ?? [];
const isDualRole = roles.includes(APP_ROLES.CUSTOMER) && roles.includes(APP_ROLES.NURSE);
const t = useTranslations('shell');
const router = useRouter();
if (!isDualRole) return null;
const destination = target === 'nurse' ? ROUTES.NURSE : ROUTES.HOME;
const label = target === 'nurse' ? t('switch_to_nurse') : t('switch_to_customer');
return (
<AppButton variant="outlined" color="primary" fullWidth onClick={() => router.push(destination)}>
{label}
</AppButton>
);
};
export default ActorSwitcher;
+18 -27
View File
@@ -1,50 +1,41 @@
'use client';
import { FunctionComponent, useCallback, useMemo } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useLocale } from 'next-intl';
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
import { LinkToPage } from '@/utils';
import { AppIcon } from '@/components';
import { usePathname, useRouter } from '@/i18n/navigation';
import { matchActivePath } from '../matchActivePath';
interface Props {
items: Array<LinkToPage>;
}
/** Prefixes an app-relative path with the active locale (e.g. `/bookings` → `/fa/bookings`). */
function withLocale(locale: string, path: string) {
return path === '/' ? `/${locale}` : `/${locale}${path}`;
}
/**
* Shared horizontal navigation bar (customer app tabs) built on MUI BottomNavigation.
* Locale-aware: it highlights the active tab from the real pathname (via usePathname,
* not the global `location`) and pushes locale-prefixed routes.
* Shared horizontal navigation bar (customer app tabs, and the nurse shell's mobile 5-tab nav)
* built on MUI BottomNavigation. Locale-aware via the `@/i18n/navigation` wrapper no manual
* `/${locale}` prefixing and highlights the active tab with the shared `matchActivePath`
* longest-prefix helper, so a nested route (e.g. `/patients/123`) still selects its parent tab.
* @component BottomBar
*/
const BottomBar: FunctionComponent<Props> = ({ items }) => {
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
// The active tab is the longest item path that prefixes the current pathname,
// so `/patients/123` still selects the `/patients` tab and `/` never over-matches.
const activePath = useMemo(() => {
const matches = items
.map((item) => item.path)
.filter((path): path is string => Boolean(path))
.filter((path) => {
const prefixed = withLocale(locale, path);
return path === '/' ? pathname === prefixed : pathname.startsWith(prefixed);
})
.sort((a, b) => b.length - a.length);
return matches[0] ?? false;
}, [items, pathname, locale]);
const activePath = useMemo(
() => matchActivePath(pathname, items.map((item) => item.path)) ?? false,
[items, pathname]
);
const onNavigationChange = useCallback(
(_event: unknown, newValue: string) => {
router.push(withLocale(locale, newValue));
const item = items.find((candidate) => candidate.path === newValue);
if (item?.onSelect) {
item.onSelect();
return;
}
router.push(newValue);
},
[router, locale]
[items, router]
);
return (
@@ -52,7 +43,7 @@ const BottomBar: FunctionComponent<Props> = ({ items }) => {
elevation={3}
square
component="nav"
sx={{ borderTop: '1px solid', borderColor: 'divider' }}
sx={{ borderTop: '1px solid', borderColor: 'divider', paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<BottomNavigation value={activePath} showLabels onChange={onNavigationChange}>
{items.map(({ title, path, icon }) => (
@@ -0,0 +1,28 @@
import { FunctionComponent } from 'react';
import { Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import LogoLockup from '@/components/common/AppIcon/icons/LogoLockup';
interface Props {
size?: number;
}
/**
* Compact, horizontal brand lockup for chrome (the customer home header + the sidebar shells'
* drawer header) the vertical, larger `BrandMark` is for the auth splash only.
* @component BrandLockup
*/
const BrandLockup: FunctionComponent<Props> = ({ size = 28 }) => {
const t = useTranslations('common');
return (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
{/* Decorative — the wordmark already announces "Balinyaar" to AT. */}
<LogoLockup size={size} aria-hidden="true" />
<Typography variant="h6" component="span" noWrap sx={{ fontWeight: 700 }}>
{t('brand')}
</Typography>
</Stack>
);
};
export default BrandLockup;
+77 -62
View File
@@ -1,82 +1,97 @@
import { FunctionComponent, useCallback, MouseEvent } from 'react';
import { Stack, Divider, Drawer, DrawerProps } from '@mui/material';
import { LinkToPage } from '@/utils';
import { useIsAuthenticated, useIsMobile } from '@/hooks';
'use client';
import { FunctionComponent, ReactNode, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { Divider, Drawer, Stack } from '@mui/material';
import { AppButton } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { useIsAuthenticated } from '@/hooks';
import { useLogout } from '@/services/auth';
import { AppIconButton, UserInfo } from '@/components';
import { SIDE_BAR_WIDTH, TOP_BAR_DESKTOP_HEIGHT } from '../config';
import { LinkToPage } from '@/utils';
import { SIDE_BAR_WIDTH } from '../config';
import SideBarNavList from './SideBarNavList';
import BrandLockup from './BrandLockup';
import { DarkModeFormSwitch } from './DarkModeButton';
export interface SideBarProps extends Pick<DrawerProps, 'anchor' | 'className' | 'open' | 'variant' | 'onClose'> {
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 SideBar with Menu and User details
* Renders the sidebar as two Drawers sharing one content tree a `temporary` (overlay) drawer for
* mobile and a `permanent` (in-flow) drawer for desktop, switched purely by CSS breakpoint (`sx`
* `display`). Because the desktop variant is `permanent`, it reserves its own width as a normal flex
* item the caller never computes a matching content offset, and there is no JS `useIsMobile`
* branching to cause a post-hydration layout jump.
*
* The close handler is wired to the nav list only (`SideBarNavList`'s `onClick`), not the whole
* drawer body toggling dark mode, switching locale, or a mis-tap on a divider never closes it.
* @component SideBar
*/
const SideBar: FunctionComponent<SideBarProps> = ({ anchor, open, variant, items, onClose, ...restOfProps }) => {
const SideBar: FunctionComponent<SideBarProps> = ({ items, anchor, mobileOpen, onMobileClose, identity }) => {
const isAuthenticated = useIsAuthenticated();
const onMobile = useIsMobile();
const t = useTranslations('nav');
const { mutate: logout } = useLogout();
const handleAfterLinkClick = useCallback(
(event: MouseEvent) => {
if (variant === 'temporary' && typeof onClose === 'function') {
onClose(event, 'backdropClick');
}
},
[variant, onClose]
const handleNavClick = useCallback(() => onMobileClose(), [onMobileClose]);
const content = (
<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={open}
variant={variant}
slotProps={{
paper: {
sx: {
width: SIDE_BAR_WIDTH,
marginTop: onMobile ? 0 : variant === 'temporary' ? 0 : TOP_BAR_DESKTOP_HEIGHT,
height: onMobile ? '100%' : variant === 'temporary' ? '100%' : `calc(100% - ${TOP_BAR_DESKTOP_HEIGHT})`,
},
},
}}
onClose={onClose}
>
<Stack
sx={{ height: '100%', padding: 2 }}
{...restOfProps}
onClick={handleAfterLinkClick}
<>
<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 } } }}
>
{isAuthenticated && (
<>
<UserInfo showAvatar />
<Divider />
</>
)}
<SideBarNavList items={items} showIcons />
<Divider />
<Stack
sx={{
flexDirection: 'row',
justifyContent: 'space-evenly',
alignItems: 'center',
marginTop: 2,
}}
>
{/* Only DarkModeFormSwitch subscribes to useColorScheme — it's the sole re-render target */}
<DarkModeFormSwitch />
{isAuthenticated && <AppIconButton icon="logout" title="Logout Current User" onClick={() => logout()} />}
</Stack>
</Stack>
</Drawer>
{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>
</>
);
};
@@ -1,41 +1,25 @@
'use client';
import { FunctionComponent, MouseEventHandler } from 'react';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AppIcon } from '@/components';
import { Link } from '@/i18n/navigation';
import { LinkToPage } from '@/utils';
import { usePathname } from 'next/navigation';
interface Props extends LinkToPage {
openInNewTab?: boolean;
selected?: boolean;
onClick?: MouseEventHandler;
}
/**
* Renders Navigation Item for SideBar, detects current url and sets selected state if needed
* Renders a single SideBar navigation item over the locale-aware `Link` (`@/i18n/navigation`)
* `href` is the unprefixed `ROUTES.*` path; the wrapper adds the active locale, so a click is one
* navigation with no middleware redirect hop. `selected` is computed by the caller (`SideBarNavList`)
* via the shared `matchActivePath` helper.
* @component SideBarNavItem
*/
const SideBarNavItem: FunctionComponent<Props> = ({
openInNewTab,
icon,
path,
selected: propSelected = false,
subtitle,
title,
onClick,
}) => {
const pathname = usePathname();
const selected = propSelected || (path && path.length > 1 && pathname.startsWith(path)) || false;
const SideBarNavItem: FunctionComponent<Props> = ({ icon, path, selected = false, subtitle, title, onClick }) => {
return (
<ListItemButton
component={AppLink}
selected={selected}
to={path}
href="" // Hard reset for .href property, otherwise links are always opened in new tab :(
openInNewTab={openInNewTab}
onClick={onClick}
>
<ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}>
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon>
<ListItemText primary={title} secondary={subtitle} />
</ListItemButton>
+32 -14
View File
@@ -1,6 +1,9 @@
import { FunctionComponent, MouseEventHandler } from 'react';
import { Fragment, FunctionComponent, MouseEventHandler, useMemo } from 'react';
import List from '@mui/material/List';
import ListSubheader from '@mui/material/ListSubheader';
import { LinkToPage } from '@/utils';
import { usePathname } from '@/i18n/navigation';
import { matchActivePath } from '../matchActivePath';
import SideBarNavItem from './SideBarNavItem';
interface Props {
@@ -10,24 +13,39 @@ interface Props {
}
/**
* Renders list of Navigation Items inside SideBar
* Renders list of Navigation Items inside SideBar, sectioned into subheaders when consecutive
* items share a `group` (e.g. the nurse workspace's امروز/حرفهٔ من/مالی/پشتیبانی sections);
* ungrouped lists (admin/partner/public) render flat exactly as before.
* @component SideBarNavList
* @param {array} items - list of objects to render as navigation items
* @param {boolean} [showIcons] - icons in navigation items are visible when true
* @param {function} [onAfterLinkClick] - optional callback called when some navigation item was clicked
* @param {function} [onClick] - optional callback called when some navigation item was clicked
*/
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick, ...restOfProps }) => {
const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick }) => {
const pathname = usePathname();
const activePath = useMemo(
() => matchActivePath(pathname, items.map((item) => item.path)),
[pathname, items]
);
return (
<List component="nav" {...restOfProps}>
{items.map(({ icon, path, title }) => (
<SideBarNavItem
key={`${title}-${path}`}
icon={showIcons ? icon : undefined}
path={path}
title={title}
onClick={onClick}
/>
))}
<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}
/>
</Fragment>
);
})}
</List>
);
};
+18 -23
View File
@@ -1,44 +1,39 @@
import { FunctionComponent, ReactNode } from 'react';
import { AppBar, Toolbar, Typography } from '@mui/material';
import { AppBar, Box, Toolbar, Typography } from '@mui/material';
interface Props {
endNode?: ReactNode;
startNode?: ReactNode;
title?: string;
/** Overrides `title` with arbitrary content (e.g. the brand lockup on the customer home). */
titleNode?: ReactNode;
/** 'start' for a breadcrumb-style label (sidebar shells); 'center' for the customer shell. */
align?: 'start' | 'center';
/** An optional second row under the main one (the customer shell's desktop top-nav tabs). */
secondaryRow?: ReactNode;
}
/**
* Renders TopBar composition
* @component TopBar
*/
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', ...restOfProps }) => {
const TopBar: FunctionComponent<Props> = ({ endNode, startNode, title = '', titleNode, align = 'center', secondaryRow }) => {
return (
<AppBar
component="div"
sx={
{
// boxShadow: 'none', // Uncomment to hide shadow
}
}
{...restOfProps}
>
<Toolbar disableGutters sx={{ paddingX: 1 }}>
<AppBar component="div">
<Toolbar disableGutters sx={{ paddingInline: 1, gap: 1 }}>
{startNode}
<Typography
variant="h6"
sx={{
marginX: 1,
flexGrow: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{title}
</Typography>
<Box sx={{ flexGrow: 1, minWidth: 0, textAlign: align }}>
{titleNode ?? (
<Typography variant="h6" component="span" noWrap sx={{ display: 'block' }}>
{title}
</Typography>
)}
</Box>
{endNode}
</Toolbar>
{secondaryRow}
</AppBar>
);
};
+2 -4
View File
@@ -5,8 +5,6 @@
/**
* SideBar configuration
*/
export const SIDE_BAR_MOBILE_ANCHOR = 'right'; // 'right';
export const SIDE_BAR_DESKTOP_ANCHOR = 'left'; // 'right';
export const SIDE_BAR_WIDTH = '240px';
/**
@@ -16,6 +14,6 @@ export const TOP_BAR_MOBILE_HEIGHT = '56px';
export const TOP_BAR_DESKTOP_HEIGHT = '64px';
/**
* BottomBar configuration
* Customer shell's desktop top-nav row height (the md replacement for the mobile BottomBar).
*/
export const BOTTOM_BAR_DESKTOP_VISIBLE = false; // true;
export const TOP_NAV_DESKTOP_HEIGHT = '48px';
+2 -1
View File
@@ -4,5 +4,6 @@ import CustomerLayout from './CustomerLayout';
import NurseLayout from './NurseLayout';
import AdminLayout from './AdminLayout';
import PartnerLayout from './PartnerLayout';
import ActorSwitcher from './components/ActorSwitcher';
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout };
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout, ActorSwitcher };
+34
View File
@@ -0,0 +1,34 @@
import { matchActivePath } from './matchActivePath';
describe('matchActivePath', () => {
const items = ['/nurse', '/nurse/requests', '/nurse/visits'];
it('picks the longest matching prefix, not the root', () => {
expect(matchActivePath('/nurse/requests', items)).toBe('/nurse/requests');
});
it('matches a nested route under a shorter item', () => {
expect(matchActivePath('/nurse/requests/42', items)).toBe('/nurse/requests');
});
it('falls back to the root item on the root route', () => {
expect(matchActivePath('/nurse', items)).toBe('/nurse');
});
it('never matches a sibling route that merely shares a prefix string', () => {
expect(matchActivePath('/nurse-other', items)).toBeUndefined();
});
it('treats "/" as an exact match only', () => {
expect(matchActivePath('/bookings', ['/', '/bookings'])).toBe('/bookings');
expect(matchActivePath('/', ['/', '/bookings'])).toBe('/');
});
it('returns undefined when nothing matches', () => {
expect(matchActivePath('/admin', items)).toBeUndefined();
});
it('ignores undefined paths in the candidate list', () => {
expect(matchActivePath('/nurse/visits', [undefined, ...items])).toBe('/nurse/visits');
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Longest-prefix, winner-takes-all active-path matching shared by the sidebar and every bottom
* bar. Given the current (locale-stripped) pathname and a set of nav-item paths, returns the
* single path that should render as active the longest candidate that prefixes the pathname
* so e.g. `/nurse/requests` lights up "Requests" and not the "Dashboard" root (`/nurse`), and
* `/patients/123` still lights up the `/patients` tab.
*/
export function matchActivePath(pathname: string, paths: ReadonlyArray<string | undefined>): string | undefined {
const isMatch = (path: string) => (path === '/' ? pathname === path : pathname === path || pathname.startsWith(`${path}/`));
return paths
.filter((path): path is string => Boolean(path))
.filter(isMatch)
.sort((a, b) => b.length - a.length)[0];
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { usePathname } from '@/i18n/navigation';
interface TitleEntry {
path: string;
key: string;
}
/**
* Static routetitle table (longest-prefix wins), read from the existing `nav` namespace one
* key per shell console/tab. Dynamic titles (a nurse's name, a booking reference) are layered on
* top via `usePageTitleOverride`; the area phases (4-6, 9) wire those in, this phase ships the
* static baseline.
*/
const TITLE_ENTRIES: TitleEntry[] = [
// Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS)
{ path: ROUTES.SEARCH, key: 'search' },
{ path: ROUTES.SEARCH_RESULTS, key: 'search' },
{ path: ROUTES.SEARCH_NURSE, key: 'search' },
{ path: ROUTES.BOOKING_REQUEST, key: 'bookings' },
{ path: ROUTES.CHECKOUT, key: 'checkout' },
{ path: ROUTES.BOOKINGS, key: 'bookings' },
{ path: ROUTES.PATIENTS, key: 'patients' },
{ path: ROUTES.ADDRESSES, key: 'addresses' },
{ path: ROUTES.WALLET, key: 'wallet' },
{ path: ROUTES.PROFILE, key: 'profile' },
{ path: ROUTES.SUPPORT_TICKETS, key: 'support' },
{ path: ROUTES.NOTIFICATIONS, key: 'notifications' },
{ path: ROUTES.ONBOARDING, key: 'home' },
// Nurse
{ path: ROUTES.NURSE_REQUESTS, key: 'requests' },
{ path: ROUTES.NURSE_PROFILE, key: 'profile' },
{ path: ROUTES.NURSE_SERVICES, key: 'services' },
{ path: ROUTES.NURSE_COVERAGE, key: 'coverage' },
{ path: ROUTES.NURSE_BANK, key: 'bank' },
{ path: ROUTES.NURSE_VERIFICATION, key: 'verification' },
{ path: ROUTES.NURSE_VISITS, key: 'visits' },
{ path: ROUTES.NURSE_EARNINGS, key: 'earnings' },
{ path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' },
{ path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' },
{ path: ROUTES.NURSE, key: 'dashboard' },
// Admin
{ path: ROUTES.ADMIN_VERIFICATION, key: 'verification' },
{ path: ROUTES.ADMIN_TICKETS, key: 'tickets' },
{ path: ROUTES.ADMIN_PAYOUTS, key: 'payouts' },
{ path: ROUTES.ADMIN_REVIEWS, key: 'reviews' },
{ path: ROUTES.ADMIN_CONFIG, key: 'config' },
{ path: ROUTES.ADMIN_HOLIDAYS, key: 'holidays' },
{ path: ROUTES.ADMIN_ALERTS, key: 'alerts' },
{ path: ROUTES.ADMIN_AUDIT, key: 'audit' },
{ path: ROUTES.ADMIN_PARTNERS, key: 'partners' },
{ path: ROUTES.ADMIN_ROLES, key: 'roles' },
{ path: ROUTES.ADMIN_USERS, key: 'users' },
{ path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' },
{ path: ROUTES.ADMIN, key: 'overview' },
// Partner
{ path: ROUTES.PARTNER_NURSES, key: 'partner_nurses' },
{ path: ROUTES.PARTNER_BOOKINGS, key: 'partner_bookings' },
{ path: ROUTES.PARTNER_SETTLEMENT, key: 'partner_settlement' },
{ path: ROUTES.PARTNER, key: 'partner_home' },
].sort((a, b) => b.path.length - a.path.length);
function findTitleEntry(pathname: string): TitleEntry | undefined {
return TITLE_ENTRIES.find((entry) =>
entry.path === '/' ? pathname === entry.path : pathname === entry.path || pathname.startsWith(`${entry.path}/`)
);
}
/** The customer shell's 5 root tabs — these render the brand lockup, never a title. */
export const CUSTOMER_ROOT_TABS: string[] = [ROUTES.HOME, ROUTES.BOOKINGS, ROUTES.PATIENTS, ROUTES.WALLET, ROUTES.PROFILE];
export function isCustomerRootTab(pathname: string): boolean {
return CUSTOMER_ROOT_TABS.includes(pathname);
}
const PageTitleOverrideContext = createContext<string | null>(null);
const SetPageTitleOverrideContext = createContext<(title: string | null) => void>(() => {});
/**
* Provides the per-page dynamic-title override slot. Mount once per shell (around the scrollable
* content), above wherever `usePageTitleOverride`/`useRouteTitle` are read.
* @component PageTitleProvider
*/
export const PageTitleProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
const [override, setOverride] = useState<string | null>(null);
return (
<SetPageTitleOverrideContext.Provider value={setOverride}>
<PageTitleOverrideContext.Provider value={override}>{children}</PageTitleOverrideContext.Provider>
</SetPageTitleOverrideContext.Provider>
);
};
/**
* Lets a pushed-route page set a dynamic chrome header title (e.g. a nurse's name, a booking
* reference) that wins over the static routetitle map. Pass `null`/omit to clear. The override
* is cleared automatically on unmount so navigating away never leaks a stale title.
*/
export function usePageTitleOverride(title: string | null | undefined) {
const setOverride = useContext(SetPageTitleOverrideContext);
useEffect(() => {
setOverride(title ?? null);
return () => setOverride(null);
}, [title, setOverride]);
}
/** The current chrome header title: the per-page override if set, else the static route→title map. */
export function useRouteTitle(): string | null {
const pathname = usePathname();
const t = useTranslations('nav');
const override = useContext(PageTitleOverrideContext);
return useMemo(() => {
if (override) return override;
const entry = findTitleEntry(pathname);
return entry ? t(entry.key) : null;
}, [override, pathname, t]);
}
+21
View File
@@ -81,6 +81,27 @@ function createAppTheme(direction: 'ltr' | 'rtl') {
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: 'var(--bal-bg-default)',
backgroundImage: 'none',
borderInlineEnd: '1px solid var(--bal-divider)',
borderInlineStart: '1px solid var(--bal-divider)',
},
},
},
MuiBottomNavigationAction: {
styleOverrides: {
root: {
color: 'var(--bal-text-secondary)',
'&.Mui-selected': { color: 'var(--bal-primary)' },
},
label: {
'&.Mui-selected': { fontWeight: 700 },
},
},
},
MuiAppBar: {
defaultProps: { color: 'transparent', elevation: 0 },
styleOverrides: {
+2
View File
@@ -9,4 +9,6 @@ export type LinkToPage = {
path?: string; // URL to navigate to
title?: string; // Title or primary text to display
subtitle?: string; // Sub-title or secondary text to display
group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader
onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer)
};