backend phase 14 & frontend phase 7
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user