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,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;
}
@@ -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 ~710-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>
);
}