manual improvement 1
This commit is contained in:
@@ -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')}
|
||||
|
||||
+2
-2
@@ -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)" />
|
||||
|
||||
+3
-3
@@ -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',
|
||||
|
||||
+1
-1
@@ -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' }}>
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+1
-1
@@ -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')}
|
||||
|
||||
+5
-5
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 2–3 trust facts under the login card — what Balinyaar actually verifies and escrows
|
||||
* The 2–3 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user