frontend phase 10

This commit is contained in:
hamid
2026-07-10 12:51:53 +03:30
parent 40cc1d163b
commit ccfa27aff6
32 changed files with 2151 additions and 3 deletions
@@ -0,0 +1,80 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import { formatIrrToToman } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import CancellationPolicyDisclosure, {
CancellationPolicyDisclosureProps,
} from './CancellationPolicyDisclosure';
const ComponentToTest: FunctionComponent<CancellationPolicyDisclosureProps> = (props) => (
<ThemeProvider>
<CancellationPolicyDisclosure {...props} />
</ThemeProvider>
);
// Multi-session, free tier: two un-started (refundable) + one completed (locked). refund + fee = total.
const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003,
cancellable: true,
cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1,
feePercentage: 0,
refundAmountIrr: '17600000',
feeAmountIrr: '0',
refundableAmountIrr: '17600000',
platformFeeRefundedIrr: '2400000',
nursePayoutRefundedIrr: '15200000',
appliesTo: 'remaining_sessions',
leadTimeLabel: 'gt_24h',
refundChannel: 'psp_card',
expectedCustomerRefundEta: null,
refundableSessionIds: [70032, 70033],
sessions: [
{ bookingSessionId: 70031, sessionIndex: 1, scheduledDate: '2026-08-01', refundable: false, reasonCode: 'completed' },
{ bookingSessionId: 70032, sessionIndex: 2, scheduledDate: '2026-08-05', refundable: true, reasonCode: 'un_started' },
{ bookingSessionId: 70033, sessionIndex: 3, scheduledDate: '2026-08-07', refundable: true, reasonCode: 'un_started' },
],
};
describe('<CancellationPolicyDisclosure/> component', () => {
it('renders the tier label off the policy code (never the raw code) and the fee %', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-policy-code="free_24h"]')).toBeInTheDocument();
expect(screen.getByText('policy_free_24h')).toBeInTheDocument();
expect(screen.getByText('fee_percent')).toBeInTheDocument();
});
it('renders a refund-vs-fee breakdown that reconciles to the refundable total', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-row="refund"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="fee"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
// Free tier: the full refundable amount is returned, so it appears on both the refund row and the total.
expect(screen.getAllByText(new RegExp(formatIrrToToman('17600000', 'en'))).length).toBeGreaterThanOrEqual(1);
// No reconciliation warning — refund + fee equals the refundable total.
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('marks un-started sessions refundable and completed sessions locked', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-session-id="70031"][data-refundable="false"]')).toBeInTheDocument();
expect(container.querySelector('[data-session-id="70032"][data-refundable="true"]')).toBeInTheDocument();
expect(screen.getByText('reason_completed')).toBeInTheDocument();
expect(screen.getAllByText('session_refundable').length).toBe(2);
});
it('shows the admin-approval explainer and the channel ETA banner', () => {
render(<ComponentToTest preview={PREVIEW} />);
expect(screen.getByTestId('admin-approval-explainer')).toBeInTheDocument();
expect(screen.getByTestId('refund-eta-banner')).toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import PriceBreakdown from '@/components/PriceBreakdown';
import StatusChip from '@/components/StatusChip';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import { formatShamsiDate } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview;
}
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */
function toPercent(fraction: number): number {
return Math.round(fraction * 100);
}
/**
* The pre-confirm cancellation disclosure (f10) — the whole point of the cancel screen: the applicable
* policy tier (label off the `cancellation_policy_code` i18n key, never the raw code), the **refund % + fee
* %**, the concrete refund-vs-fee split (reusing `PriceBreakdown`, which reconciles to the rial), the
* multi-session refundable/locked breakdown, the admin-approved reality, and a per-channel ETA preview
* (BNPL surfaces the ~710-day window honestly). Display-only — the reason field, acknowledgement and
* confirm live in the page; shared so the confirm dialog and any future per-session cancel reuse it.
* @component CancellationPolicyDisclosure
*/
const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosureProps> = ({ preview }) => {
const t = useTranslations('refunds');
const locale = useLocale();
const refundPercent = toPercent(preview.refundPercentageApplied);
const feePercent = toPercent(preview.feePercentage);
const isMultiSession = preview.sessions.length > 1;
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t(`policy_${preview.cancellationPolicyCode}`)}
</Typography>
<StatusChip
status={refundPercent > 0 ? 'active' : 'rejected'}
label={t('refund_percent', { percent: refundPercent })}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`lead_${preview.leadTimeLabel}`)}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 600 }}>
{t('fee_percent', { percent: feePercent })}
</Typography>
</Stack>
</Paper>
<PriceBreakdown
rows={[
{ key: 'refund', label: t('row_refund'), amountIrr: preview.refundAmountIrr },
{ key: 'fee', label: t('row_fee'), amountIrr: preview.feeAmountIrr },
]}
totalLabel={t('row_refundable_total')}
totalAmountIrr={preview.refundableAmountIrr}
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
</Typography>
<Divider />
{preview.sessions.map((session) => (
<Stack
key={session.bookingSessionId}
data-session-id={session.bookingSessionId}
data-refundable={session.refundable}
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, opacity: session.refundable ? 1 : 0.6 }}
>
<Typography variant="body2">
{`${session.sessionIndex}. ${formatShamsiDate(session.scheduledDate, locale)}`}
</Typography>
<StatusChip
status={session.refundable ? 'active' : 'neutral'}
label={session.refundable ? t('session_refundable') : t(`reason_${session.reasonCode}`)}
/>
</Stack>
))}
</Stack>
</Paper>
)}
<RefundEtaBanner channel={preview.refundChannel} eta={preview.expectedCustomerRefundEta} />
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon="info" size={20} color="var(--bal-info)" />}
data-testid="admin-approval-explainer"
sx={{ marginY: 0 }}
>
{t('admin_approval_explainer')}
</AppAlert>
</Stack>
);
};
export default CancellationPolicyDisclosure;
@@ -0,0 +1,2 @@
export { default } from './CancellationPolicyDisclosure';
export type { CancellationPolicyDisclosureProps } from './CancellationPolicyDisclosure';
@@ -0,0 +1,39 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys; locale = en so any date formats with ASCII we don't assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import RefundEtaBanner, { RefundEtaBannerProps } from './RefundEtaBanner';
const ComponentToTest: FunctionComponent<RefundEtaBannerProps> = (props) => (
<ThemeProvider>
<RefundEtaBanner {...props} />
</ThemeProvider>
);
describe('<RefundEtaBanner/> component', () => {
it('renders the BNPL wording + the ~710 business-day window and the ETA', () => {
const { container } = render(<ComponentToTest channel="bnpl_revert" eta="2026-08-24" />);
expect(container.querySelector('[data-channel="bnpl_revert"]')).toBeInTheDocument();
expect(screen.getByText('eta_bnpl_title')).toBeInTheDocument();
// The honest window note only renders for the BNPL channel.
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
});
it('renders the card wording and no BNPL window for a card refund', () => {
render(<ComponentToTest channel="psp_card" eta={null} />);
expect(screen.getByText('eta_card_title')).toBeInTheDocument();
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
});
it('renders the manual-transfer wording for a manual refund', () => {
render(<ComponentToTest channel="manual" eta={null} />);
expect(screen.getByText('eta_manual_title')).toBeInTheDocument();
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate } from '@/utils';
import type { RefundChannel } from '@/services/refunds/types';
export interface RefundEtaBannerProps {
channel: RefundChannel;
/** Populated for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
eta: string | null;
}
/** Per-channel title/body i18n keys + the icon glyph. One branch drives all three refund channels. */
const CHANNEL_COPY: Record<RefundChannel, { titleKey: string; bodyKey: string; icon: string }> = {
psp_card: { titleKey: 'eta_card_title', bodyKey: 'eta_card_body', icon: 'payment' },
bnpl_revert: { titleKey: 'eta_bnpl_title', bodyKey: 'eta_bnpl_body', icon: 'schedule' },
manual: { titleKey: 'eta_manual_title', bodyKey: 'eta_manual_body', icon: 'bank' },
};
/**
* How the refund reaches the customer, told honestly per channel. For `bnpl_revert` it surfaces the
* `expected_customer_refund_eta` and the ~710 business-day window in plain language (the money returns
* *through the provider*, never instantly) — the phase's load-bearing BNPL honesty rule. `psp_card` shows
* the card-refund wording; `manual` the manual-transfer wording. Info tone (never error), tokens only.
* @component RefundEtaBanner
*/
const RefundEtaBanner: FunctionComponent<RefundEtaBannerProps> = ({ channel, eta }) => {
const t = useTranslations('refunds');
const locale = useLocale();
const copy = CHANNEL_COPY[channel];
return (
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon={copy.icon} size={20} color="var(--bal-primary)" />}
data-testid="refund-eta-banner"
data-channel={channel}
sx={{
marginY: 0,
borderColor: 'var(--bal-primary)',
color: 'var(--bal-primary)',
backgroundColor: 'var(--bal-primary-soft)',
}}
>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(copy.titleKey)}
</Typography>
<Typography variant="body2">{t(copy.bodyKey)}</Typography>
{channel === 'bnpl_revert' && (
<Typography variant="caption" sx={{ fontWeight: 600 }} data-testid="refund-eta-window">
{t('eta_business_days')}
{eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''}
</Typography>
)}
</Stack>
</AppAlert>
);
};
export default RefundEtaBanner;
@@ -0,0 +1,2 @@
export { default } from './RefundEtaBanner';
export type { RefundEtaBannerProps } from './RefundEtaBanner';
@@ -0,0 +1,83 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { RefundSummary } from '@/services/refunds/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import RefundStatusCard, { RefundStatusCardProps } from './RefundStatusCard';
const ComponentToTest: FunctionComponent<RefundStatusCardProps> = (props) => (
<ThemeProvider>
<RefundStatusCard {...props} />
</ThemeProvider>
);
const base: RefundSummary = {
id: 7,
bookingId: 5001,
refundStatus: 'succeeded',
refundChannel: 'psp_card',
totalRefundedIrr: '45000000',
expectedCustomerRefundEta: null,
externalRevertReference: null,
refundPercentageApplied: 1,
cancellationPolicyCode: 'free_24h',
platformFeeRefundedIrr: '5400000',
nursePayoutRefundedIrr: '39600000',
createdAt: '2026-07-10T09:00:00Z',
completedAt: '2026-07-10T09:00:00Z',
};
describe('<RefundStatusCard/> component', () => {
it('renders the three-step stepper + a reconciling fee-leg split for a succeeded card refund', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<ComponentToTest refund={base} />);
expect(container.querySelector('[data-status="succeeded"]')).toBeInTheDocument();
expect(screen.getByText('step_submitted')).toBeInTheDocument();
expect(screen.getByText('step_completed')).toBeInTheDocument();
// Fee-leg split renders when the decomposition is present, reconciling to the total.
expect(container.querySelector('[data-row="platform_fee_refunded"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="nurse_payout_refunded"]')).toBeInTheDocument();
expect(errorSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId('refund-failed')).not.toBeInTheDocument();
errorSpy.mockRestore();
});
it('shows the BNPL ETA banner while a refund is on its way', () => {
render(
<ComponentToTest
refund={{
...base,
refundStatus: 'processing',
refundChannel: 'bnpl_revert',
totalRefundedIrr: '9000000',
expectedCustomerRefundEta: '2026-08-24',
externalRevertReference: '••••••5002',
platformFeeRefundedIrr: '1080000',
nursePayoutRefundedIrr: '7920000',
completedAt: null,
}}
/>,
);
expect(screen.getByTestId('refund-status-card')).toHaveAttribute('data-status', 'processing');
expect(screen.getByText('rstatus_on_its_way')).toBeInTheDocument();
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
});
it('renders a contact-support failed state with no stepper, no ETA banner, and no retry control', () => {
const { container } = render(<ComponentToTest refund={{ ...base, refundStatus: 'failed', completedAt: null }} />);
expect(screen.getByTestId('refund-failed')).toBeInTheDocument();
expect(screen.getByText('failed_title')).toBeInTheDocument();
// Failed collapses the happy stepper (no fourth step) and offers no retry — retry is admin-only.
expect(screen.queryByText('step_submitted')).not.toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// No success-framed 'money is on its way' surfaces on a failed refund (BNPL-honesty rule).
expect(screen.queryByTestId('refund-eta-banner')).not.toBeInTheDocument();
expect(screen.queryByText('total_refunded_label')).not.toBeInTheDocument();
expect(container.querySelector('[data-row="platform_fee_refunded"]')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,135 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import StepperHeader from '@/components/StepperHeader';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import PriceBreakdown from '@/components/PriceBreakdown';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import AppAlert from '@/components/common/AppAlert';
import { formatIrrToToman } from '@/utils';
import {
CUSTOMER_REFUND_STEP_ORDER,
refundCustomerStep,
refundStepIndex,
type CustomerRefundStep,
type RefundSummary,
} from '@/services/refunds/types';
export interface RefundStatusCardProps {
refund: RefundSummary;
}
/** The three happy steps → the semantic chip kind. `failed` is handled distinctly (never a fourth step). */
const STEP_KIND: Record<CustomerRefundStep, StatusKind> = {
submitted: 'pending',
on_its_way: 'info',
completed: 'verified',
};
/**
* The customer's read-only refund view (f10): the three-step progress (submitted → on its way →
* completed), the refunded amount, the per-channel ETA (BNPL's honest ~710-day window), and — when the
* backend serves the decomposition — the fee-leg split for transparency. `failed`/`rejected` render a
* distinct needs-attention state with contact-support copy, **never a retry button** (retry is admin-only,
* DEFERRED to f15). Shared: the dedicated refund-status screen and the booking-detail refund section both
* render it. Money is display-only via the money util; labels are i18n keys, never raw codes.
* @component RefundStatusCard
*/
const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund }) => {
const t = useTranslations('refunds');
const tc = useTranslations('common');
const locale = useLocale();
const step = refundCustomerStep(refund.refundStatus);
const isFailed = step === 'failed';
// Succeeded → mark all steps complete (activeStep past the last); otherwise sit on the mapped step.
const activeStep =
refund.refundStatus === 'succeeded' ? CUSTOMER_REFUND_STEP_ORDER.length : refundStepIndex(refund.refundStatus);
const hasDecomposition = refund.platformFeeRefundedIrr != null && refund.nursePayoutRefundedIrr != null;
return (
<Stack sx={{ gap: 2.5 }} data-testid="refund-status-card" data-status={refund.refundStatus}>
{isFailed ? (
// A failed/rejected refund must NOT show any success-framed progress, amount, or ETA — that would
// contradict "needs attention" and, for BNPL, falsely imply the money is on its way. Only the
// contact-support copy (+ the reference, for support) renders.
<AppAlert severity="error" variant="outlined" data-testid="refund-failed" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('failed_title')}
</Typography>
<Typography variant="body2">{t('failed_body')}</Typography>
{refund.externalRevertReference && (
<Typography variant="caption" sx={{ fontWeight: 600, direction: 'ltr' }}>
{t('reference_label')}: {refund.externalRevertReference}
</Typography>
)}
</Stack>
</AppAlert>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
<StepperHeader
steps={[t('step_submitted'), t('step_on_its_way'), t('step_completed')]}
activeStep={activeStep}
/>
</Stack>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('total_refunded_label')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
{formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
{refund.externalRevertReference && (
<>
<Divider />
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reference_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, direction: 'ltr' }}>
{refund.externalRevertReference}
</Typography>
</Stack>
</>
)}
</Stack>
</Paper>
{hasDecomposition && (
<PriceBreakdown
rows={[
{
key: 'platform_fee_refunded',
label: t('row_platform_fee_refunded'),
amountIrr: refund.platformFeeRefundedIrr as string,
},
{
key: 'nurse_payout_refunded',
label: t('row_nurse_payout_refunded'),
amountIrr: refund.nursePayoutRefundedIrr as string,
},
]}
totalLabel={t('fee_split_title')}
totalAmountIrr={refund.totalRefundedIrr}
/>
)}
<RefundEtaBanner channel={refund.refundChannel} eta={refund.expectedCustomerRefundEta} />
</>
)}
</Stack>
);
};
export default RefundStatusCard;
@@ -0,0 +1,2 @@
export { default } from './RefundStatusCard';
export type { RefundStatusCardProps } from './RefundStatusCard';