ui phase 6
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
'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 { Box, 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, localeTag, parseIrr } from '@/utils';
|
||||
import { useInvoice } from '@/services/payment';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { useCustomerProfile } from '@/services/profiles';
|
||||
import type { MoadianStatus } from '@/services/payment/types';
|
||||
|
||||
/** The printable region — everything else is hidden by the print rules below. */
|
||||
@@ -20,13 +22,26 @@ const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/** Best-effort read of the frozen variant display name from the booking's variant snapshot (mirrors the
|
||||
* same tolerant parse `BookingDetailView`/the review page use — REQ-045 proposes a typed shape). */
|
||||
function variantName(snapshotJson: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
|
||||
return parsed?.displayName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* issue date), a buyer/service/visit-date recap (composed client-side from the customer's own profile +
|
||||
* the booking detail read — a UI join, not money math), 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), the payment method + transaction reference, a seller fiscal-identity block, and the
|
||||
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean A4 receipt
|
||||
* (`window.print()` + a print-scoped visibility rule + `@page` sizing). 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');
|
||||
@@ -38,6 +53,8 @@ export default function BookingInvoicePage() {
|
||||
const validId = Number.isInteger(bookingId) && bookingId > 0;
|
||||
|
||||
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
|
||||
const { data: booking } = useBookingDetail(validId ? bookingId : undefined, 'customer');
|
||||
const { data: customerProfile } = useCustomerProfile();
|
||||
|
||||
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
|
||||
if (!validId) {
|
||||
@@ -123,10 +140,21 @@ export default function BookingInvoicePage() {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(invoice.vatRate);
|
||||
|
||||
const buyerName = [customerProfile?.firstName, customerProfile?.lastName].filter(Boolean).join(' ').trim();
|
||||
const serviceLabel = booking ? variantName(booking.variantSnapshotJson) : null;
|
||||
const visitDatesLabel = booking
|
||||
? booking.sessionCount > 1
|
||||
? t('invoice_visit_dates_multi', { date: formatShamsiDate(booking.scheduledDate, locale), count: booking.sessionCount })
|
||||
: formatShamsiDate(booking.scheduledDate, locale)
|
||||
: null;
|
||||
const methodLabel =
|
||||
invoice.paymentMethod === 'card' ? t('method_card') : invoice.paymentMethod === 'bnpl' ? t('invoice_method_bnpl') : null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'@page': { size: 'A4', margin: '16mm' },
|
||||
'@media print': {
|
||||
'body *': { visibility: 'hidden' },
|
||||
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
|
||||
@@ -145,8 +173,6 @@ export default function BookingInvoicePage() {
|
||||
<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>
|
||||
@@ -156,6 +182,10 @@ export default function BookingInvoicePage() {
|
||||
<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)} />
|
||||
{buyerName ? <MetaRow label={t('invoice_buyer_label')} value={buyerName} /> : null}
|
||||
{serviceLabel ? <MetaRow label={t('invoice_service_label')} value={serviceLabel} /> : null}
|
||||
{visitDatesLabel ? <MetaRow label={t('invoice_visit_dates_label')} value={visitDatesLabel} /> : null}
|
||||
<MetaRow label={t('receipt_booking_ref_label')} value={String(invoice.bookingId)} ltr />
|
||||
</Stack>
|
||||
|
||||
<PriceBreakdown
|
||||
@@ -172,6 +202,15 @@ export default function BookingInvoicePage() {
|
||||
totalAmountIrr={invoice.grossIrr}
|
||||
/>
|
||||
|
||||
{methodLabel || invoice.transactionReference ? (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
{methodLabel ? <MetaRow label={t('receipt_method_label')} value={methodLabel} /> : null}
|
||||
{invoice.transactionReference ? (
|
||||
<MetaRow label={t('invoice_transaction_ref_label')} value={invoice.transactionReference} ltr />
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{invoice.moadianStatus ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -180,6 +219,39 @@ export default function BookingInvoicePage() {
|
||||
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{invoice.sellerFiscalIdentity ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
{invoice.sellerFiscalIdentity.legalName}
|
||||
</Typography>
|
||||
{invoice.sellerFiscalIdentity.economicCode ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('invoice_seller_economic_code_label')}: {invoice.sellerFiscalIdentity.economicCode}
|
||||
</Typography>
|
||||
) : null}
|
||||
{invoice.sellerFiscalIdentity.address ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{invoice.sellerFiscalIdentity.address}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Print-only document footer — invoice number + issue date (+ مودیان reference when present). */}
|
||||
<Box sx={{ display: 'none', '@media print': { display: 'block', mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' } }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('invoice_footer_reference', { number: invoice.invoiceNumber, date: formatShamsiDate(invoice.issuedAt, locale) })}
|
||||
</Typography>
|
||||
{invoice.moadianReferenceNumber ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }} dir="ltr">
|
||||
{t('invoice_footer_moadian', { ref: invoice.moadianReferenceNumber })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useCheckEligibility } from '@/services/bnpl';
|
||||
@@ -138,7 +138,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
label={t('mobile_label')}
|
||||
value={sessionMobile}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
slotProps={{ input: { readOnly: true } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
@@ -159,8 +159,9 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
size="large"
|
||||
disabled={!consent || check.isPending}
|
||||
onClick={handleSubmit}
|
||||
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
|
||||
>
|
||||
{t('check_eligibility')}
|
||||
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
|
||||
{t('pay_with_card')}
|
||||
|
||||
+2
-26
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, EmptyState, Money } from '@/components';
|
||||
import { AppButton, AppIcon, BnplProviderLogo, EmptyState, Money } from '@/components';
|
||||
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface MethodStepProps {
|
||||
@@ -13,15 +13,6 @@ interface MethodStepProps {
|
||||
onPayWithCard: () => void;
|
||||
}
|
||||
|
||||
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
|
||||
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
|
||||
digipay: 'DG',
|
||||
snapppay: 'SP',
|
||||
balinyaar: 'ب',
|
||||
tara: 'TA',
|
||||
torobpay: 'TP',
|
||||
};
|
||||
|
||||
/**
|
||||
* D1 · روش پرداخت — the branch off C6. Shows the payable amount, the full-card option (returns to the f9
|
||||
* card flow — never rebuilt here), and the installment providers loaded **from the contract/mock** (never
|
||||
@@ -146,22 +137,7 @@ function ProviderOption({
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 28,
|
||||
borderRadius: 1,
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontWeight: 800,
|
||||
fontSize: 11,
|
||||
color: 'var(--bal-secondary-dark)',
|
||||
backgroundColor: 'var(--bal-secondary-soft)',
|
||||
}}
|
||||
>
|
||||
{PROVIDER_GLYPH[provider.providerCode]}
|
||||
</Box>
|
||||
<BnplProviderLogo providerCode={provider.providerCode} size={40} />
|
||||
<Stack sx={{ flex: 1, gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t(`provider_${provider.providerCode}`)}
|
||||
|
||||
+43
-13
@@ -3,10 +3,13 @@ import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
|
||||
import { parseIrr } from '@/utils';
|
||||
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface PlanStepProps {
|
||||
providerCode: ProviderCode;
|
||||
/** D1's payable gross — the interest-free baseline `BnplPlanCard`'s fee delta compares against. */
|
||||
orderAmountIrr: string;
|
||||
plans: BnplPlanOption[];
|
||||
selectedPlanId: string | null;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
@@ -14,6 +17,13 @@ interface PlanStepProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** The same term/installment-count label `BnplPlanCard` shows — reused here to name the header's total. */
|
||||
function termLabelFor(plan: BnplPlanOption, t: ReturnType<typeof useTranslations>): string {
|
||||
return plan.termMonths != null
|
||||
? t('plan_term_months', { months: plan.termMonths })
|
||||
: t('plan_installments', { count: plan.installmentCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* D2 · انتخاب طرح اقساط — the plan selector for the chosen provider. Shows the total amount and the plan
|
||||
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
|
||||
@@ -22,6 +32,7 @@ interface PlanStepProps {
|
||||
*/
|
||||
const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
providerCode,
|
||||
orderAmountIrr,
|
||||
plans,
|
||||
selectedPlanId,
|
||||
onSelectPlan,
|
||||
@@ -43,8 +54,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
}
|
||||
|
||||
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
|
||||
// Use the selected plan's total, falling back to the first plan's for the header before any selection.
|
||||
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0];
|
||||
// No default fallback to plans[0] — the header only shows a total once a plan is actually selected, so
|
||||
// it never silently morphs before the user has chosen anything.
|
||||
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? null;
|
||||
const feeIrr = shownPlan ? (parseIrr(shownPlan.totalIrr) - parseIrr(orderAmountIrr)).toString() : null;
|
||||
const hasFee = shownPlan != null && shownPlan.feePercent > 0 && feeIrr != null && parseIrr(feeIrr) > BigInt(0);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -52,23 +66,39 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
{t('plan_title', { provider: t(`provider_${providerCode}`) })}
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_amount')}
|
||||
</Typography>
|
||||
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
{shownPlan ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_amount_named', { plan: termLabelFor(shownPlan, t) })}
|
||||
</Typography>
|
||||
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
|
||||
</Stack>
|
||||
{hasFee && feeIrr != null ? (
|
||||
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline', justifyContent: 'flex-end' }}>
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
|
||||
+
|
||||
</Typography>
|
||||
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)' }} />
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
|
||||
{t('plan_fee_amount_suffix')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{plans.map((plan) => (
|
||||
<BnplPlanCard
|
||||
key={plan.planId}
|
||||
plan={plan}
|
||||
orderAmountIrr={orderAmountIrr}
|
||||
selected={selectedPlanId === plan.planId}
|
||||
onSelect={onSelectPlan}
|
||||
/>
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { notFound, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Stack } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
@@ -22,6 +22,12 @@ import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
|
||||
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
|
||||
*/
|
||||
export default function BnplGatewayPage() {
|
||||
// A test harness must never be reachable in a production build — mirrors how the card-gateway harness
|
||||
// was retired (refinement-phase-4). Unlike the card path, BNPL stays mock-primary, so this one is
|
||||
// env-gated rather than deleted: still reachable in `next dev`, a clean 404 everywhere else.
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<BnplGatewayScreen />
|
||||
|
||||
+26
-49
@@ -2,8 +2,8 @@
|
||||
import { Suspense, useState } 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, StepperHeader } from '@/components';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, PaymentStateCard, StepperHeader } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAuth } from '@/context/auth';
|
||||
import { useBnplOptions } from '@/services/bnpl';
|
||||
@@ -57,38 +57,47 @@ function BnplCheckoutScreen() {
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} />
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<AppButton variant="contained" color="primary" onClick={toBookings}>
|
||||
{tb('bd_my_bookings')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />;
|
||||
return (
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
|
||||
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (isLoading || !options) return <WizardSkeleton />;
|
||||
|
||||
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
|
||||
if (options.requestStatus === 'converted') {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="verified"
|
||||
tone="var(--bal-success)"
|
||||
title={tp('already_paid_title')}
|
||||
body={tp('already_paid_body')}
|
||||
ctaLabel={tb('converted_cta')}
|
||||
onCta={toRequest}
|
||||
/>
|
||||
<PaymentStateCard icon="verified" tone="var(--bal-success)" title={tp('already_paid_title')} body={tp('already_paid_body')}>
|
||||
<AppButton variant="contained" color="primary" onClick={toRequest}>
|
||||
{tb('converted_cta')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (options.requestStatus !== 'accepted_awaiting_payment') {
|
||||
const expired = options.requestStatus === 'payment_deadline_expired';
|
||||
return (
|
||||
<MessageCard
|
||||
<PaymentStateCard
|
||||
icon="pending"
|
||||
tone="var(--bal-warning)"
|
||||
title={expired ? tp('window_expired_title') : tp('not_payable_title')}
|
||||
body={expired ? tp('window_expired_body') : undefined}
|
||||
ctaLabel={t('pay_with_card')}
|
||||
onCta={toCard}
|
||||
/>
|
||||
>
|
||||
<AppButton variant="contained" color="primary" onClick={toCard}>
|
||||
{t('pay_with_card')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,6 +148,7 @@ function BnplCheckoutScreen() {
|
||||
{step === 'plan' && providerCode && activeProvider ? (
|
||||
<PlanStep
|
||||
providerCode={providerCode}
|
||||
orderAmountIrr={options.orderAmountIrr}
|
||||
plans={activeProvider.plans}
|
||||
selectedPlanId={planId}
|
||||
onSelectPlan={setPlanId}
|
||||
@@ -175,39 +185,6 @@ function BnplCheckoutScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
|
||||
+15
-43
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Suspense, useEffect, useRef } 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 } from '@/components';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
|
||||
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
|
||||
@@ -41,6 +41,7 @@ export default function BnplReturnPage() {
|
||||
function BnplReturnScreen() {
|
||||
const t = useTranslations('bnpl');
|
||||
const tp = useTranslations('payment');
|
||||
const tb = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
@@ -97,11 +98,13 @@ function BnplReturnScreen() {
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
{/* No recoverable request id — the label must match the destination (the bookings list), never
|
||||
promise a card-payment action the click can't perform. */}
|
||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||
{t('pay_with_card')}
|
||||
{tb('bd_my_bookings')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,20 +112,20 @@ function BnplReturnScreen() {
|
||||
// The payment window lapsed during the handoff — card payment is impossible now, so route to the
|
||||
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
|
||||
return (
|
||||
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
|
||||
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
>
|
||||
{tp('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
@@ -137,48 +140,17 @@ function BnplReturnScreen() {
|
||||
>
|
||||
{t('pay_with_card')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
|
||||
return (
|
||||
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
|
||||
<PaymentStateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
|
||||
<CircularProgress color="secondary" size="2.5rem" />
|
||||
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
|
||||
{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>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
+133
-29
@@ -1,12 +1,26 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, type ReactNode } 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, Money } from '@/components';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppIconButton,
|
||||
AppLoading,
|
||||
ErrorState,
|
||||
EscrowExplainer,
|
||||
Money,
|
||||
StatusTimeline,
|
||||
SurfaceCard,
|
||||
type TimelineNode,
|
||||
} from '@/components';
|
||||
import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useCheckoutSummary, usePaymentOutcome } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
import { useBnplOrder } from '@/services/bnpl';
|
||||
import {
|
||||
BNPL_QUERY_PROVIDER,
|
||||
CHECKOUT_METHOD_BNPL,
|
||||
@@ -14,12 +28,13 @@ import {
|
||||
} from '@/services/bnpl/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 («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached
|
||||
* with `?method=bnpl` it also renders a «پرداختشده با اقساط» line (a settled BNPL order is, to
|
||||
* Balinyaar, a card payment net-of-fee — there is no separate BNPL confirmation). Without a `booking_id`
|
||||
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list.
|
||||
* Post-payment confirmation — a screenshot-worthy receipt (Iranian users screenshot payment receipts): the
|
||||
* paid total, a copyable LTR کد پیگیری, the Shamsi payment date-time, the payment method, the booking
|
||||
* reference, the escrow reassurance, and a "what happens next" 2-step strip. The booking is now
|
||||
* **confirmed** (flipped by cache invalidation on the return surface, never a blanket refetch). Reused by
|
||||
* both the f9 card flow and the f11 BNPL branch: reached with `?method=bnpl` it reads the settled BNPL
|
||||
* order instead of the payment outcome for the tracking reference + paid-at timestamp. Real loading/error
|
||||
* states — a failed fetch must never silently erase the paid amount.
|
||||
*/
|
||||
export default function CheckoutConfirmationPage() {
|
||||
return (
|
||||
@@ -31,20 +46,51 @@ export default function CheckoutConfirmationPage() {
|
||||
|
||||
function ConfirmationScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const tBnpl = useTranslations('bnpl');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const validRequestId = Number.isInteger(requestId) && requestId > 0;
|
||||
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
|
||||
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
|
||||
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
|
||||
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
|
||||
|
||||
const { data: summary } = useCheckoutSummary(
|
||||
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
|
||||
);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useCheckoutSummary(validRequestId ? requestId : undefined);
|
||||
|
||||
// The receipt reference/timestamp come from whichever leg actually settled this request — the card
|
||||
// outcome or the BNPL order — never fabricated when the real path hasn't served them yet (REQ-046).
|
||||
const outcomeQuery = usePaymentOutcome(validRequestId && !isBnpl ? requestId : undefined);
|
||||
const orderQuery = useBnplOrder(validRequestId && isBnpl ? requestId : undefined);
|
||||
|
||||
const trackingCode = isBnpl
|
||||
? (orderQuery.data?.id != null ? String(orderQuery.data.id) : null)
|
||||
: (outcomeQuery.data?.trackingCode ?? null);
|
||||
const paidAt = isBnpl ? (orderQuery.data?.settledAt ?? null) : (outcomeQuery.data?.paidAt ?? null);
|
||||
const methodLabel = isBnpl
|
||||
? t('method_bnpl_provider', { provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading') })
|
||||
: t('method_card');
|
||||
|
||||
const nextStepsNodes: TimelineNode[] = [
|
||||
{ key: 'nurse_notified', label: t('next_step_nurse_notified'), state: 'completed' },
|
||||
{ key: 'visit_checkin', label: t('next_step_visit_checkin'), state: 'pending' },
|
||||
];
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!trackingCode) return;
|
||||
navigator.clipboard.writeText(trackingCode).then(() => {
|
||||
enqueueSnackbar(t('tracking_code_copied'), { variant: 'success' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
|
||||
@@ -58,29 +104,62 @@ function ConfirmationScreen() {
|
||||
</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' }}>
|
||||
{isLoading ? (
|
||||
<ReceiptSkeleton />
|
||||
) : isError || !summary ? (
|
||||
<ErrorState message={t('error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<SurfaceCard sx={{ width: '100%' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_paid_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" />
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
{isBnpl ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 500, mt: 0.5 }}>
|
||||
{tBnpl('paid_via_installments', {
|
||||
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
|
||||
})}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Divider sx={{ width: '100%', my: 0.5 }} />
|
||||
|
||||
<Stack sx={{ width: '100%', gap: 1 }}>
|
||||
{trackingCode ? (
|
||||
<ReceiptRow label={t('receipt_tracking_code_label')}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
|
||||
{trackingCode}
|
||||
</Box>
|
||||
<AppIconButton icon="copy" size="small" title={t('copy_tracking_code')} onClick={handleCopy} />
|
||||
</Stack>
|
||||
</ReceiptRow>
|
||||
) : null}
|
||||
{paidAt ? (
|
||||
<ReceiptRow label={t('receipt_paid_at_label')} value={formatShamsiDateTime(paidAt, locale)} />
|
||||
) : null}
|
||||
<ReceiptRow label={t('receipt_method_label')} value={methodLabel} />
|
||||
{bookingId != null ? (
|
||||
<ReceiptRow label={t('receipt_booking_ref_label')}>
|
||||
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
|
||||
{bookingId}
|
||||
</Box>
|
||||
</ReceiptRow>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
)}
|
||||
|
||||
<Stack sx={{ width: '100%' }}>
|
||||
<EscrowExplainer />
|
||||
</Stack>
|
||||
|
||||
<SurfaceCard sx={{ width: '100%' }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('next_steps_title')}
|
||||
</Typography>
|
||||
<StatusTimeline nodes={nextStepsNodes} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
|
||||
<Stack sx={{ gap: 1, width: '100%' }}>
|
||||
<AppButton
|
||||
@@ -107,3 +186,28 @@ function ConfirmationScreen() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptRow({ label, value, children }: { label: string; value?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{children ?? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5, width: '100%' }}>
|
||||
<Skeleton variant="text" width="40%" height={24} sx={{ mx: 'auto' }} />
|
||||
<Skeleton variant="text" width="60%" height={48} sx={{ mx: 'auto' }} />
|
||||
<Skeleton variant="rounded" height={140} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
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 { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLoading,
|
||||
CountdownTimer,
|
||||
EscrowNotice,
|
||||
EscrowExplainer,
|
||||
Money,
|
||||
PaymentStateCard,
|
||||
PriceBreakdown,
|
||||
StatusChip,
|
||||
TrustBadge,
|
||||
} from '@/components';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import StickyActionBar from '@/components/common/StickyActionBar';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
@@ -25,10 +29,11 @@ import {
|
||||
import type { CheckoutSummaryDto } from '@/services/payment/types';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the identity moment (nurse avatar + verified
|
||||
* badge), a prominent total, the served & reconciling service-cost / commission / VAT / total breakdown,
|
||||
* the load-bearing escrow trust notice, and a safe-area-aware sticky pay bar 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 (
|
||||
@@ -60,26 +65,20 @@ function CheckoutScreen() {
|
||||
// `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}`)}
|
||||
/>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('invalid_link_body')}>
|
||||
<AppButton variant="contained" color="primary" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||
{tb('bd_my_bookings')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="error"
|
||||
tone="var(--bal-error)"
|
||||
title={t('error_title')}
|
||||
body={t('error_body')}
|
||||
ctaLabel={tc('retry')}
|
||||
onCta={() => refetch()}
|
||||
/>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
|
||||
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (isLoading || !summary) return <CheckoutSkeleton />;
|
||||
@@ -92,27 +91,35 @@ function CheckoutScreen() {
|
||||
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
|
||||
if (summary.requestStatus === 'converted') {
|
||||
return (
|
||||
<MessageCard
|
||||
<PaymentStateCard
|
||||
icon="verified"
|
||||
tone="var(--bal-success)"
|
||||
title={t('already_paid_title')}
|
||||
body={t('already_paid_body')}
|
||||
ctaLabel={tb('converted_cta')}
|
||||
onCta={() => router.replace(returnUrl())}
|
||||
/>
|
||||
>
|
||||
<AppButton variant="contained" color="primary" onClick={() => router.replace(returnUrl())}>
|
||||
{tb('converted_cta')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
if (summary.requestStatus !== 'accepted_awaiting_payment') {
|
||||
const expired = summary.requestStatus === 'payment_deadline_expired';
|
||||
return (
|
||||
<MessageCard
|
||||
<PaymentStateCard
|
||||
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}`)}
|
||||
/>
|
||||
>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,10 +166,29 @@ function CheckoutScreen() {
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('title_checkout')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon="chevron_start"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ px: 0.5 }}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<EngagementSummary summary={summary} locale={locale} />
|
||||
|
||||
{/* The prominent total — the single most important figure on a payment screen, never buried in the
|
||||
breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
|
||||
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_payable_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
|
||||
{summary.paymentDeadlineAt ? (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
@@ -191,42 +217,66 @@ function CheckoutScreen() {
|
||||
totalAmountIrr={summary.totalIrr}
|
||||
/>
|
||||
|
||||
<EscrowNotice />
|
||||
<EscrowExplainer />
|
||||
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
{/* Spacer so the sticky bar never overlaps the last scrollable content on a short viewport. */}
|
||||
<Stack sx={{ pb: 1 }} />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
sx={{ py: 1.25 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
|
||||
{BNPL_ENABLED ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon="installments"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
<StickyActionBar>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('row_total')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
endIcon="forward"
|
||||
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('secure_gateway_notice')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
|
||||
{BNPL_ENABLED ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon="installments"
|
||||
disabled={busy}
|
||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</StickyActionBar>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
|
||||
/** Nurse/service/schedule mini-summary — the C6 identity moment: avatar + verified badge answer "who am
|
||||
* I paying for" at the moment of payment. */
|
||||
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}`);
|
||||
@@ -236,54 +286,32 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
|
||||
});
|
||||
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 direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<Avatar
|
||||
src={summary.nurseAvatarUrl ?? undefined}
|
||||
sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
|
||||
>
|
||||
{summary.nurseName.trim().charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5, flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{summary.nurseName}
|
||||
</Typography>
|
||||
<TrustBadge state={summary.nurseVerified ? 'verified' : 'unverified'} />
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} – {timeFmt.format(end)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</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}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
@@ -292,6 +320,7 @@ function CheckoutSkeleton() {
|
||||
<Skeleton variant="text" width="50%" height={32} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="text" width="40%" height={48} sx={{ mx: 'auto' }} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
|
||||
+59
-67
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Suspense, useEffect, useRef } 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 { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, PaymentStateCard, StatusTimeline, type TimelineNode } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
|
||||
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
/**
|
||||
* 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.
|
||||
* **pending-callback** state — a staged 2-node progress («بازگشت از درگاه ✓» → «در انتظار تایید بانک»,
|
||||
* the calm animated `StatusTimeline` `current` pulse) with an expected-duration hint, 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 (
|
||||
@@ -31,6 +33,11 @@ export default function CheckoutReturnPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const PENDING_NODES = (returnedLabel: string, confirmingLabel: string): TimelineNode[] => [
|
||||
{ key: 'returned', label: returnedLabel, state: 'completed' },
|
||||
{ key: 'confirming', label: confirmingLabel, state: 'current' },
|
||||
];
|
||||
|
||||
function ReturnScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
@@ -87,90 +94,75 @@ function ReturnScreen() {
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (windowExpired) {
|
||||
return (
|
||||
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
|
||||
<PaymentStateCard
|
||||
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}`)}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
>
|
||||
{t('retry_payment')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
|
||||
<Stack sx={{ gap: 1, width: '100%' }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
|
||||
}
|
||||
>
|
||||
{t('retry_payment')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
|
||||
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
|
||||
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait 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()}>
|
||||
{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}
|
||||
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
|
||||
{t('state_pending_title')}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
<Stack sx={{ alignSelf: 'stretch', maxWidth: 320, mx: 'auto' }}>
|
||||
<StatusTimeline nodes={PENDING_NODES(t('stage_returned'), t('stage_confirming'))} />
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{t('state_pending_duration_hint')}
|
||||
</Typography>
|
||||
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
|
||||
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
|
||||
{t('check_again')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,12 @@ import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
||||
|
||||
/**
|
||||
* D5 · پیگیری اقساط — the Wallet view of active installment plans. It reads `useWalletInstallments` and
|
||||
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment
|
||||
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar
|
||||
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays,
|
||||
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content
|
||||
* can land beside it later.
|
||||
* D5 · پیگیری اقساط — the Wallet «اقساط» section (active installment plans). It reads
|
||||
* `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
|
||||
* (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
|
||||
* *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
|
||||
* the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only — the
|
||||
* page-level heading + tab strip live in `WalletScreen`.
|
||||
*/
|
||||
const WalletInstallments: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
@@ -21,11 +21,7 @@ const WalletInstallments: FunctionComponent = () => {
|
||||
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('wallet_title')}
|
||||
</Typography>
|
||||
|
||||
<Stack sx={{ gap: 2, width: '100%' }}>
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={128} />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppLink, EmptyState, ErrorState, Money, PaymentStatusBadge, SurfaceCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useWalletHistoryRows } from './useWalletHistoryRows';
|
||||
|
||||
/**
|
||||
* The wallet «پرداختها» section — every card + BNPL payment (down-payment) the customer made, newest
|
||||
* first, with a deep-link to the booking. For a card-paying customer (the default path) this is what
|
||||
* finally fills the previously permanently-empty Wallet tab.
|
||||
*/
|
||||
const WalletPaymentHistory: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (bothErrored) {
|
||||
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState icon="payment" title={t('history_empty_title')} body={t('history_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{rows.map((row) => (
|
||||
<SurfaceCard key={row.key} padding="sm">
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(row.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<PaymentStatusBadge status={row.status} />
|
||||
{row.bookingId != null ? (
|
||||
<AppLink to={`/${locale}${ROUTES.BOOKINGS}/${row.bookingId}`} sx={{ fontSize: '0.75rem' }}>
|
||||
{t('history_view_booking')}
|
||||
</AppLink>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletPaymentHistory;
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, Money, SurfaceCard } from '@/components';
|
||||
import { bookingInvoicePath } from '@/constants';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useWalletHistoryRows } from './useWalletHistoryRows';
|
||||
|
||||
/**
|
||||
* The wallet «رسیدها» section — no receipts endpoint exists; every succeeded, booking-linked payment
|
||||
* (card or BNPL down-payment) derives its invoice deep-link client-side (a UI join over the same rows the
|
||||
* «پرداختها» tab renders, filtered to `succeeded` + a known `bookingId` — no money math).
|
||||
*/
|
||||
const WalletReceipts: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tp = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (bothErrored) {
|
||||
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
|
||||
}
|
||||
|
||||
const receipts = rows.filter((row) => row.status === 'succeeded' && row.bookingId != null);
|
||||
|
||||
if (receipts.length === 0) {
|
||||
return <EmptyState icon="document" title={t('receipts_empty_title')} body={t('receipts_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{receipts.map((row) => (
|
||||
<SurfaceCard key={row.key} padding="sm">
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(row.createdAt, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon="document"
|
||||
to={`/${locale}${bookingInvoicePath(row.bookingId as number)}`}
|
||||
>
|
||||
{tp('view_invoice_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletReceipts;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppLink, EmptyState, ErrorState } from '@/components';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import { bookingRefundStatusPath } from '@/constants';
|
||||
import { useMyRefunds } from '@/services/refunds';
|
||||
|
||||
/**
|
||||
* The wallet «استردادها» section — every refund the customer owns (REQ-048), each rendered via the shared
|
||||
* `RefundStatusCard` (step timeline + amount + per-channel ETA) with a link back to its booking.
|
||||
*/
|
||||
const WalletRefunds: FunctionComponent = () => {
|
||||
const t = useTranslations('refunds');
|
||||
const tw = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { data: refunds, isLoading, isError, refetch } = useMyRefunds();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <ErrorState message={tw('wallet_error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
if (!refunds || refunds.length === 0) {
|
||||
return <EmptyState icon="refunds" title={t('wallet_empty_title')} body={t('wallet_empty_body')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
{refunds.map((refund) => (
|
||||
<Stack key={refund.id} sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('wallet_booking_label', { id: refund.bookingId })}
|
||||
</Typography>
|
||||
<AppLink to={`/${locale}${bookingRefundStatusPath(refund.bookingId)}`} sx={{ fontSize: '0.75rem' }}>
|
||||
{t('view_refund_status')}
|
||||
</AppLink>
|
||||
</Stack>
|
||||
<RefundStatusCard refund={refund} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletRefunds;
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Stack, Tab, Tabs } from '@mui/material';
|
||||
import { AppIcon, PageHeader } from '@/components';
|
||||
import WalletPaymentHistory from './WalletPaymentHistory';
|
||||
import WalletInstallments from './WalletInstallments';
|
||||
import WalletRefunds from './WalletRefunds';
|
||||
import WalletReceipts from './WalletReceipts';
|
||||
|
||||
type WalletTab = 'payments' | 'installments' | 'refunds' | 'receipts';
|
||||
|
||||
/**
|
||||
* /wallet — the customer money hub (ui-phase-6). Four sections replace the old installments-only shell so
|
||||
* a card-paying customer (the default path) finally sees something other than a permanently empty tab:
|
||||
* «پرداختها» (payment history), «اقساط» (the unchanged f11 D5 installment tracker), «استردادها» (refunds,
|
||||
* REQ-048), «رسیدها» (client-derived invoice links). All four read at the shell's shared `CONTENT_MAX_WIDTH`
|
||||
* — no local width override.
|
||||
*/
|
||||
const WalletScreen: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const [tab, setTab] = useState<WalletTab>('payments');
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={t('wallet_hub_title')} />
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_event, value: WalletTab) => setTab(value)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Tab value="payments" label={t('tab_payments')} icon={<AppIcon icon="payment" size={18} />} iconPosition="start" />
|
||||
<Tab
|
||||
value="installments"
|
||||
label={t('tab_installments')}
|
||||
icon={<AppIcon icon="installments" size={18} />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab value="refunds" label={t('tab_refunds')} icon={<AppIcon icon="refunds" size={18} />} iconPosition="start" />
|
||||
<Tab value="receipts" label={t('tab_receipts')} icon={<AppIcon icon="document" size={18} />} iconPosition="start" />
|
||||
</Tabs>
|
||||
|
||||
<Box role="tabpanel">
|
||||
{tab === 'payments' ? <WalletPaymentHistory /> : null}
|
||||
{tab === 'installments' ? <WalletInstallments /> : null}
|
||||
{tab === 'refunds' ? <WalletRefunds /> : null}
|
||||
{tab === 'receipts' ? <WalletReceipts /> : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletScreen;
|
||||
@@ -1,10 +1,9 @@
|
||||
import WalletInstallments from './WalletInstallments';
|
||||
import WalletScreen from './WalletScreen';
|
||||
|
||||
/**
|
||||
* /wallet — the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported,
|
||||
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client
|
||||
* component (TanStack Query); this page is the thin route shell.
|
||||
* /wallet — the customer money hub (ui-phase-6): پرداختها / اقساط / استردادها / رسیدها. Thin route shell;
|
||||
* the tabbed body is a client component (TanStack Query).
|
||||
*/
|
||||
export default function WalletPage() {
|
||||
return <WalletInstallments />;
|
||||
return <WalletScreen />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePaymentHistory } from '@/services/payment';
|
||||
import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
export interface WalletHistoryRow {
|
||||
key: string;
|
||||
amountIrr: string;
|
||||
createdAt: string;
|
||||
status: PaymentTransactionStatus;
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the two independent seams a wallet history/receipt row can come from — card transactions
|
||||
* (`services/payment`, REQ-047) and each settled BNPL plan's own down-payment leg (`services/bnpl`) — into
|
||||
* one newest-first list. Shared by the wallet «پرداختها» and «رسیدها» tabs so the merge logic lives once.
|
||||
* Degrades gracefully: either source failing alone still renders the other's rows.
|
||||
*/
|
||||
export function useWalletHistoryRows() {
|
||||
const paymentHistory = usePaymentHistory();
|
||||
const walletInstallments = useWalletInstallments();
|
||||
|
||||
const rows = useMemo<WalletHistoryRow[]>(() => {
|
||||
const cardRows: WalletHistoryRow[] = (paymentHistory.data ?? []).map((row) => ({
|
||||
key: `card-${row.transactionId}`,
|
||||
amountIrr: row.amountIrr,
|
||||
createdAt: row.createdAt,
|
||||
status: row.status,
|
||||
bookingId: row.bookingId,
|
||||
}));
|
||||
const bnplRows: WalletHistoryRow[] = (walletInstallments.data ?? []).map((plan) => {
|
||||
const downPayment = plan.installments.find((i) => i.kind === 'down_payment');
|
||||
return {
|
||||
key: `bnpl-${plan.bnplTransactionId}`,
|
||||
amountIrr: downPayment?.amountIrr ?? '0',
|
||||
createdAt: plan.createdAt,
|
||||
status: 'succeeded' as const,
|
||||
bookingId: plan.bookingId,
|
||||
};
|
||||
});
|
||||
return [...cardRows, ...bnplRows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}, [paymentHistory.data, walletInstallments.data]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
isLoading: paymentHistory.isLoading || walletInstallments.isLoading,
|
||||
bothErrored: paymentHistory.isError && walletInstallments.isError,
|
||||
refetch: () => {
|
||||
paymentHistory.refetch();
|
||||
walletInstallments.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,8 @@ jest.mock('next-intl', () => ({
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
const ORDER_AMOUNT_IRR = '20000000'; // 2,000,000 Toman — the card-payable gross
|
||||
|
||||
const FEE_PLAN: BnplPlanOption = {
|
||||
planId: 'digipay_6m',
|
||||
termMonths: 6,
|
||||
@@ -20,7 +22,7 @@ const FEE_PLAN: BnplPlanOption = {
|
||||
downPaymentPercent: 0.2,
|
||||
monthlyAmountIrr: '4040000', // 404,000 Toman
|
||||
downPaymentIrr: '4660000',
|
||||
totalIrr: '23300000',
|
||||
totalIrr: '23300000', // 2,330,000 Toman — 330,000 Toman fee vs the 2,000,000 gross
|
||||
};
|
||||
|
||||
const INTEREST_FREE_PLAN: BnplPlanOption = {
|
||||
@@ -31,14 +33,14 @@ const INTEREST_FREE_PLAN: BnplPlanOption = {
|
||||
downPaymentPercent: 0,
|
||||
monthlyAmountIrr: '5825000',
|
||||
downPaymentIrr: '0',
|
||||
totalIrr: '23300000',
|
||||
totalIrr: '20000000',
|
||||
};
|
||||
|
||||
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
|
||||
const onSelect = props.onSelect ?? jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} />
|
||||
<BnplPlanCard plan={FEE_PLAN} orderAmountIrr={ORDER_AMOUNT_IRR} selected={false} onSelect={onSelect} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSelect };
|
||||
@@ -51,18 +53,24 @@ describe('<BnplPlanCard/> component', () => {
|
||||
expect(screen.getByText('monthly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => {
|
||||
it('shows the down payment and total repayment as plain Toman rows, no LinearProgress bar', () => {
|
||||
renderCard();
|
||||
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4%
|
||||
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument();
|
||||
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
expect(screen.getByText(/466,000/)).toBeInTheDocument(); // down payment
|
||||
expect(screen.getByText(/2,330,000/)).toBeInTheDocument(); // total repayment
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => {
|
||||
it('shows the fee delta in Toman (total − order amount), not a bare percent', () => {
|
||||
renderCard();
|
||||
// Anchored so it doesn't also match the "2,330,000" total, which contains "330,000" as a substring.
|
||||
expect(screen.getByText(/^330,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText('plan_fee_amount_suffix')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "interest-free" and no fee delta for a 0-fee plan', () => {
|
||||
renderCard({ plan: INTEREST_FREE_PLAN });
|
||||
expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('plan_fee_amount_suffix')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected card via aria-pressed + data-selected', () => {
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import { ButtonBase, Divider, Stack, Typography } from '@mui/material';
|
||||
import Money from '@/components/common/Money';
|
||||
import { parseIrr } from '@/utils';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
export interface BnplPlanCardProps {
|
||||
plan: BnplPlanOption;
|
||||
/** The order's card-payable gross (D1 «مبلغ قابل پرداخت») — the interest-free baseline the fee delta
|
||||
* below compares against. Both figures are served; the delta is their exact BigInt difference, the same
|
||||
* "exact remainder of served amounts" pattern the invoice page uses — never a computed rate. */
|
||||
orderAmountIrr: string;
|
||||
selected: boolean;
|
||||
onSelect: (planId: string) => void;
|
||||
}
|
||||
|
||||
/** A whole-percent from a 0..1 fraction (display only — never money math). */
|
||||
const asPercent = (fraction: number): number => Math.round(fraction * 100);
|
||||
|
||||
/**
|
||||
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
|
||||
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util — never
|
||||
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta
|
||||
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only.
|
||||
* the **served** monthly amount, a plain پیشپرداخت (امروز) amount row, and مجموع بازپرداخت with the fee
|
||||
* delta vs. paying in full made explicit in Toman (never a percent-only label, never a `LinearProgress` bar
|
||||
* standing in for a static fact). Single-select: the selected card gets the terracotta `--bal-secondary`
|
||||
* border + soft tint. Labels are i18n keys off the served fields; money is display-only.
|
||||
* @component BnplPlanCard
|
||||
*/
|
||||
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
|
||||
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountIrr, selected, onSelect }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
|
||||
const termLabel =
|
||||
plan.termMonths != null
|
||||
? t('plan_term_months', { months: plan.termMonths })
|
||||
: t('plan_installments', { count: plan.installmentCount });
|
||||
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free');
|
||||
const hasDownPayment = plan.downPaymentPercent > 0;
|
||||
const feeIrr = (parseIrr(plan.totalIrr) - parseIrr(orderAmountIrr)).toString();
|
||||
const hasFee = plan.feePercent > 0 && parseIrr(feeIrr) > BigInt(0);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
@@ -49,19 +52,11 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
|
||||
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{termLabel}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-money-emphasis)' : 'text.secondary' }}
|
||||
>
|
||||
{feeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{termLabel}
|
||||
</Typography>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
|
||||
<Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
@@ -70,28 +65,38 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{hasDownPayment ? (
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('down_payment')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.downPaymentIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_repayment')}
|
||||
</Typography>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
|
||||
<Money amountIrr={plan.totalIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
|
||||
{hasFee ? (
|
||||
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline' }}>
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||||
+
|
||||
</Typography>
|
||||
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }} />
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||||
{t('plan_fee_amount_suffix')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('down_payment')}
|
||||
{t('plan_interest_free')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={asPercent(plan.downPaymentPercent)}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'var(--bal-divider)',
|
||||
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => {
|
||||
const NAMES: Record<string, string> = {
|
||||
provider_digipay: 'Digipay',
|
||||
provider_snapppay: 'SnappPay',
|
||||
};
|
||||
return NAMES[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
import BnplProviderLogo from './BnplProviderLogo';
|
||||
|
||||
describe('<BnplProviderLogo/> component', () => {
|
||||
it('falls back to a tinted monogram roundel when no bundled asset exists', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<BnplProviderLogo providerCode="digipay" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const mark = container.querySelector('[data-provider-logo="digipay"]');
|
||||
expect(mark).toBeInTheDocument();
|
||||
expect(mark).toHaveTextContent('D');
|
||||
});
|
||||
|
||||
it('derives the monogram from the translated provider name', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<BnplProviderLogo providerCode="snapppay" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelector('[data-provider-logo="snapppay"]')).toHaveTextContent('S');
|
||||
});
|
||||
|
||||
it('sizes the mark from the size prop', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<BnplProviderLogo providerCode="digipay" size={60} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const mark = container.querySelector('[data-provider-logo="digipay"]') as HTMLElement;
|
||||
expect(mark.style.width).toBe('60px');
|
||||
expect(mark.style.height).toBe('42px');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ComponentType, FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box } from '@mui/material';
|
||||
import type { ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
export interface BnplProviderLogoProps {
|
||||
providerCode: ProviderCode;
|
||||
/** Box width in px — height follows at a 10:7 wordmark ratio. Defaults to the D1 provider-row size. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real bundled provider marks — empty until licensed assets exist (never fake a provider's logo). A real
|
||||
* SVG drops in here (`providerCode → ComponentType`) without touching any call-site: `MethodStep` and any
|
||||
* future provider list keep rendering `<BnplProviderLogo providerCode={…} />` unchanged.
|
||||
*/
|
||||
const PROVIDER_LOGO_SVG: Partial<Record<ProviderCode, ComponentType<{ width: number; height: number }>>> = {};
|
||||
|
||||
/**
|
||||
* D1 provider mark — a registry component so real logos can drop in without touching call-sites (the
|
||||
* decision this phase made: no licensed provider assets exist yet, so every provider falls back to a
|
||||
* *designed* neutral chip — a tinted monogram roundel, replacing the old two-letter text-glyph stand-in
|
||||
* (`DG`/`SP`/…) that read as unfinished). The provider's full name is rendered by the caller alongside it
|
||||
* (unchanged) — this component is only the mark.
|
||||
* @component BnplProviderLogo
|
||||
*/
|
||||
const BnplProviderLogo: FunctionComponent<BnplProviderLogoProps> = ({ providerCode, size = 40 }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const height = Math.round(size * 0.7);
|
||||
const LogoSvg = PROVIDER_LOGO_SVG[providerCode];
|
||||
|
||||
if (LogoSvg) return <LogoSvg width={size} height={height} />;
|
||||
|
||||
const name = t(`provider_${providerCode}`);
|
||||
const monogram = name.trim().charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-provider-logo={providerCode}
|
||||
aria-hidden
|
||||
style={{ width: size, height }}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
flex: 'none',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
fontWeight: 800,
|
||||
fontSize: Math.round(height * 0.55),
|
||||
color: 'var(--bal-secondary-dark)',
|
||||
backgroundColor: 'var(--bal-secondary-soft)',
|
||||
}}
|
||||
>
|
||||
{monogram}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BnplProviderLogo;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './BnplProviderLogo';
|
||||
export type { BnplProviderLogoProps } from './BnplProviderLogo';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import EscrowExplainer from './EscrowExplainer';
|
||||
|
||||
describe('<EscrowExplainer/> component', () => {
|
||||
it('always renders the mandated EscrowNotice', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<EscrowExplainer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('escrow-notice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the 3-step explainer is collapsed by default and expands on toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<EscrowExplainer />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('escrow_step_pay')).not.toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'escrow_explainer_toggle' }));
|
||||
expect(screen.getByText('escrow_step_pay')).toBeVisible();
|
||||
expect(screen.getByText('escrow_step_hold')).toBeVisible();
|
||||
expect(screen.getByText('escrow_step_release')).toBeVisible();
|
||||
expect(screen.getByText('escrow_cancellation_note')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Collapse, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import EscrowNotice from '@/components/EscrowNotice';
|
||||
|
||||
interface ExplainerStep {
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const STEPS: ExplainerStep[] = [
|
||||
{ icon: 'payment', labelKey: 'escrow_step_pay' },
|
||||
{ icon: 'lock', labelKey: 'escrow_step_hold' },
|
||||
{ icon: 'verified', labelKey: 'escrow_step_release' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wraps the product-mandated `EscrowNotice` (never edited) with an optional «چطور کار میکند؟» expander: a
|
||||
* 3-step visual (پرداخت ← امانت نزد بالینیار ← آزادسازی پس از تایید پایان ویزیت) grounded in
|
||||
* `product/payments/escrow-ledger.md`, plus the cancellation/refund implication. Used from checkout and the
|
||||
* confirmation receipt so escrow — the platform's whole reason to pay on-platform — gets more than one
|
||||
* alert line at the moment of maximum skepticism.
|
||||
* @component EscrowExplainer
|
||||
*/
|
||||
const EscrowExplainer: FunctionComponent = () => {
|
||||
const t = useTranslations('payment');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<EscrowNotice />
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
endIcon={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: 'inline-flex', transition: 'transform 150ms ease', transform: open ? 'rotate(180deg)' : 'none' }}
|
||||
>
|
||||
<AppIcon icon="expand" size={18} />
|
||||
</Box>
|
||||
}
|
||||
sx={{ alignSelf: 'flex-start', px: 0.5 }}
|
||||
>
|
||||
{t('escrow_explainer_toggle')}
|
||||
</AppButton>
|
||||
<Collapse in={open}>
|
||||
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
|
||||
>
|
||||
{STEPS.map((step, index) => (
|
||||
<Stack key={step.labelKey} direction="row" sx={{ gap: 1, alignItems: 'center', flex: '1 1 auto' }}>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center', minWidth: 72 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--bal-primary-soft)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={step.icon} size={20} color="var(--bal-primary)" />
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, textAlign: 'center' }}>
|
||||
{t(step.labelKey)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{index < STEPS.length - 1 ? (
|
||||
<Box sx={{ flex: 'none', display: { xs: 'none', sm: 'block' }, pt: 2 }}>
|
||||
<AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" />
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('escrow_cancellation_note')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscrowExplainer;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EscrowExplainer';
|
||||
@@ -50,7 +50,6 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<Money
|
||||
amountIrr={row.amountIrr}
|
||||
hideUnit
|
||||
size="sm"
|
||||
tone={row.kind === 'down_payment' ? 'emphasis' : 'default'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import PaymentStateCard from './PaymentStateCard';
|
||||
|
||||
describe('<PaymentStateCard/> component', () => {
|
||||
it('renders the title and icon', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<PaymentStateCard icon="error" tone="var(--bal-error)" title="Payment failed" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Payment failed')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-icon="error"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the body only when provided', () => {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" body="Hang tight" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Hang tight')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.queryByText('Hang tight')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the actions slot', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<PaymentStateCard icon="verified" tone="var(--bal-success)" title="Done">
|
||||
<button type="button">Continue</button>
|
||||
</PaymentStateCard>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
export interface PaymentStateCardProps {
|
||||
/** AppIcon registry name. */
|
||||
icon: string;
|
||||
/** A `--bal-*` token (or MUI palette reference) — never a hard-coded hex. */
|
||||
tone: string;
|
||||
/** Already-translated title. */
|
||||
title: string;
|
||||
/** Already-translated body. */
|
||||
body?: string;
|
||||
/** Actions/badges/spinners below the body (e.g. an `AppButton`, a `PaymentStatusBadge`, a `CircularProgress`). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one terminal/wait-state card for the card and BNPL payment flows — replaces the four copy-pasted
|
||||
* private `MessageCard`/`StateCard` functions (`checkout/page.tsx` + `bnpl/page.tsx`,
|
||||
* `checkout/return/page.tsx` + `bnpl/return/page.tsx`) so the two flows can no longer visually drift.
|
||||
* Presentational, caller-owned i18n; `children` is the actions slot (a single CTA or a stack of them).
|
||||
* @component PaymentStateCard
|
||||
*/
|
||||
const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone, title, body, children }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payment-state-card
|
||||
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>
|
||||
);
|
||||
|
||||
export default PaymentStateCard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PaymentStateCard';
|
||||
export type { PaymentStateCardProps } from './PaymentStateCard';
|
||||
@@ -3,9 +3,10 @@ import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
|
||||
// next-intl mocked to echo keys (currency_toman resolved so <Money> renders "X Toman"); locale = en so
|
||||
// money formats with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key),
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
@@ -26,12 +27,14 @@ const ROWS = [
|
||||
const TOTAL = '45000000';
|
||||
|
||||
describe('<PriceBreakdown/> component', () => {
|
||||
it('renders every row label with its Toman-formatted amount', () => {
|
||||
it('renders every row label with its Toman-formatted amount, unit included', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
for (const row of ROWS) {
|
||||
expect(screen.getByText(row.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(formatIrrToToman(row.amountIrr, 'en')))).toBeInTheDocument();
|
||||
}
|
||||
// Every row — not just the total — carries the currency unit (the Toman/Rial ambiguity this guards).
|
||||
expect(screen.getAllByText(/Toman/).length).toBeGreaterThanOrEqual(ROWS.length + 1);
|
||||
});
|
||||
|
||||
it('renders a total equal to the integer sum of the served rows', () => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import Money from '@/components/common/Money';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
import { parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
|
||||
@@ -31,8 +30,6 @@ export interface PriceBreakdownProps {
|
||||
* @component PriceBreakdown
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
if (sum !== parseIrr(totalAmountIrr)) {
|
||||
@@ -50,9 +47,7 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
<Money amountIrr={row.amountIrr} size="sm" sx={{ fontWeight: 500 }} />
|
||||
</Stack>
|
||||
))}
|
||||
<Divider />
|
||||
|
||||
@@ -36,4 +36,9 @@ describe('<Money/>', () => {
|
||||
);
|
||||
expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the xl size as an h4 for the checkout/confirmation prominent-total hero', () => {
|
||||
wrap(<Money amountIrr="45000000" size="xl" />);
|
||||
expect(screen.getByText(/4,500,000/).closest('.MuiTypography-h4')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,16 @@ import Box from '@mui/material/Box';
|
||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
|
||||
export type MoneySize = 'sm' | 'md' | 'lg';
|
||||
export type MoneySize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error';
|
||||
|
||||
const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = {
|
||||
sm: 'body2',
|
||||
md: 'body1',
|
||||
lg: 'h6',
|
||||
// The C6/confirmation "prominent total" hero figure — the single most important number on a payment
|
||||
// screen gets its own weight class, above the existing card/row emphasis.
|
||||
xl: 'h4',
|
||||
};
|
||||
|
||||
// House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise.
|
||||
|
||||
@@ -34,6 +34,9 @@ import ReviewTagSelector from './ReviewTagSelector';
|
||||
import VisitNoteCard from './VisitNoteCard';
|
||||
import PatientHeader from './PatientHeader';
|
||||
import VerificationPanel from './VerificationPanel';
|
||||
import PaymentStateCard from './PaymentStateCard';
|
||||
import BnplProviderLogo from './BnplProviderLogo';
|
||||
import EscrowExplainer from './EscrowExplainer';
|
||||
|
||||
export {
|
||||
ProfileSummary,
|
||||
@@ -70,6 +73,9 @@ export {
|
||||
VisitNoteCard,
|
||||
PatientHeader,
|
||||
VerificationPanel,
|
||||
PaymentStateCard,
|
||||
BnplProviderLogo,
|
||||
EscrowExplainer,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -103,3 +109,5 @@ export type { ReviewTagSelectorProps } from './ReviewTagSelector';
|
||||
export type { VisitNoteCardProps } from './VisitNoteCard';
|
||||
export type { PatientHeaderProps } from './PatientHeader';
|
||||
export type { VerificationPanelProps } from './VerificationPanel';
|
||||
export type { PaymentStateCardProps } from './PaymentStateCard';
|
||||
export type { BnplProviderLogoProps } from './BnplProviderLogo';
|
||||
|
||||
@@ -23,8 +23,6 @@ export const ROUTES = {
|
||||
BOOKING_REQUEST_STATUS: '/bookings/request',
|
||||
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
|
||||
CHECKOUT: '/bookings/checkout',
|
||||
// Dev mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
|
||||
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
|
||||
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
|
||||
CHECKOUT_RETURN: '/bookings/checkout/return',
|
||||
// Post-payment success screen — links to the booking detail + invoice.
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
InitiatePaymentResult,
|
||||
InvoiceDto,
|
||||
PaymentApi,
|
||||
PaymentHistoryItem,
|
||||
PaymentOutcomeDto,
|
||||
} from '../types';
|
||||
|
||||
@@ -70,10 +71,19 @@ export const paymentClientApi: PaymentApi = {
|
||||
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
|
||||
// to the bookings list until the field lands.
|
||||
bookingId: null,
|
||||
// REQ-046: no client-readable transaction read exists yet — the receipt hides these rows rather
|
||||
// than render a fabricated tracking code or paid-at timestamp.
|
||||
trackingCode: null,
|
||||
paidAt: null,
|
||||
};
|
||||
return outcome;
|
||||
},
|
||||
|
||||
getInvoice: async (bookingId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
|
||||
|
||||
getPaymentHistory: async () =>
|
||||
// REQ-047 proposed slug — no customer payment-transactions list exists yet; 404s until delivered
|
||||
// (the wallet «پرداختها» tab renders its empty state until then).
|
||||
unwrap(await clientFetch<ApiEnvelope<PaymentHistoryItem[]>>(`${BOOKINGS}/payment_history`)),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { multiplyIrr, parseIrr, sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
bookingRequestsMockApi,
|
||||
mockMarkBookingRequestConverted,
|
||||
@@ -8,9 +7,8 @@ import {
|
||||
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
|
||||
import type { BookingRequestDto } from '@/services/bookingRequests/types';
|
||||
import {
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
MOCK_PLATFORM_FEE_RATE,
|
||||
MOCK_SELLER_FISCAL_IDENTITY,
|
||||
MOCK_VAT_RATE,
|
||||
} from '../constants';
|
||||
import type {
|
||||
@@ -65,6 +63,9 @@ interface MockTransaction {
|
||||
gatewayReferenceCode: string;
|
||||
grossPriceIrr: string;
|
||||
bookingId: number | null;
|
||||
createdAt: string;
|
||||
/** Set at capture — the receipt's «تاریخ پرداخت» (REQ-046). */
|
||||
capturedAt: string | null;
|
||||
}
|
||||
|
||||
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
|
||||
@@ -93,6 +94,8 @@ function toOutcome(request: BookingRequestDto, transaction: MockTransaction | un
|
||||
requestStatus: request.status,
|
||||
transactionStatus: transaction?.status ?? null,
|
||||
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
|
||||
trackingCode: transaction?.gatewayReferenceCode ?? null,
|
||||
paidAt: transaction?.capturedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +133,7 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
|
||||
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
|
||||
transaction.status = 'succeeded';
|
||||
transaction.bookingId = booking.id;
|
||||
transaction.capturedAt = new Date().toISOString();
|
||||
|
||||
invoices[booking.id] = {
|
||||
id: nextInvoiceId,
|
||||
@@ -145,6 +149,9 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
|
||||
moadianStatus: 'pending',
|
||||
pdfUrl: null,
|
||||
issuedAt: new Date().toISOString(),
|
||||
paymentMethod: 'card',
|
||||
transactionReference: transaction.gatewayReferenceCode,
|
||||
sellerFiscalIdentity: MOCK_SELLER_FISCAL_IDENTITY,
|
||||
};
|
||||
nextInvoiceId += 1;
|
||||
|
||||
@@ -168,6 +175,11 @@ export const paymentMockApi: PaymentApi = {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
nurseName: request.nurseName,
|
||||
// The mock stands in for REQ-046 (nurseAvatarUrl/nurseVerified aren't on BookingRequestDto) — every
|
||||
// seeded request nurse is verified by construction; there is no avatar in the f7 mock store, so the
|
||||
// C6 identity moment falls back to initials (never a fabricated image URL).
|
||||
nurseAvatarUrl: null,
|
||||
nurseVerified: true,
|
||||
patientName: request.patientName,
|
||||
variantLabel: request.variantLabel,
|
||||
variantPriceUnit: request.variantPriceUnit,
|
||||
@@ -213,6 +225,8 @@ export const paymentMockApi: PaymentApi = {
|
||||
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
|
||||
grossPriceIrr: requestGross(request),
|
||||
bookingId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
capturedAt: null,
|
||||
};
|
||||
transactions = [transaction, ...transactions];
|
||||
return toInitiateResult(transaction);
|
||||
@@ -252,16 +266,26 @@ export const paymentMockApi: PaymentApi = {
|
||||
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
|
||||
return { ...invoice };
|
||||
},
|
||||
|
||||
getPaymentHistory: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return transactions.map((t) => ({
|
||||
transactionId: t.transactionId,
|
||||
bookingRequestId: t.bookingRequestId,
|
||||
bookingId: t.bookingId,
|
||||
status: t.status,
|
||||
amountIrr: t.grossPriceIrr,
|
||||
createdAt: t.createdAt,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
|
||||
const query = new URLSearchParams();
|
||||
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
|
||||
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
|
||||
return {
|
||||
transactionId: transaction.transactionId,
|
||||
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
|
||||
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
|
||||
// No gateway hop to make — the dev card-gateway harness was retired in refinement-phase-4. The
|
||||
// checkout page's `!result.redirectUrl` branch already handles this: it reads the outcome directly.
|
||||
redirectUrl: null,
|
||||
gatewayReferenceCode: transaction.gatewayReferenceCode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,3 +52,13 @@ export const CHECKOUT_QUERY_OUTCOME = 'outcome';
|
||||
*/
|
||||
export const MOCK_PLATFORM_FEE_RATE = 0.12;
|
||||
export const MOCK_VAT_RATE = 0.1;
|
||||
|
||||
/**
|
||||
* Mock-only seller fiscal identity (REQ-049) — a fixed platform-level fact (not per-invoice data) the
|
||||
* real path serves from platform config once registered. Placeholder values, never a real economic code.
|
||||
*/
|
||||
export const MOCK_SELLER_FISCAL_IDENTITY = {
|
||||
legalName: 'شرکت بالینیار',
|
||||
economicCode: null,
|
||||
address: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The customer's card payment history (wallet «پرداختها» tab, REQ-047). BNPL rows are sourced separately
|
||||
* from `services/bnpl`'s wallet installments — the two domains stay independent seams; the wallet screen
|
||||
* merges them for display.
|
||||
*/
|
||||
export function usePaymentHistory() {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.history(),
|
||||
queryFn: () => paymentApi.getPaymentHistory(),
|
||||
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { useInitiatePayment } from './hooks/useInitiatePayment';
|
||||
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
|
||||
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
|
||||
export { useInvoice } from './hooks/useInvoice';
|
||||
export { usePaymentHistory } from './hooks/usePaymentHistory';
|
||||
|
||||
@@ -11,4 +11,5 @@ export const paymentKeys = {
|
||||
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
|
||||
invoices: () => [...paymentKeys.all, 'invoice'] as const,
|
||||
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
|
||||
history: () => [...paymentKeys.all, 'history'] as const,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface CheckoutSummaryDto {
|
||||
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
|
||||
requestStatus: BookingRequestStatus;
|
||||
nurseName: string;
|
||||
/** The C6 identity moment (REQ-046) — `null` on the real path until served; the avatar/badge hide gracefully. */
|
||||
nurseAvatarUrl: string | null;
|
||||
nurseVerified: boolean;
|
||||
patientName: string;
|
||||
variantLabel: string;
|
||||
variantPriceUnit: PriceUnit;
|
||||
@@ -107,6 +110,21 @@ export interface PaymentOutcomeDto {
|
||||
transactionStatus: PaymentTransactionStatus | null;
|
||||
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
|
||||
bookingId: number | null;
|
||||
/** کد پیگیری — the receipt's copyable reference (REQ-046). `null` on the real path until served; the
|
||||
* confirmation receipt hides the row rather than render a fabricated code. */
|
||||
trackingCode: string | null;
|
||||
/** UTC ISO timestamp of capture (REQ-046) — `null` until served on the real path. */
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
/** One row of the customer's card/BNPL payment history (REQ-047 — the wallet «پرداختها» tab). */
|
||||
export interface PaymentHistoryItem {
|
||||
transactionId: number;
|
||||
bookingRequestId: number;
|
||||
bookingId: number | null;
|
||||
status: PaymentTransactionStatus;
|
||||
amountIrr: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
|
||||
@@ -118,6 +136,13 @@ export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Seller fiscal-identity block (REQ-049) — a fixed platform-level fact, not per-invoice data. */
|
||||
export interface InvoiceSellerFiscalIdentity {
|
||||
legalName: string;
|
||||
economicCode: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
|
||||
export interface InvoiceDto {
|
||||
id: number;
|
||||
@@ -135,6 +160,11 @@ export interface InvoiceDto {
|
||||
moadianStatus: MoadianStatus | null;
|
||||
pdfUrl: string | null;
|
||||
issuedAt: string;
|
||||
/** --- Fiscal-grade fields (REQ-049): `null` on the real path until served — the invoice hides the row. --- */
|
||||
paymentMethod: 'card' | 'bnpl' | null;
|
||||
/** Opaque payment/settlement reference — never parsed. */
|
||||
transactionReference: string | null;
|
||||
sellerFiscalIdentity: InvoiceSellerFiscalIdentity | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,4 +177,6 @@ export interface PaymentApi {
|
||||
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
|
||||
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
|
||||
getInvoice(bookingId: number): Promise<InvoiceDto>;
|
||||
/** The customer's card payment history (REQ-047 — wallet «پرداختها»; BNPL rows come from `services/bnpl`). */
|
||||
getPaymentHistory(): Promise<PaymentHistoryItem[]>;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,13 @@ export const refundsClientApi: RefundsApi = {
|
||||
getRefund: async (refundId: number) =>
|
||||
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
|
||||
|
||||
// REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s
|
||||
// until delivered (the wallet «استردادها» tab renders its empty state until then).
|
||||
getMyRefunds: async () => {
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
|
||||
return wire.map(toSummary);
|
||||
},
|
||||
|
||||
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
|
||||
// read-only preview route), yet the admin console must disclose the split before initiating. Filed as a
|
||||
// proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today.
|
||||
|
||||
@@ -356,6 +356,16 @@ export const refundsMockApi: RefundsApi = {
|
||||
return toRefundSummary(refund);
|
||||
},
|
||||
|
||||
getMyRefunds: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const refunds = Object.values(refundsByBooking);
|
||||
refunds.forEach(advanceRefund);
|
||||
return refunds
|
||||
.slice()
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
|
||||
.map(toRefundSummary);
|
||||
},
|
||||
|
||||
// --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---
|
||||
|
||||
getRefundPreview: async (bookingId, _ticketId) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { refundKeys } from '../keys';
|
||||
import { REFUND_STATUS_STALE_TIME } from '../constants';
|
||||
|
||||
/** Every refund the customer owns (wallet «استردادها» tab, REQ-048) — newest first. */
|
||||
export function useMyRefunds() {
|
||||
return useQuery({
|
||||
queryKey: refundKeys.mine(),
|
||||
queryFn: () => refundsApi.getMyRefunds(),
|
||||
staleTime: REFUND_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
|
||||
export { useCancelBooking } from './hooks/useCancelBooking';
|
||||
export { useRefundStatus } from './hooks/useRefundStatus';
|
||||
export { useMyRefunds } from './hooks/useMyRefunds';
|
||||
|
||||
// Admin refund tooling (b11 admin_refunds; ticket-linked).
|
||||
export { useRefundPreview } from './hooks/useRefundPreview';
|
||||
|
||||
@@ -13,6 +13,8 @@ export const refundKeys = {
|
||||
byBookings: () => [...refundKeys.all, 'by_booking'] as const,
|
||||
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
|
||||
|
||||
mine: () => [...refundKeys.all, 'mine'] as const,
|
||||
|
||||
details: () => [...refundKeys.all, 'detail'] as const,
|
||||
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ export interface RefundsApi {
|
||||
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
|
||||
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
|
||||
getRefund(refundId: number): Promise<RefundSummary>;
|
||||
/** Every refund the caller owns, newest first (REQ-048 — the wallet «استردادها» tab). */
|
||||
getMyRefunds(): Promise<RefundSummary[]>;
|
||||
|
||||
/* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */
|
||||
/** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */
|
||||
|
||||
Reference in New Issue
Block a user