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,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';