backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
@@ -0,0 +1,56 @@
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked so the locale is `en` and the countdown formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
import CountdownTimer, { CountdownTimerProps } from './CountdownTimer';
const deadlineInSeconds = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString();
function renderTimer(props: CountdownTimerProps) {
return render(
<ThemeProvider>
<CountdownTimer {...props} />
</ThemeProvider>,
);
}
describe('<CountdownTimer/> component', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-09T10:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('renders MM:SS remaining for a sub-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
expect(screen.getByText('01:30')).toBeInTheDocument();
});
it('renders HH:MM:SS for a multi-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(3661), elapsedText: 'time up' });
expect(screen.getByText('01:01:01')).toBeInTheDocument();
});
it('ticks down each second without lifting state to the page', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('01:29')).toBeInTheDocument();
});
it('shows the elapsed text and fires onElapsed exactly once at zero', () => {
const onElapsed = jest.fn();
renderTimer({ deadlineIso: deadlineInSeconds(2), elapsedText: 'time up', onElapsed });
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('time up')).toBeInTheDocument();
expect(onElapsed).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale } from 'next-intl';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '@/components/common/AppIcon';
export interface CountdownTimerProps {
/**
* The **server-supplied** absolute UTC instant to count down to (e.g. `nurseResponseDeadlineAt`). The
* client only renders the difference against `Date.now()` — it never computes or recomputes a deadline.
*/
deadlineIso: string;
/** Optional label above the digits (already translated by the caller). */
label?: string;
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
elapsedText: string;
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
urgent?: boolean;
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
onElapsed?: () => void;
}
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
/**
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
* this component re-renders each second — never the page around it (the summary card / form stay put).
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
* order is correct under RTL.
* @component CountdownTimer
*/
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
deadlineIso,
label,
elapsedText,
urgent = false,
onElapsed,
}) => {
const locale = useLocale();
const [now, setNow] = useState(() => Date.now());
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
const elapsed = remainingMs <= 0;
// Recreated only when `elapsed` flips (once) — not every tick, since `elapsed` stays false until zero.
useEffect(() => {
if (elapsed) return undefined;
const interval = setInterval(() => setNow(Date.now()), MS_PER_SECOND);
return () => clearInterval(interval);
}, [elapsed]);
const firedRef = useRef(false);
useEffect(() => {
if (elapsed && !firedRef.current) {
firedRef.current = true;
onElapsed?.();
} else if (!elapsed) {
firedRef.current = false;
}
}, [elapsed, onElapsed]);
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
if (elapsed) {
return (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{elapsedText}
</Typography>
</Stack>
);
}
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
.filter((part) => part !== null)
.join(':');
return (
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
{label ? (
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{label}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="pending" size={20} color={accent} />
<Typography
component="span"
dir="ltr"
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
>
{clock}
</Typography>
</Stack>
</Stack>
);
};
export default CountdownTimer;
@@ -0,0 +1,2 @@
export { default } from './CountdownTimer';
export type { CountdownTimerProps } from './CountdownTimer';