ui phase 1

This commit is contained in:
hamid
2026-07-17 17:10:39 +03:30
parent f1cba6cf74
commit 370c1beefa
151 changed files with 4254 additions and 1840 deletions
@@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AccentCard from './AccentCard';
describe('<AccentCard/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders its children with the tone data attribute', () => {
wrap(<AccentCard tone="success">content</AccentCard>);
expect(screen.getByText('content')).toBeInTheDocument();
expect(screen.getByText('content').closest('[data-accent-tone]')).toHaveAttribute('data-accent-tone', 'success');
});
});
@@ -0,0 +1,41 @@
import { FunctionComponent } from 'react';
import SurfaceCard, { SurfaceCardProps } from '../SurfaceCard';
export type AccentTone = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' | 'trust' | 'neutral';
const ACCENT_WIDTH = '4px';
const TONE_VAR: Record<AccentTone, string> = {
primary: 'var(--bal-primary)',
secondary: 'var(--bal-secondary)',
success: 'var(--bal-success)',
error: 'var(--bal-error)',
warning: 'var(--bal-warning)',
info: 'var(--bal-info)',
trust: 'var(--bal-trust)',
neutral: 'var(--bal-text-secondary)',
};
export interface AccentCardProps extends SurfaceCardProps {
/** Semantic tone driving the `borderInlineStart` accent stripe — never a raw color. */
tone: AccentTone;
}
/**
* `SurfaceCard` plus a `borderInlineStart` accent stripe at one standardized width (4px — this app
* previously drifted between 3px and 4px across `EarningsBalanceHeader`/`PayoutHistoryRow` vs
* `BankStatusPanel`/`DocumentUpload`). Logical property, so the stripe sits on the correct edge under
* RTL automatically. Used for stateful panels (bank ownership, upload state, a nurse's earnings header).
* @component AccentCard
*/
const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, sx, children, ...rest }) => (
<SurfaceCard
sx={{ borderInlineStart: `${ACCENT_WIDTH} solid ${TONE_VAR[tone]}`, ...sx }}
data-accent-tone={tone}
{...rest}
>
{children}
</SurfaceCard>
);
export default AccentCard;
@@ -0,0 +1,4 @@
import AccentCard from './AccentCard';
export default AccentCard;
export type { AccentCardProps, AccentTone } from './AccentCard';
@@ -93,8 +93,9 @@ 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 navigation, share/copy, attachments
// 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';
@@ -197,6 +198,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
assign: AssignIcon,
back: BackIcon,
chevron_start: BackIcon,
forward: ForwardIcon,
share: ShareIcon,
copy: CopyIcon,
attachment: AttachmentIcon,
@@ -207,4 +209,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
* 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.
*/
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start']);
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']);
@@ -0,0 +1,24 @@
import { render } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import AppLoading from './AppLoading';
describe('<AppLoading/>', () => {
it('renders a status region with the brand mark', () => {
const { container } = render(
<ThemeProvider>
<AppLoading />
</ThemeProvider>,
);
expect(container.querySelector('[role="status"]')).toBeInTheDocument();
expect(container.querySelector('[data-icon="logo"]')).toBeInTheDocument();
});
it('forwards sx overrides to the root', () => {
const { container } = render(
<ThemeProvider>
<AppLoading data-testid="loading-root" />
</ThemeProvider>,
);
expect(container.querySelector('[data-testid="loading-root"]')).toBeInTheDocument();
});
});
@@ -1,36 +1,36 @@
'use client'
import { FunctionComponent } from 'react';
import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
import { keyframes } from '@emotion/react';
import Stack, { StackProps } from '@mui/material/Stack';
import AppIcon from '../AppIcon';
interface Props extends StackProps {
color?: CircularProgressProps['color'];
size?: number | string;
type?: 'circular' | 'linear';
value?: number;
}
export type AppLoadingProps = StackProps;
const breathe = keyframes`
0%, 100% { opacity: 0.55; transform: scale(0.92); }
50% { opacity: 1; transform: scale(1); }
`;
/**
* Renders MI circular progress centered inside Stack
* The branded full-page loading splash — the Balinyaar mark with a subtle breathing motion, respecting
* `prefers-reduced-motion` (the animation only applies under the `no-preference` media query; a
* reduced-motion user sees a static, still-legible mark). Reserved for **true full-page waits** (the auth
* splash, a payment-gateway return, a route-level `Suspense` fallback) — inline/list loading should reach
* for a shaped skeleton (a co-located `<X.Skeleton />` twin) instead, never a bare spinner.
* @component AppLoading
* @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
*/
const AppLoading: FunctionComponent<Props> = ({
color = APP_LOADING_COLOR,
size = APP_LOADING_SIZE,
type = APP_LOADING_TYPE,
value,
...restOfProps
}) => {
const alignItems = type === 'linear' ? undefined : 'center';
return (
<Stack sx={{ my: 2, alignItems }} {...restOfProps}>
{type === 'linear' ? (
<LinearProgress color={color} value={value} />
) : (
<CircularProgress color={color} size={size} value={value} />
)}
const AppLoading: FunctionComponent<AppLoadingProps> = ({ sx, ...rest }) => (
<Stack role="status" sx={{ my: 4, alignItems: 'center', justifyContent: 'center', ...sx }} {...rest}>
<Stack
sx={{
'@media (prefers-reduced-motion: no-preference)': {
animation: `${breathe} 1.6s ease-in-out infinite`,
},
}}
>
<AppIcon icon="logo" size={48} color="var(--bal-primary)" />
</Stack>
);
};
</Stack>
);
export default AppLoading;
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import ConfirmDialog from './ConfirmDialog';
describe('<ConfirmDialog/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const base = {
title: 'Run batch?',
confirmLabel: 'Run',
cancelLabel: 'Cancel',
onClose: jest.fn(),
};
it('renders the title and body when open', () => {
wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />);
expect(screen.getByText('Run batch?')).toBeInTheDocument();
expect(screen.getByText('This moves money')).toBeInTheDocument();
});
it('calls onConfirm with no reason for a plain confirm', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('disables confirm until a required reason is entered, then passes it', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
expect(confirmBtn).toBeDisabled();
fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith('bad docs');
});
it('calls onClose from cancel', () => {
const onClose = jest.fn();
wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import {
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from '@mui/material';
import AppButton from '../AppButton';
export interface ConfirmDialogProps {
open: boolean;
/** Already-translated title. */
title: string;
/** Already-translated body (string or node). */
body?: ReactNode;
confirmLabel: string;
cancelLabel: string;
/** Called with the entered reason (undefined when `requireReason` is false). */
onConfirm: (reason?: string) => void;
onClose: () => void;
loading?: boolean;
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
/**
* The shared confirmation dialog behind every audited/irreversible action — approve/reject a
* verification, run/retry a payout, save a config, resolve an alert, archive a patient, delete an
* address. Optionally collects a **required reason** (reject/hide/resolve) and disables confirm until it
* is provided. The dialog owns the reason field only; the caller owns the mutation and closes on success.
* `loading` disables the buttons and shows a spinner so a double-submit is impossible. Promoted from
* `components/admin` (kept there as a thin alias) — the contract is unchanged.
* @component ConfirmDialog
*/
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
open,
title,
body,
confirmLabel,
cancelLabel,
onConfirm,
onClose,
loading = false,
requireReason = false,
reasonLabel,
reasonPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const close = () => {
setReason('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 700 }}>{title}</DialogTitle>
<DialogContent>
{body ? (
typeof body === 'string' ? (
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
) : (
body
)
) : null}
{requireReason ? (
<TextField
autoFocus
fullWidth
multiline
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
label={reasonLabel}
placeholder={reasonPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
{cancelLabel}
</AppButton>
<AppButton
variant="contained"
color={confirmColor}
onClick={confirm}
disabled={confirmDisabled}
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{confirmLabel}
</AppButton>
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;
@@ -0,0 +1,4 @@
import ConfirmDialog from './ConfirmDialog';
export default ConfirmDialog;
export type { ConfirmDialogProps } from './ConfirmDialog';
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import EmptyState from './EmptyState';
describe('<EmptyState/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the title and body', () => {
wrap(<EmptyState title="Nothing here" body="Add your first patient" />);
expect(screen.getByText('Nothing here')).toBeInTheDocument();
expect(screen.getByText('Add your first patient')).toBeInTheDocument();
});
it('omits the body when not given', () => {
wrap(<EmptyState title="Empty" />);
expect(screen.getByText('Empty')).toBeInTheDocument();
});
it('renders the action slot when provided', () => {
wrap(<EmptyState title="Empty" action={<button>Add</button>} />);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
import { FunctionComponent, ReactNode } from 'react';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '../AppIcon';
export interface EmptyStateProps {
/** AppIcon registry name — already-translated copy is the caller's job, the icon is decorative. */
icon?: string;
/** Already-translated title. */
title: string;
/** Already-translated body — a string for the common case, or a node for richer copy (e.g. a list of
* suggestions). */
body?: ReactNode;
/** Optional call-to-action (e.g. an `AppButton`). */
action?: ReactNode;
}
/**
* The calm, branded "nothing here" panel — replaces the dashed-border `Paper` blocks hand-rolled across
* the app. A flat card (phase-0 surface/radius tokens) with a soft-primary icon roundel, never alarming.
* Presentational + caller-owned i18n; the illustration set (per-domain artwork) is DEFERRED to phase 12.
* @component EmptyState
*/
const EmptyState: FunctionComponent<EmptyStateProps> = ({ icon = 'info', title, body, action }) => (
<Paper
elevation={0}
data-empty-state
sx={{
p: { xs: 3, sm: 5 },
textAlign: 'center',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'var(--bal-bg-paper)',
}}
>
<Stack sx={{ alignItems: 'center', gap: 1.5 }}>
<Stack
sx={{
width: 56,
height: 56,
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
</Stack>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
typeof body === 'string' ? (
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
{body}
</Typography>
) : (
<Stack sx={{ color: 'text.secondary', maxWidth: 360 }}>{body}</Stack>
)
) : null}
{action ? <Stack sx={{ mt: 0.5 }}>{action}</Stack> : null}
</Stack>
</Paper>
);
export default EmptyState;
@@ -0,0 +1,4 @@
import EmptyState from './EmptyState';
export default EmptyState;
export type { EmptyStateProps } from './EmptyState';
@@ -0,0 +1,62 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import ErrorBoundary from './ErrorBoundary';
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('boom');
return <div>safe content</div>;
}
const COPY = { title: 'Something went wrong', body: 'Please try again.', retryLabel: 'Retry' };
describe('<ErrorBoundary/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders children when nothing throws', () => {
wrap(
<ErrorBoundary name="Test" {...COPY}>
<Bomb shouldThrow={false} />
</ErrorBoundary>,
);
expect(screen.getByText('safe content')).toBeInTheDocument();
});
it('renders the branded fallback with a retry affordance when a child throws', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
wrap(
<ErrorBoundary name="Test" {...COPY}>
<Bomb shouldThrow={true} />
</ErrorBoundary>,
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
errorSpy.mockRestore();
});
it('re-renders children after retry is clicked (state resets)', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { rerender } = render(
<ThemeProvider>
<ErrorBoundary name="Test" {...COPY}>
<Bomb shouldThrow={true} />
</ErrorBoundary>
</ThemeProvider>,
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
// Swap in a child that no longer throws BEFORE retrying — the boundary's `hasError` state
// only clears on retry, and re-rendering while it's still true keeps showing the fallback
// (it doesn't re-invoke the previously-thrown children). Retry re-renders `this.props.children`
// with whatever was most recently passed down, so the non-throwing swap must land first.
rerender(
<ThemeProvider>
<ErrorBoundary name="Test" {...COPY}>
<Bomb shouldThrow={false} />
</ErrorBoundary>
</ThemeProvider>,
);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(screen.getByText('safe content')).toBeInTheDocument();
errorSpy.mockRestore();
});
});
+51 -24
View File
@@ -1,9 +1,20 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
import { Stack, Typography } from '@mui/material';
import AppButton from './AppButton';
import AppIcon from './AppIcon';
interface Props {
export interface ErrorBoundaryProps {
children: ReactNode;
/** Diagnostic-only segment name (console log label) — never shown to the user. */
name: string;
/** Already-translated fallback copy — kept presentational/caller-owned (like every other shared
* primitive in this kit) so this file never imports next-intl; it sits at the top of the `common`
* barrel, so an eager i18n dependency here would drag `next-intl` into every test that imports
* anything from `@/components`, mocked or not. */
title: string;
body: string;
retryLabel: string;
}
interface State {
@@ -13,47 +24,63 @@ interface State {
}
/**
* Error boundary wrapper to save Application parts from falling
* Error boundary wrapping a subtree of the app (a shell's content area). Renders a branded fallback with
* a retry affordance (re-mounts the children by clearing `hasError`); the raw error + component stack are
* **logged, never shown**, in production — only a development build renders the dev-only `<details>`
* block.
* @component ErrorBoundary
* @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
*/
class ErrorBoundary extends Component<Props, State> {
static defaultProps = {
name: 'Error Boundary',
};
constructor(props: Props) {
class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
// The next render will show the Error UI
return { hasError: true };
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Save information to help render Error UI
this.setState({ error, errorInfo });
// TODO: Add log error messages to an error reporting service here
console.error(`[ErrorBoundary:${this.props.name}]`, error, errorInfo.componentStack);
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
render() {
if (this.state.hasError) {
// Error UI rendering
const { error, errorInfo } = this.state;
return (
<div>
<h2>{this.props.name} - Something went wrong</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state?.error?.toString()}
<br />
{this.state?.errorInfo?.componentStack}
</details>
</div>
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}>
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{this.props.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
{this.props.body}
</Typography>
<AppButton color="primary" variant="outlined" onClick={this.handleRetry}>
{this.props.retryLabel}
</AppButton>
{process.env.NODE_ENV !== 'production' && (error || errorInfo) ? (
<Typography
component="details"
variant="caption"
sx={{ color: 'text.secondary', maxWidth: 480, textAlign: 'start', whiteSpace: 'pre-wrap' }}
>
<Typography component="summary" variant="caption" sx={{ cursor: 'pointer' }}>
Dev-only error detail
</Typography>
{error?.toString()}
{'\n'}
{errorInfo?.componentStack}
</Typography>
) : null}
</Stack>
);
}
// Normal UI rendering
return this.props.children;
}
}
@@ -0,0 +1,20 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import ErrorState from './ErrorState';
describe('<ErrorState/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the message and calls onRetry on click', () => {
const onRetry = jest.fn();
wrap(<ErrorState message="Couldn't load" retryLabel="Retry" onRetry={onRetry} />);
expect(screen.getByText("Couldn't load")).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('renders the caller-supplied retry label', () => {
wrap(<ErrorState message="Failed" retryLabel="Try again" onRetry={jest.fn()} />);
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { FunctionComponent } from 'react';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppButton from '../AppButton';
import AppIcon from '../AppIcon';
export interface ErrorStateProps {
/** Already-translated message — page-specific copy stays the caller's job. */
message: string;
/** Already-translated retry label (e.g. `t('common.retry')`) — kept caller-owned rather than a
* component-internal default so this file never imports next-intl: it sits at the top of the
* `common` barrel, and an eager i18n dependency there would drag `next-intl` into every test that
* imports anything from `@/components`, mocked or not (see `ErrorBoundary` for the same rule). */
retryLabel: string;
/** Required — the convention this component exists to enforce: an error is never a dead end. */
onRetry: () => void;
}
/**
* The calm, branded "something went wrong" panel with a **required** retry affordance — the fix for the
* false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is
* mandatory by the type; there is no way to render this component without a way back. Presentational,
* caller-owned i18n throughout — everything is already-translated copy.
* @component ErrorState
*/
const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => (
<Paper
elevation={0}
data-error-state
sx={{
p: { xs: 3, sm: 5 },
textAlign: 'center',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'var(--bal-bg-paper)',
}}
>
<Stack sx={{ alignItems: 'center', gap: 1.5 }}>
<Stack
sx={{
width: 56,
height: 56,
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-error-soft)',
}}
>
<AppIcon icon="error" size={28} color="var(--bal-error)" />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry}>
{retryLabel}
</AppButton>
</Stack>
</Paper>
);
export default ErrorState;
@@ -0,0 +1,4 @@
import ErrorState from './ErrorState';
export default ErrorState;
export type { ErrorStateProps } from './ErrorState';
@@ -0,0 +1,28 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useLocale: () => 'en',
}));
import JalaliDateField from './JalaliDateField';
describe('<JalaliDateField/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders a read-only input showing the formatted value', () => {
wrap(<JalaliDateField value="2026-07-17" onChange={jest.fn()} label="Date" />);
const input = screen.getByLabelText('Date') as HTMLInputElement;
expect(input).toHaveAttribute('readOnly');
expect(input.value).not.toBe('');
});
it('opens the picker popover on click and emits the picked ISO date', () => {
const onChange = jest.fn();
wrap(<JalaliDateField value="2026-07-15" onChange={onChange} label="Date" />);
fireEvent.click(screen.getByLabelText('Date'));
expect(screen.getByText('July 2026')).toBeInTheDocument();
fireEvent.click(screen.getByText('20'));
expect(onChange).toHaveBeenCalledWith('2026-07-20');
});
});
@@ -0,0 +1,91 @@
'use client';
import { FunctionComponent, MouseEvent, useState } from 'react';
import { useLocale } from 'next-intl';
import Popover from '@mui/material/Popover';
import TextField, { TextFieldProps } from '@mui/material/TextField';
import Box from '@mui/material/Box';
import { formatShamsiDate } from '@/utils';
import AppIcon from '../AppIcon';
import JalaliDatePicker from '../JalaliDatePicker';
export interface JalaliDateFieldProps
extends Omit<TextFieldProps, 'value' | 'onChange' | 'select' | 'type' | 'InputProps'> {
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */
value: string | null;
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
onChange: (iso: string) => void;
min?: string;
max?: string;
/** Already-translated accessible labels forwarded to the picker's month-navigation buttons. */
prevMonthLabel?: string;
nextMonthLabel?: string;
}
/**
* A read-only text field that opens `JalaliDatePicker` (grid variant) in a popover — the replacement for
* every native `<input type="date">` in the app (which only ever rendered the Gregorian calendar). Display
* is Shamsi on `fa` / Gregorian on `en` via the same `utils/date.ts` formatter used everywhere else in the
* app; the emitted value is always ISO Gregorian.
* @component JalaliDateField
*/
const JalaliDateField: FunctionComponent<JalaliDateFieldProps> = ({
value,
onChange,
min,
max,
prevMonthLabel,
nextMonthLabel,
...textFieldProps
}) => {
const locale = useLocale();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
// MUI Popover's anchor/transform origins are physical (left/right), not logical — resolve the reading
// "start" edge from the locale ourselves so the popover opens on the correct side under RTL.
const startEdge = locale === 'fa' ? 'right' : 'left';
const open = (event: MouseEvent<HTMLDivElement>) => setAnchorEl(event.currentTarget);
const close = () => setAnchorEl(null);
const displayValue = value ? formatShamsiDate(value, locale) : '';
return (
<>
<TextField
{...textFieldProps}
value={displayValue}
onClick={open}
slotProps={{
input: {
readOnly: true,
endAdornment: <AppIcon icon="calendar" size={20} color="var(--bal-text-secondary)" />,
sx: { cursor: 'pointer' },
},
htmlInput: { readOnly: true, style: { cursor: 'pointer' } },
}}
/>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: startEdge }}
transformOrigin={{ vertical: 'top', horizontal: startEdge }}
>
<Box sx={{ p: 2, width: 320 }}>
<JalaliDatePicker
value={value}
min={min}
max={max}
prevMonthLabel={prevMonthLabel}
nextMonthLabel={nextMonthLabel}
onChange={(iso) => {
onChange(iso);
close();
}}
/>
</Box>
</Popover>
</>
);
};
export default JalaliDateField;
@@ -0,0 +1,4 @@
import JalaliDateField from './JalaliDateField';
export default JalaliDateField;
export type { JalaliDateFieldProps } from './JalaliDateField';
@@ -0,0 +1,57 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useLocale: () => 'en',
}));
import JalaliDatePicker from './JalaliDatePicker';
import { gregorianEngine, todayIso } from './calendarEngine';
describe('<JalaliDatePicker/> — grid variant', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('emits the ISO Gregorian date of the clicked day', () => {
const onChange = jest.fn();
const value = '2026-07-01';
wrap(<JalaliDatePicker value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('15'));
expect(onChange).toHaveBeenCalledWith('2026-07-15');
});
it('marks today distinctly', () => {
const { container } = wrap(<JalaliDatePicker value={null} onChange={jest.fn()} />);
expect(container.querySelector(`[data-day="${todayIso()}"][data-today="true"]`)).toBeInTheDocument();
});
it('disables days outside the min/max range', () => {
const cursorMonth = gregorianEngine.fromIso('2026-07-15');
const min = gregorianEngine.toIso(cursorMonth.year, cursorMonth.month, 10);
wrap(<JalaliDatePicker value="2026-07-15" min={min} onChange={jest.fn()} />);
const early = screen.getByText('5');
expect(early.closest('button')).toBeDisabled();
});
it('navigates to the next month', () => {
const { container } = wrap(<JalaliDatePicker value="2026-07-15" onChange={jest.fn()} />);
expect(screen.getByText('July 2026')).toBeInTheDocument();
const nextButton = container.querySelector('[data-icon="forward"]')?.closest('button');
expect(nextButton).not.toBeNull();
fireEvent.click(nextButton as HTMLButtonElement);
expect(screen.getByText('August 2026')).toBeInTheDocument();
});
});
describe('<JalaliDatePicker/> — chips variant', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders chipDayCount tappable chips starting today and emits ISO on click', () => {
const onChange = jest.fn();
const { container } = wrap(<JalaliDatePicker variant="chips" value={null} onChange={onChange} chipDayCount={5} />);
const chips = container.querySelectorAll('[data-day]');
expect(chips.length).toBe(5);
expect(chips[0]).toHaveAttribute('data-day', todayIso());
fireEvent.click(chips[1]);
expect(onChange).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,211 @@
'use client';
import { FunctionComponent, KeyboardEvent, useMemo, useRef, useState } from 'react';
import { useLocale } from 'next-intl';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIconButton from '../AppIconButton';
import { formatNumber, localeTag } from '@/utils';
import { addDaysIso, engineForLocale, todayIso, type CalendarCursor } from './calendarEngine';
export type JalaliDatePickerVariant = 'grid' | 'chips';
export interface JalaliDatePickerProps {
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */
value: string | null;
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
onChange: (iso: string) => void;
/** Inclusive ISO lower bound. */
min?: string;
/** Inclusive ISO upper bound. */
max?: string;
/** `grid` (default) — a month calendar with navigation. `chips` — a horizontal day-chip strip for near
* dates (the C4 booking-form shape), starting from `min` (or today) and running `chipDayCount` days. */
variant?: JalaliDatePickerVariant;
chipDayCount?: number;
/** Already-translated accessible label for the previous/next-month buttons. */
prevMonthLabel?: string;
nextMonthLabel?: string;
}
const GRID_COLUMNS = 7;
/**
* Shamsi-native date selection. Renders Jalaali on `fa`, plain Gregorian on `en` — always **emits and
* accepts ISO Gregorian** (the wire never sees a Jalaali date; display-only, per the money-and-types
* convention already established for Shamsi dates in `utils/date.ts`). See `calendarEngine.ts` for the
* arithmetic decision. Keyboard-navigable (arrow keys roving across the grid, Enter/Space to pick — RTL
* flips left/right automatically), `min`/`max` bound both variants.
* @component JalaliDatePicker
*/
const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
value,
onChange,
min,
max,
variant = 'grid',
chipDayCount = 14,
prevMonthLabel,
nextMonthLabel,
}) => {
const locale = useLocale();
const engine = useMemo(() => engineForLocale(locale), [locale]);
const dir = locale === 'fa' ? 'rtl' : 'ltr';
const initialCursor = useMemo<CalendarCursor>(() => {
const base = value ?? min ?? todayIso();
const { year, month } = engine.fromIso(base);
return { year, month };
}, [value, min, engine]);
const [cursor, setCursor] = useState<CalendarCursor>(initialCursor);
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
if (variant === 'chips') {
const startIso = min && min > todayIso() ? min : todayIso();
const days = Array.from({ length: chipDayCount }, (_, i) => addDaysIso(startIso, i));
return (
<Stack
direction="row"
dir={dir}
data-jalali-picker="chips"
sx={{ gap: 1, overflowX: 'auto', pb: 0.5, WebkitOverflowScrolling: 'touch' }}
>
{days.map((dayIso) => {
const disabled = Boolean(max && dayIso > max);
const selected = value === dayIso;
const cell = engine.fromIso(dayIso);
const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format(
new Date(`${dayIso}T00:00:00`),
);
return (
<Box
key={dayIso}
component="button"
type="button"
disabled={disabled}
onClick={() => onChange(dayIso)}
data-day={dayIso}
data-selected={selected ? 'true' : undefined}
sx={{
flexShrink: 0,
minWidth: 56,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
px: 1.5,
py: 1,
borderRadius: 'var(--bal-radius-sm)',
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'var(--bal-divider)',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent',
color: disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekday}
</Typography>
<Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}>
{formatNumber(cell.day, locale)}
</Typography>
</Box>
);
})}
</Stack>
);
}
const daysInMonth = engine.daysInMonth(cursor.year, cursor.month);
const offset = engine.firstWeekdayOffset(cursor.year, cursor.month);
const cells: Array<{ iso: string; day: number } | null> = [
...Array.from({ length: offset }, () => null),
...Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
return { iso: engine.toIso(cursor.year, cursor.month, day), day };
}),
];
const todayIsoValue = todayIso();
const goToMonth = (delta: number) => setCursor((c) => engine.addMonths(c, delta));
const focusCell = (index: number) => {
const el = cellRefs.current[index];
if (el && !el.disabled) el.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const deltas: Record<string, number> = {
ArrowRight: dir === 'rtl' ? -1 : 1,
ArrowLeft: dir === 'rtl' ? 1 : -1,
ArrowDown: GRID_COLUMNS,
ArrowUp: -GRID_COLUMNS,
};
const delta = deltas[event.key];
if (delta === undefined) return;
event.preventDefault();
focusCell(index + delta);
};
return (
<Stack dir={dir} data-jalali-picker="grid" sx={{ gap: 1.5, width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<AppIconButton icon="back" title={prevMonthLabel} onClick={() => goToMonth(-1)} iconProps={{ size: 18 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{engine.monthYearLabel(cursor, locale === 'fa' ? 'fa-IR-u-ca-persian' : 'en-US')}
</Typography>
<AppIconButton icon="forward" title={nextMonthLabel} onClick={() => goToMonth(1)} iconProps={{ size: 18 }} />
</Stack>
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${GRID_COLUMNS}, 1fr)`, gap: 0.5 }}>
{engine.weekdayLabels(localeTag(locale)).map((label, i) => (
<Typography key={i} variant="caption" align="center" sx={{ color: 'text.secondary', fontWeight: 500 }}>
{label}
</Typography>
))}
{cells.map((cell, index) => {
if (!cell) return <Box key={`pad-${index}`} />;
const disabled = Boolean((min && cell.iso < min) || (max && cell.iso > max));
const selected = value === cell.iso;
const isToday = cell.iso === todayIsoValue;
return (
<Box
key={cell.iso}
component="button"
type="button"
ref={(el: HTMLElement | null) => {
cellRefs.current[index] = el as HTMLButtonElement | null;
}}
disabled={disabled}
tabIndex={selected || (!value && isToday) ? 0 : -1}
onClick={() => onChange(cell.iso)}
onKeyDown={(e) => handleKeyDown(e, index)}
data-day={cell.iso}
data-selected={selected ? 'true' : undefined}
data-today={isToday ? 'true' : undefined}
sx={{
aspectRatio: '1 / 1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
border: isToday && !selected ? '1px solid var(--bal-primary)' : '1px solid transparent',
bgcolor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)',
fontWeight: selected ? 700 : 500,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
fontSize: '0.875rem',
'&:hover': disabled ? undefined : { bgcolor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)' },
}}
>
{formatNumber(cell.day, locale)}
</Box>
);
})}
</Box>
</Stack>
);
};
export default JalaliDatePicker;
@@ -0,0 +1,45 @@
import { jalaliEngine, gregorianEngine, addDaysIso } from './calendarEngine';
describe('jalaliEngine', () => {
it('converts a known Gregorian ISO date to Jalaali', () => {
// Reference pair documented by jalaali-js itself.
expect(jalaliEngine.fromIso('2016-04-11')).toEqual({ year: 1395, month: 1, day: 23 });
});
it('round-trips Jalaali -> ISO -> Jalaali', () => {
const iso = jalaliEngine.toIso(1404, 5, 15);
expect(jalaliEngine.fromIso(iso)).toEqual({ year: 1404, month: 5, day: 15 });
});
it('knows Esfand length across a leap and a non-leap year', () => {
expect(jalaliEngine.daysInMonth(1394, 12)).toBe(29);
expect(jalaliEngine.daysInMonth(1395, 12)).toBe(30);
});
it('adds months across a year boundary', () => {
expect(jalaliEngine.addMonths({ year: 1404, month: 11 }, 2)).toEqual({ year: 1405, month: 1 });
expect(jalaliEngine.addMonths({ year: 1404, month: 2 }, -3)).toEqual({ year: 1403, month: 11 });
});
});
describe('gregorianEngine', () => {
it('is the identity conversion', () => {
expect(gregorianEngine.fromIso('2026-07-17')).toEqual({ year: 2026, month: 7, day: 17 });
expect(gregorianEngine.toIso(2026, 7, 17)).toBe('2026-07-17');
});
it('knows February length across a leap and a non-leap year', () => {
expect(gregorianEngine.daysInMonth(2023, 2)).toBe(28);
expect(gregorianEngine.daysInMonth(2024, 2)).toBe(29);
});
});
describe('addDaysIso', () => {
it('adds days across a month boundary', () => {
expect(addDaysIso('2026-07-30', 3)).toBe('2026-08-02');
});
it('subtracts days across a year boundary', () => {
expect(addDaysIso('2026-01-01', -1)).toBe('2025-12-31');
});
});
@@ -0,0 +1,149 @@
/**
* The arithmetic layer behind `JalaliDatePicker`/`JalaliDateField`. Two small, pure "calendar engines"
* (Jalaali for `fa`, plain Gregorian for `en`) share one shape so the picker's grid/navigation logic is
* calendar-agnostic; both always emit/accept **ISO Gregorian** `YYYY-MM-DD` — the wire never sees Jalaali.
*
* Arithmetic decision: `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is enough to
* *display* a Shamsi date, but has no reverse direction — there is no way to ask Intl "what Gregorian
* date is Jalaali 1404/5/15", which is exactly what a date *picker* (as opposed to a date *label*) needs
* for month navigation and emitting the selected day. `jalaali-js` (MIT, zero runtime deps, ~9KB
* unminified / low single-digit KB gzipped — see the phase report) supplies that reverse conversion
* (`toGregorian`) plus month-length/leap-year math, so it's used here instead of hand-rolling the
* Borkowski algorithm. Intl is still used for locale-correct month/weekday *names* — jalaali-js only
* supplies numbers.
*/
import { toJalaali, toGregorian, jalaaliMonthLength } from 'jalaali-js';
export interface CalendarCursor {
year: number;
month: number; // 1-based
}
export interface CalendarEngine {
today(): CalendarCursor & { day: number };
/** Parses a wire ISO (Gregorian) date into this calendar's year/month/day. */
fromIso(iso: string): CalendarCursor & { day: number };
/** Emits the wire ISO (Gregorian) date for a day in this calendar. */
toIso(year: number, month: number, day: number): string;
daysInMonth(year: number, month: number): number;
addMonths(cursor: CalendarCursor, delta: number): CalendarCursor;
/** 0-based offset of day 1 within the week grid (this engine's week-start convention). */
firstWeekdayOffset(year: number, month: number): number;
monthYearLabel(cursor: CalendarCursor, locale: string): string;
/** 7 short weekday labels, in this engine's week-start order. */
weekdayLabels(locale: string): string[];
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function isoOf(y: number, m: number, d: number): string {
return `${y}-${pad2(m)}-${pad2(d)}`;
}
function parseIso(iso: string): { y: number; m: number; d: number } {
const [y, m, d] = iso.split('-').map(Number);
return { y, m, d };
}
/** Weekday of a Gregorian y/m/d as a UTC-anchored computation — immune to the runtime's local timezone. */
function utcWeekday(y: number, m: number, d: number): number {
return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sunday … 6 = Saturday
}
function addMonthsGeneric(cursor: CalendarCursor, delta: number): CalendarCursor {
const total = cursor.year * 12 + (cursor.month - 1) + delta;
const year = Math.floor(total / 12);
const month = ((total % 12) + 12) % 12;
return { year, month: month + 1 };
}
export const jalaliEngine: CalendarEngine = {
today() {
const { jy, jm, jd } = toJalaali(new Date());
return { year: jy, month: jm, day: jd };
},
fromIso(iso) {
const { y, m, d } = parseIso(iso);
const { jy, jm, jd } = toJalaali(y, m, d);
return { year: jy, month: jm, day: jd };
},
toIso(year, month, day) {
const { gy, gm, gd } = toGregorian(year, month, day);
return isoOf(gy, gm, gd);
},
daysInMonth(year, month) {
return jalaaliMonthLength(year, month);
},
addMonths: addMonthsGeneric,
firstWeekdayOffset(year, month) {
const { gy, gm, gd } = toGregorian(year, month, 1);
// Jalaali weeks start Saturday; utcWeekday is Sunday-based, so shift by one.
return (utcWeekday(gy, gm, gd) + 1) % 7;
},
monthYearLabel(cursor, locale) {
const { gy, gm, gd } = toGregorian(cursor.year, cursor.month, 1);
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(gy, gm - 1, gd));
},
weekdayLabels(locale) {
// A known Saturday (2024-01-06 is a Saturday) anchors the fa week-start order.
const anchor = new Date(2024, 0, 6);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(anchor);
d.setDate(anchor.getDate() + i);
return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d);
});
},
};
export const gregorianEngine: CalendarEngine = {
today() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() };
},
fromIso(iso) {
const { y, m, d } = parseIso(iso);
return { year: y, month: m, day: d };
},
toIso: isoOf,
daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
},
addMonths: addMonthsGeneric,
firstWeekdayOffset(year, month) {
return utcWeekday(year, month, 1); // Gregorian week starts Sunday — no shift needed.
},
monthYearLabel(cursor, locale) {
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(
new Date(cursor.year, cursor.month - 1, 1),
);
},
weekdayLabels(locale) {
// A known Sunday (2024-01-07) anchors the en week-start order.
const anchor = new Date(2024, 0, 7);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(anchor);
d.setDate(anchor.getDate() + i);
return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d);
});
},
};
/** Selects the display calendar for the app locale — `fa` gets Jalaali, everything else Gregorian. */
export function engineForLocale(locale: string): CalendarEngine {
return locale === 'fa' ? jalaliEngine : gregorianEngine;
}
/** Adds whole days to an ISO (Gregorian) date via UTC arithmetic — DST-safe, calendar-agnostic. */
export function addDaysIso(iso: string, days: number): string {
const { y, m, d } = parseIso(iso);
const next = new Date(Date.UTC(y, m - 1, d) + days * 86_400_000);
return isoOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate());
}
/** Today's date as a wire ISO string, from the local wall clock. */
export function todayIso(): string {
const now = new Date();
return isoOf(now.getFullYear(), now.getMonth() + 1, now.getDate());
}
@@ -0,0 +1,6 @@
import JalaliDatePicker from './JalaliDatePicker';
export default JalaliDatePicker;
export type { JalaliDatePickerProps, JalaliDatePickerVariant } from './JalaliDatePicker';
export { engineForLocale, jalaliEngine, gregorianEngine, addDaysIso, todayIso } from './calendarEngine';
export type { CalendarEngine, CalendarCursor } from './calendarEngine';
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key),
useLocale: () => 'en',
}));
import Money from './Money';
describe('<Money/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the Toman-formatted amount with the unit label', () => {
wrap(<Money amountIrr="45000000" />);
expect(screen.getByText(/4,500,000/)).toBeInTheDocument();
expect(screen.getByText(/Toman/)).toBeInTheDocument();
});
it('hides the unit label when hideUnit is set', () => {
wrap(<Money amountIrr="45000000" hideUnit />);
expect(screen.queryByText(/Toman/)).not.toBeInTheDocument();
});
it('prefixes an explicit minus sign for a deduction, held with the digits', () => {
wrap(<Money amountIrr="45000000" deduction />);
const el = screen.getByText((_, node) => node?.textContent === '4,500,000');
expect(el).toHaveAttribute('dir', 'ltr');
});
it('marks the root with data-deduction when a deduction', () => {
const { container } = render(
<ThemeProvider>
<Money amountIrr="1000" deduction />
</ThemeProvider>,
);
expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,92 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Box from '@mui/material/Box';
import Typography, { TypographyProps } from '@mui/material/Typography';
import { formatIrrToToman } from '@/utils';
export type MoneySize = 'sm' | 'md' | 'lg';
export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error';
const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = {
sm: 'body2',
md: 'body1',
lg: 'h6',
};
// House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise.
const TONE_STYLE: Record<MoneyTone, { color: string; fontWeight: 400 | 500 | 700 }> = {
default: { color: 'inherit', fontWeight: 500 },
emphasis: { color: 'var(--bal-money-emphasis)', fontWeight: 700 },
muted: { color: 'var(--bal-text-secondary)', fontWeight: 400 },
error: { color: 'var(--bal-error)', fontWeight: 700 },
};
export interface MoneyProps extends Omit<TypographyProps, 'children' | 'color'> {
/** A served IRR digit-string, straight off the wire. `<Money>` only formats — it never computes. */
amountIrr: string;
size?: MoneySize;
tone?: MoneyTone;
/** Marks the amount as a deduction (a commission, a fee): prefixes an explicit `` sign, held together
* with the digits in a forced `dir="ltr"` run so the sign never gets swallowed inside RTL text — never
* relies on a bare Unicode minus floating in a Persian paragraph. Defaults `tone` to `muted` unless the
* caller overrides it. */
deduction?: boolean;
/** Hides the "تومان"/"Toman" unit label — the bare grouped number only. */
hideUnit?: boolean;
/** Renders the amount with a line-through (e.g. a superseded price next to a discounted one). */
strikethrough?: boolean;
}
/**
* The single display primitive for every served IRR amount: formats via `utils/money.ts` (BigInt,
* Toman + Persian digits) and never computes a figure itself. Replaces the 24 hand-assembled
* `formatIrrToToman(x) + t('currency_toman')` call sites — including the ones that colored the total
* terracotta (`--bal-secondary` fails AA contrast for small text; emphasis uses `--bal-money-emphasis`
* instead, see the frontend-designer skill).
* @component Money
*/
const Money: FunctionComponent<MoneyProps> = ({
amountIrr,
size = 'md',
tone,
deduction = false,
hideUnit = false,
strikethrough = false,
sx,
...rest
}) => {
const locale = useLocale();
const tc = useTranslations('common');
const effectiveTone = tone ?? (deduction ? 'muted' : 'default');
const { color, fontWeight } = TONE_STYLE[effectiveTone];
const formatted = formatIrrToToman(amountIrr, locale);
return (
<Typography
variant={SIZE_VARIANT[size]}
component="span"
data-money
data-deduction={deduction ? 'true' : undefined}
sx={{
color,
fontWeight,
textDecoration: strikethrough ? 'line-through' : undefined,
...sx,
}}
{...rest}
>
{deduction ? (
<Box component="span" dir="ltr" sx={{ display: 'inline-block' }}>
{''}
{formatted}
</Box>
) : (
formatted
)}
{hideUnit ? null : ` ${tc('currency_toman')}`}
</Typography>
);
};
export default Money;
@@ -0,0 +1,4 @@
import Money from './Money';
export default Money;
export type { MoneyProps, MoneySize, MoneyTone } from './Money';
@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import PageHeader from './PageHeader';
describe('<PageHeader/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the title and subtitle', () => {
wrap(<PageHeader title="Patients" subtitle="Manage your family" />);
expect(screen.getByRole('heading', { name: 'Patients' })).toBeInTheDocument();
expect(screen.getByText('Manage your family')).toBeInTheDocument();
});
it('renders the actions slot when provided', () => {
wrap(<PageHeader title="T" actions={<button>Do</button>} />);
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
});
it('omits the back button when backTo is not given', () => {
wrap(<PageHeader title="T" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders a back link when backTo is given', () => {
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
});
});
@@ -0,0 +1,48 @@
import { FunctionComponent, ReactNode } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIconButton from '../AppIconButton';
export interface PageHeaderProps {
/** Already-translated title (i18n is the caller's job — labels are keys). */
title: string;
/** Optional already-translated subtitle. */
subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode;
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
backTo?: string;
/** Already-translated accessible label for the back button (required when `backTo` is set). */
backLabel?: string;
}
/**
* The standard page header — title + optional subtitle, an end-aligned actions slot, and an optional
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational,
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`).
* @component PageHeader
*/
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
{backTo ? (
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
) : null}
<Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{subtitle ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{subtitle}
</Typography>
) : null}
</Box>
</Stack>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
</Stack>
);
export default PageHeader;
@@ -0,0 +1,4 @@
import PageHeader from './PageHeader';
export default PageHeader;
export type { PageHeaderProps } from './PageHeader';
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import QueryStateGate from './QueryStateGate';
describe('<QueryStateGate/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const base = {
onRetry: jest.fn(),
errorMessage: 'Failed',
retryLabel: 'Retry',
skeleton: <div>SKELETON</div>,
empty: <div>EMPTY</div>,
};
it('renders the skeleton while loading, even if isError/isEmpty are also true', () => {
wrap(
<QueryStateGate {...base} isLoading isError isEmpty>
<div>DATA</div>
</QueryStateGate>,
);
expect(screen.getByText('SKELETON')).toBeInTheDocument();
});
it('renders the error state before checking isEmpty — an error is never an empty', () => {
wrap(
<QueryStateGate {...base} isLoading={false} isError isEmpty>
<div>DATA</div>
</QueryStateGate>,
);
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(screen.queryByText('EMPTY')).not.toBeInTheDocument();
});
it('renders empty when not loading/erroring but isEmpty', () => {
wrap(
<QueryStateGate {...base} isLoading={false} isError={false} isEmpty>
<div>DATA</div>
</QueryStateGate>,
);
expect(screen.getByText('EMPTY')).toBeInTheDocument();
});
it('renders children when data is present', () => {
wrap(
<QueryStateGate {...base} isLoading={false} isError={false} isEmpty={false}>
<div>DATA</div>
</QueryStateGate>,
);
expect(screen.getByText('DATA')).toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
import { FunctionComponent, ReactNode } from 'react';
import ErrorState from '../ErrorState';
export interface QueryStateGateProps {
isLoading: boolean;
isError: boolean;
onRetry: () => void;
isEmpty: boolean;
/** Already-translated, page-specific error copy — passed straight to `ErrorState`. */
errorMessage: string;
/** Already-translated retry label — passed straight to `ErrorState` (e.g. `t('common.retry')`). */
retryLabel: string;
/** Rendered while `isLoading` — pass a shaped skeleton, not a bare spinner. */
skeleton: ReactNode;
/** Rendered when the query succeeded but returned nothing. */
empty: ReactNode;
children: ReactNode;
}
/**
* The convention this phase exists to enforce, as one component: **an errored query must never render
* as empty**. Branches in a fixed order — skeleton → error (with a mandatory retry, via `ErrorState`) →
* empty → data — so a page can't accidentally check `isEmpty` before `isError` and show "no results" for
* a network failure. Pages with naturally inline branching (infinite lists with `fetchNextPage`) may
* follow the same order by hand instead of reaching for this component; the convention is what matters.
* @component QueryStateGate
*/
const QueryStateGate: FunctionComponent<QueryStateGateProps> = ({
isLoading,
isError,
onRetry,
isEmpty,
errorMessage,
retryLabel,
skeleton,
empty,
children,
}) => {
if (isLoading) return <>{skeleton}</>;
if (isError) return <ErrorState message={errorMessage} retryLabel={retryLabel} onRetry={onRetry} />;
if (isEmpty) return <>{empty}</>;
return <>{children}</>;
};
export default QueryStateGate;
@@ -0,0 +1,4 @@
import QueryStateGate from './QueryStateGate';
export default QueryStateGate;
export type { QueryStateGateProps } from './QueryStateGate';
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import StatusTimeline, { TimelineNode } from './StatusTimeline';
describe('<StatusTimeline/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const nodes: TimelineNode[] = [
{ key: 'submitted', label: 'Submitted', state: 'completed', timestamp: 'Jul 1' },
{ key: 'on_its_way', label: 'On its way', state: 'current', note: 'Bank transfer' },
{ key: 'completed', label: 'Completed', state: 'pending' },
];
it('renders every node label, timestamp, and note', () => {
wrap(<StatusTimeline nodes={nodes} />);
expect(screen.getByText('Submitted')).toBeInTheDocument();
expect(screen.getByText('Jul 1')).toBeInTheDocument();
expect(screen.getByText('On its way')).toBeInTheDocument();
expect(screen.getByText('Bank transfer')).toBeInTheDocument();
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('exposes each node state via a data attribute', () => {
const { container } = render(<StatusTimeline nodes={nodes} />);
expect(container.querySelector('[data-node="submitted"][data-node-state="completed"]')).toBeInTheDocument();
expect(container.querySelector('[data-node="on_its_way"][data-node-state="current"]')).toBeInTheDocument();
});
it('renders a failed node distinctly', () => {
const { container } = render(
<StatusTimeline nodes={[{ key: 'failed', label: 'Failed', state: 'failed' }]} />,
);
expect(container.querySelector('[data-node-state="failed"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
'use client'
import { FunctionComponent } from 'react';
import { keyframes } from '@emotion/react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '../AppIcon';
export type TimelineNodeState = 'completed' | 'current' | 'pending' | 'failed';
export interface TimelineNode {
/** Stable key — also exposed as `data-node` for tests. */
key: string;
/** Already-translated label. */
label: string;
/** Already-translated, formatted timestamp (e.g. a Shamsi date/time). */
timestamp?: string;
/** Already-translated freeform note (a channel, a reference). */
note?: string;
state: TimelineNodeState;
}
export interface StatusTimelineProps {
nodes: TimelineNode[];
}
const pulse = keyframes`
0%, 100% { box-shadow: 0 0 0 0 var(--bal-primary-soft); }
50% { box-shadow: 0 0 0 6px var(--bal-primary-soft); }
`;
const DOT_SIZE = 22;
function dotStyle(state: TimelineNodeState) {
switch (state) {
case 'completed':
return { bg: 'var(--bal-primary)', border: 'var(--bal-primary)' };
case 'current':
return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-primary)' };
case 'failed':
return { bg: 'var(--bal-error)', border: 'var(--bal-error)' };
case 'pending':
default:
return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-divider)' };
}
}
/**
* A designed vertical status timeline: node = label + optional timestamp + optional note, with four
* states — `completed` (filled), `current` (an animated ring, respecting `prefers-reduced-motion`),
* `pending` (outlined), and `failed` (a distinct terminal marker, never styled as if progress continues
* past it). Replaces `StepperHeader` misused as a status display (the refund progress card, the booking
* lifecycle) — `StepperHeader` itself stays reserved for real multi-step wizards.
* @component StatusTimeline
*/
const StatusTimeline: FunctionComponent<StatusTimelineProps> = ({ nodes }) => (
<Stack data-status-timeline sx={{ gap: 0 }}>
{nodes.map((node, index) => {
const isLast = index === nodes.length - 1;
const { bg, border } = dotStyle(node.state);
return (
<Stack key={node.key} direction="row" sx={{ gap: 1.5 }} data-node={node.key} data-node-state={node.state}>
<Stack sx={{ alignItems: 'center', width: DOT_SIZE }}>
<Box
sx={{
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: '50%',
flexShrink: 0,
bgcolor: bg,
border: '2px solid',
borderColor: border,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...(node.state === 'current' && {
'@media (prefers-reduced-motion: no-preference)': {
animation: `${pulse} 1.6s ease-in-out infinite`,
},
}),
}}
>
{node.state === 'completed' && <AppIcon icon="verified" size={14} color="var(--bal-primary-contrast)" />}
{node.state === 'current' && (
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} />
)}
{node.state === 'failed' && <AppIcon icon="close" size={14} color="var(--bal-error-contrast)" />}
</Box>
{!isLast && (
<Box
sx={{
width: 2,
flex: 1,
minHeight: 28,
bgcolor: node.state === 'completed' ? 'var(--bal-primary)' : 'var(--bal-divider)',
}}
/>
)}
</Stack>
<Stack sx={{ pb: isLast ? 0 : 2.5, gap: 0.25, pt: 0.25 }}>
<Typography
variant="body2"
sx={{
fontWeight: node.state === 'current' ? 700 : 500,
color: node.state === 'pending' ? 'text.secondary' : 'text.primary',
}}
>
{node.label}
</Typography>
{node.timestamp ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{node.timestamp}
</Typography>
) : null}
{node.note ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{node.note}
</Typography>
) : null}
</Stack>
</Stack>
);
})}
</Stack>
);
export default StatusTimeline;
@@ -0,0 +1,4 @@
import StatusTimeline from './StatusTimeline';
export default StatusTimeline;
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import SurfaceCard from './SurfaceCard';
describe('<SurfaceCard/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders its children', () => {
wrap(<SurfaceCard>content</SurfaceCard>);
expect(screen.getByText('content')).toBeInTheDocument();
});
it('passes through data attributes and other Paper props', () => {
wrap(<SurfaceCard data-testid="card">content</SurfaceCard>);
expect(screen.getByTestId('card')).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { FunctionComponent } from 'react';
import Paper, { PaperProps } from '@mui/material/Paper';
export type SurfaceCardPadding = 'sm' | 'md' | 'lg';
const PADDING_SCALE: Record<SurfaceCardPadding, number> = {
sm: 2,
md: 2.5,
lg: 3,
};
export interface SurfaceCardProps extends Omit<PaperProps, 'elevation' | 'variant'> {
/** Padding scale — `sm`≈16px / `md`≈20px / `lg`≈24px (theme spacing units 2/2.5/3). Default `md`. */
padding?: SurfaceCardPadding;
}
/**
* The de-facto card anatomy this app already hand-rolls ~12 times, codified once: a flat `Paper`
* (`elevation={0}`), a 1px `divider` border, the house radius, and one padding scale. Every plain card
* (`EarningsRow`, `PatientCard`, `VariantCard`, `PriceBreakdown`, …) should compose this instead of
* re-declaring the same `sx`. Purely a visual shell — no state, no i18n.
* @component SurfaceCard
*/
const SurfaceCard: FunctionComponent<SurfaceCardProps> = ({ padding = 'md', sx, children, ...rest }) => (
<Paper
elevation={0}
sx={{
p: PADDING_SCALE[padding],
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
...sx,
}}
{...rest}
>
{children}
</Paper>
);
export default SurfaceCard;
@@ -0,0 +1,4 @@
import SurfaceCard from './SurfaceCard';
export default SurfaceCard;
export type { SurfaceCardProps, SurfaceCardPadding } from './SurfaceCard';
+42 -1
View File
@@ -5,5 +5,46 @@ import AppIconButton from './AppIconButton';
import AppLink from './AppLink';
import AppLoading from './AppLoading';
import ErrorBoundary from './ErrorBoundary';
import EmptyState from './EmptyState';
import ErrorState from './ErrorState';
import QueryStateGate from './QueryStateGate';
import PageHeader from './PageHeader';
import ConfirmDialog from './ConfirmDialog';
import SurfaceCard from './SurfaceCard';
import AccentCard from './AccentCard';
import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppLink, AppLoading };
export {
ErrorBoundary,
AppAlert,
AppButton,
AppIcon,
AppIconButton,
AppLink,
AppLoading,
EmptyState,
ErrorState,
QueryStateGate,
PageHeader,
ConfirmDialog,
SurfaceCard,
AccentCard,
Money,
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
export type { QueryStateGateProps } from './QueryStateGate';
export type { PageHeaderProps } from './PageHeader';
export type { ConfirmDialogProps } from './ConfirmDialog';
export type { SurfaceCardProps } from './SurfaceCard';
export type { AccentCardProps, AccentTone } from './AccentCard';
export type { MoneyProps } from './Money';
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
export type { JalaliDatePickerProps } from './JalaliDatePicker';
export type { JalaliDateFieldProps } from './JalaliDateField';