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,58 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys; locale = en so money/date format with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import BookingRequestSummaryCard, { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
const BASE: BookingRequestSummaryCardProps = {
nurseName: 'Maryam Rezaei',
nurseAvatarUrl: null,
nurseRating: 4.8,
patientName: 'Haj Mousavi',
variantLabel: 'Elderly care — day shift',
variantPrice: '2800000',
variantPriceUnit: 'per_hour',
addressLabel: 'Home · Tehran · Saadat Abad',
requestedDate: '2026-08-01',
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
};
function renderCard(props: Partial<BookingRequestSummaryCardProps> = {}) {
return render(
<ThemeProvider>
<BookingRequestSummaryCard {...BASE} {...props} />
</ThemeProvider>,
);
}
describe('<BookingRequestSummaryCard/> component', () => {
it('renders the nurse, patient, service and address', () => {
renderCard();
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Haj Mousavi')).toBeInTheDocument();
expect(screen.getByText('Elderly care — day shift')).toBeInTheDocument();
expect(screen.getByText('Home · Tehran · Saadat Abad')).toBeInTheDocument();
});
it('prices the service in grouped Toman when a variantPrice is present', () => {
renderCard();
// 2,800,000 IRR = 280,000 Toman.
expect(screen.getByText(/280,000/)).toBeInTheDocument();
});
it('hides the price line when variantPrice is null (real-path contract gap)', () => {
renderCard({ variantPrice: null });
expect(screen.queryByText(/280,000/)).not.toBeInTheDocument();
});
it('hides the rating row when no rating is supplied', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import PriceDisplay from '@/components/PriceDisplay';
import { formatShamsiDate } from '@/utils';
import type { PriceUnit } from '@/services/catalog/types';
export interface BookingRequestSummaryCardProps {
nurseName: string;
nurseAvatarUrl?: string | null;
/** Average rating; `null`/omitted hides the rating row (e.g. the nurse-side view). */
nurseRating?: number | null;
patientName: string;
variantLabel: string;
/** IRR digit-string; when `null` the price line is hidden (contract gap REQ-013 on the real path). */
variantPrice?: string | null;
variantPriceUnit: PriceUnit;
/** Localised "title · city · district" label, computed by the caller (locale-aware region names). */
addressLabel: string;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
}
/**
* The engagement summary shared by the customer's awaiting screen (C5), the nurse request detail, and
* (later) the f8 booking detail: nurse identity + rating, patient, priced service, address label, and the
* requested date/time (Shamsi). Presentational — it reads only the `booking` caption keys and formats
* money/dates through the shared utils; every value is supplied by the caller. Kept at the shared level so
* f8 reuses it rather than re-deriving the layout.
* @component BookingRequestSummaryCard
*/
const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProps> = ({
nurseName,
nurseAvatarUrl,
nurseRating,
patientName,
variantLabel,
variantPrice,
variantPriceUnit,
addressLabel,
requestedDate,
requestedTimeStart,
requestedTimeEnd,
}) => {
const t = useTranslations('booking');
const locale = useLocale();
const name = nurseName.trim() || t('unnamed_nurse');
const startDate = new Date(`${requestedDate}T${requestedTimeStart}`);
const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(nurseRating)
: null;
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar
src={nurseAvatarUrl ?? undefined}
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
{ratingLabel ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
</Stack>
) : null}
</Stack>
</Stack>
<Divider />
<Stack sx={{ gap: 1.5 }}>
<SummaryRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{patientName}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{variantLabel}
</Typography>
{variantPrice ? (
<PriceDisplay price={variantPrice} priceUnit={variantPriceUnit} align="start" />
) : null}
</Stack>
</SummaryRow>
<SummaryRow caption={t('summary_address')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{addressLabel}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</SummaryRow>
</Stack>
</Stack>
</Paper>
);
};
function SummaryRow({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
export default BookingRequestSummaryCard;
@@ -0,0 +1,2 @@
export { default } from './BookingRequestSummaryCard';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
@@ -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';
@@ -58,6 +58,9 @@ import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
import StarIcon from '@mui/icons-material/Star';
import TuneIcon from '@mui/icons-material/TuneOutlined';
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
import RequestsIcon from '@mui/icons-material/AssignmentOutlined';
import PaymentIcon from '@mui/icons-material/CreditCardOutlined';
/**
* List of all available Icon names
@@ -128,4 +131,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
publish: PublishIcon,
star: StarIcon,
tune: TuneIcon,
requests: RequestsIcon,
payment: PaymentIcon,
};
+6
View File
@@ -19,6 +19,8 @@ import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload';
import NurseResultCard from './NurseResultCard';
import ServicePriceRow from './ServicePriceRow';
import CountdownTimer from './CountdownTimer';
import BookingRequestSummaryCard from './BookingRequestSummaryCard';
export {
UserInfo,
@@ -40,6 +42,8 @@ export {
DocumentUpload,
NurseResultCard,
ServicePriceRow,
CountdownTimer,
BookingRequestSummaryCard,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -59,3 +63,5 @@ export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
export type { NurseResultCardProps } from './NurseResultCard';
export type { ServicePriceRowProps } from './ServicePriceRow';
export type { CountdownTimerProps } from './CountdownTimer';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';