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