backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
+8 -2
View File
@@ -127,7 +127,9 @@ client/
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /bookings
│ │ │ │ ── request/page.tsx # /bookings/request — f6→f7 booking handoff target (DEFERRED→f7 stub; echoes carried nurse/variant/required_gender intent)
│ │ │ │ ── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards
│ │ │ │ └── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id)
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet
@@ -135,6 +137,7 @@ client/
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
@@ -174,6 +177,8 @@ client/
│ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
│ ├── NurseResultCard/ # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "from X تومان/unit" via PriceDisplay; presentational + memoized (tested)
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/
@@ -225,6 +230,7 @@ client/
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
│ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -318,7 +324,7 @@ async function MyServerComponent() {
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
- `'booking'` — the f6→f7 booking-request handoff placeholder (title + "arrives next phase" + carried nurse/variant/gender echo); f7 fills it out
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions); consumed by the C4/C5 pages, the nurse requests pages, and the shared `BookingRequestSummaryCard`
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
+109 -5
View File
@@ -9,6 +9,7 @@
"coverage": "Coverage",
"services": "Services",
"dashboard": "Dashboard",
"requests": "Requests",
"verification": "Verification",
"visits": "Visits",
"admin": "Admin",
@@ -365,12 +366,115 @@
"request_booking": "Request booking"
},
"booking": {
"gender_female": "Woman",
"gender_male": "Man",
"gender_any": "No preference",
"unnamed_nurse": "Nurse",
"summary_patient": "Patient",
"summary_service": "Service",
"summary_address": "Address",
"summary_when": "Time",
"request_title": "Booking request",
"deferred": "The booking request form arrives in the next phase.",
"handoff_echo": "Nurse #{nurse}, service #{variant}, caregiver: {gender}.",
"gender_female": "woman",
"gender_male": "man",
"gender_any": "no preference"
"form_subtitle": "Send a request to this nurse. No payment is taken yet — the nurse reviews it first.",
"missing_nurse_title": "No nurse selected",
"missing_nurse_body": "Choose a nurse from search, then send a request.",
"missing_nurse_cta": "Find a nurse",
"patient_label": "Patient (care recipient)",
"patient_placeholder": "Select a patient",
"patient_empty": "You haven't added a patient yet.",
"patient_add_cta": "Add a patient",
"service_label": "Service type",
"service_placeholder": "Select a service",
"service_empty": "This nurse has no bookable service.",
"address_label": "Address",
"address_placeholder": "Select an address",
"address_empty": "You haven't added an address yet.",
"address_add_cta": "Add an address",
"address_whole_city": "Whole city",
"date_label": "Date",
"time_start_label": "From",
"time_end_label": "To",
"gender_label": "Caregiver gender",
"gender_hint": "For personal/bodily care, a same-gender caregiver matters. Your choice is sent with the request.",
"notes_label": "Notes for the nurse",
"notes_placeholder": "What the nurse sees before accepting…",
"notes_hint": "This is the only thing the nurse sees before accepting. The full care record is added after confirmation.",
"notes_counter": "{count}/{max}",
"submit": "Send request",
"submitting": "Sending…",
"error_patient_required": "Choose a patient.",
"error_service_required": "Choose a service.",
"error_address_required": "Choose an address.",
"error_gender_required": "Choose the caregiver gender.",
"error_date_required": "Choose a date.",
"error_time_required": "Choose a start and end time.",
"error_time_range": "The end time must be after the start time.",
"error_past_date": "Choose a future date and time.",
"error_notes_long": "Notes are too long.",
"error_gender_mismatch": "The chosen gender doesn't match this nurse.",
"error_not_bookable": "This service can't be booked right now.",
"error_tenancy": "That patient or address wasn't found.",
"error_generic": "Couldn't send the request. Please try again.",
"awaiting_title": "Request sent to the nurse",
"awaiting_subtitle": "Awaiting the nurse's response",
"step_submitted": "Request submitted",
"step_awaiting": "Awaiting nurse approval",
"step_payment": "Payment & final confirmation",
"response_countdown_label": "Nurse response window",
"response_elapsed": "Awaiting server confirmation…",
"accepted_badge": "The nurse accepted",
"accepted_body": "Pay within the window below to confirm the booking.",
"payment_countdown_label": "Payment window",
"payment_elapsed": "The payment window has closed",
"continue_payment": "Continue to payment ←",
"cancel_request": "Cancel request",
"cancelling": "Cancelling…",
"cancel_confirm_title": "Cancel this request?",
"cancel_confirm_body": "The nurse will no longer see it. You can request another nurse anytime.",
"cancel_confirm_yes": "Yes, cancel",
"rejected_title": "The nurse declined the request",
"rejected_reason_label": "Nurse's reason",
"expired_title": "The nurse didn't respond in time",
"payment_expired_title": "The payment window has closed",
"cancelled_title": "Request cancelled",
"converted_title": "Your booking is confirmed",
"converted_cta": "View booking",
"terminal_rerequest": "Request another nurse",
"not_found_title": "Request not found",
"not_found_body": "This request may have been removed.",
"error_title": "Something went wrong",
"error_body": "We couldn't load this request.",
"retry": "Try again",
"inbox_title": "Incoming requests",
"inbox_subtitle": "Families requesting your care. You see only their notes until you accept.",
"inbox_empty": "You have no new requests.",
"inbox_notes_label": "Family's notes",
"required_gender_chip": "Caregiver: {gender}",
"open_detail": "View",
"detail_title": "Request detail",
"location_label": "Location",
"disclosure_note": "Before accepting you see only the family's notes. The full address and care record appear after you accept.",
"accept": "Accept request",
"accepting": "Accepting…",
"reject": "Decline",
"reject_dialog_title": "Decline request",
"reject_reason_label": "Reason",
"reject_reason_placeholder": "A short reason the family will see…",
"reject_submit": "Submit decline",
"rejecting": "Declining…",
"reason_required": "A reason is required.",
"reason_too_long": "The reason is too long.",
"action_stale": "This request can no longer be actioned.",
"accepted_toast": "Request accepted.",
"rejected_toast": "Request declined.",
"cancelled_toast": "Request cancelled.",
"status_pending_nurse_response": "Awaiting nurse response",
"status_accepted_awaiting_payment": "Accepted — awaiting payment",
"status_converted": "Booked",
"status_rejected_by_nurse": "Declined by nurse",
"status_expired_no_response": "No response in time",
"status_payment_deadline_expired": "Payment window lapsed",
"status_cancelled_by_customer": "Cancelled"
},
"auth": {
"customer_title": "Sign in to Balinyaar",
+108 -4
View File
@@ -9,6 +9,7 @@
"coverage": "پوشش",
"services": "خدمات",
"dashboard": "داشبورد",
"requests": "درخواست‌ها",
"verification": "احراز هویت",
"visits": "ویزیت‌ها",
"admin": "مدیریت",
@@ -365,12 +366,115 @@
"request_booking": "درخواست رزرو"
},
"booking": {
"request_title": "درخواست رزرو",
"deferred": "فرم درخواست رزرو در فاز بعدی اضافه می‌شود.",
"handoff_echo": "پرستار #{nurse}، خدمت #{variant}، جنسیت مراقب: {gender}.",
"gender_female": "خانم",
"gender_male": "آقا",
"gender_any": "فرقی ندارد"
"gender_any": "فرقی ندارد",
"unnamed_nurse": "پرستار",
"summary_patient": "بیمار",
"summary_service": "خدمت",
"summary_address": "آدرس",
"summary_when": "زمان",
"request_title": "درخواست رزرو",
"form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمی‌شود؛ ابتدا پرستار درخواست را بررسی می‌کند.",
"missing_nurse_title": "پرستاری انتخاب نشده است",
"missing_nurse_body": "ابتدا از جست‌وجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
"missing_nurse_cta": "یافتن پرستار",
"patient_label": "بیمار (مددجو)",
"patient_placeholder": "انتخاب بیمار",
"patient_empty": "هنوز بیماری ثبت نکرده‌اید.",
"patient_add_cta": "افزودن بیمار",
"service_label": "نوع خدمت",
"service_placeholder": "انتخاب خدمت",
"service_empty": "این پرستار خدمتی برای رزرو ندارد.",
"address_label": "آدرس",
"address_placeholder": "انتخاب آدرس",
"address_empty": "هنوز آدرسی ثبت نکرده‌اید.",
"address_add_cta": "افزودن آدرس",
"address_whole_city": "کل شهر",
"date_label": "تاریخ",
"time_start_label": "از ساعت",
"time_end_label": "تا ساعت",
"gender_label": "جنسیت مراقب",
"gender_hint": "برای مراقبت‌های بدنی، هم‌جنس بودن مراقب اهمیت دارد. انتخاب شما همراه درخواست ارسال می‌شود.",
"notes_label": "توضیحات برای پرستار",
"notes_placeholder": "آنچه پرستار پیش از پذیرش می‌بیند…",
"notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش می‌بیند؛ شرح کامل مراقبت پس از تایید ثبت می‌شود.",
"notes_counter": "{count}/{max}",
"submit": "ارسال درخواست",
"submitting": "در حال ارسال…",
"error_patient_required": "یک بیمار انتخاب کنید.",
"error_service_required": "یک خدمت انتخاب کنید.",
"error_address_required": "یک آدرس انتخاب کنید.",
"error_gender_required": "جنسیت مراقب را انتخاب کنید.",
"error_date_required": "تاریخ را انتخاب کنید.",
"error_time_required": "ساعت شروع و پایان را انتخاب کنید.",
"error_time_range": "ساعت پایان باید پس از ساعت شروع باشد.",
"error_past_date": "یک تاریخ و ساعت آینده انتخاب کنید.",
"error_notes_long": "توضیحات بیش از حد طولانی است.",
"error_gender_mismatch": "جنسیت انتخاب‌شده با این پرستار هم‌خوانی ندارد.",
"error_not_bookable": "این خدمت در حال حاضر قابل رزرو نیست.",
"error_tenancy": "بیمار یا آدرس یافت نشد.",
"error_generic": "ارسال درخواست ناموفق بود. دوباره تلاش کنید.",
"awaiting_title": "درخواست برای پرستار ارسال شد",
"awaiting_subtitle": "در انتظار پاسخ پرستار",
"step_submitted": "درخواست ثبت شد",
"step_awaiting": "در انتظار تایید پرستار",
"step_payment": "پرداخت و تایید نهایی",
"response_countdown_label": "مهلت پاسخ پرستار",
"response_elapsed": "در انتظار تایید سرور…",
"accepted_badge": "پرستار تایید کرد",
"accepted_body": "برای نهایی‌شدن رزرو، در مهلت زیر پرداخت را انجام دهید.",
"payment_countdown_label": "مهلت پرداخت",
"payment_elapsed": "مهلت پرداخت به پایان رسید",
"continue_payment": "ادامه پرداخت ←",
"cancel_request": "انصراف از درخواست",
"cancelling": "در حال لغو…",
"cancel_confirm_title": "این درخواست لغو شود؟",
"cancel_confirm_body": "پرستار دیگر آن را نمی‌بیند. هر زمان می‌توانید پرستار دیگری انتخاب کنید.",
"cancel_confirm_yes": "بله، لغو کن",
"rejected_title": "پرستار درخواست را رد کرد",
"rejected_reason_label": "دلیل پرستار",
"expired_title": "پرستار در مهلت مقرر پاسخ نداد",
"payment_expired_title": "مهلت پرداخت به پایان رسید",
"cancelled_title": "درخواست لغو شد",
"converted_title": "رزرو شما ثبت شد",
"converted_cta": "مشاهده رزرو",
"terminal_rerequest": "انتخاب پرستار دیگر",
"not_found_title": "درخواست یافت نشد",
"not_found_body": "ممکن است این درخواست حذف شده باشد.",
"error_title": "خطایی رخ داد",
"error_body": "بارگذاری این درخواست ممکن نشد.",
"retry": "تلاش دوباره",
"inbox_title": "درخواست‌های دریافتی",
"inbox_subtitle": "خانواده‌هایی که پرستاری شما را خواسته‌اند. تا زمان پذیرش تنها توضیحات آن‌ها را می‌بینید.",
"inbox_empty": "درخواست جدیدی ندارید.",
"inbox_notes_label": "توضیحات خانواده",
"required_gender_chip": "مراقب: {gender}",
"open_detail": "مشاهده",
"detail_title": "جزئیات درخواست",
"location_label": "موقعیت",
"disclosure_note": "پیش از پذیرش تنها توضیحات خانواده در دسترس است؛ نشانی کامل و سوابق درمانی پس از پذیرش نمایش داده می‌شود.",
"accept": "پذیرش درخواست",
"accepting": "در حال پذیرش…",
"reject": "رد کردن",
"reject_dialog_title": "رد درخواست",
"reject_reason_label": "دلیل",
"reject_reason_placeholder": "دلیل کوتاهی که خانواده می‌بیند…",
"reject_submit": "ثبت رد",
"rejecting": "در حال رد…",
"reason_required": "دلیل الزامی است.",
"reason_too_long": "دلیل بیش از حد طولانی است.",
"action_stale": "این درخواست دیگر قابل اقدام نیست.",
"accepted_toast": "درخواست پذیرفته شد.",
"rejected_toast": "درخواست رد شد.",
"cancelled_toast": "درخواست لغو شد.",
"status_pending_nurse_response": "در انتظار پاسخ پرستار",
"status_accepted_awaiting_payment": "پذیرفته‌شده — در انتظار پرداخت",
"status_converted": "رزرو شد",
"status_rejected_by_nurse": "ردشده توسط پرستار",
"status_expired_no_response": "بدون پاسخ در مهلت",
"status_payment_deadline_expired": "پایان مهلت پرداخت",
"status_cancelled_by_customer": "لغوشده"
},
"auth": {
"customer_title": "ورود به بلینیار",
@@ -0,0 +1,25 @@
'use client';
import { Suspense } from 'react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { AppLoading, PlaceholderScreen } from '@/components';
/**
* Checkout (pay & confirm) — **DEFERRED → frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with
* the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder
* confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
<Suspense fallback={<AppLoading />}>
<CheckoutDeferred />
</Suspense>
);
}
function CheckoutDeferred() {
const t = useTranslations('booking');
const params = useSearchParams();
const requestId = params.get('request_id') ?? '—';
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
}
@@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
/**
* C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
* surfaces without a manual refresh. It renders the shared summary card, the 3-step tracker, and a
* countdown driven by the **server-frozen** deadline: the response window while pending, then the 30-min
* payment window once accepted (with the hand-off to checkout). Terminal states show their own card.
*/
export default function BookingRequestStatusPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'customer',
);
const cancelRequest = useCancelBookingRequest();
const [confirmCancel, setConfirmCancel] = useState(false);
if (isLoading) return <StatusSkeleton />;
if (isError || !request) {
return (
<TerminalCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
<BookingRequestSummaryCard
nurseName={request.nurseName}
nurseRating={request.nurseRating}
patientName={request.patientName}
variantLabel={request.variantLabel}
variantPrice={request.variantPrice}
variantPriceUnit={request.variantPriceUnit}
addressLabel={addressLabel}
requestedDate={request.requestedDate}
requestedTimeStart={request.requestedTimeStart}
requestedTimeEnd={request.requestedTimeEnd}
/>
);
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
/>
</Stack>
);
}
if (request.status === 'expired_no_response') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'payment_deadline_expired') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'cancelled_by_customer') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'converted') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}
/>
</Stack>
);
}
const accepted = request.status === 'accepted_awaiting_payment';
const activeStep = accepted ? 2 : 1;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="pending" size={40} color="var(--bal-primary)" />
<Typography variant="h6" component="h1">
{t('awaiting_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('awaiting_subtitle')}
</Typography>
</Stack>
<StepperHeader
steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]}
activeStep={activeStep}
/>
{summary}
{accepted ? (
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-secondary)',
}}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status="verified" label={t('accepted_badge')} />
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('accepted_body')}
</Typography>
{request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
) : null}
<AppButton
color="secondary"
variant="contained"
size="large"
endIcon="payment"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ m: 0, py: 1.25 }}
>
{t('continue_payment')}
</AppButton>
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
</Paper>
)}
<AppButton
variant="text"
color="error"
disabled={cancelRequest.isPending}
onClick={() => setConfirmCancel(true)}
sx={{ m: 0, alignSelf: 'center' }}
>
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
</Stack>
);
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${request.addressTitle} · ${city} · ${district}`;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function StatusSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width="60%" height={28} />
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,32 +1,545 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { AppLoading, PlaceholderScreen } from '@/components';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Box,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
/**
* Booking-request handoff target — **DEFERRED → frontend-phase-7-b8**. C3's "درخواست رزرو" lands here
* carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes
* `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder
* confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
* C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
* family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address
* (f3), a future date + time window, the **first-class caregiver-gender** preference, and stage-1 notes,
* then sends the request → lands on C5. Money-free: no price breakdown, no booking row (that's f9/b9).
* `useSearchParams` requires a Suspense boundary.
*/
export default function BookingRequestPage() {
export default function BookingRequestFormPage() {
return (
<Suspense fallback={<AppLoading />}>
<BookingRequestDeferred />
<BookingRequestForm />
</Suspense>
);
}
function BookingRequestDeferred() {
function BookingRequestForm() {
const t = useTranslations('booking');
const params = useSearchParams();
const gender = params.get('required_gender');
const echo = t('handoff_echo', {
nurse: params.get('nurse_id') ?? '—',
variant: params.get('variant_id') ?? '—',
gender: gender ? t(`gender_${gender}`) : t('gender_any'),
});
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
return <PlaceholderScreen icon="bookings" title={t('request_title')} description={[t('deferred'), echo].join(' ')} />;
const nurseId = Number(query.get('nurse_id'));
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
const addressesQuery = useAddresses();
const createRequest = useCreateBookingRequest();
const profile = profileQuery.data;
const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]);
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : '';
const variantId = variantSel !== '' ? variantSel : firstVariantId;
const primaryAddressId: number | '' =
addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : '';
const addressId = addressSel !== '' ? addressSel : primaryAddressId;
const selectedVariant = useMemo(
() => services.find((service) => service.variantId === variantId),
[services, variantId],
);
const selectedAddress = useMemo(
() => addresses.find((address) => address.id === addressId),
[addresses, addressId],
);
const selectedPatient = useMemo(
() => patients.find((patient) => patient.id === patientId),
[patients, patientId],
);
// A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) —
// block it inline before the round-trip; the server re-validates and is authoritative.
const genderMismatch =
gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender;
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const district =
selectedAddress.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
};
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
// Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the
// handler (not render) so the render path stays pure; the result drives the inline date error.
if (Date.parse(`${date}T${timeStart}`) < Date.now()) {
setPastDateError(true);
return;
}
setPastDateError(false);
if (genderMismatch) return;
const context: BookingRequestDisplayContext | undefined =
profile && selectedVariant && selectedAddress && selectedPatient
? {
nurseName: profile.nurseName,
nurseRating: profile.averageRating,
nurseTotalReviews: profile.totalReviews,
patientName: selectedPatient.displayName,
variantLabel: selectedVariant.displayName,
variantPriceUnit: selectedVariant.priceUnit,
variantPrice: selectedVariant.priceIrr,
addressTitle: selectedAddress.title,
cityId: selectedAddress.cityId,
cityNameFa: selectedAddress.cityNameFa,
cityNameEn: selectedAddress.cityNameEn,
districtId: selectedAddress.districtId,
districtNameFa: selectedAddress.districtNameFa,
districtNameEn: selectedAddress.districtNameEn,
addressLine: selectedAddress.addressLine,
postalCode: selectedAddress.postalCode,
recipientName: selectedAddress.recipientName,
recipientPhone: selectedAddress.recipientPhone,
}
: undefined;
createRequest.mutate(
{
payload: {
nurseId,
variantId: variantId as number,
patientId: patientId as number,
customerAddressId: addressId as number,
requestedDate: date,
requestedTimeStart: `${timeStart}:00`,
requestedTimeEnd: `${timeEnd}:00`,
requiredCaregiverGender: gender as RequiredCaregiverGender,
customerNotes: notes.trim() || null,
},
context,
},
{
onSuccess: (dto) => {
router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`);
},
onError: (error) => setFormError(mapCreateError(error, t)),
},
);
};
if (!hasNurse) {
return (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
ctaLabel={t('missing_nurse_cta')}
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
{/* Address */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
if (next) setGender(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 600,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ m: 0, py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
);
}
/** Map a create `ApiError` (domain 400/404 codes) to a translated, user-facing message. */
function mapCreateError(error: unknown, t: (key: string) => string): string {
if (error instanceof ApiError) {
switch (error.code) {
case 'gender_required':
return t('error_gender_required');
case 'invalid_time_range':
return t('error_time_range');
case 'past_date':
return t('error_past_date');
case 'notes_too_long':
return t('error_notes_long');
case 'gender_mismatch':
return t('error_gender_mismatch');
case 'inactive_variant':
case 'not_bookable':
return t('error_not_bookable');
case 'not_found':
return t('error_tenancy');
default:
break;
}
if (error.status === 404) return t('error_tenancy');
}
return t('error_generic');
}
function FieldEmpty({
label,
message,
ctaLabel,
onCta,
}: {
label: string;
message: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
</Stack>
);
}
function EmptyState({
icon,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
title: string;
body: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="text" width="50%" height={36} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -0,0 +1,317 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Paper,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import {
useAcceptBookingRequest,
useBookingRequest,
useRejectBookingRequest,
} from '@/services/bookingRequests';
import { REJECTION_REASON_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { isTerminalBookingRequestStatus, type BookingRequestDto } from '@/services/bookingRequests/types';
/**
* Nurse request detail (نمای پرستار). Renders the request summary and **only `customerNotes`** as the
* clinical context (two-stage disclosure — the address is masked to city/district and no clinical field
* exists pre-accept). A pending request offers accept / reject (with a reason); both invalidate the inbox
* + this detail so the request leaves the pending list and the customer's C5 reflects it. A stale action
* returns `409`, surfaced then refetched.
*/
export default function NurseRequestDetailPage() {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const id = Number(params.id);
const { data: request, isLoading, isError, refetch } = useBookingRequest(
Number.isInteger(id) && id > 0 ? id : undefined,
'nurse',
);
const acceptRequest = useAcceptBookingRequest();
const rejectRequest = useRejectBookingRequest();
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [reasonError, setReasonError] = useState(false);
if (isLoading) return <DetailSkeleton />;
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('not_found_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}>
{t('inbox_title')}
</AppButton>
</Paper>
);
}
const pending = request.status === 'pending_nurse_response';
const isTerminal = isTerminalBookingRequestStatus(request.status);
const location = coarseLocation(request, locale, t('address_whole_city'));
const handleStaleError = (error: unknown) => {
if (error instanceof ApiError && error.status === 409) {
enqueueSnackbar(t('action_stale'), { variant: 'warning' });
refetch();
} else {
enqueueSnackbar(t('error_generic'), { variant: 'error' });
}
};
const handleAccept = () => {
acceptRequest.mutate(request.id, {
onSuccess: () => enqueueSnackbar(t('accepted_toast'), { variant: 'success' }),
onError: handleStaleError,
});
};
const handleReject = () => {
const trimmed = reason.trim();
if (!trimmed) {
setReasonError(true);
return;
}
rejectRequest.mutate(
{ id: request.id, payload: { reason: trimmed } },
{
onSuccess: () => {
setRejectOpen(false);
setReason('');
enqueueSnackbar(t('rejected_toast'), { variant: 'success' });
},
onError: (error) => {
setRejectOpen(false);
handleStaleError(error);
},
},
);
};
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.patientName}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.variantLabel}
</Typography>
{request.variantPrice ? (
<PriceDisplay price={request.variantPrice} priceUnit={request.variantPriceUnit} />
) : null}
</Stack>
</DetailRow>
<DetailRow caption={t('location_label')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{location}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</DetailRow>
{request.requiredCaregiverGender ? (
<DetailRow caption={t('gender_label')}>
<Chip
size="small"
label={t(`gender_${request.requiredCaregiverGender}`)}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</DetailRow>
) : null}
</Stack>
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: request.customerNotes ? 'text.primary' : 'text.secondary' }}>
{request.customerNotes || '—'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('disclosure_note')}
</Typography>
</Stack>
</Stack>
</Paper>
{pending ? (
<Stack sx={{ gap: 1.5 }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="verified"
disabled={acceptRequest.isPending}
onClick={handleAccept}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{acceptRequest.isPending ? t('accepting') : t('accept')}
</AppButton>
<AppButton
color="error"
variant="outlined"
startIcon="rejected"
disabled={acceptRequest.isPending}
onClick={() => setRejectOpen(true)}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{t('reject')}
</AppButton>
</Stack>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
</Paper>
)}
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
label={t('reject_reason_label')}
placeholder={t('reject_reason_placeholder')}
value={reason}
onChange={(event) => {
setReason(event.target.value.slice(0, REJECTION_REASON_MAX_LENGTH));
if (reasonError) setReasonError(false);
}}
error={reasonError}
helperText={reasonError ? t('reason_required') : undefined}
multiline
minRows={2}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRejectOpen(false)}>
{t('cancel_request')}
</AppButton>
<AppButton color="error" variant="contained" disabled={rejectRequest.isPending} onClick={handleReject}>
{rejectRequest.isPending ? t('rejecting') : t('reject_submit')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
/** Coarse, masked location (city · district) — the nurse view never receives the full address. */
function coarseLocation(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
const district =
request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa;
return `${city} · ${district}`;
}
function statusKind(status: BookingRequestDto['status']) {
switch (status) {
case 'accepted_awaiting_payment':
return 'active' as const;
case 'converted':
return 'verified' as const;
case 'rejected_by_nurse':
case 'payment_deadline_expired':
return 'rejected' as const;
case 'expired_no_response':
case 'cancelled_by_customer':
return 'neutral' as const;
default:
return 'pending' as const;
}
}
function DetailRow({ caption, children }: { caption: string; children: React.ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
function DetailSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={56} />
</Box>
);
}
@@ -0,0 +1,115 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, CountdownTimer } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
/**
* Nurse incoming-requests inbox (نمای پرستار). Lists pending requests — each a card with the family's
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows
* only `customerNotes` — never an address or any clinical field. Lightly polled so new requests appear.
*/
export default function NurseRequestsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useNurseRequestInbox();
const items = data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('inbox_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('inbox_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('inbox_empty')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<InboxCard key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
function InboxCard({ item }: { item: BookingRequestListItem }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel}
</Typography>
</Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
</Stack>
{item.requiredCaregiverGender ? (
<Box>
<Chip
size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</Box>
) : null}
{item.customerNotes ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
{item.customerNotes}
</Typography>
</Box>
) : null}
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys; locale = en so money/date format with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import BookingRequestSummaryCard, { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
const BASE: BookingRequestSummaryCardProps = {
nurseName: 'Maryam Rezaei',
nurseAvatarUrl: null,
nurseRating: 4.8,
patientName: 'Haj Mousavi',
variantLabel: 'Elderly care — day shift',
variantPrice: '2800000',
variantPriceUnit: 'per_hour',
addressLabel: 'Home · Tehran · Saadat Abad',
requestedDate: '2026-08-01',
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
};
function renderCard(props: Partial<BookingRequestSummaryCardProps> = {}) {
return render(
<ThemeProvider>
<BookingRequestSummaryCard {...BASE} {...props} />
</ThemeProvider>,
);
}
describe('<BookingRequestSummaryCard/> component', () => {
it('renders the nurse, patient, service and address', () => {
renderCard();
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Haj Mousavi')).toBeInTheDocument();
expect(screen.getByText('Elderly care — day shift')).toBeInTheDocument();
expect(screen.getByText('Home · Tehran · Saadat Abad')).toBeInTheDocument();
});
it('prices the service in grouped Toman when a variantPrice is present', () => {
renderCard();
// 2,800,000 IRR = 280,000 Toman.
expect(screen.getByText(/280,000/)).toBeInTheDocument();
});
it('hides the price line when variantPrice is null (real-path contract gap)', () => {
renderCard({ variantPrice: null });
expect(screen.queryByText(/280,000/)).not.toBeInTheDocument();
});
it('hides the rating row when no rating is supplied', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import PriceDisplay from '@/components/PriceDisplay';
import { formatShamsiDate } from '@/utils';
import type { PriceUnit } from '@/services/catalog/types';
export interface BookingRequestSummaryCardProps {
nurseName: string;
nurseAvatarUrl?: string | null;
/** Average rating; `null`/omitted hides the rating row (e.g. the nurse-side view). */
nurseRating?: number | null;
patientName: string;
variantLabel: string;
/** IRR digit-string; when `null` the price line is hidden (contract gap REQ-013 on the real path). */
variantPrice?: string | null;
variantPriceUnit: PriceUnit;
/** Localised "title · city · district" label, computed by the caller (locale-aware region names). */
addressLabel: string;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
}
/**
* The engagement summary shared by the customer's awaiting screen (C5), the nurse request detail, and
* (later) the f8 booking detail: nurse identity + rating, patient, priced service, address label, and the
* requested date/time (Shamsi). Presentational — it reads only the `booking` caption keys and formats
* money/dates through the shared utils; every value is supplied by the caller. Kept at the shared level so
* f8 reuses it rather than re-deriving the layout.
* @component BookingRequestSummaryCard
*/
const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProps> = ({
nurseName,
nurseAvatarUrl,
nurseRating,
patientName,
variantLabel,
variantPrice,
variantPriceUnit,
addressLabel,
requestedDate,
requestedTimeStart,
requestedTimeEnd,
}) => {
const t = useTranslations('booking');
const locale = useLocale();
const name = nurseName.trim() || t('unnamed_nurse');
const startDate = new Date(`${requestedDate}T${requestedTimeStart}`);
const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(nurseRating)
: null;
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar
src={nurseAvatarUrl ?? undefined}
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
{ratingLabel ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
</Stack>
) : null}
</Stack>
</Stack>
<Divider />
<Stack sx={{ gap: 1.5 }}>
<SummaryRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{patientName}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{variantLabel}
</Typography>
{variantPrice ? (
<PriceDisplay price={variantPrice} priceUnit={variantPriceUnit} align="start" />
) : null}
</Stack>
</SummaryRow>
<SummaryRow caption={t('summary_address')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{addressLabel}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</SummaryRow>
</Stack>
</Stack>
</Paper>
);
};
function SummaryRow({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
export default BookingRequestSummaryCard;
@@ -0,0 +1,2 @@
export { default } from './BookingRequestSummaryCard';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
@@ -0,0 +1,56 @@
import { act, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked so the locale is `en` and the countdown formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
import CountdownTimer, { CountdownTimerProps } from './CountdownTimer';
const deadlineInSeconds = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString();
function renderTimer(props: CountdownTimerProps) {
return render(
<ThemeProvider>
<CountdownTimer {...props} />
</ThemeProvider>,
);
}
describe('<CountdownTimer/> component', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-09T10:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('renders MM:SS remaining for a sub-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
expect(screen.getByText('01:30')).toBeInTheDocument();
});
it('renders HH:MM:SS for a multi-hour deadline', () => {
renderTimer({ deadlineIso: deadlineInSeconds(3661), elapsedText: 'time up' });
expect(screen.getByText('01:01:01')).toBeInTheDocument();
});
it('ticks down each second without lifting state to the page', () => {
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('01:29')).toBeInTheDocument();
});
it('shows the elapsed text and fires onElapsed exactly once at zero', () => {
const onElapsed = jest.fn();
renderTimer({ deadlineIso: deadlineInSeconds(2), elapsedText: 'time up', onElapsed });
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('time up')).toBeInTheDocument();
expect(onElapsed).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale } from 'next-intl';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '@/components/common/AppIcon';
export interface CountdownTimerProps {
/**
* The **server-supplied** absolute UTC instant to count down to (e.g. `nurseResponseDeadlineAt`). The
* client only renders the difference against `Date.now()` — it never computes or recomputes a deadline.
*/
deadlineIso: string;
/** Optional label above the digits (already translated by the caller). */
label?: string;
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
elapsedText: string;
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
urgent?: boolean;
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
onElapsed?: () => void;
}
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
/**
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
* this component re-renders each second — never the page around it (the summary card / form stay put).
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
* order is correct under RTL.
* @component CountdownTimer
*/
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
deadlineIso,
label,
elapsedText,
urgent = false,
onElapsed,
}) => {
const locale = useLocale();
const [now, setNow] = useState(() => Date.now());
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
const elapsed = remainingMs <= 0;
// Recreated only when `elapsed` flips (once) — not every tick, since `elapsed` stays false until zero.
useEffect(() => {
if (elapsed) return undefined;
const interval = setInterval(() => setNow(Date.now()), MS_PER_SECOND);
return () => clearInterval(interval);
}, [elapsed]);
const firedRef = useRef(false);
useEffect(() => {
if (elapsed && !firedRef.current) {
firedRef.current = true;
onElapsed?.();
} else if (!elapsed) {
firedRef.current = false;
}
}, [elapsed, onElapsed]);
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
if (elapsed) {
return (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{elapsedText}
</Typography>
</Stack>
);
}
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
const seconds = totalSeconds % SECONDS_PER_MINUTE;
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
.filter((part) => part !== null)
.join(':');
return (
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
{label ? (
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{label}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="pending" size={20} color={accent} />
<Typography
component="span"
dir="ltr"
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
>
{clock}
</Typography>
</Stack>
</Stack>
);
};
export default CountdownTimer;
@@ -0,0 +1,2 @@
export { default } from './CountdownTimer';
export type { CountdownTimerProps } from './CountdownTimer';
@@ -58,6 +58,9 @@ import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
import StarIcon from '@mui/icons-material/Star';
import TuneIcon from '@mui/icons-material/TuneOutlined';
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
import RequestsIcon from '@mui/icons-material/AssignmentOutlined';
import PaymentIcon from '@mui/icons-material/CreditCardOutlined';
/**
* List of all available Icon names
@@ -128,4 +131,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
publish: PublishIcon,
star: StarIcon,
tune: TuneIcon,
requests: RequestsIcon,
payment: PaymentIcon,
};
+6
View File
@@ -19,6 +19,8 @@ import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload';
import NurseResultCard from './NurseResultCard';
import ServicePriceRow from './ServicePriceRow';
import CountdownTimer from './CountdownTimer';
import BookingRequestSummaryCard from './BookingRequestSummaryCard';
export {
UserInfo,
@@ -40,6 +42,8 @@ export {
DocumentUpload,
NurseResultCard,
ServicePriceRow,
CountdownTimer,
BookingRequestSummaryCard,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -59,3 +63,5 @@ export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
export type { NurseResultCardProps } from './NurseResultCard';
export type { ServicePriceRowProps } from './ServicePriceRow';
export type { CountdownTimerProps } from './CountdownTimer';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
+7 -1
View File
@@ -14,8 +14,12 @@ export const ROUTES = {
// C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this).
SEARCH_NURSE: '/search/nurse',
BOOKINGS: '/bookings',
// Booking-request handoff target (f7 owns the form) — C3's "درخواست رزرو" lands here with intent.
// C4 booking-request form (f7) — C3's "درخواست رزرو" lands here carrying the nurse + variant + gender.
BOOKING_REQUEST: '/bookings/request',
// C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status.
BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (pay & confirm) — the C5 accept CTA hands off here (screen itself is DEFERRED → f9 stub).
CHECKOUT: '/bookings/checkout',
PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses',
@@ -30,6 +34,8 @@ export const ROUTES = {
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
NURSE_COVERAGE: '/nurse/coverage',
NURSE_BANK: '/nurse/bank',
// Incoming booking-requests inbox (f7) — pending requests + accept/reject; append `/{id}` for detail.
NURSE_REQUESTS: '/nurse/requests',
// Verification (trust engine) subtree — B3 hub + the staged submission screens (B4/B5/B6).
NURSE_VERIFICATION: '/nurse/verification',
NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity',
+1
View File
@@ -18,6 +18,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const sidebarItems: Array<LinkToPage> = useMemo(
() => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
{ title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
@@ -0,0 +1,82 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { BOOKING_REQUEST_PAGE_SIZE } from '../constants';
import type {
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
} from '../types';
const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null };
}
/**
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
*
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse,
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice`
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
*/
export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
get: async (id: number) =>
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams();
query.set('role', params.role);
if (params.status) query.set('status', params.status);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<BookingRequestListItem>>>(`${BASE}/list?${query.toString()}`),
);
},
accept: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
cancel: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
};
@@ -0,0 +1,12 @@
import { USE_BOOKING_REQUESTS_MOCK } from '../constants';
import type { BookingRequestsApi } from '../types';
import { bookingRequestsClientApi } from './clientApi';
import { bookingRequestsMockApi } from './mockApi';
/**
* The selected `BookingRequestsApi` implementation — the single seam the hooks import. Selection is by
* config (`USE_BOOKING_REQUESTS_MOCK`), never by scattered `if (mock)` checks.
*/
export const bookingRequestsApi: BookingRequestsApi = USE_BOOKING_REQUESTS_MOCK
? bookingRequestsMockApi
: bookingRequestsClientApi;
@@ -0,0 +1,285 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import {
BOOKING_REQUEST_PAGE_SIZE,
CUSTOMER_NOTES_MAX_LENGTH,
MOCK_PAYMENT_DEADLINE_MINUTES,
MOCK_RESPONSE_DEADLINE_MINUTES,
REJECTION_REASON_MAX_LENGTH,
} from '../constants';
import type {
BookingRequestDisplayContext,
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
BookingRequestStatus,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
RequestRole,
} from '../types';
import { isTerminalBookingRequestStatus } from '../types';
const MOCK_LATENCY_MS = 350;
// Shared, module-level store so the customer's created request appears in the nurse inbox and a nurse
// accept/reject flips the customer's polled C5 — all within one browser session (one module instance).
// Seeded with two pending requests so a nurse-only visit sees a non-empty inbox before any create.
let store: BookingRequestDto[] = [];
let nextId = 1;
const minutesFromNow = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString();
function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
const id = nextId++;
return {
id,
status: 'pending_nurse_response',
nurseId: 1,
nurseName: 'مریم رضایی',
nurseRating: 4.9,
nurseTotalReviews: 37,
patientId: 900 + id,
patientName: 'حاج‌آقا موسوی',
variantId: 11,
variantLabel: 'مراقبت سالمند — شیفت روز',
variantPriceUnit: 'per_hour',
variantPrice: '2800000',
customerAddressId: 800 + id,
addressTitle: 'منزل',
cityId: 101,
cityNameFa: 'تهران',
cityNameEn: 'Tehran',
districtId: 1003,
districtNameFa: 'سعادت‌آباد',
districtNameEn: 'Saadat Abad',
addressLine: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766',
recipientName: 'زهرا موسوی',
recipientPhone: '09121234567',
requiredCaregiverGender: 'female',
requestedDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10),
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
customerNotes: 'لطفاً در تعویض سِرُم دقت شود.',
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
...overrides,
};
}
store = [
seedRow({}),
seedRow({
nurseName: 'مریم رضایی',
patientName: 'خانم احمدی',
variantLabel: 'مراقبت پس از جراحی',
requiredCaregiverGender: 'female',
customerNotes: 'بیمار پس از عمل زانو، نیاز به کمک در جابه‌جایی دارد.',
requestedTimeStart: '15:00:00',
requestedTimeEnd: '19:00:00',
}),
];
/**
* Lazily transition any past-deadline row to its terminal state — the client stand-in for the server's
* background expiry sweep, so `expired_no_response` / `payment_deadline_expired` surface through the poll
* without a real cron. Runs on every read/write.
*/
function sweep(): void {
const now = Date.now();
store = store.map((row) => {
if (row.status === 'pending_nurse_response' && Date.parse(row.nurseResponseDeadlineAt) < now) {
return { ...row, status: 'expired_no_response' as BookingRequestStatus };
}
if (
row.status === 'accepted_awaiting_payment' &&
row.paymentDeadlineAt != null &&
Date.parse(row.paymentDeadlineAt) < now
) {
return { ...row, status: 'payment_deadline_expired' as BookingRequestStatus };
}
return row;
});
}
function find(id: number): BookingRequestDto {
const row = store.find((r) => r.id === id);
// Party-scoping is not modelled in the single-session mock; existence is not leaked either way (404).
if (!row) throw new ApiError(404, 'Booking request not found', 'not_found');
return row;
}
/** The nurse view masks the full address (two-stage disclosure) — coarse city/district only. */
function maskForNurse(dto: BookingRequestDto): BookingRequestDto {
return { ...dto, addressLine: null, postalCode: null, recipientName: null, recipientPhone: null };
}
function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestListItem {
return {
id: dto.id,
status: dto.status,
counterpartyName: role === 'nurse' ? dto.patientName : dto.nurseName,
nurseRating: role === 'customer' ? dto.nurseRating : null,
requiredCaregiverGender: dto.requiredCaregiverGender,
requestedDate: dto.requestedDate,
requestedTimeStart: dto.requestedTimeStart,
requestedTimeEnd: dto.requestedTimeEnd,
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
paymentDeadlineAt: dto.paymentDeadlineAt,
customerNotes: role === 'nurse' ? dto.customerNotes : null,
};
}
// Actionable (non-terminal) rows first, then by soonest response deadline — mirrors the contract's
// "actionable rows sort first".
function actionableFirst(a: BookingRequestDto, b: BookingRequestDto): number {
const at = isTerminalBookingRequestStatus(a.status) ? 1 : 0;
const bt = isTerminalBookingRequestStatus(b.status) ? 1 : 0;
if (at !== bt) return at - bt;
return Date.parse(a.nurseResponseDeadlineAt) - Date.parse(b.nurseResponseDeadlineAt);
}
function assertCreateValid(payload: CreateBookingRequestPayload): void {
if (!payload.requiredCaregiverGender) {
throw new ApiError(400, 'requiredCaregiverGender is required', 'gender_required');
}
if (payload.requestedTimeEnd <= payload.requestedTimeStart) {
throw new ApiError(400, 'requestedTimeEnd must be after requestedTimeStart', 'invalid_time_range');
}
if ((payload.customerNotes?.length ?? 0) > CUSTOMER_NOTES_MAX_LENGTH) {
throw new ApiError(400, 'customerNotes too long', 'notes_too_long');
}
const start = Date.parse(`${payload.requestedDate}T${payload.requestedTimeStart}`);
if (Number.isFinite(start) && start < Date.now()) {
throw new ApiError(400, 'requestedDate/time is in the past', 'past_date');
}
}
function buildFromContext(
id: number,
payload: CreateBookingRequestPayload,
context: BookingRequestDisplayContext | undefined,
): BookingRequestDto {
return {
id,
status: 'pending_nurse_response',
nurseId: payload.nurseId,
nurseName: context?.nurseName ?? '',
nurseRating: context?.nurseRating ?? 0,
nurseTotalReviews: context?.nurseTotalReviews ?? 0,
patientId: payload.patientId,
patientName: context?.patientName ?? '',
variantId: payload.variantId,
variantLabel: context?.variantLabel ?? '',
variantPriceUnit: context?.variantPriceUnit ?? 'per_hour',
variantPrice: context?.variantPrice ?? null,
customerAddressId: payload.customerAddressId,
addressTitle: context?.addressTitle ?? '',
cityId: context?.cityId ?? 0,
cityNameFa: context?.cityNameFa ?? '',
cityNameEn: context?.cityNameEn ?? '',
districtId: context?.districtId ?? null,
districtNameFa: context?.districtNameFa ?? null,
districtNameEn: context?.districtNameEn ?? null,
addressLine: context?.addressLine ?? null,
postalCode: context?.postalCode ?? null,
recipientName: context?.recipientName ?? null,
recipientPhone: context?.recipientPhone ?? null,
requiredCaregiverGender: payload.requiredCaregiverGender,
requestedDate: payload.requestedDate,
requestedTimeStart: payload.requestedTimeStart,
requestedTimeEnd: payload.requestedTimeEnd,
customerNotes: payload.customerNotes?.trim() || null,
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
};
}
/**
* In-memory mock behind the `BookingRequestsApi` seam. Drives the full request lifecycle over shared
* session state so C4 → C5 → the nurse inbox → accept/reject/expire all demo end-to-end without a live
* b8 backend (and without the upstream mock domains' ids needing to exist server-side). Mirrors the real
* shapes + status/deadline/masking semantics for a one-line swap once the stack is live
* (`USE_BOOKING_REQUESTS_MOCK = false`).
*/
export const bookingRequestsMockApi: BookingRequestsApi = {
create: async (payload, context) => {
await sleep(MOCK_LATENCY_MS);
assertCreateValid(payload);
const dto = buildFromContext(nextId++, payload, context);
store = [dto, ...store];
return dto;
},
get: async (id, role) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
return role === 'nurse' ? maskForNurse(dto) : dto;
},
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
await sleep(MOCK_LATENCY_MS);
sweep();
const matched = [...store]
.filter((row) => (params.status ? row.status === params.status : true))
.sort(actionableFirst)
.map((row) => toListItem(row, params.role));
const page = params.page ?? 1;
const pageSize = params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
accept: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = {
...dto,
status: 'accepted_awaiting_payment',
paymentDeadlineAt: minutesFromNow(MOCK_PAYMENT_DEADLINE_MINUTES),
};
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
reject: async (id, payload: RejectBookingRequestPayload) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
const reason = payload.reason?.trim() ?? '';
if (!reason) throw new ApiError(400, 'reason is required', 'reason_required');
if (reason.length > REJECTION_REASON_MAX_LENGTH) {
throw new ApiError(400, 'reason too long', 'reason_too_long');
}
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = { ...dto, status: 'rejected_by_nurse', nurseRejectionReason: reason };
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
cancel: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response' && dto.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request can no longer be cancelled', 'not_cancellable');
}
const updated: BookingRequestDto = { ...dto, status: 'cancelled_by_customer' };
store = store.map((row) => (row.id === id ? updated : row));
return updated;
},
};
@@ -0,0 +1,43 @@
/**
* When true, the booking-requests domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `BookingRequestsApi` seam.
*
* **Mock is primary this phase.** The b8 endpoints are live server-side, but every *input* to a request
* — the nurse (search, f6), the patient (patients, f2), the address (addresses, f3) — is itself served by
* a **mock-primary** client domain today, so a real `booking_requests/create` would reference ids that
* exist only in those in-memory stores. Running the whole flow end-to-end (create → nurse inbox → accept
* → the customer's C5 flips → expiry) therefore needs a mock that shares the same session state. The mock
* drives exactly that. Additionally the contract DTO omits the variant price the summary card renders
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
*/
export const USE_BOOKING_REQUESTS_MOCK = true;
/**
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
* (accept / reject / expire) surfaces without a manual refresh; polling **stops** on a terminal/`converted`
* status (see the hooks' `refetchInterval` guard). 15s balances freshness against request volume.
*/
export const BOOKING_REQUEST_POLL_MS = 15 * 1000;
/** A single request is cheap and changes only on the other party's action — a short stale window. */
export const BOOKING_REQUEST_STALE_TIME = 10 * 1000;
export const BOOKING_REQUEST_GC_TIME = 5 * 60 * 1000;
/** api-conventions default/max page sizes (max 100 server-side); an inbox page. */
export const BOOKING_REQUEST_PAGE_SIZE = 20;
/** `customerNotes` limit (contract: ≤ 1000) — enforced client-side before the CTA. */
export const CUSTOMER_NOTES_MAX_LENGTH = 1000;
/** `nurseRejectionReason` limit (contract: ≤ 500) — enforced client-side in the reject dialog. */
export const REJECTION_REASON_MAX_LENGTH = 500;
/**
* Mock-only deadline windows. The **payment** window is contract-accurate (30 min); the **response**
* window is a demo-shortened stand-in for the server's real 24h so a session can observe the
* `expired_no_response` terminal path. Neither is used on the real path — the server freezes the true
* deadlines from `IPlatformConfig`. Documented in the phase report + mocks note.
*/
export const MOCK_RESPONSE_DEADLINE_MINUTES = 30;
export const MOCK_PAYMENT_DEADLINE_MINUTES = 30;
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Nurse accept — opens the 30-minute payment window server-side. On success it invalidates every inbox
* list (so the request leaves the pending inbox immediately) and the request detail (so the customer's
* polled C5 reflects the accept). A stale accept (past deadline / not pending) returns `409` via
* `mutation.error` — the caller surfaces it and refetches.
*/
export function useAcceptBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.accept(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import { BOOKING_REQUEST_GC_TIME, BOOKING_REQUEST_POLL_MS, BOOKING_REQUEST_STALE_TIME } from '../constants';
import { isTerminalBookingRequestStatus, type RequestRole } from '../types';
/**
* A single booking request (C5 customer view + the nurse request detail). **Polls** while the status is
* non-terminal so an accept / reject / expire transition surfaces without a manual refresh, and **stops**
* polling once a terminal/`converted` status is reached. `role` drives the mock's nurse-view masking (the
* real server infers it from auth). Enabled only when an id is present.
*/
export function useBookingRequest(id: number | undefined, role: RequestRole) {
return useQuery({
queryKey: bookingRequestKeys.detail(id ?? -1),
queryFn: () => bookingRequestsApi.get(id as number, role),
enabled: id != null && id > 0,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && isTerminalBookingRequestStatus(status) ? false : BOOKING_REQUEST_POLL_MS;
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Customer cancel — withdraws a request that is still `pending_nurse_response` or
* `accepted_awaiting_payment` (before paying). Invalidates the lists + the request detail so both inboxes
* and the C5 reflect the cancellation. A cancel on a terminal request returns `409` via `mutation.error`.
*/
export function useCancelBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.cancel(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { BookingRequestDisplayContext, CreateBookingRequestPayload } from '../types';
interface CreateArgs {
payload: CreateBookingRequestPayload;
/** Mock-only display aid (ignored by the real client) so the created request renders fully on C5. */
context?: BookingRequestDisplayContext;
}
/**
* Creates a booking request (C4 → C5). On success it seeds the new request into the detail cache and
* invalidates the customer inbox so both reflect it immediately; the page owns the navigation to C5.
* Domain `400`s (same-gender mismatch, tenancy 404, inactive variant, invalid time/date) surface via
* `mutation.error` — the fetch layer owns 401/403/5xx toasts.
*/
export function useCreateBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ payload, context }: CreateArgs) => bookingRequestsApi.create(payload, context),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
},
});
}
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The customer's own booking-requests inbox (`list?role=customer`), optionally filtered by status. C5 is
* keyed by a single request id, so this list is not required by a phase-7 screen — it exists so f8's
* "my bookings" surface (and any future customer request list) reads the same cached domain, and so a
* create/cancel invalidation has a list to refresh.
*/
export function useCustomerRequests(status?: BookingRequestStatus, page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'customer' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.list(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
});
}
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_POLL_MS,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly
* polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this
* list so an actioned request leaves the pending inbox immediately.
*/
export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.nurseInbox(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: BOOKING_REQUEST_POLL_MS,
});
}
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { RejectBookingRequestPayload } from '../types';
interface RejectArgs {
id: number;
payload: RejectBookingRequestPayload;
}
/**
* Nurse reject (with a required reason). On success it invalidates every inbox list (the request leaves
* the pending inbox) and the request detail (the customer's polled C5 shows the rejected terminal card
* with the reason). A stale reject returns `409` via `mutation.error`.
*/
export function useRejectBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: RejectArgs) => bookingRequestsApi.reject(id, payload),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,11 @@
/**
* Booking-requests domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCreateBookingRequest } from './hooks/useCreateBookingRequest';
export { useBookingRequest } from './hooks/useBookingRequest';
export { useNurseRequestInbox } from './hooks/useNurseRequestInbox';
export { useCustomerRequests } from './hooks/useCustomerRequests';
export { useAcceptBookingRequest } from './hooks/useAcceptBookingRequest';
export { useRejectBookingRequest } from './hooks/useRejectBookingRequest';
export { useCancelBookingRequest } from './hooks/useCancelBookingRequest';
@@ -0,0 +1,23 @@
import type { BookingRequestListParams } from './types';
/**
* React Query key factory for the booking-requests domain (hierarchical, per the `services/{domain}`
* pattern). The list key carries the role + status filter so the customer inbox and the nurse inbox are
* distinct cache entries and a nurse accept/reject invalidates exactly the right list.
*/
export const bookingRequestKeys = {
all: ['bookingRequests'] as const,
lists: () => [...bookingRequestKeys.all, 'list'] as const,
list: (params: BookingRequestListParams) =>
[
...bookingRequestKeys.lists(),
params.role,
params.status ?? 'all',
params.page ?? 1,
params.pageSize ?? 0,
] as const,
/** The nurse incoming-requests inbox (a `list` with `role='nurse'`), factored for readability. */
nurseInbox: (params: BookingRequestListParams) => bookingRequestKeys.list(params),
details: () => [...bookingRequestKeys.all, 'detail'] as const,
detail: (id: number) => [...bookingRequestKeys.details(), id] as const,
};
@@ -0,0 +1,198 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
/**
* Booking-requests domain — the **pre-payment intent** layer of the engagement lifecycle (b8). A
* customer requests a nurse for a patient/variant/address/date; the nurse accepts (opening a
* config-driven 30-minute payment window) or rejects; both sides read a role-scoped inbox and a single
* request. **There is no money and no `bookings` row here** — that conversion is b9/b10. Shapes mirror
* the b8 contract (`dev/contracts/domains/booking-requests.md`); the wire is **camelCase** and
* `clientFetch` unwraps the `ApiEnvelope<T>`, so these are the post-`unwrap()` payloads.
*
* Load-bearing semantics (contract "Key semantics" + phase §5):
* - **Deadlines are server-frozen absolute UTC instants.** The client renders countdowns from
* `nurseResponseDeadlineAt` / `paymentDeadlineAt` against `Date.now()` — it never computes a deadline.
* `paymentDeadlineAt` is `null` until the nurse accepts.
* - **`requiredCaregiverGender` is first-class and required on create** (`male`/`female`/`any`) — never
* silently defaulted. `male`/`female` must match the nurse's gender (a mismatch is a `400`).
* - **Two-stage clinical disclosure.** The nurse sees **only** `customerNotes` (stage-1 plaintext) and a
* **masked** address (`addressLine`/`postalCode`/recipient are `null` in the nurse view). Full
* clinical/care instructions do not exist until b9 and must never appear in this UI.
* - **Forward-only status machine.** Terminal states have no outgoing edges; a stale accept/reject/cancel
* returns `409`.
*/
/** The same-gender matching facet carried from search into the request (`any` = فرقی ندارد). */
export type RequiredCaregiverGender = 'male' | 'female' | 'any';
/** The full `booking_request_status` enum (contract). */
export type BookingRequestStatus =
| 'pending_nurse_response'
| 'accepted_awaiting_payment'
| 'converted'
| 'rejected_by_nurse'
| 'expired_no_response'
| 'payment_deadline_expired'
| 'cancelled_by_customer';
/** Which inbox to read — disambiguates a user who holds both roles (contract `list?role=`). */
export type RequestRole = 'customer' | 'nurse';
/** Terminal states: no outgoing edges, so polling stops and no accept/reject/cancel is offered. */
export const TERMINAL_BOOKING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'converted',
'rejected_by_nurse',
'expired_no_response',
'payment_deadline_expired',
'cancelled_by_customer',
] as const;
export function isTerminalBookingRequestStatus(status: BookingRequestStatus): boolean {
return TERMINAL_BOOKING_REQUEST_STATUSES.includes(status);
}
/**
* `BookingRequestDto` — the full single-request view. The customer/admin view carries the full address;
* the **nurse view masks it** (`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are `null`),
* leaving only the coarse city/district.
*
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
* real client leaves it `null` (the summary then hides the amount) until the field lands.
*/
export interface BookingRequestDto {
id: number;
status: BookingRequestStatus;
nurseId: number;
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientId: number;
patientName: string;
variantId: number;
variantLabel: string;
variantPriceUnit: PriceUnit;
/** Client-augmented (REQ-013): IRR digit-string, or `null` on the real path until the DTO carries it. */
variantPrice: string | null;
customerAddressId: number;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
/** Full-address PII — present in the customer/admin view, **`null` in the nurse view** (masked). */
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Stage-1 plaintext — the ONLY clinical text the nurse sees before accepting. */
customerNotes: string | null;
/** Server-frozen absolute UTC instant. */
nurseResponseDeadlineAt: string;
/** Server-frozen UTC; `null` until the nurse accepts. */
paymentDeadlineAt: string | null;
nurseRejectionReason: string | null;
createdAt: string;
}
/**
* `BookingRequestListItemDto` — an inbox row. The **customer** inbox sets `counterpartyName` = nurse name
* (+ `nurseRating`); the **nurse** inbox sets `counterpartyName` = patient name (+ `customerNotes`,
* stage-1 only). Actionable rows sort first server-side.
*/
export interface BookingRequestListItem {
id: number;
status: BookingRequestStatus;
counterpartyName: string;
/** Customer view only; `null` in the nurse inbox. */
nurseRating: number | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
requestedDate: string;
requestedTimeStart: string;
requestedTimeEnd: string;
nurseResponseDeadlineAt: string;
paymentDeadlineAt: string | null;
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
customerNotes: string | null;
}
/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */
export interface CreateBookingRequestPayload {
nurseId: number;
variantId: number;
patientId: number;
customerAddressId: number;
/** `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Required, never silently defaulted. */
requiredCaregiverGender: RequiredCaregiverGender;
/** ≤ 1000 chars; the only text the nurse sees pre-accept. */
customerNotes?: string | null;
}
/** The `booking_requests/reject` body. */
export interface RejectBookingRequestPayload {
/** Required, ≤ 500 chars. */
reason: string;
}
/** `booking_requests/list` query params (role-scoped, paginated, optional status filter). */
export interface BookingRequestListParams extends PageParams {
role: RequestRole;
status?: BookingRequestStatus;
}
/**
* Display fields the mock needs to build a faithful DTO, resolved by the C4 form from the already-loaded
* nurse profile / patient / address / variant queries. **The real `clientApi` ignores this** — the
* server returns the joined DTO from the ids alone; only the mock (which cannot read the other domains'
* in-memory stores) uses it. This is the b8 analogue of the search/patients/addresses "client-augmented"
* display fields, kept off the wire `CreateBookingRequestPayload`.
*/
export interface BookingRequestDisplayContext {
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
variantPrice: string | null;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
}
/**
* The booking-requests API seam — the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_BOOKING_REQUESTS_MOCK`), never scattered `if (mock)` checks.
*
* `get`/`create` take an optional `role`/`context` that only the mock uses (to mask the nurse view and
* to build a faithful DTO respectively); the real client infers the view from auth and ignores them.
*/
export interface BookingRequestsApi {
create(payload: CreateBookingRequestPayload, context?: BookingRequestDisplayContext): Promise<BookingRequestDto>;
get(id: number, role?: RequestRole): Promise<BookingRequestDto>;
list(params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>>;
accept(id: number): Promise<BookingRequestDto>;
reject(id: number, payload: RejectBookingRequestPayload): Promise<BookingRequestDto>;
cancel(id: number): Promise<BookingRequestDto>;
}