ui phase 5
This commit is contained in:
@@ -1,63 +1,212 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
|
||||
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
|
||||
import type { AccentTone, StatusKind } from '@/components';
|
||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { bookingReviewPath, ROUTES } from '@/constants';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
|
||||
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
|
||||
import { useCustomerRequests } from '@/services/bookingRequests';
|
||||
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
|
||||
import { useReviewEligibility } from '@/services/reviews';
|
||||
|
||||
type BookingsTab = 'pending' | 'active' | 'past';
|
||||
|
||||
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
|
||||
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
|
||||
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
|
||||
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
|
||||
'pending_nurse_response',
|
||||
'accepted_awaiting_payment',
|
||||
];
|
||||
|
||||
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
|
||||
neutral: 'neutral',
|
||||
info: 'info',
|
||||
pending: 'primary',
|
||||
verified: 'success',
|
||||
active: 'success',
|
||||
rejected: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||
* Customer رزروها — the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
|
||||
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
|
||||
* `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
|
||||
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
|
||||
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
|
||||
* `pageSize` (the C2 results pattern) — booking #21+ stays reachable.
|
||||
*/
|
||||
export default function BookingsScreen() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading, isError, refetch } = useBookingList('customer');
|
||||
const items = data?.items ?? [];
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
const [tab, setTab] = useState<BookingsTab>('active');
|
||||
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
|
||||
|
||||
const pendingQuery = useCustomerRequests();
|
||||
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
|
||||
PENDING_REQUEST_STATUSES.includes(item.status),
|
||||
);
|
||||
|
||||
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
|
||||
const allBookings = bookingsQuery.data?.items ?? [];
|
||||
const total = bookingsQuery.data?.total ?? 0;
|
||||
const hasMore = allBookings.length < total;
|
||||
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
|
||||
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
|
||||
|
||||
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
|
||||
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
|
||||
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('list_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={120} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
|
||||
<Tab
|
||||
value="pending"
|
||||
data-tab="pending"
|
||||
label={
|
||||
pendingItems.length > 0 ? (
|
||||
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
|
||||
{t('tab_pending')}
|
||||
</Badge>
|
||||
) : (
|
||||
t('tab_pending')
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Tab value="active" data-tab="active" label={t('tab_active')} />
|
||||
<Tab value="past" data-tab="past" label={t('tab_past')} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 'pending' ? (
|
||||
pendingQuery.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : pendingQuery.isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
|
||||
) : pendingItems.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="pending"
|
||||
title={t('pending_empty_title')}
|
||||
body={t('pending_empty_body')}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
|
||||
{t('missing_nurse_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{pendingItems.map((item) => (
|
||||
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{tab === 'active' ? (
|
||||
bookingsQuery.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : bookingsQuery.isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
|
||||
) : activeItems.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="bookings"
|
||||
title={t('active_empty_title')}
|
||||
body={t('active_empty_body')}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
|
||||
{t('missing_nurse_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{tab === 'past' ? (
|
||||
bookingsQuery.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : bookingsQuery.isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
|
||||
) : pastItems.length === 0 ? (
|
||||
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
|
||||
) : (
|
||||
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
function BookingRows({
|
||||
items,
|
||||
locale,
|
||||
onOpen,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
loadingMore,
|
||||
loadMoreLabel,
|
||||
}: {
|
||||
items: BookingListItemDto[];
|
||||
locale: string;
|
||||
onOpen: (id: number) => void;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
loadingMore: boolean;
|
||||
loadMoreLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
|
||||
))}
|
||||
{hasMore ? (
|
||||
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
|
||||
{loadMoreLabel}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const kind = BOOKING_STATUS_KIND[item.status];
|
||||
const isCompleted = item.status === 'completed' || item.status === 'closed';
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AccentCard
|
||||
tone={KIND_TO_ACCENT[kind]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
data-booking-row={item.id}
|
||||
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
@@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Typography>
|
||||
|
||||
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
|
||||
* The eligibility read is gated to completed/closed rows only (`enabled`) — an active booking never fires
|
||||
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
|
||||
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
|
||||
const t = useTranslations('reviews');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const eligibility = useReviewEligibility(bookingId, { enabled });
|
||||
|
||||
if (!eligibility.data?.canReview) return null;
|
||||
|
||||
return (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
|
||||
}}
|
||||
sx={{ alignSelf: 'flex-start', px: 0 }}
|
||||
>
|
||||
{t('cta_leave')}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
|
||||
function PendingRequestRow({
|
||||
item,
|
||||
locale,
|
||||
onOpen,
|
||||
}: {
|
||||
item: BookingRequestListItem;
|
||||
locale: string;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const t = useTranslations('booking');
|
||||
const accepted = item.status === 'accepted_awaiting_payment';
|
||||
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||
const dateLabel = formatShamsiDate(startDate, locale);
|
||||
const timeLabel = timeFmt.format(startDate);
|
||||
|
||||
return (
|
||||
<AccentCard
|
||||
tone={accepted ? 'secondary' : 'primary'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
data-request-row={item.id}
|
||||
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
|
||||
>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{dateLabel} ·{' '}
|
||||
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeLabel}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
|
||||
</Stack>
|
||||
{deadline ? (
|
||||
<CountdownTimer
|
||||
deadlineIso={deadline}
|
||||
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
|
||||
urgent={accepted}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={96} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user