ui phase 2
This commit is contained in:
@@ -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;
|
||||
@@ -1,4 +0,0 @@
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
export { UserInfo };
|
||||
export default UserInfo;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { usePathname } from '@/i18n/navigation';
|
||||
|
||||
interface TitleEntry {
|
||||
path: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static route→title table (longest-prefix wins), read from the existing `nav` namespace — one
|
||||
* key per shell console/tab. Dynamic titles (a nurse's name, a booking reference) are layered on
|
||||
* top via `usePageTitleOverride`; the area phases (4-6, 9) wire those in, this phase ships the
|
||||
* static baseline.
|
||||
*/
|
||||
const TITLE_ENTRIES: TitleEntry[] = [
|
||||
// Customer — pushed routes only; the 5 root tabs render the brand lockup instead (see CUSTOMER_ROOT_TABS)
|
||||
{ path: ROUTES.SEARCH, key: 'search' },
|
||||
{ path: ROUTES.SEARCH_RESULTS, key: 'search' },
|
||||
{ path: ROUTES.SEARCH_NURSE, key: 'search' },
|
||||
{ path: ROUTES.BOOKING_REQUEST, key: 'bookings' },
|
||||
{ path: ROUTES.CHECKOUT, key: 'checkout' },
|
||||
{ path: ROUTES.BOOKINGS, key: 'bookings' },
|
||||
{ path: ROUTES.PATIENTS, key: 'patients' },
|
||||
{ path: ROUTES.ADDRESSES, key: 'addresses' },
|
||||
{ path: ROUTES.WALLET, key: 'wallet' },
|
||||
{ path: ROUTES.PROFILE, key: 'profile' },
|
||||
{ path: ROUTES.SUPPORT_TICKETS, key: 'support' },
|
||||
{ path: ROUTES.NOTIFICATIONS, key: 'notifications' },
|
||||
{ path: ROUTES.ONBOARDING, key: 'home' },
|
||||
// Nurse
|
||||
{ path: ROUTES.NURSE_REQUESTS, key: 'requests' },
|
||||
{ path: ROUTES.NURSE_PROFILE, key: 'profile' },
|
||||
{ path: ROUTES.NURSE_SERVICES, key: 'services' },
|
||||
{ path: ROUTES.NURSE_COVERAGE, key: 'coverage' },
|
||||
{ path: ROUTES.NURSE_BANK, key: 'bank' },
|
||||
{ path: ROUTES.NURSE_VERIFICATION, key: 'verification' },
|
||||
{ path: ROUTES.NURSE_VISITS, key: 'visits' },
|
||||
{ path: ROUTES.NURSE_EARNINGS, key: 'earnings' },
|
||||
{ path: ROUTES.NURSE_SUPPORT_TICKETS, key: 'support' },
|
||||
{ path: ROUTES.NURSE_NOTIFICATIONS, key: 'notifications' },
|
||||
{ path: ROUTES.NURSE, key: 'dashboard' },
|
||||
// Admin
|
||||
{ path: ROUTES.ADMIN_VERIFICATION, key: 'verification' },
|
||||
{ path: ROUTES.ADMIN_TICKETS, key: 'tickets' },
|
||||
{ path: ROUTES.ADMIN_PAYOUTS, key: 'payouts' },
|
||||
{ path: ROUTES.ADMIN_REVIEWS, key: 'reviews' },
|
||||
{ path: ROUTES.ADMIN_CONFIG, key: 'config' },
|
||||
{ path: ROUTES.ADMIN_HOLIDAYS, key: 'holidays' },
|
||||
{ path: ROUTES.ADMIN_ALERTS, key: 'alerts' },
|
||||
{ path: ROUTES.ADMIN_AUDIT, key: 'audit' },
|
||||
{ path: ROUTES.ADMIN_PARTNERS, key: 'partners' },
|
||||
{ path: ROUTES.ADMIN_ROLES, key: 'roles' },
|
||||
{ path: ROUTES.ADMIN_USERS, key: 'users' },
|
||||
{ path: ROUTES.ADMIN_NOTIFICATIONS, key: 'notifications' },
|
||||
{ path: ROUTES.ADMIN, key: 'overview' },
|
||||
// Partner
|
||||
{ path: ROUTES.PARTNER_NURSES, key: 'partner_nurses' },
|
||||
{ path: ROUTES.PARTNER_BOOKINGS, key: 'partner_bookings' },
|
||||
{ path: ROUTES.PARTNER_SETTLEMENT, key: 'partner_settlement' },
|
||||
{ path: ROUTES.PARTNER, key: 'partner_home' },
|
||||
].sort((a, b) => b.path.length - a.path.length);
|
||||
|
||||
function findTitleEntry(pathname: string): TitleEntry | undefined {
|
||||
return TITLE_ENTRIES.find((entry) =>
|
||||
entry.path === '/' ? pathname === entry.path : pathname === entry.path || pathname.startsWith(`${entry.path}/`)
|
||||
);
|
||||
}
|
||||
|
||||
/** The customer shell's 5 root tabs — these render the brand lockup, never a title. */
|
||||
export const CUSTOMER_ROOT_TABS: string[] = [ROUTES.HOME, ROUTES.BOOKINGS, ROUTES.PATIENTS, ROUTES.WALLET, ROUTES.PROFILE];
|
||||
|
||||
export function isCustomerRootTab(pathname: string): boolean {
|
||||
return CUSTOMER_ROOT_TABS.includes(pathname);
|
||||
}
|
||||
|
||||
const PageTitleOverrideContext = createContext<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 route→title map. Pass `null`/omit to clear. The override
|
||||
* is cleared automatically on unmount so navigating away never leaks a stale title.
|
||||
*/
|
||||
export function usePageTitleOverride(title: string | null | undefined) {
|
||||
const setOverride = useContext(SetPageTitleOverrideContext);
|
||||
useEffect(() => {
|
||||
setOverride(title ?? null);
|
||||
return () => setOverride(null);
|
||||
}, [title, setOverride]);
|
||||
}
|
||||
|
||||
/** The current chrome header title: the per-page override if set, else the static route→title map. */
|
||||
export function useRouteTitle(): string | null {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations('nav');
|
||||
const override = useContext(PageTitleOverrideContext);
|
||||
return useMemo(() => {
|
||||
if (override) return override;
|
||||
const entry = findTitleEntry(pathname);
|
||||
return entry ? t(entry.key) : null;
|
||||
}, [override, pathname, t]);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user