frontend phase 10
This commit is contained in:
+80
@@ -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 0–1 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 ~7–10-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 ~7–10 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 ~7–10 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 ~7–10 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 ~7–10-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';
|
||||
Reference in New Issue
Block a user