frontend phase 9

This commit is contained in:
hamid
2026-07-10 11:49:55 +03:30
parent cd6c2591a6
commit 40cc1d163b
49 changed files with 4130 additions and 20 deletions
@@ -0,0 +1,215 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment';
import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */
const PRINT_AREA_CLASS = 'invoice-print-area';
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
registered: 'verified',
failed: 'rejected',
};
/**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math;
* the service line is the exact integer remainder of served amounts (gross commission VAT).
*/
export default function BookingInvoicePage() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const bookingId = Number(params.id);
const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('invalid_link_body')}
</Typography>
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Paper>
);
}
if (isLoading) {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
</Typography>
{notIssued ? (
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
) : (
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
)}
</Stack>
</Paper>
);
}
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
const handlePrint = () => {
const root = document.documentElement;
const previous = root.getAttribute('data-mui-color-scheme');
if (previous === 'dark') {
const restore = () => {
root.setAttribute('data-mui-color-scheme', previous);
window.removeEventListener('afterprint', restore);
};
window.addEventListener('afterprint', restore);
root.setAttribute('data-mui-color-scheme', 'light');
}
window.print();
};
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
// service + commission + VAT reconciles to the gross total by construction.
const serviceIrr = (
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
).toString();
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
style: 'percent',
maximumFractionDigits: 2,
}).format(invoice.vatRate);
return (
<Stack sx={{ gap: 2 }}>
<GlobalStyles
styles={{
'@media print': {
'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
},
}}
/>
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" component="h1">
{t('invoice_title')}
</Typography>
{/* The issuer line uses the product spelling «بالین‌یار» — fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')}
</Typography>
</Stack>
<Divider />
<Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
</Stack>
<PriceBreakdown
rows={[
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
{
key: 'vat',
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
amountIrr: invoice.vatIrr,
},
]}
totalLabel={t('row_total')}
totalAmountIrr={invoice.grossIrr}
/>
{invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('moadian_label')}
</Typography>
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack>
) : null}
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{invoice.pdfUrl ? (
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
{t('download_invoice')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
{t('print_invoice')}
</AppButton>
)}
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
);
}
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
{value}
</Typography>
</Stack>
);
}
@@ -0,0 +1,98 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCheckoutSummary } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
/**
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
* to the bookings list and the invoice link is hidden — the invoice route needs the booking id.
*/
export default function CheckoutConfirmationPage() {
return (
<Suspense fallback={<AppLoading />}>
<ConfirmationScreen />
</Suspense>
);
}
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('confirm_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm_subtitle')}
</Typography>
</Stack>
{summary ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="primary"
variant="contained"
size="large"
onClick={() =>
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
{bookingId != null ? (
<AppButton
variant="outlined"
color="primary"
startIcon="document"
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('download_invoice')}
</AppButton>
) : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,76 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('gateway_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('gateway_hint')}
</Typography>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -1,25 +1,299 @@
'use client';
import { Suspense } from 'react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { AppLoading, PlaceholderScreen } from '@/components';
import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
CountdownTimer,
EscrowNotice,
PriceBreakdown,
StatusChip,
} from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
import {
BNPL_ENABLED,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { CheckoutSummaryDto } from '@/services/payment/types';
/**
* Checkout (pay & confirm) — **DEFERRED → frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with
* the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder
* confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the
* «ادامه پرداخت ←» CTA that initiates the card payment and follows the gateway redirect. Reached from
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<CheckoutDeferred />
<CheckoutScreen />
</Suspense>
);
}
function CheckoutDeferred() {
const t = useTranslations('booking');
function CheckoutScreen() {
const t = useTranslations('payment');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get('request_id') ?? '—';
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
const initiate = useInitiatePayment();
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
// user comes back to a fresh mount of this screen — gets a new key.
const attemptKeyRef = useRef<string | null>(null);
// A malformed/missing request_id can never load — navigation, not a retry that would fire
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('invalid_link_body')}
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
);
}
if (isError) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
);
}
if (isLoading || !summary) return <CheckoutSkeleton />;
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
};
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={t('already_paid_title')}
body={t('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={() => router.replace(returnUrl())}
/>
);
}
if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
/>
);
}
const handlePay = () => {
attemptKeyRef.current ??= crypto.randomUUID();
initiate.mutate(
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => {
if (!result.redirectUrl) {
// No gateway hop to make — read the outcome directly.
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real PSP page — a full navigation, outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
// the return surface reads the actual outcome and routes accordingly.
router.replace(returnUrl());
}
},
},
);
};
const busy = initiate.isPending || initiate.isSuccess;
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
const inlineError =
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
? t('initiate_failed')
: null;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
<StatusChip status="verified" label={tb('accepted_badge')} />
<Typography variant="h6" component="h1">
{t('title_checkout')}
</Typography>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowNotice />
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
sx={{ m: 0, py: 1.25 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
{t('bnpl_option')}
</AppButton>
{!BNPL_ENABLED ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{tc('coming_soon')}
</Typography>
) : null}
</Stack>
</Stack>
);
}
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.nurseName} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Paper>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="rounded" width={140} height={24} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
}
@@ -0,0 +1,180 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
/**
* Return-from-gateway surface — drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" — pending is
* normal, reflected calmly) until a terminal outcome: succeeded → invalidate the booking/request caches
* and hand off to the confirmation screen; failed → a retry affordance (a fresh C6 mount = a new attempt
* with a NEW idempotency key); window lapsed → back to the request's terminal card.
*/
export default function CheckoutReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<ReturnScreen />
</Suspense>
);
}
function ReturnScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const confirm = useConfirmGatewayReturn();
const { mutate: confirmMutate } = confirm;
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
const confirmFiredRef = useRef(false);
useEffect(() => {
if (confirmFiredRef.current || !validId) return;
confirmFiredRef.current = true;
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
// The poll takes over once the return report settles (its result is primed into the outcome key).
const confirmSettled = confirm.isSuccess || confirm.isError;
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
enabled: confirmSettled,
});
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
// while the current attempt's capture is still in flight.
const outcome = confirmSettled ? outcomeQuery.data : undefined;
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
useEffect(() => {
if (!succeeded || navigatedRef.current || !outcome) return;
navigatedRef.current = true;
if (!confirmSawSuccess) {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</StateCard>
);
}
if (windowExpired) {
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" />
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
sx={{ m: 0 }}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
<CircularProgress color="primary" size="2.5rem" />
<PaymentStatusBadge status="pending" />
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
}
@@ -120,7 +120,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
/>
</Stack>
);