frontend phase 10
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user