manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
@@ -35,7 +35,7 @@ interface NudgeCardProps {
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
@@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -132,7 +132,7 @@ export default function CancelBookingPage() {
{/* Off-ramps before the kill switch — exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
@@ -211,7 +211,7 @@ export default function CancelBookingPage() {
</>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
@@ -59,7 +59,7 @@ export default function BookingInvoicePage() {
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -89,7 +89,7 @@ export default function BookingInvoicePage() {
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -166,7 +166,7 @@ export default function BookingInvoicePage() {
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
@@ -84,7 +84,7 @@ export default function LeaveReviewPage() {
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
@@ -149,7 +149,7 @@ export default function LeaveReviewPage() {
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
@@ -61,7 +61,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-success)',
backgroundColor: 'var(--bal-primary-soft)',
@@ -190,7 +190,7 @@ function DeclinedPanel({
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
@@ -36,7 +36,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payable_amount')}
@@ -51,7 +51,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: 'divider',
@@ -128,7 +128,7 @@ function ProviderOption({
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1.5,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -69,7 +69,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{shownPlan ? (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
@@ -63,7 +63,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
// Handoff in progress — the provider redirect is being followed.
if (busy) {
return (
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 4, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<CircularProgress color="secondary" size="2.5rem" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'var(--bal-secondary)',
backgroundColor: 'var(--bal-secondary-soft)',
@@ -245,7 +245,7 @@ function CheckoutScreen() {
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
@@ -284,7 +284,7 @@ function CheckoutScreen() {
width: 300,
}}
>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
{payActions}
</Paper>
</Box>
@@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
src={summary.nurseAvatarUrl ?? undefined}
@@ -148,7 +148,7 @@ function ReturnScreen() {
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return (
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')}
@@ -213,7 +213,7 @@ export default function BookingRequestStatusPage() {
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -247,7 +247,7 @@ export default function BookingRequestStatusPage() {
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
@@ -329,7 +329,7 @@ function TerminalCard({
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
@@ -106,7 +106,7 @@ export default function PatientRecordPage() {
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
@@ -296,7 +296,7 @@ function SheetActions({
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
return (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
{onOpen ? (
<AppButton
variant="text"
@@ -724,7 +724,7 @@ function TasksTab({
) : (
<Stack sx={{ gap: 1 }}>
{data.map((task) => (
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<FormControlLabel
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
@@ -14,6 +14,7 @@ import {
ProfileSummary,
} from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { ThemeModeSetting } from '@/components/settings';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
@@ -143,6 +144,9 @@ const AccountHub: FunctionComponent<{
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
{/* The app's appearance control lives here (and in each other actor's settings hub) — it
used to occupy a permanent slot in every top bar for a preference set once. */}
<ThemeModeSetting />
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
</Stack>
@@ -287,7 +291,7 @@ const AccountRow: FunctionComponent<{
alignItems: 'center',
gap: 1.5,
p: 1.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
'&:hover': { bgcolor: 'action.hover' },
}}
@@ -309,7 +313,7 @@ const EmergencyContactCard: FunctionComponent<{
}> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile');
return (
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
@@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (
{isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : isError ? (
@@ -355,7 +355,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) {
const t = useTranslations('reviews');
const locale = useLocale();
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
@@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => {
<Skeleton variant="rounded" height={56} />
</Stack>
) : isError ? (
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Paper elevation={0} sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -71,7 +71,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
<Paper
elevation={0}
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
sx={{ p: 2.25, borderRadius: 'var(--bal-radius-lg)', backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
>
<Typography variant="caption" sx={{ opacity: 0.85 }}>
{t('outstanding_balance')}
@@ -3,12 +3,12 @@ import Stack from '@mui/material/Stack';
import SurfaceCard from '@/components/common/SurfaceCard';
/**
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) the
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
* generic worklist/detail cards. A private (`_`-prefixed) folder not a route.
* The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
* (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
* so this only shapes the content area: a heading line + a short stack of generic worklist cards.
* A private (`_`-prefixed) folder not a route.
*/
export default function SidebarShellSkeleton() {
export default function ShellContentSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width={220} height={32} />
@@ -1,81 +1,63 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AdminPageHeader } from '@/components/admin';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
interface ConsoleEntry extends NavHubItem {
/** Section this console belongs to — the same four groups the bottom nav carries. */
section: 'trust' | 'finance' | 'support' | 'system';
enabled: boolean;
}
/**
* Admin overview landing (f15) the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
* Admin overview landing (f15) the backoffice index. Every worklist the current principal may
* act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
* on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
* still enforces every command's role scope): a `support` admin sees verification/tickets/alerts,
* a `finance` admin sees payouts/config, only a `super_admin` sees roles.
*
* The old 3-column card grid is gone inside a phone-width frame it collapsed to a single column
* of oversized tiles carrying nothing but an icon and one word each.
*/
export default function AdminOverviewScreen() {
const t = useTranslations('admin');
const th = useTranslations('hub');
const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label.
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
].filter((c) => c.enabled);
const consoles: ConsoleEntry[] = [
{ section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ section: 'trust', title: tNav('reviews'), subtitle: th('admin_reviews_sub'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
{ section: 'system', title: tNav('roles'), subtitle: th('admin_roles_sub'), path: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
];
const sections: Array<{ key: ConsoleEntry['section']; label: string }> = [
{ key: 'trust', label: tNav('group_trust') },
{ key: 'finance', label: tNav('group_finance') },
{ key: 'support', label: tNav('group_support') },
{ key: 'system', label: tNav('group_system') },
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Stack sx={{ gap: 2.5 }}>
<PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{consoles.map((c) => (
<AppLink
key={c.key}
to={`/${locale}${c.route}`}
color="inherit"
underline="none"
sx={{ display: 'block', height: '100%' }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1.5,
cursor: 'pointer',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
}}
>
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{tNav(c.key)}
</Typography>
</Paper>
</AppLink>
))}
</Box>
</Box>
{sections.map((section) => {
const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
if (items.length === 0) return null;
return <NavHubList key={section.key} title={section.label} items={items} />;
})}
</Stack>
);
}
@@ -0,0 +1,45 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { EmptyState, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
export interface AdminGroupConsole extends NavHubItem {
/** Capability gate for this console — a hidden row is one the current admin role can't act on. */
enabled: boolean;
}
interface Props {
title: string;
subtitle?: string;
consoles: Array<AdminGroupConsole>;
/** Rendered below the console list (the system group's settings + sign-out). */
children?: ReactNode;
}
/**
* The body every admin group-root page shares: a header, the capability-filtered consoles in that
* group, and an optional tail. Gating stays per-console and is still only a UI hint the server
* authorizes every command regardless of what the nav shows.
* A private (`_`-prefixed) folder, so this is not itself a route.
* @component AdminGroupHub
*/
const AdminGroupHub: FunctionComponent<Props> = ({ title, subtitle, consoles, children }) => {
const t = useTranslations('hub');
const permitted = consoles.filter((console_) => console_.enabled);
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={title} subtitle={subtitle} />
{permitted.length > 0 ? (
<NavHubList items={permitted} />
) : (
<EmptyState icon="lock" title={t('admin_group_empty_title')} body={t('admin_group_empty_body')} />
)}
{children}
</Stack>
);
};
export default AdminGroupHub;
@@ -71,7 +71,7 @@ function AdminAuditPageInner() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -229,7 +229,7 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
) : (
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
@@ -0,0 +1,28 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «مالی» group root — the weekly payout dashboard. */
export default function AdminFinancePage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_finance')}
subtitle={t('admin_finance_subtitle')}
consoles={[
{
title: tn('payouts'),
subtitle: t('admin_payouts_sub'),
icon: 'earnings',
path: ROUTES.ADMIN_PAYOUTS,
enabled: caps.canPayout,
},
]}
/>
);
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -123,7 +123,7 @@ export default function AdminPartnerCenterDetailPage() {
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
/>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
@@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() {
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
@@ -83,7 +83,7 @@ function AdminPayoutBatchDetailScreen() {
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
@@ -180,7 +180,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
};
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Stack
direction="row"
@@ -217,7 +217,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
<Box
sx={{
p: 1.25,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -283,7 +283,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
@@ -317,7 +317,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
@@ -166,7 +166,7 @@ function ModerationCard({
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «پشتیبانی» group root — the global ticket queue and the internal alert worklist. */
export default function AdminSupportPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_support')}
subtitle={t('admin_support_subtitle')}
consoles={[
{
title: tn('tickets'),
subtitle: t('admin_tickets_sub'),
icon: 'support',
path: ROUTES.ADMIN_TICKETS,
enabled: caps.canManageTickets,
},
{
title: tn('alerts'),
subtitle: t('admin_alerts_sub'),
icon: 'alerts',
path: ROUTES.ADMIN_ALERTS,
enabled: caps.canManageAlerts,
},
]}
/>
);
}
@@ -0,0 +1,88 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { ProfileSummary, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import { useMe } from '@/services/auth';
import AdminGroupHub from '../_hub/AdminGroupHub';
/**
* «سیستم» group root platform configuration plus the identity/appearance/sign-out block that
* used to live in the top bar and drawer footer. Always reachable (even for an admin role with no
* system console permitted), because it is the only way out of the app.
*/
export default function AdminSystemPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const ta = useTranslations('admin');
const caps = useAdminCapabilities();
const { data: me } = useMe();
const primaryRoleCode = caps.roles[0];
return (
<AdminGroupHub
title={tn('group_system')}
subtitle={t('admin_system_subtitle')}
consoles={[
{
title: tn('config'),
subtitle: t('admin_config_sub'),
icon: 'config',
path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig,
},
{
title: tn('holidays'),
subtitle: t('admin_holidays_sub'),
icon: 'calendar',
path: ROUTES.ADMIN_HOLIDAYS,
enabled: caps.canConfig,
},
{
title: tn('audit'),
subtitle: t('admin_audit_sub'),
icon: 'audit',
path: ROUTES.ADMIN_AUDIT,
enabled: caps.canViewAudit,
},
{
title: tn('partners'),
subtitle: t('admin_partners_sub'),
icon: 'partners',
path: ROUTES.ADMIN_PARTNERS,
enabled: caps.canManagePartners,
},
{
title: tn('users'),
subtitle: t('admin_users_sub'),
icon: 'users',
path: ROUTES.ADMIN_USERS,
enabled: caps.canManageRoles,
},
{
title: tn('roles'),
subtitle: t('admin_roles_sub'),
icon: 'roles',
path: ROUTES.ADMIN_ROLES,
enabled: caps.canManageRoles,
},
]}
>
<Stack sx={{ gap: 2 }}>
<SurfaceCard>
<ProfileSummary
displayName={me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''}
phone={me?.phone}
roleLabel={primaryRoleCode ? ta(`role_${primaryRoleCode}`) : undefined}
loading={!me}
/>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
</AdminGroupHub>
);
}
@@ -167,7 +167,7 @@ export default function AdminTicketThreadPage() {
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined}
@@ -202,7 +202,7 @@ export default function AdminTicketThreadPage() {
<Paper
id={REFUND_PANEL_ID}
elevation={0}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
@@ -229,7 +229,7 @@ export default function AdminTicketThreadPage() {
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
@@ -112,7 +112,7 @@ function AdminTicketsQueue() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -0,0 +1,35 @@
'use client';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import AdminGroupHub from '../_hub/AdminGroupHub';
/** «اعتماد» group root — the verification queue and review moderation. */
export default function AdminTrustPage() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const caps = useAdminCapabilities();
return (
<AdminGroupHub
title={tn('group_trust')}
subtitle={t('admin_trust_subtitle')}
consoles={[
{
title: tn('verification'),
subtitle: t('admin_verification_sub'),
icon: 'verification',
path: ROUTES.ADMIN_VERIFICATION,
enabled: caps.canVerify,
},
{
title: tn('reviews'),
subtitle: t('admin_reviews_sub'),
icon: 'moderation',
path: ROUTES.ADMIN_REVIEWS,
enabled: caps.canModerate,
},
]}
/>
);
}
@@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() {
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
@@ -207,7 +207,7 @@ function AdminVerificationCaseScreen() {
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
@@ -325,7 +325,7 @@ function StepCard({
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
@@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
size="small"
@@ -1,14 +1,15 @@
'use client';
import { ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AccentCard,
AppButton,
AppIcon,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
InitialsAvatar,
Money,
SurfaceCard,
TrustBadge,
@@ -19,78 +20,114 @@ import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useUnreadCount } from '@/services/notifications';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
const DASHBOARD_MAX_WIDTH = 960;
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) the operational home replacing the `PlaceholderScreen`.
* Pure assembly: every widget reads an already-cached query. Order matters the pending-requests strip
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
* The nurse «امروز» home the first bottom-nav destination.
*
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
* important than anything else. This version gives the page one visual hierarchy: a quiet identity
* strip, then exactly one hero action (the next visit), then sections introduced by a plain label
* with a text link instead of a competing button.
*
* Composition only every widget reads a query that is already cached elsewhere in the shell, and
* order encodes urgency: a missed request expires, an unread earnings figure does not.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
const { data: me, isLoading: meLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{meLoading ? (
<>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={160} height={32} />
</>
) : (
<>
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
</Stack>
</>
)}
</Stack>
<Stack sx={{ gap: 2.5 }}>
<GreetingHeader />
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<RequestsSection />
<EarningsSection />
<DashboardActivationSlot />
<NotificationsEntryRow />
</Stack>
);
}
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
/**
* A section label + an optional text link. Deliberately not a button: on a 480px row a
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
*/
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
const locale = useLocale();
return (
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{actionLabel && actionTo ? (
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
{actionLabel}
</AppLink>
) : null}
</Stack>
);
}
/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */
function GreetingHeader() {
const t = useTranslations('dashboard');
const { data: me, isLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
if (isLoading) {
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={180} height={28} />
</Stack>
);
}
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
<InitialsAvatar name={displayName} size={44} />
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{verification.isLoading ? null : (
<Box>
<TrustBadge state={ownBadgeState(verification.data)} />
</Box>
)}
</Stack>
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('next_visit_title')} />
<EmptyState icon="visits" title={t('next_visit_empty')} />
</Stack>
);
}
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
@@ -98,48 +135,41 @@ function NextVisitCard() {
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<SurfaceCard data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<AccentCard tone="secondary" data-widget="next-visit">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
{t('next_visit_title')}
</Typography>
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{next.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Typography>
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
</Typography>
<MetaLine
items={[
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Box>,
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
]}
/>
</Stack>
<AppButton
variant="contained"
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
{t('next_visit_cta')}
</AppButton>
</Stack>
</SurfaceCard>
</AccentCard>
);
}
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
function RequestsStrip() {
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
function RequestsSection() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -148,76 +178,70 @@ function RequestsStrip() {
const total = data?.total ?? 0;
if (items.length === 0) {
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
return (
<Stack sx={{ gap: 1 }}>
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
<EmptyState icon="requests" title={t('requests_strip_empty')} />
</Stack>
);
}
const mostUrgent = items[0];
return (
<SurfaceCard data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('requests_strip_title', { count: total })}
</Typography>
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
<SectionHeader
title={t('requests_strip_title', { count: total })}
actionLabel={t('requests_strip_cta')}
actionTo={ROUTES.NURSE_REQUESTS}
/>
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` the old
layout let the countdown wrap under a long Persian name and collide with the date. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<Box sx={{ flexShrink: 0 }}>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Box>
</Stack>
<AppButton
variant="text"
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
fullWidth
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
>
{t('requests_strip_cta')}
{t('requests_strip_open')}
</AppButton>
</Stack>
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Stack>
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
</SurfaceCard>
</Stack>
);
}
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
function EarningsSnapshotCard() {
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
function EarningsSection() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={120} />;
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
@@ -228,69 +252,48 @@ function EarningsSnapshotCard() {
const magnitude = isOwed ? -net : net;
return (
<SurfaceCard data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('earnings_snapshot_title')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
>
{t('earnings_snapshot_cta')}
</AppButton>
</Stack>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}
</Typography>
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
</Stack>
</Box>
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
<SectionHeader
title={t('earnings_snapshot_title')}
actionLabel={t('earnings_snapshot_cta')}
actionTo={ROUTES.NURSE_EARNINGS}
/>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<StatTile
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
/>
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
</Box>
</Stack>
);
}
function StatTile({ label, value }: { label: string; value: ReactNode }) {
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{value}
</Stack>
</SurfaceCard>
);
}
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
function MetaLine({ items }: { items: Array<ReactNode> }) {
const present = items.filter(Boolean);
return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-widget="notifications-entry">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('notifications_entry_title')}
</Typography>
</Stack>
<Typography
variant="body2"
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
</Typography>
</Stack>
</SurfaceCard>
</AppLink>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{present.map((item, index) => (
<Box key={index} component="span">
{index > 0 ? ' · ' : null}
{item}
</Box>
))}
</Typography>
);
}
@@ -132,7 +132,7 @@ export default function NurseCoveragePage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -153,7 +153,7 @@ export default function NurseCoveragePage() {
</Paper>
)}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')}
@@ -157,7 +157,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -168,7 +168,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 'var(--bal-radius-sm)' }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -66,14 +66,14 @@ export default function NursePayoutDetailPage() {
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -112,7 +112,7 @@ export default function NursePayoutDetailPage() {
<Box
sx={{
p: 1.75,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
@@ -174,7 +174,7 @@ export default function NursePayoutDetailPage() {
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
@@ -0,0 +1,83 @@
'use client';
import { Skeleton, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AccentCard, ErrorState, Money, NavHubList, PageHeader } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { parseIrr } from '@/utils';
import { useNurseEarningsBalance } from '@/services/payouts';
/**
* «مالی» group root. The one number a nurse opens this tab for the signed net payable balance
* is answered before any navigation, then the three money screens are one tap away. The balance is
* **signed**: an outstanding clawback can exceed accrued earnings, and clamping it to zero would
* quietly tell a nurse they are owed nothing when they in fact owe money back.
*/
export default function NurseFinanceScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const tp = useTranslations('payouts');
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
const items: Array<NavHubItem> = [
{
title: tn('earnings'),
subtitle: t('finance_earnings_sub'),
icon: 'earnings',
path: ROUTES.NURSE_EARNINGS,
},
{
title: tn('payouts'),
subtitle: t('finance_payouts_sub'),
icon: 'history',
path: ROUTES.NURSE_EARNINGS_PAYOUTS,
},
{
title: tn('bank'),
subtitle: t('finance_bank_sub'),
icon: 'bank',
path: ROUTES.NURSE_BANK,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_finance')} subtitle={t('finance_subtitle')} />
<BalanceSummary data={data} isLoading={isLoading} isError={isError} onRetry={() => refetch()} tp={tp} t={t} />
<NavHubList items={items} />
</Stack>
);
}
interface BalanceSummaryProps {
data: ReturnType<typeof useNurseEarningsBalance>['data'];
isLoading: boolean;
isError: boolean;
onRetry: () => void;
tp: ReturnType<typeof useTranslations<'payouts'>>;
t: ReturnType<typeof useTranslations<'hub'>>;
}
function BalanceSummary({ data, isLoading, isError, onRetry, tp, t }: BalanceSummaryProps) {
if (isLoading) return <Skeleton variant="rounded" height={104} />;
if (isError) return <ErrorState message={t('finance_balance_error')} retryLabel={t('retry')} onRetry={onRetry} />;
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<AccentCard tone={isOwed ? 'error' : 'primary'}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="xl" tone={isOwed ? 'error' : 'emphasis'} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}: <Money amountIrr={data.eligibleTotalIrr} size="sm" component="span" />
</Typography>
</Stack>
</AccentCard>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseFinanceScreen from './NurseFinanceScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_finance') };
}
export default function NurseFinancePage() {
return <NurseFinanceScreen />;
}
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,73 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { NavHubList, PageHeader, ProfileSummary, SurfaceCard } from '@/components';
import type { NavHubItem } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { ROUTES } from '@/constants';
import { ActorSwitcher } from '@/layout';
import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles';
import { useUnreadCount } from '@/services/notifications';
import { useSupportUnreadTotal } from '@/services/tickets';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «بیشتر» group root the fourth bottom-nav destination: who you are signed in as, the two
* conversation surfaces (support, notifications), appearance/language, and sign-out. Everything
* here used to live in the sidebar drawer's header and footer, where a preference toggle sat
* permanently next to primary navigation.
*/
export default function NurseMoreScreen() {
const t = useTranslations('hub');
const tn = useTranslations('nav');
const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const notificationsUnread = useUnreadCount();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const items: Array<NavHubItem> = [
{
title: tn('support'),
subtitle: t('more_support_sub'),
icon: 'support',
path: ROUTES.NURSE_SUPPORT_TICKETS,
badgeCount: supportUnreadTotal ?? undefined,
},
{
title: tn('notifications'),
subtitle: t('more_notifications_sub'),
icon: 'notifications',
path: ROUTES.NURSE_NOTIFICATIONS,
badgeCount: notificationsUnread || undefined,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5 }}>
<ProfileSummary
displayName={displayName}
phone={me?.phone}
avatarUrl={nurseProfile?.avatarUrl}
trustState={ownBadgeState(verification)}
loading={!me}
/>
<ActorSwitcher target="customer" />
</Stack>
</SurfaceCard>
<NavHubList items={items} />
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NurseMoreScreen from './NurseMoreScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'hub' });
return { title: t('more_title') };
}
export default function NurseMorePage() {
return <NurseMoreScreen />;
}
@@ -0,0 +1,98 @@
'use client';
import { Stack, Typography } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import { AccentCard, AppLink, NavHubList, PageHeader, StatusChip, TrustBadge } from '@/components';
import type { NavHubItem } from '@/components';
import { ROUTES } from '@/constants';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseProfile } from '@/services/profiles';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
/**
* «حرفهٔ من» group root the page the bottom-nav tab lands on. It answers the question the
* sidebar section never could ("is my listing actually live, and what's missing?") before handing
* off to the four screens that fix it. Every number here is read off a query that already answers
* it; nothing is derived optimistically, and a count is simply omitted until its query resolves.
*/
export default function NursePracticeScreen() {
const t = useTranslations('hub');
const locale = useLocale();
const tn = useTranslations('nav');
const { data: verification } = useVerificationStatus();
const { data: profile } = useNurseProfile();
const { data: variants } = useMyVariants();
const { data: areas } = useServiceAreas();
const activeVariants = variants?.items.filter((variant) => variant.isActive).length;
const isAccepting = profile?.isAcceptingBookings;
const items: Array<NavHubItem> = [
{
title: tn('profile'),
subtitle: t('practice_profile_sub'),
icon: 'account',
path: ROUTES.NURSE_PROFILE,
},
{
title: tn('services'),
subtitle: t('practice_services_sub'),
icon: 'services',
path: ROUTES.NURSE_SERVICES,
meta: activeVariants === undefined ? undefined : <MetaCount value={activeVariants} />,
},
{
title: tn('coverage'),
subtitle: t('practice_coverage_sub'),
icon: 'coverage',
path: ROUTES.NURSE_COVERAGE,
meta: areas === undefined ? undefined : <MetaCount value={areas.total} />,
},
{
title: tn('verification'),
subtitle: t('practice_verification_sub'),
icon: 'verification',
path: ROUTES.NURSE_VERIFICATION,
},
];
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={tn('group_profession')} subtitle={t('practice_subtitle')} />
<AccentCard tone={isAccepting ? 'success' : 'warning'}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('practice_status_title')}
</Typography>
<TrustBadge state={ownBadgeState(verification)} />
</Stack>
{isAccepting === undefined ? null : (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<StatusChip
status={isAccepting ? 'active' : 'neutral'}
label={isAccepting ? t('practice_accepting_on') : t('practice_accepting_off')}
/>
<AppLink to={`/${locale}${ROUTES.NURSE_SERVICES}`} variant="caption">
{t('practice_accepting_manage')}
</AppLink>
</Stack>
)}
</Stack>
</AccentCard>
<NavHubList items={items} />
</Stack>
);
}
/** A count read straight off a resolved query — never a placeholder while one is in flight. */
function MetaCount({ value }: { value: number }) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import NursePracticeScreen from './NursePracticeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('group_profession') };
}
export default function NursePracticePage() {
return <NursePracticeScreen />;
}
@@ -139,7 +139,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
@@ -61,7 +61,7 @@ export default function NurseRequestDetailPage() {
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
@@ -131,7 +131,7 @@ export default function NurseRequestDetailPage() {
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -171,7 +171,7 @@ export default function NurseRequestDetailPage() {
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
@@ -225,7 +225,7 @@ export default function NurseRequestDetailPage() {
elevation={0}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -105,7 +105,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -22,7 +22,7 @@ const PublishGate: FunctionComponent = () => {
const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings();
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => {
@@ -309,7 +309,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
@@ -375,7 +375,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
@@ -163,7 +163,7 @@ export default function CredentialsSubmitPage() {
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
@@ -318,7 +318,7 @@ export default function CredentialsSubmitPage() {
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -119,7 +119,7 @@ export default function IdentitySubmitPage() {
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -51,9 +51,9 @@ export default function NurseVerificationPage() {
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Stack>
) : isError ? (
@@ -127,7 +127,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
elevation={0}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
@@ -70,7 +70,7 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
{canAppend ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
@@ -78,7 +78,7 @@ function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const { severity, key } = banner[state];
return (
<Alert severity={severity} sx={{ borderRadius: 2 }}>
<Alert severity={severity} sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t(key)}
</Alert>
);
@@ -89,7 +89,7 @@ function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner');
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')}
</Typography>
@@ -69,7 +69,7 @@ export default function PartnerBookingDetailPage() {
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -91,7 +91,7 @@ export default function PartnerBookingDetailPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
@@ -92,7 +92,7 @@ function PartnerBookingsScreen() {
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<TextField
select
@@ -1,5 +1,5 @@
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
export default function Loading() {
return <SidebarShellSkeleton />;
return <ShellContentSkeleton />;
}
@@ -0,0 +1,38 @@
'use client';
import { Stack } from '@mui/material';
import { useTranslations } from 'next-intl';
import { PageHeader, ProfileSummary, StatusChip, SurfaceCard } from '@/components';
import { SettingsPanel, SignOutRow } from '@/components/settings';
import { useMyPartnerCenter } from '@/services/partnerCenter';
/**
* «بیشتر» group root for the partner portal the center's identity and merchant-of-record status
* (previously squeezed into the top bar, where the MoR indicator was hidden below `sm`), plus
* appearance/language and sign-out.
*/
export default function PartnerMorePage() {
const t = useTranslations('hub');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter();
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('more_title')} />
<SurfaceCard>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<ProfileSummary displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
/>
) : null}
</Stack>
</SurfaceCard>
<SettingsPanel />
<SignOutRow />
</Stack>
);
}
@@ -112,7 +112,7 @@ function PartnerSettlementScreen() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}>
<Alert severity="info" sx={{ borderRadius: 'var(--bal-radius-md)' }}>
{t('settlement_not_mor')}
</Alert>
</Box>
@@ -149,7 +149,7 @@ function PartnerSettlementScreen() {
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')}
@@ -0,0 +1,13 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
/*
* First-use role picker. It sits outside every actor route group on purpose the session has no
* public role yet, so there is no app to show tabs for but it still needs the phone-width frame
* that the actor shells provide, hence the chrome-free `FocusedLayout` (logo strip + content, no
* bottom nav). No `RoleGuard` here: gating on a role is exactly what this page exists to resolve.
*/
export default function SelectRoleLayout({ children }: { children: ReactNode }) {
return <FocusedLayout>{children}</FocusedLayout>;
}
@@ -27,7 +27,7 @@ const ActivationChecklist: FunctionComponent = () => {
const t = useTranslations('activation');
const state = useActivationChecklist();
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 2 }} />;
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
}
@@ -44,7 +44,7 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountI
display: 'block',
width: '100%',
textAlign: 'start',
borderRadius: 2.5,
borderRadius: 'var(--bal-radius-md)',
p: 1.75,
border: '1px solid',
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
@@ -39,7 +39,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
@@ -69,7 +69,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
@@ -55,7 +55,7 @@ const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, on
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
@@ -171,7 +171,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 'var(--bal-radius-sm)', height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
@@ -184,7 +184,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 'var(--bal-radius-sm)', flexShrink: 0 }}
/>
) : (
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
@@ -262,7 +262,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
}}
sx={{
p: 3,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px dashed',
borderColor: 'divider',
textAlign: 'center',
@@ -54,7 +54,7 @@ const EscrowExplainer: FunctionComponent = () => {
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Stack
direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
@@ -65,7 +65,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
alignItems: 'flex-start',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
cursor: 'pointer',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
@@ -145,7 +145,7 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />
+19 -3
View File
@@ -20,7 +20,13 @@ export interface OtpInputProps {
}
const DEFAULT_LENGTH = 5;
const BOX_SIZE = 48;
/**
* The box is sized in `ch`-free absolute terms but must survive 6 boxes + gaps inside the
* ~480px app frame minus the card padding, so it shrinks rather than overflowing.
*/
const BOX_MIN_SIZE = 40;
const BOX_MAX_SIZE = 52;
const BOX_GAP = 1;
/**
* One-time-code input: `length` single-digit boxes with auto-advance, backspace-to-previous,
@@ -110,10 +116,20 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
};
return (
<Stack direction="row" spacing={1} dir="ltr" role="group" aria-label={ariaLabel} sx={{ justifyContent: 'center' }}>
// `gap`, never Stack's `spacing`: spacing compiles to a directional margin that the RTL Emotion
// cache mirrors, while this group is force-`dir="ltr"` so the code reads left-to-right. The two
// disagreed on /fa and collapsed the gap between the first two boxes while doubling it at the end.
<Stack
direction="row"
dir="ltr"
role="group"
aria-label={ariaLabel}
sx={{ justifyContent: 'center', gap: BOX_GAP, width: '100%' }}
>
{chars.map((char, index) => (
<TextField
key={index}
sx={{ flex: `1 1 ${BOX_MIN_SIZE}px`, minWidth: BOX_MIN_SIZE, maxWidth: BOX_MAX_SIZE }}
value={char}
disabled={disabled}
error={error}
@@ -130,7 +146,7 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
autoComplete: 'one-time-code',
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
style: { textAlign: 'center', fontSize: '1.25rem', padding: 12, width: '100%' },
},
}}
/>
@@ -88,7 +88,7 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
minWidth: 0,
justifyContent: 'flex-start',
textAlign: 'start',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
p: 1,
m: -1,
gap: 1,
@@ -29,7 +29,7 @@ const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone
elevation={0}
data-payment-state-card
aria-live="polite"
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
@@ -52,7 +52,7 @@ const RelationSelect: FunctionComponent<RelationSelectProps> = ({ options, value
gap: 2,
border: '2px solid',
borderColor: selected ? 'primary.main' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent',
'&:hover': { borderColor: selected ? 'primary.main' : 'text.secondary' },
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
@@ -86,7 +86,7 @@ function AdminDataTable<T>({
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
overflowX: 'auto',
...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
}}
@@ -38,7 +38,7 @@ const AdminMessageBubble: FunctionComponent<AdminMessageBubbleProps> = ({ messag
<Box
sx={{
p: 1.5,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: internal ? '1px dashed' : '1px solid',
borderColor: internal ? 'var(--bal-warning)' : 'divider',
bgcolor: internal ? 'var(--bal-secondary-soft)' : mine ? 'var(--bal-primary-soft)' : 'background.paper',
+1 -1
View File
@@ -58,7 +58,7 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel })
<Paper
elevation={0}
data-audit-id={entry.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}
>
<Stack
role={hasDetail ? 'button' : undefined}
+2 -2
View File
@@ -29,7 +29,7 @@ const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false,
<Paper
elevation={0}
data-config-key={config.key}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 0 }}>
@@ -69,7 +69,7 @@ const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false,
<Typography
variant="body2"
data-config-value
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 1 }}
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 'var(--bal-radius-sm)' }}
>
{config.value}
</Typography>
@@ -34,7 +34,7 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
return (
<Box
data-document-id={document.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 1 }}>
<AppIcon icon="document" size={18} color="var(--bal-text-secondary)" />
@@ -71,7 +71,7 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
component="img"
src={signed.data.url}
alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 1, display: 'block' }}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
/>
) : (
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab>
@@ -39,7 +39,7 @@ const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ in
<Paper
elevation={0}
data-invoice-id={invoice.id}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', mb: 1.5 }}>
<Box>
@@ -65,7 +65,7 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
data-alert-id={alert.id}
sx={{
p: 2,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '4px solid',
+19 -35
View File
@@ -1,50 +1,34 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH, AUTH_HERO_MAX_WIDTH } from './constants';
import { Divider, Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH } from './constants';
import BrandMark from './BrandMark';
import AuthIllustration from './AuthIllustration';
import TrustBullets from './TrustBullets';
/**
* Centered branded hero that hosts each auth step (phone, OTP). The card stays readable at
* 320px; a calm illustration joins beside it once the viewport has room to breathe (desktop),
* with the platform's trust facts underneath — login is the product's only front door, so it
* carries trust evidence rather than a bare form.
* Centered branded card that hosts each auth step (phone, OTP). One surface, top to bottom:
* brand mark the step's form → a divider → the platform's trust facts. The facts used to float
* on the page *under* the card, which read as unrelated page furniture and left the card looking
* cut off; login is the product's only front door, so the evidence belongs on the same surface as
* the form it is vouching for. The desktop side-illustration is gone with it the app now renders
* inside a phone-width frame at every viewport, so it never had room to appear.
* @component AuthCard
*/
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
<Stack
direction={{ xs: 'column', md: 'row' }}
sx={{
width: '100%',
maxWidth: AUTH_HERO_MAX_WIDTH,
alignItems: 'center',
justifyContent: 'center',
gap: { xs: 0, md: 6 },
}}
<Stack sx={{ alignItems: 'center', justifyContent: 'center', flexGrow: 1, px: 2, py: 3 }}>
<Paper
elevation={0}
// Radius comes from the house token (MuiPaper). The old `borderRadius: 3` multiplied the
// shape unit into a ~30px pill — exactly the "too round" this pass is undoing.
sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, px: { xs: 2.5, sm: 3 }, py: 3 }}
>
<AuthIllustration sx={{ display: { xs: 'none', md: 'flex' } }} />
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3, flexShrink: 0 }}>
<Paper
elevation={0}
sx={{
width: '100%',
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
</Stack>
</Paper>
<Stack sx={{ gap: 2.5 }}>
<BrandMark withTagline />
{children}
<Divider flexItem />
<TrustBullets />
</Stack>
</Stack>
</Paper>
</Stack>
);
@@ -1,85 +0,0 @@
'use client';
import { FunctionComponent } from 'react';
import { Box, SxProps, Theme } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
const ILLUSTRATION_SIZE = 220;
interface AuthIllustrationProps {
sx?: SxProps<Theme>;
}
/**
* A calm, abstract hero graphic for the login front door layered soft-tint circles (brand
* tokens only, no stock photos or raster art) with a centered family glyph and a floating
* trust badge, echoing the trust bullets underneath. Purely decorative.
* @component AuthIllustration
*/
const AuthIllustration: FunctionComponent<AuthIllustrationProps> = ({ sx }) => (
<Box
aria-hidden="true"
sx={{
position: 'relative',
width: ILLUSTRATION_SIZE,
height: ILLUSTRATION_SIZE,
flexShrink: 0,
...sx,
}}
>
<Box sx={{ position: 'absolute', inset: 0, borderRadius: '50%', bgcolor: 'var(--bal-primary-soft)' }} />
<Box
sx={{
position: 'absolute',
insetInlineStart: '16%',
insetBlockEnd: '4%',
width: '46%',
height: '46%',
borderRadius: '50%',
bgcolor: 'var(--bal-secondary-soft)',
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '58%',
height: '58%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="family" size={64} color="var(--bal-primary)" />
</Box>
</Box>
<Box
sx={{
position: 'absolute',
insetInlineEnd: '8%',
insetBlockStart: '10%',
width: '30%',
height: '30%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="verified" size={30} color="var(--bal-trust)" />
</Box>
</Box>
);
export default AuthIllustration;
+5 -2
View File
@@ -102,6 +102,9 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('otp_sent_to', {
// A rich-text TAG (`<phone></phone>` in both message files), not a `{phone}` value — a
// function passed for a value placeholder is handed straight to React as a child, which
// is the "Functions are not valid as a React child" crash this replaced.
// <bdi dir="ltr"> isolates the masked number so the RTL sentence's bidi algorithm
// never reorders the digit/bullet runs around it (the classic digits-around-neutrals bug).
phone: () => (
@@ -149,8 +152,8 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
{resendDisabled && countdown.isActive && !locked ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('resend_in', {
// Same bidi-isolation reasoning as the phone echo above — keeps mm:ss reading
// left-to-right (in Persian digits on /fa) inside the RTL sentence.
// Same tag-not-value rule and bidi-isolation reasoning as the phone echo above —
// keeps mm:ss reading left-to-right (in Persian digits on /fa) inside the RTL sentence.
time: () => (
<Box component="bdi" dir="ltr">
{formatClock(countdown.seconds, locale)}
@@ -41,6 +41,14 @@ describe('<RoleGuard/>', () => {
expect(mockReplace).not.toHaveBeenCalled();
});
it('bounces to login (never an endless splash) when there is no session at all', async () => {
// `useMe` is gated on the session, so without one it never resolves — the guard must not wait.
hydration = { status: 'unauthenticated' };
renderGuard('nurse');
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
});
it('renders the account-error state (not the shell) when /me failed', () => {
hydration = { status: 'error', retry: jest.fn(), isRetrying: false };
renderGuard('nurse');
+14 -4
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { type AppRole } from '@/constants';
import { ROUTES, type AppRole } from '@/constants';
import { useRoleHydration } from '@/services/auth';
import { resolveRoleDestination } from '@/services/auth/routing';
import AuthSplash from './AuthSplash';
@@ -25,10 +25,15 @@ interface RoleGuardProps {
* The client-side role-aware navigation guard for the private shells (refinement-phase-2). It gates a shell
* on the resolved-vs-pending role state (`useRoleHydration`) so the wrong actor app never renders:
* - **loading** a brand splash while `/me` is in flight (never the customer shell as a stand-in);
* - **unauthenticated** no session at all, so `/me` will never resolve: bounce to login carrying the
* attempted path, rather than holding a splash that can never end;
* - **error** an explicit account-error recovery when `/me` failed (never a silent customer fallback);
* - **role mismatch** redirect to the caller's real app (`resolveRoleDestination`, the single source of
* "which app") with a toast, rather than rendering a shell they lack the role for.
*
* This is also what makes the locale root role-aware: `/` is the customer app's home, so a nurse landing
* there is a role mismatch and is sent to `/nurse`, a role-less account to `/select-role`.
*
* A dual customer+nurse session holds both roles, so it passes either shell's guard and can move freely
* between the family and nurse apps.
* @component RoleGuard
@@ -45,6 +50,7 @@ const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) =>
const allowed = !expected || (appRoles?.includes(expected) ?? false);
// Stable across renders for a given identity (a string), so the redirect effect fires once, not per render.
const redirectTo = me && !allowed ? `/${locale}${resolveRoleDestination(me)}` : null;
const isUnauthenticated = hydration.status === 'unauthenticated';
useEffect(() => {
if (!redirectTo) return;
@@ -52,11 +58,15 @@ const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) =>
router.replace(redirectTo);
}, [redirectTo, enqueueSnackbar, t, router]);
if (hydration.status === 'loading') return <AuthSplash message={t('routing_title')} />;
useEffect(() => {
if (!isUnauthenticated) return;
router.replace(`/${locale}${ROUTES.LOGIN}`);
}, [isUnauthenticated, router, locale]);
if (hydration.status === 'error')
return <AuthAccountError onRetry={hydration.retry} isRetrying={hydration.isRetrying} />;
// Role mismatch — hold the neutral splash while the redirect above navigates away.
if (!allowed) return <AuthSplash message={t('routing_title')} />;
// Loading, signed-out, or a role mismatch — hold the neutral splash while the effect above navigates.
if (hydration.status !== 'ready' || !allowed) return <AuthSplash message={t('routing_title')} />;
return <>{children}</>;
};
+1 -1
View File
@@ -84,7 +84,7 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
gap: 2,
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
// Selection is never color-only: the soft fill pairs with a check glyph below.
bgcolor: isSelected ? 'var(--bal-primary-soft)' : 'transparent',
transition:
+22 -11
View File
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent } from 'react';
import { Stack, Typography } from '@mui/material';
import { Box, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import AppIcon from '@/components/common/AppIcon';
@@ -10,26 +10,37 @@ const BULLETS: ReadonlyArray<{ icon: string; key: string }> = [
{ icon: 'support', key: 'trust_support' },
];
const GLYPH_BOX = 28;
/**
* The 23 trust facts under the login card what Balinyaar actually verifies and escrows
* The 23 trust facts at the foot of the login card what Balinyaar actually verifies and escrows
* (licensed + identity-verified nurses, escrow held until a confirmed check-out, support).
* Copy is scoped to features the platform implements today, never aspirational claims.
* Rendered inside `AuthCard`, so it is deliberately quiet: caption type and a soft glyph chip, not
* a second set of headings competing with the form above it.
* @component TrustBullets
*/
const TrustBullets: FunctionComponent = () => {
const t = useTranslations('auth');
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
<Stack sx={{ gap: 1.25, width: '100%' }}>
{BULLETS.map((bullet) => (
<Stack key={bullet.key} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={bullet.icon}
size={20}
color="var(--bal-primary)"
style={{ flexShrink: 0, marginTop: 2 }}
<Stack key={bullet.key} direction="row" sx={{ gap: 1.25, alignItems: 'center' }}>
<Box
aria-hidden="true"
/>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
sx={{
width: GLYPH_BOX,
height: GLYPH_BOX,
flexShrink: 0,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-sm)',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={bullet.icon} size={16} color="var(--bal-primary)" />
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t(bullet.key)}
</Typography>
</Stack>
-3
View File
@@ -1,5 +1,2 @@
/** Reading width for the branded auth screens (login, OTP, role selection). */
export const AUTH_CARD_MAX_WIDTH = 420;
/** Width of the login hero row (illustration + card) once the illustration joins on desktop. */
export const AUTH_HERO_MAX_WIDTH = 760;
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { NextIntlClientProvider, useTranslations } from 'next-intl';
import { FunctionComponent } from 'react';
import enMessages from '../../../messages/en.json';
import faMessages from '../../../messages/fa.json';
/*
* The auth screens' `t.rich` call sites, exercised against the REAL message catalogue and the
* REAL next-intl formatter.
*
* `OtpStep.test.tsx` stubs `t.rich` to an identity function, which is precisely how a broken
* message shipped: `otp_sent_to` carried a `{phone}` *value* placeholder while the component
* passed a *function* for it, so next-intl handed the function straight through and React was
* asked to render it "Functions are not valid as a React child". A value placeholder and a
* rich-text tag are not interchangeable, and only the real formatter can tell them apart.
*/
const MASKED_PHONE = '0912*****34';
const CLOCK = '01:59';
const PhoneEcho: FunctionComponent = () => {
const t = useTranslations('auth');
return <p>{t.rich('otp_sent_to', { phone: () => <bdi dir="ltr">{MASKED_PHONE}</bdi> })}</p>;
};
const ResendCountdown: FunctionComponent = () => {
const t = useTranslations('auth');
return <p>{t.rich('resend_in', { time: () => <bdi dir="ltr">{CLOCK}</bdi> })}</p>;
};
describe.each([
['fa', faMessages],
['en', enMessages],
])('auth rich-text messages (%s)', (locale, messages) => {
function renderWithLocale(node: React.ReactNode) {
render(
<NextIntlClientProvider locale={locale} messages={messages}>
{node}
</NextIntlClientProvider>
);
}
it('renders the masked phone as a real node inside otp_sent_to', () => {
renderWithLocale(<PhoneEcho />);
expect(screen.getByText(MASKED_PHONE)).toBeInTheDocument();
});
it('isolates the phone in an LTR <bdi> so RTL bidi never reorders the digit runs', () => {
renderWithLocale(<PhoneEcho />);
expect(screen.getByText(MASKED_PHONE).tagName.toLowerCase()).toBe('bdi');
});
it('renders the countdown as a real node inside resend_in', () => {
renderWithLocale(<ResendCountdown />);
expect(screen.getByText(CLOCK)).toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
}}
>
@@ -197,7 +197,7 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
{onSiteCheckInAt ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 2, bgcolor: 'var(--bal-success-soft)' }}
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-success-soft)' }}
>
<AppIcon icon="location" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
@@ -451,7 +451,7 @@ function StatusNote({ booking }: { booking: BookingDetailDto }) {
const color = tone === 'warning' ? 'var(--bal-secondary-dark)' : 'var(--bal-text-secondary)';
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 2, bgcolor: bg }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: bg }}>
<AppIcon icon={icon} size={18} color={color} />
<Stack sx={{ gap: 0.25 }}>
{lines.map((line) => (
@@ -499,7 +499,7 @@ function CareSection({
if (care.isLoading) return <Skeleton variant="rounded" height={180} />;
if (care.isError || !care.data) {
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('care_error')}
</Typography>
@@ -513,7 +513,7 @@ function NotFoundCard({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640, mx: 'auto' }}
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: 640, mx: 'auto' }}
>
<AppIcon icon="error" size={44} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
@@ -31,7 +31,7 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
const t = useTranslations('booking');
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>
{t('money_title')}
</Typography>
@@ -55,7 +55,7 @@ const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({
{status === 'cancelled' ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)' }}
>
<AppIcon icon="rejected" size={20} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 500, color: 'text.secondary' }}>
@@ -40,7 +40,7 @@ const CareInstructionsCard: FunctionComponent<CareInstructionsCardProps> = ({ da
p: 2.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-primary)',
}}
@@ -66,7 +66,7 @@ const EvvStatusBanner: FunctionComponent<EvvStatusBannerProps> = ({ checkInAtIso
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 2,
borderRadius: 'var(--bal-radius-md)',
backgroundColor: style.bg,
color: style.fg,
}}
@@ -2,7 +2,7 @@ import { FunctionComponent } from 'react';
import { render, screen, within } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AppButton, { AppButtonProps } from './AppButton';
import DefaultIcon from '@mui/icons-material/MoreHoriz';
import { Ellipsis as DefaultIcon } from 'lucide-react';
import { randomText, capitalize } from '@/utils';
/**
@@ -130,7 +130,8 @@ describe('<AppButton/> component', () => {
let text = 'button with start icon';
render(<ComponentToTest text={text} startIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-startIcon');
@@ -140,7 +141,8 @@ describe('<AppButton/> component', () => {
let text = 'button with end icon as string';
render(<ComponentToTest text={text} endIcon="default" />);
let button = screen.getByText(text);
let icon = within(button).getByTestId('MoreHorizRoundedIcon'); //Note: this is valid only when "default" icon is <MoreHorizRoundedIcon />
// The registry is Lucide now — identify the glyph by AppIcon's own data-icon, not a MUI testid.
let icon = button.querySelector('[data-icon="default"]') as HTMLElement;
expect(icon).toBeDefined();
let span = icon.closest('span');
expect(span).toHaveClass('MuiButton-endIcon');
@@ -2,7 +2,7 @@ import { ElementType, FunctionComponent, ReactNode, useMemo } from 'react';
import Button, { ButtonProps } from '@mui/material/Button';
import AppIcon from '../AppIcon';
import AppLink from '../AppLink';
import { APP_BUTTON_VARIANT } from '../../config';
import { APP_BUTTON_ICON_SIZE, APP_BUTTON_VARIANT } from '../../config';
const MUI_BUTTON_COLORS = ['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning'];
@@ -44,13 +44,26 @@ const AppButton: FunctionComponent<AppButtonProps> = ({
variant = APP_BUTTON_VARIANT,
...restOfProps
}) => {
// Sized explicitly: MUI's own `fontSize: 20` rule on the icon slot only bites on an SvgIcon that
// sizes off 1em — the Lucide registry sizes off real width/height attributes and would otherwise
// render at the full 24px default inside every button.
const iconStart: ReactNode = useMemo(
() => (!startIcon ? undefined : typeof startIcon === 'string' ? <AppIcon icon={String(startIcon)} /> : startIcon),
() =>
!startIcon ? undefined : typeof startIcon === 'string' ? (
<AppIcon icon={String(startIcon)} size={APP_BUTTON_ICON_SIZE} />
) : (
startIcon
),
[startIcon]
);
const iconEnd: ReactNode = useMemo(
() => (!endIcon ? undefined : typeof endIcon === 'string' ? <AppIcon icon={String(endIcon)} /> : endIcon),
() =>
!endIcon ? undefined : typeof endIcon === 'string' ? (
<AppIcon icon={String(endIcon)} size={APP_BUTTON_ICON_SIZE} />
) : (
endIcon
),
[endIcon]
);
@@ -1,8 +1,8 @@
import { render, screen } from '@testing-library/react';
import AppIcon from './AppIcon';
import { APP_ICON_SIZE } from '../../config';
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
import { randomColor, randomText } from '@/utils';
import { ICONS } from './config';
import { DIRECTIONAL_ICONS, ICONS } from './config';
const ComponentToTest = AppIcon;
@@ -16,22 +16,22 @@ describe('<AppIcon/> component', () => {
const svg = screen.getByTestId(testId);
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('data-icon', 'default');
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE)); // default size when .size is not set
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE)); // default size when .size is not se
// Size is actually driven by fontSize (MUI SvgIcon's `1em` sizing beats
// width/height attributes) — never assert a `size` DOM attribute, it's invalid.
expect(svg).toHaveStyle(`font-size: ${APP_ICON_SIZE}px`);
// Lucide sizes off real width/height attributes — no 1em/fontSize indirection to work around.
expect(svg).toHaveAttribute('height', String(APP_ICON_SIZE));
expect(svg).toHaveAttribute('width', String(APP_ICON_SIZE));
expect(svg).toHaveAttribute('stroke-width', String(APP_ICON_STROKE_WIDTH));
});
it('supports .color property', () => {
const testId = randomText(8);
const color = randomColor(); // Note: 'rgb(255, 128, 0)' format is used by react-icons npm, so tests may fail
const color = randomColor();
render(<ComponentToTest data-testid={testId} color={color} />);
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('data-icon', 'default');
// expect(svg).toHaveAttribute('color', color); // TODO: Looks like MUI Icons exclude .color property from <svg> rendering
expect(svg).toHaveStyle(`color: ${color}`);
expect(svg).toHaveAttribute('fill', 'currentColor'); // .fill must be 'currentColor' when .color property is set
// The whole family is stroked, never filled — a `fill` would paint the glyph solid.
expect(svg).toHaveAttribute('stroke', color);
expect(svg).toHaveAttribute('fill', 'none');
});
it('supports .icon property', () => {
@@ -52,9 +52,6 @@ describe('<AppIcon/> component', () => {
const svg = screen.getByTestId(testId);
expect(svg).toHaveAttribute('height', String(size));
expect(svg).toHaveAttribute('width', String(size));
// The bug this regression-guards: MUI SvgIcon's own CSS sets width/height:1em,
// which beats plain width/height attributes — only fontSize actually resizes it.
expect(svg).toHaveStyle(`font-size: ${size}px`);
});
it('supports .title property', () => {
@@ -65,4 +62,15 @@ describe('<AppIcon/> component', () => {
expect(svg).toBeDefined();
expect(svg).toHaveAttribute('title', title);
});
it('marks only the registered directional icons for the RTL mirror', () => {
for (const icon of Object.keys(ICONS) as Array<keyof typeof ICONS>) {
const testId = randomText(8);
render(<ComponentToTest data-testid={testId} icon={icon} />);
const svg = screen.getByTestId(testId);
// The single globals.css rule keys off this attribute — a name drifting out of
// DIRECTIONAL_ICONS silently stops mirroring under RTL, which is invisible in review.
expect(svg.hasAttribute('data-icon-directional')).toBe(DIRECTIONAL_ICONS.has(icon));
}
});
});
@@ -1,15 +1,13 @@
import { ComponentType, FunctionComponent, SVGAttributes } from 'react';
import { APP_ICON_SIZE } from '../../config';
import { ComponentType, CSSProperties, FunctionComponent } from 'react';
import { APP_ICON_SIZE, APP_ICON_STROKE_WIDTH } from '../../config';
import { IconName, ICONS, DIRECTIONAL_ICONS } from './config';
import { IconProps } from './utils';
/**
* Props of the AppIcon component, also can be used for SVG icons
*/
export interface Props extends SVGAttributes<SVGElement> {
color?: string;
export interface Props extends IconProps {
icon?: IconName | string;
size?: string | number;
title?: string;
}
/**
@@ -18,18 +16,20 @@ export interface Props extends SVGAttributes<SVGElement> {
* @param {string} [color] - color of the icon as a CSS color value
* @param {string} [icon] - name of the Icon to render
* @param {string} [title] - title/hint to show when the cursor hovers the icon
* @param {string | number} [size] - size of the icon, default is ICON_SIZE
* @param {string | number} [size] - size of the icon, default is APP_ICON_SIZE
* @param {string | number} [strokeWidth] - Lucide stroke weight, default is APP_ICON_STROKE_WIDTH
*/
const AppIcon: FunctionComponent<Props> = ({
color,
icon = 'default',
size = APP_ICON_SIZE,
strokeWidth = APP_ICON_STROKE_WIDTH,
style,
...restOfProps
}) => {
const iconName = (icon || 'default').trim().toLowerCase() as IconName;
let ComponentToRender: ComponentType = ICONS[iconName];
let ComponentToRender: ComponentType<IconProps> = ICONS[iconName];
if (!ComponentToRender) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`AppIcon: icon "${iconName}" is not found!`);
@@ -37,29 +37,30 @@ const AppIcon: FunctionComponent<Props> = ({
ComponentToRender = ICONS.default;
}
const sizeValue = typeof size === 'number' ? `${size}px` : size;
// MUI's SvgIcon sets width/height:1em via its own class, which beats plain
// width/height attributes — driving size through fontSize (the basis for 1em)
// is what actually resizes MUI-registry icons. Custom raw <svg> icons (the
// brand mark) aren't MUI SvgIcon-based, so they keep scaling off the explicit
// width/height attributes below; the extra fontSize style is harmless for them.
const propsToRender = {
height: size,
width: size,
color,
fill: color && 'currentColor',
style: { ...style, color, fontSize: sizeValue },
...restOfProps,
};
// Lucide renders a stroked (never filled) <svg> sized off real width/height attributes, so
// `size` alone is authoritative — the old fontSize/1em dance MUI's SvgIcon needed is gone.
// `flexShrink: 0` is the house default because an icon squashed by a flex sibling is the one
// layout bug this component was silently reintroducing on every narrow row; a caller can still
// override it through `style`.
const styleToRender: CSSProperties = { flexShrink: 0, ...style, ...(color ? { color } : null) };
// Directional icons (back/chevron_start, …) are authored for LTR and mirror
// under RTL via the single CSS rule in tokens.css keyed on this attribute —
// under RTL via the single CSS rule in globals.css keyed on this attribute —
// registering a name in DIRECTIONAL_ICONS (config.ts) is the only step a
// later phase needs; never hand-roll a flip at the call site.
const directionalProps = DIRECTIONAL_ICONS.has(iconName) ? { 'data-icon-directional': true } : undefined;
return <ComponentToRender data-icon={iconName} {...directionalProps} {...propsToRender} />;
return (
<ComponentToRender
data-icon={iconName}
{...directionalProps}
size={size}
color={color}
strokeWidth={strokeWidth}
style={styleToRender}
{...restOfProps}
/>
);
};
export default AppIcon;
+263 -199
View File
@@ -1,107 +1,123 @@
// SVG assets — the Balinyaar brand mark
import LogoMark from './icons/LogoMark';
// MUI Icons — normalized to ONE visual family (Rounded): warmer, softer strokes
// than the filled/outlined mix this registry used to carry, fitting the
// clinical-but-human tone. Adding an icon = import here + one lowercase key
// below; never import a raw MUI icon anywhere else in the app.
import DefaultIcon from '@mui/icons-material/MoreHorizRounded';
import CloseIcon from '@mui/icons-material/CloseRounded';
import MenuIcon from '@mui/icons-material/MenuRounded';
import SearchIcon from '@mui/icons-material/SearchRounded';
import InfoIcon from '@mui/icons-material/InfoRounded';
import HomeIcon from '@mui/icons-material/HomeRounded';
import AccountCircleIcon from '@mui/icons-material/AccountCircleRounded';
import ExitToAppIcon from '@mui/icons-material/ExitToAppRounded';
import NotificationsIcon from '@mui/icons-material/NotificationsRounded';
import DangerousIcon from '@mui/icons-material/DangerousRounded';
import EventNoteIcon from '@mui/icons-material/EventNoteRounded';
import GroupsIcon from '@mui/icons-material/GroupsRounded';
import PeopleAltIcon from '@mui/icons-material/PeopleAltRounded';
import WalletIcon from '@mui/icons-material/AccountBalanceWalletRounded';
import DashboardIcon from '@mui/icons-material/DashboardRounded';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUserRounded';
import CheckCircleIcon from '@mui/icons-material/CheckCircleRounded';
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmptyRounded';
import CancelIcon from '@mui/icons-material/CancelRounded';
import MedicalServicesIcon from '@mui/icons-material/MedicalServicesRounded';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettingsRounded';
import AddIcon from '@mui/icons-material/AddRounded';
import EditIcon from '@mui/icons-material/EditRounded';
import ArchiveIcon from '@mui/icons-material/Inventory2Rounded';
import BankIcon from '@mui/icons-material/AccountBalanceRounded';
import CameraIcon from '@mui/icons-material/PhotoCameraRounded';
import WarningIcon from '@mui/icons-material/WarningAmberRounded';
import LocationIcon from '@mui/icons-material/LocationOnRounded';
import DeleteIcon from '@mui/icons-material/DeleteRounded';
import CoverageIcon from '@mui/icons-material/MapRounded';
// Catalog — nurse services surface + the customer Home service-category grid (f4/b5)
import ServicesIcon from '@mui/icons-material/LocalOfferRounded';
import CategoryIcon from '@mui/icons-material/CategoryRounded';
import ElderlyIcon from '@mui/icons-material/ElderlyRounded';
import PostSurgeryIcon from '@mui/icons-material/HealingRounded';
import InfantIcon from '@mui/icons-material/ChildCareRounded';
import ChronicIcon from '@mui/icons-material/MonitorHeartRounded';
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismRounded';
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
import UploadIcon from '@mui/icons-material/CloudUploadRounded';
import DocumentIcon from '@mui/icons-material/InsertDriveFileRounded';
import RefreshIcon from '@mui/icons-material/RefreshRounded';
import IdentityIcon from '@mui/icons-material/BadgeRounded';
import LicenseIcon from '@mui/icons-material/WorkspacePremiumRounded';
import PublishIcon from '@mui/icons-material/RocketLaunchRounded';
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
import StarIcon from '@mui/icons-material/StarRounded';
import StarHalfIcon from '@mui/icons-material/StarHalfRounded';
import TuneIcon from '@mui/icons-material/TuneRounded';
import SortIcon from '@mui/icons-material/SortRounded';
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
import RequestsIcon from '@mui/icons-material/AssignmentRounded';
import PaymentIcon from '@mui/icons-material/CreditCardRounded';
// Bookings, sessions & EVV — the post-payment engagement (f8/b9): check-in/out, GPS, care instructions
import CheckInIcon from '@mui/icons-material/LoginRounded';
import CheckOutIcon from '@mui/icons-material/LogoutRounded';
import GpsIcon from '@mui/icons-material/MyLocationRounded';
import ScheduleIcon from '@mui/icons-material/ScheduleRounded';
import ClinicalIcon from '@mui/icons-material/HealthAndSafetyRounded';
import MedicationIcon from '@mui/icons-material/MedicationRounded';
import EmergencyIcon from '@mui/icons-material/LocalPhoneRounded';
import PhoneIcon from '@mui/icons-material/PhoneRounded';
import LockIcon from '@mui/icons-material/LockRounded';
import NavigateIcon from '@mui/icons-material/NavigationRounded';
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
import InstallmentsIcon from '@mui/icons-material/PaymentsRounded';
// Payouts — the nurse earnings & payout-history surface (f12/b13)
import EarningsIcon from '@mui/icons-material/PaidRounded';
// Reviews & patient care records (f13/b14): visit-note authoring, record tabs, family-ownership banner
import NotesIcon from '@mui/icons-material/NoteAltRounded';
import RoutineIcon from '@mui/icons-material/EventRepeatRounded';
import TasksIcon from '@mui/icons-material/ChecklistRounded';
import HistoryIcon from '@mui/icons-material/HistoryRounded';
import FamilyIcon from '@mui/icons-material/FamilyRestroomRounded';
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
import SupportIcon from '@mui/icons-material/SupportAgentRounded';
import SendIcon from '@mui/icons-material/SendRounded';
// Admin backoffice & partner consoles (f15/b15): config, holidays, audit, alerts, moderation, partners, refunds
import CalendarIcon from '@mui/icons-material/CalendarMonthRounded';
import AuditIcon from '@mui/icons-material/FactCheckRounded';
import AlertsIcon from '@mui/icons-material/NotificationImportantRounded';
import ModerationIcon from '@mui/icons-material/GavelRounded';
import PartnersIcon from '@mui/icons-material/ApartmentRounded';
import RolesIcon from '@mui/icons-material/ManageAccountsRounded';
import RefundsIcon from '@mui/icons-material/CurrencyExchangeRounded';
import DownloadIcon from '@mui/icons-material/FileDownloadRounded';
import ExpandIcon from '@mui/icons-material/ExpandMoreRounded';
import ExternalIcon from '@mui/icons-material/OpenInNewRounded';
import AssignIcon from '@mui/icons-material/AssignmentIndRounded';
// Cross-cutting affordances: back/forward navigation, share/copy, attachments
import BackIcon from '@mui/icons-material/ArrowBackRounded';
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';
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
/*
* MUI Icons are gone. The registry now maps every name onto **Lucide**
* (`lucide-react`) a single contemporary outline family drawn on a 24px grid
* with round caps/joins, which reads far lighter than the old filled/Rounded
* glyphs at the small sizes a phone-width app actually uses.
*
* Two rules keep the set coherent:
* 1. ONE family. Never import an icon from anywhere but `lucide-react` here,
* and never import a raw icon component anywhere else in the app.
* 2. The mapping is SEMANTIC, not incidental a name describes the domain
* concept ("verification", "earnings", "coverage") and the glyph is chosen
* to depict that concept, so a rename of the underlying glyph never leaks
* into call sites. Related concepts deliberately share a visual root
* (every trust/verification name is a shield, every money name is a coin or
* card, every clinical name is a pulse/cross).
*
* Adding an icon = one import here + one lowercase key in ICONS below.
*/
import {
Activity,
Archive,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUpDown,
Award,
Baby,
Bandage,
Bell,
BellRing,
Building2,
Calendar,
CalendarCheck,
CalendarClock,
CalendarDays,
Camera,
ChevronDown,
ChevronLeft,
ChevronRight,
CircleCheckBig,
CircleHelp,
CircleUserRound,
CircleX,
ClipboardList,
Clock,
CloudUpload,
Coins,
Copy,
CreditCard,
Download,
Ellipsis,
ExternalLink,
FileSearch,
FileText,
Gavel,
HandHeart,
Headset,
Heart,
HeartHandshake,
HeartPulse,
History,
Hourglass,
House,
IdCard,
Info,
Landmark,
Languages,
LayoutDashboard,
LayoutGrid,
ListChecks,
LocateFixed,
Lock,
LogIn,
LogOut,
Map,
MapPin,
Menu,
MonitorSmartphone,
Moon,
Navigation,
NotebookPen,
OctagonAlert,
Paperclip,
Pencil,
PersonStanding,
Phone,
PhoneCall,
Pill,
Plus,
RefreshCw,
Repeat,
Rocket,
Search,
Send,
Settings,
Settings2,
Share2,
Shield,
ShieldCheck,
ShieldPlus,
SlidersHorizontal,
Star,
StarHalf,
Stethoscope,
Sun,
SunMoon,
Tag,
Trash2,
TrendingUp,
TriangleAlert,
Undo2,
UserCog,
UserPlus,
Users,
UsersRound,
Wallet,
X,
} from 'lucide-react';
/**
* List of all available Icon names
@@ -110,108 +126,156 @@ export type IconName = keyof typeof ICONS;
/**
* How to use:
* 1. Import all required React, MUI or other SVG icons into this file.
* 2. Add icons with "unique lowercase names" into ICONS object. Lowercase is a must!
* 3. Use icons everywhere in the App by their names in <Icon icon="xxx" /> component
* 1. Import the Lucide icon into this file.
* 2. Add it with a "unique lowercase name" to the ICONS object. Lowercase is a must!
* 3. Use it everywhere in the app by name: <AppIcon icon="xxx" />
* Important: properties of ICONS object MUST be lowercase!
* Note: You can use camelCase or UPPERCASE in the <Icon icon="someIconByName" /> component
* Note: You can use camelCase or UPPERCASE in the <AppIcon icon="someIconByName" /> component
*/
export const ICONS /* Note: Setting type disables property autocomplete :( was - : Record<string, ComponentType> */ = {
default: DefaultIcon,
export const ICONS = {
// ── Chrome & navigation ───────────────────────────────────────────────
default: Ellipsis,
logo: LogoMark,
close: CloseIcon,
menu: MenuIcon,
search: SearchIcon,
info: InfoIcon,
home: HomeIcon,
account: AccountCircleIcon,
logout: ExitToAppIcon,
notifications: NotificationsIcon,
error: DangerousIcon,
bookings: EventNoteIcon,
patients: GroupsIcon,
users: PeopleAltIcon,
wallet: WalletIcon,
profile: AccountCircleIcon,
dashboard: DashboardIcon,
verification: VerifiedUserIcon,
verified: CheckCircleIcon,
pending: HourglassEmptyIcon,
rejected: CancelIcon,
visits: MedicalServicesIcon,
admin: AdminPanelSettingsIcon,
add: AddIcon,
edit: EditIcon,
archive: ArchiveIcon,
bank: BankIcon,
camera: CameraIcon,
warning: WarningIcon,
location: LocationIcon,
delete: DeleteIcon,
coverage: CoverageIcon,
services: ServicesIcon,
category: CategoryIcon,
elderly: ElderlyIcon,
post_surgery: PostSurgeryIcon,
infant: InfantIcon,
chronic: ChronicIcon,
companionship: CompanionshipIcon,
upload: UploadIcon,
document: DocumentIcon,
refresh: RefreshIcon,
identity: IdentityIcon,
license: LicenseIcon,
publish: PublishIcon,
star: StarIcon,
star_half: StarHalfIcon,
tune: TuneIcon,
sort: SortIcon,
requests: RequestsIcon,
payment: PaymentIcon,
check_in: CheckInIcon,
check_out: CheckOutIcon,
gps: GpsIcon,
schedule: ScheduleIcon,
clinical: ClinicalIcon,
medication: MedicationIcon,
emergency: EmergencyIcon,
phone: PhoneIcon,
lock: LockIcon,
navigate: NavigateIcon,
installments: InstallmentsIcon,
earnings: EarningsIcon,
notes: NotesIcon,
routine: RoutineIcon,
tasks: TasksIcon,
history: HistoryIcon,
family: FamilyIcon,
support: SupportIcon,
send: SendIcon,
config: TuneIcon,
calendar: CalendarIcon,
audit: AuditIcon,
alerts: AlertsIcon,
moderation: ModerationIcon,
partners: PartnersIcon,
roles: RolesIcon,
refunds: RefundsIcon,
download: DownloadIcon,
expand: ExpandIcon,
external: ExternalIcon,
assign: AssignIcon,
back: BackIcon,
chevron_start: BackIcon,
forward: ForwardIcon,
share: ShareIcon,
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
favorite: FavoriteIcon,
close: X,
menu: Menu,
more: Ellipsis,
search: Search,
info: Info,
help: CircleHelp,
home: House,
account: CircleUserRound,
logout: LogOut,
notifications: Bell,
error: OctagonAlert,
settings: Settings,
// ── Actor destinations ────────────────────────────────────────────────
bookings: CalendarDays,
patients: HeartHandshake, // «حلقهٔ مراقبت» — a care circle, not a patient list
users: UsersRound,
wallet: Wallet,
profile: CircleUserRound,
dashboard: LayoutDashboard,
today: CalendarCheck,
practice: Stethoscope, // the nurse's «حرفهٔ من» group root
visits: Stethoscope,
admin: Shield,
// ── Trust & verification (all shields — one visual root) ──────────────
verification: ShieldCheck,
verified: CircleCheckBig,
pending: Hourglass,
rejected: CircleX,
identity: IdCard,
license: Award,
// ── Common actions ────────────────────────────────────────────────────
add: Plus,
edit: Pencil,
archive: Archive,
delete: Trash2,
refresh: RefreshCw,
upload: CloudUpload,
download: Download,
document: FileText,
publish: Rocket,
camera: Camera,
warning: TriangleAlert,
// ── Geography & coverage ──────────────────────────────────────────────
location: MapPin,
coverage: Map,
gps: LocateFixed,
navigate: Navigation,
// ── Catalog — the customer Home category grid (f4/b5) ─────────────────
services: Tag,
category: LayoutGrid,
elderly: PersonStanding,
post_surgery: Bandage,
infant: Baby,
chronic: HeartPulse,
companionship: HandHeart,
// ── Search & discovery ────────────────────────────────────────────────
star: Star,
star_half: StarHalf,
tune: SlidersHorizontal,
sort: ArrowUpDown,
// ── Booking requests, sessions & EVV ──────────────────────────────────
requests: ClipboardList,
check_in: LogIn,
check_out: LogOut,
schedule: Clock,
clinical: ShieldPlus,
medication: Pill,
emergency: PhoneCall,
phone: Phone,
lock: Lock,
// ── Money (coins/cards — one visual root) ─────────────────────────────
payment: CreditCard,
installments: CalendarClock,
earnings: Coins,
bank: Landmark,
refunds: Undo2,
trending: TrendingUp,
// ── Reviews & patient care records ────────────────────────────────────
notes: NotebookPen,
routine: Repeat,
tasks: ListChecks,
history: History,
family: Users,
activity: Activity,
// ── Messaging & support ───────────────────────────────────────────────
support: Headset,
send: Send,
// ── Admin backoffice & partner consoles ───────────────────────────────
config: Settings2,
calendar: Calendar,
audit: FileSearch,
alerts: BellRing,
moderation: Gavel,
partners: Building2,
roles: UserCog,
assign: UserPlus,
// ── Cross-cutting affordances ─────────────────────────────────────────
back: ArrowLeft,
forward: ArrowRight,
chevron_start: ChevronLeft,
chevron_end: ChevronRight,
expand: ChevronDown,
external: ExternalLink,
share: Share2,
copy: Copy,
attachment: Paperclip,
switch: ArrowLeftRight,
// ── Settings surface ──────────────────────────────────────────────────
language: Languages,
appearance: SunMoon,
light_mode: Sun,
dark_mode: Moon,
/** "Follow the operating system" — the third theme choice, not a device list. */
system_mode: MonitorSmartphone,
favorite: Heart,
};
/**
* Icons authored for LTR that must mirror horizontally under RTL (back arrows,
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
* attribute + the single CSS rule in globals.css add a name here, nothing else.
* chevrons pointing "start"/"end", the send paper-plane). AppIcon applies the flip via a
* `data-icon-directional` attribute + the single CSS rule in globals.css add a
* name here, nothing else.
*/
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
export const DIRECTIONAL_ICONS = new Set<IconName>([
'back',
'chevron_start',
'chevron_end',
'forward',
'send',
]);
@@ -11,8 +11,15 @@ import { IconProps } from '../utils';
* LogoLockup.tsx instead.
* @component LogoMark
*/
const LogoMark: FunctionComponent<IconProps> = ({ color = 'currentColor', ...props }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" {...props}>
const LogoMark: FunctionComponent<IconProps> = ({
color = 'currentColor',
size = 24,
// Swallowed on purpose: the registry hands every icon Lucide's stroke weight, and this mark is
// solid-fill — letting it through would emit a meaningless stroke-width attribute.
strokeWidth: _strokeWidth,
...props
}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width={size} height={size} {...props}>
<path
fill={color}
fillRule="evenodd"
@@ -1,11 +1,16 @@
import { SVGAttributes } from 'react';
/**
* Props to use with custom SVG icons, similar to AppIcon's Props
* Props a custom SVG icon must accept to sit in the same registry as the Lucide set.
* Mirrors Lucide's own `LucideProps` shape (`size` drives width/height, `color` paints,
* `strokeWidth` is accepted and may be ignored by a solid-fill mark) so `AppIcon` can
* render every registry entry through one uniform call.
*/
export interface IconProps extends SVGAttributes<SVGElement> {
export interface IconProps extends Omit<SVGAttributes<SVGElement>, 'color'> {
color?: string;
icon?: string;
size?: string | number;
/** Accepted for contract parity with Lucide; a solid-fill brand mark ignores it. */
strokeWidth?: string | number;
title?: string;
}

Some files were not shown because too many files have changed in this diff Show More