frontend phase 11
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly, formatIrrToToman } from '@/utils';
|
||||
import { useCheckEligibility } from '@/services/bnpl';
|
||||
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
|
||||
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface EligibilityStepProps {
|
||||
bookingRequestId: number;
|
||||
providerCode: ProviderCode;
|
||||
/** Mobile prefilled from the session (may be empty if unknown). */
|
||||
sessionMobile: string;
|
||||
/** A prior approval to re-show on back-navigation from D4 (so the approved panel survives, not the form). */
|
||||
initialResult?: BnplEligibilityResult | null;
|
||||
onApproved: (result: BnplEligibilityResult) => void;
|
||||
onPayWithCard: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 · اعتبارسنجی — the provider credit check. کد ملی (client-side format only — the real check is the
|
||||
* provider's), موبایل (prefilled from the session, read-only), and a consent checkbox that **gates** the
|
||||
* submit. On approval → the credit ceiling + «تایید و ادامه» → D4. On decline / ceiling-exceeded → the
|
||||
* declined panel + a card fall-back (never a dead end). The verdict is surfaced, never pre-judged.
|
||||
*/
|
||||
const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
bookingRequestId,
|
||||
providerCode,
|
||||
sessionMobile,
|
||||
initialResult,
|
||||
onApproved,
|
||||
onPayWithCard,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const check = useCheckEligibility();
|
||||
// A fresh check wins; otherwise re-show a prior approval carried back from D4.
|
||||
const result = check.data ?? initialResult ?? undefined;
|
||||
|
||||
const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId);
|
||||
const nationalIdError = submitted && !nationalIdValid;
|
||||
const providerName = t(`provider_${providerCode}`);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitted(true);
|
||||
if (!nationalIdValid || !consent) return;
|
||||
check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent });
|
||||
};
|
||||
|
||||
// Approved — show the ceiling + advance.
|
||||
if (result?.isEligible) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--bal-success)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
|
||||
{t('approved_title')}
|
||||
</Typography>
|
||||
{result.creditCeilingIrr ? (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('credit_ceiling_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)} sx={{ m: 0 }}>
|
||||
{t('approve_continue')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Declined (not_eligible / ceiling_exceeded) — a clear panel + the card fall-back.
|
||||
if (result && !result.isEligible) {
|
||||
const ceiling = result.eligibilityStatus === 'ceiling_exceeded';
|
||||
return (
|
||||
<DeclinedPanel
|
||||
title={ceiling ? t('declined_ceiling_title') : t('declined_not_eligible_title')}
|
||||
body={ceiling ? t('declined_ceiling_body') : t('declined_not_eligible_body')}
|
||||
cardLabel={t('pay_with_card')}
|
||||
onPayWithCard={onPayWithCard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Error / timeout — retry or fall back to card.
|
||||
if (check.isError) {
|
||||
return (
|
||||
<DeclinedPanel
|
||||
title={t('eligibility_title')}
|
||||
body={t('eligibility_error')}
|
||||
cardLabel={t('pay_with_card')}
|
||||
onPayWithCard={onPayWithCard}
|
||||
onRetry={handleSubmit}
|
||||
retryLabel={tc('retry')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('eligibility_title')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('national_id_label')}
|
||||
placeholder={t('national_id_placeholder')}
|
||||
value={nationalId}
|
||||
onChange={(e) => setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))}
|
||||
error={nationalIdError}
|
||||
helperText={nationalIdError ? t('national_id_invalid') : undefined}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<PhoneNumberField
|
||||
label={t('mobile_label')}
|
||||
value={sessionMobile}
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('consent_label', { provider: providerName })}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ alignItems: 'flex-start', m: 0 }}
|
||||
/>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!consent || check.isPending}
|
||||
onClick={handleSubmit}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('check_eligibility')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" onClick={onPayWithCard} sx={{ m: 0 }}>
|
||||
{t('pay_with_card')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function DeclinedPanel({
|
||||
title,
|
||||
body,
|
||||
cardLabel,
|
||||
onPayWithCard,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
cardLabel: string;
|
||||
onPayWithCard: () => void;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{onRetry && retryLabel ? (
|
||||
<AppButton variant="outlined" color="secondary" onClick={onRetry} sx={{ m: 0 }}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
) : null}
|
||||
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard} sx={{ m: 0 }}>
|
||||
{cardLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default EligibilityStep;
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface MethodStepProps {
|
||||
options: BnplOptions;
|
||||
selectedProvider: ProviderCode | null;
|
||||
onSelectProvider: (code: ProviderCode) => void;
|
||||
onContinue: () => void;
|
||||
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
|
||||
* hardcoded). Primary action «ادامه با {provider}». Empty provider set → only the card option.
|
||||
*/
|
||||
const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
options,
|
||||
selectedProvider,
|
||||
onSelectProvider,
|
||||
onContinue,
|
||||
onPayWithCard,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const hasProviders = options.providers.length > 0;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('method_title')}
|
||||
</Typography>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payable_amount')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
|
||||
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
|
||||
<ButtonBase
|
||||
onClick={onPayWithCard}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
borderRadius: 2,
|
||||
p: 1.75,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<AppIcon icon="payment" size={24} color="var(--bal-primary)" />
|
||||
<Stack sx={{ flex: 1, gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('method_card')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('method_card_hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
|
||||
{hasProviders ? (
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-secondary-dark)' }}>
|
||||
{t('installments_heading')}
|
||||
</Typography>
|
||||
{/* Ownership disclosure at the point of choice: the provider finances & owns the repayment. */}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.8 }}>
|
||||
{t('ownership_note')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{options.providers.map((provider) => (
|
||||
<ProviderOption
|
||||
key={provider.providerCode}
|
||||
provider={provider}
|
||||
selected={selectedProvider === provider.providerCode}
|
||||
onSelect={() => onSelectProvider(provider.providerCode)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={selectedProvider == null}
|
||||
onClick={onContinue}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{selectedProvider
|
||||
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
|
||||
: t('continue')}
|
||||
</AppButton>
|
||||
</>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_providers_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('no_providers_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function ProviderOption({
|
||||
provider,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
provider: BnplProvider;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const t = useTranslations('bnpl');
|
||||
return (
|
||||
<ButtonBase
|
||||
data-provider={provider.providerCode}
|
||||
data-selected={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Stack sx={{ flex: 1, gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t(`provider_${provider.providerCode}`)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t(`provider_tagline_${provider.providerCode}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Decorative radio indicator — the whole card is the ButtonBase; a real <input> here would nest
|
||||
interactive content inside a <button> (invalid HTML) and warn on checked-without-onChange. */}
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
flex: 'none',
|
||||
border: '2px solid',
|
||||
borderColor: selected ? 'var(--bal-secondary)' : 'var(--bal-divider)',
|
||||
backgroundColor: selected ? 'var(--bal-secondary)' : 'transparent',
|
||||
boxShadow: selected ? 'inset 0 0 0 3px var(--bal-bg-paper)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default MethodStep;
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, BnplPlanCard } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface PlanStepProps {
|
||||
providerCode: ProviderCode;
|
||||
plans: BnplPlanOption[];
|
||||
selectedPlanId: string | null;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
onContinue: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every amount comes through the money util from served IRR strings — the client computes nothing about
|
||||
* money. Empty plans → back to D1.
|
||||
*/
|
||||
const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
providerCode,
|
||||
plans,
|
||||
selectedPlanId,
|
||||
onSelectPlan,
|
||||
onContinue,
|
||||
onBack,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_plans_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('no_plans_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<AppButton variant="outlined" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
||||
{t('back_to_providers')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{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>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{plans.map((plan) => (
|
||||
<BnplPlanCard
|
||||
key={plan.planId}
|
||||
plan={plan}
|
||||
selected={selectedPlanId === plan.planId}
|
||||
onSelect={onSelectPlan}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={selectedPlanId == null}
|
||||
onClick={onContinue}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanStep;
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Checkbox, CircularProgress, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow } from '@/components';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { useBnplSchedule, useIssueBnplToken } from '@/services/bnpl';
|
||||
import type { IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface ScheduleStepProps {
|
||||
bookingRequestId: number;
|
||||
providerCode: ProviderCode;
|
||||
planId: string;
|
||||
onBack: () => void;
|
||||
/** The provider handoff — the page follows `redirectUrl`. */
|
||||
onIssued: (result: IssueBnplTokenResult) => void;
|
||||
/** A `409` (already paid / in progress / window lapsed) — the page converges by reading the order. */
|
||||
onConverged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* D4 · تایید طرح و قرارداد — the repayment schedule + contract acceptance. Renders the **served** repayment
|
||||
* rows (پیشپرداخت today + قسط ۱…N with Shamsi due dates + amounts), the ownership-truth note (the
|
||||
* agreement is customer ↔ provider; Balinyaar is paid in full), and a contract-acceptance checkbox that
|
||||
* **gates** the final action. «تایید نهایی و پرداخت پیشپرداخت» issues the provider token and hands off
|
||||
* (the page follows the redirect); on success the booking confirms exactly as the card path.
|
||||
*/
|
||||
const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
|
||||
bookingRequestId,
|
||||
providerCode,
|
||||
planId,
|
||||
onBack,
|
||||
onIssued,
|
||||
onConverged,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
|
||||
const { data: schedule, isLoading, isError, refetch } = useBnplSchedule(bookingRequestId, providerCode, planId);
|
||||
const issue = useIssueBnplToken();
|
||||
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
// One idempotency key per handoff attempt, reused across retries of that attempt (mirrors C6).
|
||||
const attemptKeyRef = useRef<string | null>(null);
|
||||
const providerName = t(`provider_${providerCode}`);
|
||||
|
||||
const busy = issue.isPending || issue.isSuccess;
|
||||
|
||||
const handleConfirm = () => {
|
||||
attemptKeyRef.current ??= crypto.randomUUID();
|
||||
issue.mutate(
|
||||
{ bookingRequestId, providerCode, planId, idempotencyKey: attemptKeyRef.current },
|
||||
{
|
||||
onSuccess: (result) => onIssued(result),
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) onConverged();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Handoff in progress — the provider redirect is being followed.
|
||||
if (busy) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<CircularProgress color="secondary" size="2.5rem" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('redirecting', { provider: providerName })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <ScheduleSkeleton />;
|
||||
|
||||
if (isError || !schedule) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{t('error_body')}
|
||||
</AppAlert>
|
||||
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const inlineError =
|
||||
issue.error && !(issue.error instanceof ApiError && issue.error.status === 409) ? t('settle_failed_body') : null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('schedule_title')}
|
||||
</Typography>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{schedule.rows.map((row) => (
|
||||
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* The ownership truth: the installment agreement is customer ↔ provider; Balinyaar is paid in full. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.75,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--bal-secondary)',
|
||||
backgroundColor: 'var(--bal-secondary-soft)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-secondary-dark)" />
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', lineHeight: 1.9 }}>
|
||||
{t('contract_note', { provider: providerName })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={accepted} onChange={(e) => setAccepted(e.target.checked)} color="secondary" />}
|
||||
label={
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('contract_consent')}
|
||||
</Typography>
|
||||
}
|
||||
sx={{ alignItems: 'flex-start', m: 0 }}
|
||||
/>
|
||||
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!accepted}
|
||||
onClick={handleConfirm}
|
||||
sx={{ m: 0, py: 1.25 }}
|
||||
>
|
||||
{t('pay_down_payment')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function ScheduleSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="text" width="40%" height={28} />
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} variant="rounded" height={56} />
|
||||
))}
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScheduleStep;
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
BNPL_QUERY_OUTCOME,
|
||||
BNPL_QUERY_PLAN,
|
||||
BNPL_QUERY_PROVIDER,
|
||||
BNPL_QUERY_REQUEST_ID,
|
||||
BNPL_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/bnpl/constants';
|
||||
import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
/**
|
||||
* Dev provider-handoff harness — a **test harness, not a product feature**. It stands in for the BNPL
|
||||
* provider so the initiate → redirect → return round-trip is exercisable without a real provider: the
|
||||
* mock's `redirectUrl` points here, and the pay/cancel buttons drive both outcome branches of the return
|
||||
* surface (a real provider redirects back after the customer completes or abandons the agreement). On the
|
||||
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
|
||||
*/
|
||||
export default function BnplGatewayPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<BnplGatewayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BnplGatewayScreen() {
|
||||
const t = useTranslations('bnpl');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = params.get(BNPL_QUERY_REQUEST_ID) ?? '';
|
||||
const transactionId = params.get(BNPL_QUERY_TRANSACTION_ID) ?? '';
|
||||
const provider = (params.get(BNPL_QUERY_PROVIDER) ?? '') as ProviderCode | '';
|
||||
const providerName = provider ? t(`provider_${provider}`) : t('installments_heading');
|
||||
|
||||
const returnWith = (outcome: BnplHandoffOutcome) => {
|
||||
const query = new URLSearchParams({
|
||||
[BNPL_QUERY_REQUEST_ID]: requestId,
|
||||
[BNPL_QUERY_TRANSACTION_ID]: transactionId,
|
||||
[BNPL_QUERY_PROVIDER]: provider,
|
||||
[BNPL_QUERY_PLAN]: params.get(BNPL_QUERY_PLAN) ?? '',
|
||||
[BNPL_QUERY_OUTCOME]: outcome,
|
||||
});
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('handoff_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('handoff_body', { provider: providerName })}
|
||||
</Typography>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
|
||||
{t('handoff_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
|
||||
{t('handoff_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
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 { ROUTES } from '@/constants';
|
||||
import { useAuth } from '@/context/auth';
|
||||
import { useBnplOptions } from '@/services/bnpl';
|
||||
import { BNPL_QUERY_REQUEST_ID, BNPL_QUERY_TRANSACTION_ID } from '@/services/bnpl/constants';
|
||||
import { CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
import type { BnplEligibilityResult, IssueBnplTokenResult, ProviderCode } from '@/services/bnpl/types';
|
||||
import MethodStep from './MethodStep';
|
||||
import PlanStep from './PlanStep';
|
||||
import EligibilityStep from './EligibilityStep';
|
||||
import ScheduleStep from './ScheduleStep';
|
||||
|
||||
type WizardStep = 'provider' | 'plan' | 'eligibility' | 'schedule';
|
||||
const STEP_ORDER: WizardStep[] = ['provider', 'plan', 'eligibility', 'schedule'];
|
||||
|
||||
/**
|
||||
* BNPL installment checkout (D1–D4) — the alternate branch off C6. A single stateful wizard: D1 method /
|
||||
* provider → D2 plan → D3 eligibility → D4 schedule + contract, then the provider handoff. On a cleared
|
||||
* down-payment the return surface routes to the **reused f9 confirmation** (the booking confirms exactly
|
||||
* as the card path). Reached with `?request_id=`; `useSearchParams` needs a Suspense boundary.
|
||||
*/
|
||||
export default function BnplCheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<BnplCheckoutScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BnplCheckoutScreen() {
|
||||
const t = useTranslations('bnpl');
|
||||
const tb = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const tp = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const [{ currentUser }] = useAuth();
|
||||
|
||||
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const { data: options, isLoading, isError, refetch } = useBnplOptions(validId ? requestId : undefined);
|
||||
|
||||
const [step, setStep] = useState<WizardStep>('provider');
|
||||
const [providerCode, setProviderCode] = useState<ProviderCode | null>(null);
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const [eligibility, setEligibility] = useState<BnplEligibilityResult | null>(null);
|
||||
|
||||
const toCard = () => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`);
|
||||
const toBookings = () => router.replace(`/${locale}${ROUTES.BOOKINGS}`);
|
||||
const toRequest = () => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`);
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} />
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />;
|
||||
}
|
||||
if (isLoading || !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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (options.requestStatus !== 'accepted_awaiting_payment') {
|
||||
const expired = options.requestStatus === 'payment_deadline_expired';
|
||||
return (
|
||||
<MessageCard
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const returnUrl = (extra?: Record<string, string>) => {
|
||||
const query = new URLSearchParams({ [BNPL_QUERY_REQUEST_ID]: String(requestId), ...extra });
|
||||
return `/${locale}${ROUTES.CHECKOUT_BNPL_RETURN}?${query.toString()}`;
|
||||
};
|
||||
|
||||
const onIssued = (result: IssueBnplTokenResult) => {
|
||||
if (!result.redirectUrl) {
|
||||
// Nothing to hand off to — read the order directly on the return surface.
|
||||
router.push(returnUrl({ [BNPL_QUERY_TRANSACTION_ID]: String(result.bnplTransactionId) }));
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(result.redirectUrl)) {
|
||||
// The real provider page — a full navigation outside the app router.
|
||||
window.location.assign(result.redirectUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/${locale}${result.redirectUrl}`);
|
||||
};
|
||||
|
||||
const activeProvider = options.providers.find((p) => p.providerCode === providerCode);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25, alignItems: 'center', textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<StepperHeader
|
||||
activeStep={STEP_ORDER.indexOf(step)}
|
||||
steps={[t('step_provider'), t('step_plan'), t('step_eligibility'), t('step_schedule')]}
|
||||
/>
|
||||
|
||||
{step === 'provider' ? (
|
||||
<MethodStep
|
||||
options={options}
|
||||
selectedProvider={providerCode}
|
||||
onSelectProvider={setProviderCode}
|
||||
onContinue={() => setStep('plan')}
|
||||
onPayWithCard={toCard}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'plan' && providerCode && activeProvider ? (
|
||||
<PlanStep
|
||||
providerCode={providerCode}
|
||||
plans={activeProvider.plans}
|
||||
selectedPlanId={planId}
|
||||
onSelectPlan={setPlanId}
|
||||
onContinue={() => setStep('eligibility')}
|
||||
onBack={() => setStep('provider')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'eligibility' && providerCode ? (
|
||||
<EligibilityStep
|
||||
bookingRequestId={requestId}
|
||||
providerCode={providerCode}
|
||||
sessionMobile={currentUser?.phone ?? ''}
|
||||
initialResult={eligibility}
|
||||
onApproved={(result) => {
|
||||
setEligibility(result);
|
||||
setStep('schedule');
|
||||
}}
|
||||
onPayWithCard={toCard}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 'schedule' && providerCode && planId ? (
|
||||
<ScheduleStep
|
||||
bookingRequestId={requestId}
|
||||
providerCode={providerCode}
|
||||
planId={planId}
|
||||
onBack={() => setStep('eligibility')}
|
||||
onIssued={onIssued}
|
||||
onConverged={() => router.replace(returnUrl())}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
ctaLabel: string;
|
||||
onCta: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="text" width="50%" height={32} sx={{ mx: 'auto' }} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
|
||||
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
|
||||
import { isBnplSettlementSuccess } from '@/services/bnpl/types';
|
||||
import {
|
||||
BNPL_QUERY_OUTCOME,
|
||||
BNPL_QUERY_PROVIDER,
|
||||
BNPL_QUERY_REQUEST_ID,
|
||||
BNPL_QUERY_TRANSACTION_ID,
|
||||
CHECKOUT_METHOD_BNPL,
|
||||
CHECKOUT_QUERY_METHOD,
|
||||
} from '@/services/bnpl/constants';
|
||||
import {
|
||||
CHECKOUT_QUERY_BOOKING_ID,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
} from '@/services/payment/constants';
|
||||
|
||||
/**
|
||||
* Return-from-provider surface — drives the tail of the BNPL checkout: report the return
|
||||
* (`useAcceptBnplSchedule`; the settle trigger in the mock, an order read on the real path), then a brief
|
||||
* settle-pending state backed by the bounded order poll until terminal. On settlement: hand off to the
|
||||
* **reused f9 confirmation** marked «paid via installments» (the booking confirmed exactly as the card
|
||||
* path); on decline: a retry (a fresh D4 = a new attempt) or the card fall-back; on window-lapse: back to
|
||||
* the request.
|
||||
*/
|
||||
export default function BnplReturnPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<BnplReturnScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BnplReturnScreen() {
|
||||
const t = useTranslations('bnpl');
|
||||
const tp = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const requestId = Number(params.get(BNPL_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const transactionIdParam = params.get(BNPL_QUERY_TRANSACTION_ID);
|
||||
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
|
||||
const provider = params.get(BNPL_QUERY_PROVIDER) ?? '';
|
||||
const outcome = params.get(BNPL_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
|
||||
|
||||
const accept = useAcceptBnplSchedule();
|
||||
const { mutate: acceptMutate } = accept;
|
||||
|
||||
// Fire the settle report exactly once per mount — a refresh replays it (idempotent convergence).
|
||||
const firedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (firedRef.current || !validId) return;
|
||||
firedRef.current = true;
|
||||
acceptMutate({ bookingRequestId: requestId, bnplTransactionId: transactionId, outcome });
|
||||
}, [acceptMutate, validId, requestId, transactionId, outcome]);
|
||||
|
||||
const settled = accept.isSuccess || accept.isError;
|
||||
const acceptSawSuccess = accept.data ? isBnplSettlementSuccess(accept.data) : false;
|
||||
// Poll only for the late-settle case: when the accept result is ALREADY a settlement success (the mock
|
||||
// down-payment-cleared path), the navigation effect hands off immediately — reading the order would be a
|
||||
// needless fetch. Poll only when accept resolved without a settlement (real provider callback still in flight).
|
||||
const orderQuery = useBnplOrder(validId ? requestId : undefined, { enabled: settled && !acceptSawSuccess });
|
||||
const order = settled ? orderQuery.data : undefined;
|
||||
|
||||
const succeeded = acceptSawSuccess || order?.status === 'settled';
|
||||
const windowExpired = accept.data?.requestStatus === 'payment_deadline_expired';
|
||||
const failed = !succeeded && !windowExpired && (accept.data?.status === 'failed' || order?.status === 'failed');
|
||||
|
||||
// Hand off to the confirmation exactly once. The accept mutation already invalidated on immediate
|
||||
// success; a success that arrived later through the poll invalidates here instead (never twice).
|
||||
const navigatedRef = useRef(false);
|
||||
const bookingId = accept.data?.bookingId ?? order?.bookingId ?? null;
|
||||
useEffect(() => {
|
||||
if (!succeeded || navigatedRef.current) return;
|
||||
navigatedRef.current = true;
|
||||
if (!acceptSawSuccess) {
|
||||
invalidateAfterBnplSettlement(queryClient, requestId, bookingId);
|
||||
}
|
||||
const query = new URLSearchParams({
|
||||
[CHECKOUT_QUERY_REQUEST_ID]: String(requestId),
|
||||
[CHECKOUT_QUERY_METHOD]: CHECKOUT_METHOD_BNPL,
|
||||
});
|
||||
if (bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(bookingId));
|
||||
if (provider) query.set(BNPL_QUERY_PROVIDER, provider);
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
|
||||
}, [succeeded, acceptSawSuccess, bookingId, queryClient, requestId, provider, router, locale]);
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
||||
{t('pay_with_card')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (windowExpired) {
|
||||
// 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')}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{tp('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('retry_installments')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('pay_with_card')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
// 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')}>
|
||||
<CircularProgress color="secondary" size="2.5rem" />
|
||||
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()} sx={{ m: 0 }}>
|
||||
{t('check_again')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StateCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+19
-2
@@ -8,12 +8,19 @@ import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
import {
|
||||
BNPL_QUERY_PROVIDER,
|
||||
CHECKOUT_METHOD_BNPL,
|
||||
CHECKOUT_QUERY_METHOD,
|
||||
} 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 («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
|
||||
* to the bookings list and the invoice link is hidden — the invoice route needs the booking id.
|
||||
* 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.
|
||||
*/
|
||||
export default function CheckoutConfirmationPage() {
|
||||
return (
|
||||
@@ -26,6 +33,7 @@ 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();
|
||||
@@ -33,6 +41,8 @@ function ConfirmationScreen() {
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
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,
|
||||
@@ -65,6 +75,13 @@ function ConfirmationScreen() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
{isBnpl ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 600, mt: 0.5 }}>
|
||||
{tBnpl('paid_via_installments', {
|
||||
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
|
||||
})}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
@@ -210,14 +210,17 @@ function CheckoutScreen() {
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
|
||||
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
{!BNPL_ENABLED ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{tc('coming_soon')}
|
||||
</Typography>
|
||||
{/* 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}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
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.
|
||||
*/
|
||||
const WalletInstallments: FunctionComponent = () => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
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>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={128} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('wallet_error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('wallet_error_body')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0, mt: 1 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : !plans || plans.length === 0 ? (
|
||||
<PlaceholderScreen icon="installments" title={t('wallet_empty_title')} description={t('wallet_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
{plans.map((plan) => (
|
||||
<InstallmentPlanSection key={plan.bnplTransactionId} plan={plan} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const providerName = t(`provider_${plan.providerCode}`);
|
||||
|
||||
const handleEarlyPay = () => {
|
||||
// Early-pay is a PROVIDER action — hand off to the provider, never a Balinyaar payment.
|
||||
if (plan.earlyPayUrl) window.open(plan.earlyPayUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
{t('outstanding_balance')}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
|
||||
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
|
||||
{plan.serviceLabel}
|
||||
</Typography>
|
||||
|
||||
{plan.nextDueDate ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-end', mt: 1.5, gap: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
|
||||
</Typography>
|
||||
{plan.nextAmountIrr ? (
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{plan.earlyPayUrl ? (
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleEarlyPay}
|
||||
sx={{ m: 0, flex: 'none' }}
|
||||
>
|
||||
{t('early_pay')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('due_dates')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{plan.installments.map((row) => (
|
||||
<InstallmentScheduleRow key={`${row.kind}-${row.sequence}`} row={row} showStatus />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Ownership note: Balinyaar displays, it does not manage, this provider-owned schedule. */}
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'flex-start', px: 0.5 }}>
|
||||
<AppIcon icon="info" size={16} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('provider_owned_note', { provider: providerName })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default WalletInstallments;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
import WalletInstallments from './WalletInstallments';
|
||||
|
||||
export default async function WalletPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="wallet" title={t('wallet')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* /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.
|
||||
*/
|
||||
export default function WalletPage() {
|
||||
return <WalletInstallments />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user