frontend phase 11
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl mocked to echo keys (+ params) and locale = en so the money util groups with ASCII digits.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
const FEE_PLAN: BnplPlanOption = {
|
||||
planId: 'digipay_6m',
|
||||
termMonths: 6,
|
||||
installmentCount: 6,
|
||||
feePercent: 0.04,
|
||||
downPaymentPercent: 0.2,
|
||||
monthlyAmountIrr: '4040000', // 404,000 Toman
|
||||
downPaymentIrr: '4660000',
|
||||
totalIrr: '23300000',
|
||||
};
|
||||
|
||||
const INTEREST_FREE_PLAN: BnplPlanOption = {
|
||||
planId: 'snapppay_4',
|
||||
termMonths: null,
|
||||
installmentCount: 4,
|
||||
feePercent: 0,
|
||||
downPaymentPercent: 0,
|
||||
monthlyAmountIrr: '5825000',
|
||||
downPaymentIrr: '0',
|
||||
totalIrr: '23300000',
|
||||
};
|
||||
|
||||
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
|
||||
const onSelect = props.onSelect ?? jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { onSelect };
|
||||
}
|
||||
|
||||
describe('<BnplPlanCard/> component', () => {
|
||||
it('renders the served monthly amount as grouped Toman (never derived)', () => {
|
||||
renderCard();
|
||||
expect(screen.getByText(/404,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText('monthly')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => {
|
||||
renderCard();
|
||||
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4%
|
||||
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument();
|
||||
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => {
|
||||
renderCard({ plan: INTEREST_FREE_PLAN });
|
||||
expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks the selected card via aria-pressed + data-selected', () => {
|
||||
renderCard({ selected: true });
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(card).toHaveAttribute('data-selected', 'true');
|
||||
});
|
||||
|
||||
it('calls onSelect with the planId when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSelect } = renderCard();
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(onSelect).toHaveBeenCalledWith('digipay_6m');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
export interface BnplPlanCardProps {
|
||||
plan: BnplPlanOption;
|
||||
selected: boolean;
|
||||
onSelect: (planId: string) => void;
|
||||
}
|
||||
|
||||
/** A whole-percent from a 0..1 fraction (display only — never money math). */
|
||||
const asPercent = (fraction: number): number => Math.round(fraction * 100);
|
||||
|
||||
/**
|
||||
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
|
||||
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util — never
|
||||
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta
|
||||
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only.
|
||||
* @component BnplPlanCard
|
||||
*/
|
||||
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const termLabel =
|
||||
plan.termMonths != null
|
||||
? t('plan_term_months', { months: plan.termMonths })
|
||||
: t('plan_installments', { count: plan.installmentCount });
|
||||
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free');
|
||||
const hasDownPayment = plan.downPaymentPercent > 0;
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
data-plan-id={plan.planId}
|
||||
data-selected={selected}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(plan.planId)}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
borderRadius: 2.5,
|
||||
p: 1.75,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{termLabel}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary' }}
|
||||
>
|
||||
{feeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(plan.monthlyAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('monthly')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{hasDownPayment ? (
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('down_payment')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>
|
||||
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={asPercent(plan.downPaymentPercent)}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'var(--bal-divider)',
|
||||
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
export default BnplPlanCard;
|
||||
@@ -0,0 +1,4 @@
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
|
||||
export { BnplPlanCard as default, BnplPlanCard };
|
||||
export type { BnplPlanCardProps } from './BnplPlanCard';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl mocked to echo keys (+ params); locale = en so the money util groups with ASCII digits.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import InstallmentScheduleRow from './InstallmentScheduleRow';
|
||||
import type { BnplInstallmentRow } from '@/services/bnpl/types';
|
||||
|
||||
const DOWN_PAYMENT: BnplInstallmentRow = {
|
||||
sequence: 0,
|
||||
kind: 'down_payment',
|
||||
dueDate: '2026-07-10',
|
||||
amountIrr: '4660000', // 466,000 Toman
|
||||
};
|
||||
|
||||
const INSTALLMENT: BnplInstallmentRow = {
|
||||
sequence: 2,
|
||||
kind: 'installment',
|
||||
dueDate: '2026-09-01',
|
||||
amountIrr: '4040000', // 404,000 Toman
|
||||
status: 'paid',
|
||||
};
|
||||
|
||||
function renderRow(props: React.ComponentProps<typeof InstallmentScheduleRow>) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<InstallmentScheduleRow {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<InstallmentScheduleRow/> component', () => {
|
||||
it('renders the down-payment row: label, «today» due, and served amount', () => {
|
||||
renderRow({ row: DOWN_PAYMENT });
|
||||
expect(screen.getByText('down_payment')).toBeInTheDocument();
|
||||
expect(screen.getByText('today')).toBeInTheDocument();
|
||||
expect(screen.getByText(/466,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText('down_payment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an installment row with the numbered label and no status chip by default', () => {
|
||||
const { container } = renderRow({ row: INSTALLMENT });
|
||||
expect(screen.getByText('installment_n:{"n":2}')).toBeInTheDocument();
|
||||
expect(screen.getByText(/404,000/)).toBeInTheDocument();
|
||||
// showStatus defaults to false → no chip even when a status is present.
|
||||
expect(container.querySelector('[data-status]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the provider-reported status chip when showStatus is set', () => {
|
||||
const { container } = renderRow({ row: INSTALLMENT, showStatus: true });
|
||||
// status 'paid' → StatusChip kind 'verified' + label key 'status_paid'.
|
||||
expect(container.querySelector('[data-status="verified"]')).not.toBeNull();
|
||||
expect(screen.getByText('status_paid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the row kind for automation', () => {
|
||||
const { container } = renderRow({ row: DOWN_PAYMENT });
|
||||
expect(container.querySelector('[data-row-kind="down_payment"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import StatusChip from '../StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { installmentStatusKind, type BnplInstallmentRow, type BnplInstallmentStatus } from '@/services/bnpl/types';
|
||||
|
||||
export interface InstallmentScheduleRowProps {
|
||||
row: BnplInstallmentRow;
|
||||
/** Render the provider-reported status chip (D5 Wallet). The D4 schedule preview omits it. */
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
/** i18n key for each provider-reported installment status (labels are keys off the code, never derived). */
|
||||
const STATUS_LABEL_KEY: Record<BnplInstallmentStatus, string> = {
|
||||
paid: 'status_paid',
|
||||
due_soon: 'status_due_soon',
|
||||
upcoming: 'status_upcoming',
|
||||
overdue: 'status_overdue',
|
||||
};
|
||||
|
||||
/**
|
||||
* One repayment row — the down-payment (due «امروز») or an installment (Shamsi due date), with the
|
||||
* **served** amount (Toman via the money util). Reused by the D4 schedule table and the D5 Wallet due list;
|
||||
* `showStatus` adds the provider-reported status chip (D5). No money is computed here — display only.
|
||||
* @component InstallmentScheduleRow
|
||||
*/
|
||||
const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = ({ row, showStatus = false }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const locale = useLocale();
|
||||
|
||||
const label =
|
||||
row.kind === 'down_payment' ? t('down_payment') : t('installment_n', { n: row.sequence });
|
||||
const dueLabel = row.kind === 'down_payment' ? t('today') : formatShamsiDate(`${row.dueDate}T00:00:00`, locale);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-row-kind={row.kind}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{dueLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: row.kind === 'down_payment' ? 'var(--bal-secondary)' : undefined }}
|
||||
>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
{showStatus && row.status ? (
|
||||
<StatusChip status={installmentStatusKind(row.status)} label={t(STATUS_LABEL_KEY[row.status])} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstallmentScheduleRow;
|
||||
@@ -0,0 +1,4 @@
|
||||
import InstallmentScheduleRow from './InstallmentScheduleRow';
|
||||
|
||||
export { InstallmentScheduleRow as default, InstallmentScheduleRow };
|
||||
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
|
||||
@@ -70,6 +70,8 @@ import ClinicalIcon from '@mui/icons-material/HealthAndSafetyOutlined';
|
||||
import MedicationIcon from '@mui/icons-material/MedicationOutlined';
|
||||
import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
|
||||
import LockIcon from '@mui/icons-material/LockOutlined';
|
||||
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
|
||||
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -150,4 +152,5 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
medication: MedicationIcon,
|
||||
emergency: EmergencyIcon,
|
||||
lock: LockIcon,
|
||||
installments: InstallmentsIcon,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ import BookingRequestSummaryCard from './BookingRequestSummaryCard';
|
||||
import PriceBreakdown from './PriceBreakdown';
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
import InstallmentScheduleRow from './InstallmentScheduleRow';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -50,6 +52,8 @@ export {
|
||||
PriceBreakdown,
|
||||
EscrowNotice,
|
||||
PaymentStatusBadge,
|
||||
BnplPlanCard,
|
||||
InstallmentScheduleRow,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -73,3 +77,5 @@ export type { CountdownTimerProps } from './CountdownTimer';
|
||||
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
export type { BnplPlanCardProps } from './BnplPlanCard';
|
||||
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
|
||||
|
||||
Reference in New Issue
Block a user