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,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,
};
+6
View File
@@ -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';