frontend phase 9
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+98
@@ -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>
|
||||
);
|
||||
}
|
||||
+76
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+180
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user