ui phase 2

This commit is contained in:
hamid
2026-07-17 19:05:17 +03:30
parent 370c1beefa
commit 222856d600
42 changed files with 1354 additions and 451 deletions
@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import ProfileSummary from './ProfileSummary';
function renderSummary(props: Partial<React.ComponentProps<typeof ProfileSummary>> = {}) {
return render(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" {...props} />
</ThemeProvider>,
);
}
describe('<ProfileSummary/> component', () => {
it('renders the display name, phone, and role label', () => {
renderSummary({ phone: '0912*****33', roleLabel: 'پرستار' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('0912*****33')).toBeInTheDocument();
expect(screen.getByText('پرستار')).toBeInTheDocument();
});
it('renders a TrustBadge only when trustState is set', () => {
const { rerender } = renderSummary();
expect(screen.queryByText('badge_verified')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" trustState="verified" />
</ThemeProvider>,
);
expect(screen.getByText('badge_verified')).toBeInTheDocument();
});
it('renders skeleton placeholders instead of text while loading', () => {
const { container } = renderSummary({ loading: true, phone: '0912*****33' });
expect(screen.queryByText('سارا احمدی')).not.toBeInTheDocument();
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
});
it('renders the compact horizontal chip variant', () => {
const { container } = renderSummary({ compact: true, roleLabel: 'مالی' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('مالی')).toBeInTheDocument();
expect(container.querySelector('.MuiAvatar-root')).toHaveStyle({ width: '32px', height: '32px' });
});
});
@@ -0,0 +1,95 @@
import { FunctionComponent } from 'react';
import { Avatar, Skeleton, Stack, Typography } from '@mui/material';
import TrustBadge from '../TrustBadge';
import type { BadgeState } from '@/services/verification/types';
export interface ProfileSummaryProps {
/** Already-resolved display name (caller composes first/last name — i18n-free). */
displayName: string;
/** The server-masked phone (e.g. `0912*****33`) — rendered as a `dir="ltr"` island. */
phone?: string;
/** Already-translated role/fine-grained-role label (e.g. "پرستار", "مالی"). */
roleLabel?: string;
avatarUrl?: string | null;
/** Renders a `TrustBadge` next to the name when set (nurse identity only). */
trustState?: BadgeState;
/** True while the identity is still resolving — renders skeleton placeholders instead of text. */
loading?: boolean;
/** Dense horizontal chip form for the admin/partner TopBar identity slot; default is the vertical card. */
compact?: boolean;
}
/**
* The one identity card for authenticated chrome — avatar, name, masked phone, role label, and an
* optional `TrustBadge`. Replaces the starter `UserInfo` (`user?: any`, eternal "Current User").
* Presentational: the caller (each shell) sources data from `useMe`/the profiles domain and passes
* it down, so this component never fetches on its own.
* @component ProfileSummary
*/
const ProfileSummary: FunctionComponent<ProfileSummaryProps> = ({
displayName,
phone,
roleLabel,
avatarUrl,
trustState,
loading = false,
compact = false,
}) => {
const avatarSize = compact ? 32 : 56;
if (loading) {
return (
<Stack direction={compact ? 'row' : 'column'} sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
<Skeleton variant="circular" width={avatarSize} height={avatarSize} />
<Stack sx={{ alignItems: compact ? 'flex-start' : 'center', gap: 0.5, minWidth: 0 }}>
<Skeleton variant="text" width={compact ? 80 : 120} />
{!compact && <Skeleton variant="text" width={90} />}
</Stack>
</Stack>
);
}
const avatar = <Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />;
if (compact) {
return (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
{avatar}
<Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{displayName}
</Typography>
{roleLabel && (
<Typography variant="caption" noWrap sx={{ color: 'text.secondary', lineHeight: 1.2 }}>
{roleLabel}
</Typography>
)}
</Stack>
</Stack>
);
}
return (
<Stack sx={{ alignItems: 'center', gap: 1, width: '100%', textAlign: 'center' }}>
{avatar}
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{displayName}
</Typography>
{trustState && <TrustBadge state={trustState} />}
</Stack>
{phone && (
<Typography variant="body2" dir="ltr" sx={{ color: 'text.secondary' }}>
{phone}
</Typography>
)}
{roleLabel && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{roleLabel}
</Typography>
)}
</Stack>
);
};
export default ProfileSummary;
@@ -0,0 +1,4 @@
import ProfileSummary from './ProfileSummary';
export default ProfileSummary;
export type { ProfileSummaryProps } from './ProfileSummary';
@@ -1,41 +0,0 @@
import { Avatar, Stack, Typography } from '@mui/material';
interface UserInfoProps {
className?: string;
showAvatar?: boolean;
user?: any;
}
/**
* Renders User info with Avatar
* @component UserInfo
* @param {boolean} [showAvatar] - user's avatar picture is shown when true
* @param {object} [user] - logged user data {name, email, avatar...}
*/
const UserInfo = ({ showAvatar = false, user, ...restOfProps }: UserInfoProps) => {
const fullName = user?.name || [user?.nameFirst || '', user?.nameLast || ''].join(' ').trim();
const srcAvatar = user?.avatar ? user?.avatar : undefined;
const userPhoneOrEmail = user?.phone || (user?.email as string);
return (
<Stack sx={{ alignItems: 'center', minHeight: 'fit-content', marginBottom: 2 }} {...restOfProps}>
{showAvatar ? (
<Avatar
sx={{
width: 64,
height: 64,
fontSize: '3rem',
}}
alt={fullName || 'User Avatar'}
src={srcAvatar}
/>
) : null}
<Typography sx={{ mt: 1 }} variant="h6">
{fullName || 'Current User'}
</Typography>
<Typography variant="body2">{userPhoneOrEmail || 'Loading...'}</Typography>
</Stack>
);
};
export default UserInfo;
-4
View File
@@ -1,4 +0,0 @@
import UserInfo from './UserInfo';
export { UserInfo };
export default UserInfo;
-1
View File
@@ -34,7 +34,6 @@ interface RoleGuardProps {
* @component RoleGuard
*/
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
return children;
const t = useTranslations('auth');
const router = useRouter();
const locale = useLocale();
@@ -99,6 +99,7 @@ import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
import ShareIcon from '@mui/icons-material/ShareRounded';
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
import LanguageIcon from '@mui/icons-material/TranslateRounded';
/**
* List of all available Icon names
@@ -202,6 +203,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
share: ShareIcon,
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
};
/**
@@ -0,0 +1,40 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
const replace = jest.fn();
jest.mock('next-intl', () => ({
useLocale: () => 'fa',
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${params.locale}` : key,
}));
jest.mock('@/i18n/navigation', () => ({
usePathname: () => '/nurse/earnings',
useRouter: () => ({ replace }),
}));
import LocaleSwitcher from './LocaleSwitcher';
describe('<LocaleSwitcher/> component', () => {
beforeEach(() => replace.mockClear());
it('renders a single icon button', () => {
render(
<ThemeProvider>
<LocaleSwitcher />
</ThemeProvider>,
);
expect(screen.getAllByRole('button')).toHaveLength(1);
});
it('replaces the current path with the other locale on click, preserving the route', () => {
render(
<ThemeProvider>
<LocaleSwitcher />
</ThemeProvider>,
);
fireEvent.click(screen.getByRole('button'));
expect(replace).toHaveBeenCalledWith('/nurse/earnings', { locale: 'en' });
});
});
@@ -0,0 +1,32 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import type { Locale } from '@/i18n/routing';
import AppIconButton from '../AppIconButton';
const LOCALE_LABEL: Record<Locale, string> = { fa: 'فارسی', en: 'English' };
/**
* Switches between `fa`/`en` while preserving the current route — `router.replace(pathname,
* { locale })` via the `@/i18n/navigation` wrapper, so a deep link (e.g. `/fa/nurse/earnings`)
* lands on the same page in the other locale rather than resetting to home.
* @component LocaleSwitcher
*/
const LocaleSwitcher: FunctionComponent = () => {
const locale = useLocale() as Locale;
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('common');
const nextLocale: Locale = locale === 'fa' ? 'en' : 'fa';
return (
<AppIconButton
icon="language"
title={t('switch_locale', { locale: LOCALE_LABEL[nextLocale] })}
onClick={() => router.replace(pathname, { locale: nextLocale })}
/>
);
};
export default LocaleSwitcher;
@@ -0,0 +1,3 @@
import LocaleSwitcher from './LocaleSwitcher';
export default LocaleSwitcher;
+2
View File
@@ -16,6 +16,7 @@ import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
import LocaleSwitcher from './LocaleSwitcher';
export {
ErrorBoundary,
@@ -36,6 +37,7 @@ export {
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
LocaleSwitcher,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
+2 -2
View File
@@ -1,6 +1,6 @@
export * from './common';
import UserInfo from './UserInfo';
import ProfileSummary from './ProfileSummary';
import PlaceholderScreen from './PlaceholderScreen';
import OtpInput from './OtpInput';
import PhoneNumberField from './PhoneNumberField';
@@ -35,7 +35,7 @@ import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader';
export {
UserInfo,
ProfileSummary,
PlaceholderScreen,
OtpInput,
PhoneNumberField,
@@ -8,7 +8,7 @@ import NotificationBellView from './NotificationBellView';
export interface NotificationBellProps {
/** The shell the bell lives in — decides which notification center it opens. */
role: 'customer' | 'nurse';
role: 'customer' | 'nurse' | 'admin';
}
/**