frontend phase 10
This commit is contained in:
@@ -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