frontend phase 11

This commit is contained in:
hamid
2026-07-10 13:55:42 +03:30
parent ccfa27aff6
commit 67c028562e
43 changed files with 3275 additions and 24 deletions
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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 (D1D4) — 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>
);
}
@@ -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>
);
}
@@ -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 />;
}