116 lines
5.1 KiB
TypeScript
116 lines
5.1 KiB
TypeScript
'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 { CANCELLATION_LEAD_HOURS } from '@/constants';
|
||
import { formatShamsiDate } from '@/utils';
|
||
import type { CancellationPolicyPreview } from '@/services/refunds/types';
|
||
|
||
export interface CancellationPolicyDisclosureProps {
|
||
preview: CancellationPolicyPreview;
|
||
}
|
||
|
||
/** Rounds an already-0–100 percent value for display — a small display number, never money, so JS math is
|
||
* safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
|
||
function toPercent(percent: number): number {
|
||
return Math.round(percent);
|
||
}
|
||
|
||
/**
|
||
* 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: 'var(--bal-radius-md)' }}>
|
||
<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}`, { hours: CANCELLATION_LEAD_HOURS })}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||
{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: 'var(--bal-radius-md)' }}>
|
||
<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;
|