frontend phase 10
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
||||
import type { CancelReasonCategory } from '@/services/refunds/types';
|
||||
|
||||
const REASON_CATEGORIES: CancelReasonCategory[] = [
|
||||
'changed_mind',
|
||||
'schedule_conflict',
|
||||
'found_other_care',
|
||||
'other',
|
||||
];
|
||||
|
||||
/** Maps the cancel mutation's `409` code to a user-facing message; anything else is the generic failure. */
|
||||
function cancelErrorKey(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.code === 'not_cancellable') return 'err_not_cancellable';
|
||||
if (error.code === 'nothing_refundable' || error.code === 'session_not_refundable') {
|
||||
return 'err_nothing_refundable';
|
||||
}
|
||||
}
|
||||
return 'err_generic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
|
||||
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
|
||||
* the confirm button is gated behind an explicit acknowledgement. Step 2 restates the numbers and submits
|
||||
* via `useCancelBooking` (which invalidates the booking + primes the refund cache), then routes to the
|
||||
* refund status. Refunds are admin-approved — the copy makes clear the request is *submitted* and
|
||||
* *processed by the team*, never self-issued.
|
||||
*/
|
||||
export default function CancelBookingPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
const tc = useTranslations('common');
|
||||
|
||||
const rawId = Number(params.id);
|
||||
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
|
||||
|
||||
const { data: preview, isLoading, isError } = useCancellationPolicyPreview(bookingId);
|
||||
const cancel = useCancelBooking();
|
||||
|
||||
const [step, setStep] = useState<0 | 1>(0);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
|
||||
const [reasonNotes, setReasonNotes] = useState('');
|
||||
|
||||
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
if (isError || !preview || bookingId == null) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('error_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview.cancellable) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('not_cancellable_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('not_cancellable_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_refund_status')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
const submit = () =>
|
||||
cancel.mutate(
|
||||
{
|
||||
bookingId,
|
||||
sessionIds: preview.refundableSessionIds,
|
||||
reasonCategory,
|
||||
reasonNotes: reasonNotes.trim() || undefined,
|
||||
},
|
||||
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('cancel_title')}
|
||||
</Typography>
|
||||
<StepperHeader steps={[t('step_review'), t('step_confirm')]} activeStep={step} />
|
||||
|
||||
{step === 0 ? (
|
||||
<>
|
||||
<CancellationPolicyDisclosure preview={preview} />
|
||||
|
||||
<TextField
|
||||
select
|
||||
label={t('reason_field_label')}
|
||||
value={reasonCategory}
|
||||
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
|
||||
fullWidth
|
||||
>
|
||||
{REASON_CATEGORIES.map((category) => (
|
||||
<MenuItem key={category} value={category}>
|
||||
{t(`reason_cat_${category}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label={t('reason_notes_label')}
|
||||
value={reasonNotes}
|
||||
onChange={(event) => setReasonNotes(event.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
|
||||
label={t('acknowledge_label')}
|
||||
/>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!acknowledged}
|
||||
onClick={() => setStep(1)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('continue_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
|
||||
</Paper>
|
||||
|
||||
{cancel.isError && (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{t(cancelErrorKey(cancel.error))}
|
||||
</AppAlert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => setStep(0)}
|
||||
disabled={cancel.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={submit}
|
||||
disabled={cancel.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{cancel.isPending ? t('submitting') : t('confirm_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,87 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { bookingCancelPath, bookingRefundStatusPath } from '@/constants';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
import { useRefundStatus } from '@/services/refunds';
|
||||
import { isBookingCancellable } from '@/services/refunds/types';
|
||||
|
||||
/**
|
||||
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
|
||||
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
|
||||
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
|
||||
*
|
||||
* f10 hangs the cancellation/refund entry off this screen: a **Cancel booking** CTA while the booking is
|
||||
* cancellable, or the **refund status** section once it's cancelled — both page-only glue (the booking
|
||||
* domain stays decoupled from refunds).
|
||||
*/
|
||||
export default function CustomerBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />;
|
||||
const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
|
||||
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's cancel/refund entry — reads the already-cached booking detail (same query key as
|
||||
* `BookingDetailView`, so no extra fetch) to decide between the Cancel CTA and the refund section. The
|
||||
* refund read is enabled only once the booking is cancelled, so an active booking triggers no refund query.
|
||||
*/
|
||||
function CustomerBookingActions({ bookingId }: { bookingId: number }) {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
|
||||
const { data: booking } = useBookingDetail(bookingId, 'customer');
|
||||
const isCancelled = booking?.status === 'cancelled';
|
||||
const { data: refund } = useRefundStatus(bookingId, { enabled: isCancelled });
|
||||
|
||||
if (!booking) return null;
|
||||
|
||||
if (isBookingCancellable(booking.status)) {
|
||||
return (
|
||||
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon="rejected"
|
||||
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('cancel_booking_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCancelled && refund) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('refund_section_title')}
|
||||
</Typography>
|
||||
<RefundStatusCard refund={refund} />
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('view_refund_status')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import RefundStatusCard from '@/components/RefundStatusCard';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useRefundStatus } from '@/services/refunds';
|
||||
|
||||
/**
|
||||
* Customer refund status (f10) — read-only. Renders the three-step progress (pending → on-its-way →
|
||||
* completed), the refunded amount, the honest per-channel ETA (BNPL's ~7–10-day window), and — where the
|
||||
* backend serves it — the fee-leg split. `failed` shows a needs-attention / contact-support state, never a
|
||||
* retry (retry is admin-only, DEFERRED to f15). Polling runs only while the refund is non-terminal (see
|
||||
* `useRefundStatus`). An empty state renders when the booking has no refund (e.g. it wasn't cancelled).
|
||||
*/
|
||||
export default function RefundStatusPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('refunds');
|
||||
|
||||
const rawId = Number(params.id);
|
||||
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
|
||||
|
||||
const { data: refund, isLoading, isError } = useRefundStatus(bookingId);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('status_title')}
|
||||
</Typography>
|
||||
|
||||
{isLoading ? (
|
||||
<AppLoading />
|
||||
) : isError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('error_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
) : refund ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('status_subtitle')}
|
||||
</Typography>
|
||||
<RefundStatusCard refund={refund} />
|
||||
</>
|
||||
) : (
|
||||
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_refund_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2">{t('no_refund_body')}</Typography>
|
||||
</Stack>
|
||||
</AppAlert>
|
||||
)}
|
||||
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back_to_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user