ui phase 2
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user