Files
baya-monorepo/archive/post-phase/ui/audit/booking-lifecycle.md
T
2026-08-02 18:48:32 +03:30

20 KiB
Raw Blame History

Customer booking-request + booking lifecycle screens (C4 request form → C5 pending tracker → bookings list → booking detail/EVV → cancel → refund status → review)

Current state

The flow is complete and functionally rich but visually generic. C4 (client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx) is a single long page of default MUI selects (patient, service variant, saved address, native date/time inputs, a 3-way gender ToggleButtonGroup, notes with counter), with per-field dashed-border empty states, a form skeleton, and a domain-code→message error mapper. C5 (bookings/request/[id]/page.tsx) polls the request and renders the shared BookingRequestSummaryCard, a raw-MUI StepperHeader 3-step tracker, CountdownTimer (server-frozen deadline, LTR-forced Persian digits), five distinct terminal cards, and a cancel confirm Dialog. The bookings list (bookings/page.tsx) is a flat stack of bordered Paper rows — counterparty name, Shamsi date, session count, StatusChip, Toman total, "view" button — with skeleton/error/empty branches but no tabs, filters, pagination, or row click.

Problems (19)

  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx — In the cancel-request confirmation dialog, the DISMISS button is labeled with the destructive action's own label: t('cancel_request') = "انصراف از درخواست". Clicking the button that says "Cancel request" actually keeps the request and closes the dialog; the real destructive button is cancel_confirm_yes. Users who want to cancel will click the dismiss button; users who want to keep it may click confirm. Dismiss must read "بازگشت"/"نگه داشتن درخواست".
    • evidence: Lines 223225: <AppButton variant="text" onClick={() => setConfirmCancel(false)}>{t('cancel_request')}</AppButton> inside DialogActions, next to the contained error confirm button.
  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx — All required-field inline errors are unreachable dead code. The submit button is disabled={!requiredChosen …}, but setAttempted(true) only runs inside handleSubmit — which can never fire while a required field is missing. So error={attempted && patientId === ''} etc. never render, and the only feedback for an incomplete form is a silently disabled button with no explanation of what's missing.
    • evidence: Line 439 disabled={!requiredChosen || genderMismatch || createRequest.isPending} vs line 128 setAttempted(true) (only in handleSubmit) and line 230 error={attempted && patientId === ''}.
  • [high] client/src/services/bookingRequests/hooks/useCustomerRequests.ts — The customer has NO way back to a pending request. useCustomerRequests (the customer requests inbox hook) is exported but consumed by zero pages; the bottom nav (CustomerLayout: Home/Bookings/Patients/Wallet/Profile) has no requests entry, and bookings/page.tsx lists only post-payment bookings (list_empty_body: "پس از تایید پرستار و پرداخت…"). If the user leaves C5 or closes the app while a request is pending — or during the 30-minute payment window — the request is orphaned unless they remember the URL.
    • evidence: Grep: useCustomerRequests appears only in services/bookingRequests/index.ts and its own hook file; CustomerLayout.tsx lines 3337 show the five nav items with no requests route.
  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx — The request form never shows WHO the request is for. useNurseProfile is fetched (used only for the gender-mismatch check and hidden display-context), but no nurse name, avatar, rating, or verification badge renders anywhere on the page — the header is just "request_title" + a generic subtitle. In a trust-first nursing marketplace, asking a family to hand over patient + home address to an unnamed party is the single biggest trust failure in the flow.
    • evidence: Lines 206215 render only t('request_title')/t('form_subtitle'); profile.nurseName is referenced only inside the context object at line 144.
  • [high] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx — Date selection uses the browser-native Gregorian type="date" input while every displayed date in the product is Shamsi (formatShamsiDate). Persian users must mentally convert Jalali → Gregorian to book a visit, then see the confirmation back in Shamsi. Time inputs are fine; the date picker needs a Jalali calendar.
    • evidence: Lines 335347: <TextField type="date" label={t('date_label')} …/> with slotProps={{ inputLabel: { shrink: true } }}.
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx — The address preview embeds the fake-map stand-in (AddressMapPicker with pointerEvents: 'none') — a 220px grid-pattern canvas with a pin and lat/lng labels that the component's own doc admits "is NOT a real map". In a read-only confirmation context it communicates nothing a text line doesn't, looks like placeholder scaffolding, and eats a large chunk of the form's vertical space.
    • evidence: Lines 315327 wrap <AddressMapPicker …/> in <Box sx={{ pointerEvents: 'none' }}>; AddressMapPicker.tsx lines 2935 document the stand-in nature.
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx — The bookings list has no status tabs/filters, no upcoming-vs-past grouping, and no pagination even though the service is paginated (BOOKINGS_PAGE_SIZE=20, total returned) — booking #21 is unreachable. Rows aren't clickable (only the small outlined button navigates), the error state has no retry, and the empty state has no CTA into search. Scannability is one-note: every status renders identically except the chip.
    • evidence: Line 19 useBookingList('customer') with no page/status params; lines 3954 error/empty branches with no action; row nav only via AppButton at lines 9199.
  • [medium] client/src/components/booking/BookingDetailView/BookingDetailView.tsx — The booking detail header omits the facts a customer most needs: no visit address/location, no headline date/time (buried per-session), no nurse avatar or contact affordance, no total-at-a-glance. The header is service name + booking ref + two tiny label/value pairs — a customer opening "my booking" cannot answer where/when without scanning session cards.
    • evidence: Lines 6395: header Paper contains only service ?? t('bd_title'), bd_ref, and two HeaderFacts (patient, nurse name).
  • [medium] client/src/components/CountdownTimer/CountdownTimer.tsx — The countdown — the emotional core of C5 — is bare HH:MM:SS digits next to a generic 'pending' icon, ticking every second. There is no progress ring/bar showing how much of the response window remains, no humanized framing ("پاسخ معمولاً تا چند ساعت"), and for multi-hour windows a per-second ticker reads as anxiety-inducing rather than calm. The 'urgent' variant only swaps the color to terracotta.
    • evidence: Lines 92109: label caption + AppIcon icon="pending" + a 1.5rem tabular-nums span; no other presentation.
  • [medium] client/messages/en.json — A directional arrow is hard-coded inside the translated CTA copy: fa "ادامه پرداخت ←" and en "Continue to payment ←" — in English (LTR) the forward arrow should be →, so the EN button points backwards; and the button already carries endIcon="payment", duplicating the affordance. Directionality must come from layout/icons, never from string literals.
    • evidence: fa.json:445 / en.json:445 "continue_payment": "Continue to payment ←"; consumed at bookings/request/[id]/page.tsx lines 182191.
  • [medium] client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx — The date·time-range label is not bidi-isolated: whenLabel concatenates Shamsi date, '·', and "start end" times with no dir="ltr" wrapper (unlike SessionCard, which wraps its identical time range in a dir="ltr" span). With Persian (AN-class) digits in an RTL paragraph, the range can visually render end-before-start.
    • evidence: Line 60 builds whenLabel; lines 121125 render it in a plain Typography with textAlign: 'end' — compare SessionCard.tsx line 99's <Typography component="span" dir="ltr" …>{timeLabel}.
  • [medium] client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx — The review form lacks context and expectation-setting: no recap of the booking being reviewed (service/date), no up-front note that reviews are moderated before publishing (the user only learns post-submit via the 'under review' state), no visible character counter for the 2000-char body (input silently sliced), and no hover/selected labels on the stars (just 5 identical icons).
    • evidence: Lines 135185: heading is only nurse name; line 150 onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))} with no counter; RatingInput has no per-star labels.
  • [medium] client/src/components/StatusChip/StatusChip.tsx — Every status in the entire flow renders as a solid, fully-saturated pill (solid success/error/warning/info backgrounds with cream text), all at equal visual weight — a cancelled booking shouts as loudly as a disputed one, and screens with several chips (list rows, sessions, refund card, review state) read as a loud, cold dashboard rather than the calm clinical-warm tone. Soft-tinted chips (bg-soft + strong fg) would fit the brand and let severity actually rank.
    • evidence: Lines 1522: verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)' … } — solid token fill for all six kinds.
  • [low] client/src/components/booking/BookingDetailView/BookingDetailView.tsx — The i18n fallback key unnamed_nurse ("پرستار", intended as the no-name placeholder) is repurposed as the field LABEL for the nurse in the header facts — semantically wrong key reuse that will break the moment the fallback copy changes.
    • evidence: Line 92: <HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />.
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx — Negative-margin layout hacks stitch related elements together instead of composed grouping: the price display is pulled up under the service select with mt: -1.5, and the notes counter is pulled under its TextField with mt: -2 — fragile spacing that breaks when helper text appears.
    • evidence: Line 272 <Box sx={{ mt: -1.5 }}> and line 424 sx={{ …, textAlign: 'end', mt: -2 }}.
  • [low] client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx — The cancelled terminal row uses the brand-teal info tint (--bal-primary-soft) as its background while the sibling StatusNote in BookingDetailView uses --bal-divider for the same cancelled state — two different 'neutral' treatments for one status on one screen, and teal reads as informational/brand, not terminated.
    • evidence: Line 45 bgcolor: 'var(--bal-primary-soft)' vs BookingDetailView.tsx line 185 tone === 'neutral' ? 'var(--bal-divider)'.
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx — The cancellation reason select pre-defaults to 'changed_mind', so users can submit without ever choosing — biasing the reason analytics and skipping a moment of reflection the trust-first flow otherwise builds carefully.
    • evidence: Line 58: useState<CancelReasonCategory>('changed_mind') with no empty/placeholder option.
  • [low] client/src/components/StepperHeader/StepperHeader.tsx — StepperHeader is a raw default-MUI Stepper (default numbered circles, default connectors, default typography) and it is the status-communication backbone of the whole lifecycle — C5 tracker, booking timeline, refund progress, cancel flow all render this unstyled starter component, which is the single most 'MUI beginner example' element in the flow.
    • evidence: Lines 2028: bare <Stepper activeStep alternativeLabel> with zero styling beyond py: 2.
  • [low] client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsxuseMyReviewForBooking(bookingId) fires unconditionally on the review page, while the detail page carefully gates the same hook with { enabled: reviewable } — inconsistent pattern and a wasted request for non-eligible bookings.
    • evidence: Line 43 const myReview = useMyReviewForBooking(bookingId); vs bookings/[id]/page.tsx line 52's gated call.

Opportunities (10)

  • Customer requests inbox: merge requests + bookings under one tabbed surface (impact: high, effort: medium) — Wire the already-built useCustomerRequests hook into a customer-facing surface: segmented tabs on /bookings (در انتظار پاسخ / فعال / گذشته). Pending-request rows carry a live mini-countdown chip and deep-link to C5; accepted-awaiting-payment rows surface the payment deadline as the row's primary CTA. This closes the orphaned-request hole (the highest-stakes UX gap: money-adjacent deadlines the user cannot find again) with zero new backend work.
  • Redesign C4 as a trust-anchored request flow (impact: high, effort: medium) — Put a sticky nurse identity card at the top (avatar, name, rating + review count, verified badge, gender) so the family always sees who they're inviting home; add a 3-step 'what happens next' strip (درخواست → پاسخ پرستار → پرداخت) reinforcing the money-free promise already in form_subtitle. Replace the dead-end disabled submit with always-enabled submit + scroll-to-first-error. Optionally split into two steps: details → review-and-send with a compact BookingRequestSummaryCard preview, which doubles as the fix for the missing pre-submit recap.
  • Jalali date picker + time-window presets (impact: high, effort: large) — Replace native type=date with a Shamsi calendar picker (weekend/holiday aware — the ops holiday table already exists server-side), and replace free start/end time fields with tappable window chips (صبح ۸–۱۲ / بعدازظهر ۱۲–۱۶ / عصر ۱۶–۲۰ + custom). This removes the Gregorian mental conversion, kills the end<=start error class for most users, and later becomes the seam for nurse-availability hints.
  • Booking detail 'next visit' hero + designed vertical timeline (impact: high, effort: medium) — Rebuild the detail header as an actionable hero: next upcoming session ("ویزیت ۲ · فردا ۹:۰۰"), the visit address, nurse avatar with message/support entry, and add-to-calendar. Replace the generic horizontal Stepper with a designed vertical timeline (per-stage icons, timestamps where known, terracotta marker on the current stage, distinct terminal branch rendering) — this is where 'status via raw chips + default stepper' should become the product's signature trust visual.
  • Countdown as a calm progress ring (impact: medium, effort: small) — Wrap the CountdownTimer digits in a circular progress ring fed by (deadline createdAt) so users see the fraction of the window remaining, drop per-second ticking above 10 minutes remaining (show 'حدود ۳ ساعت'), switch to seconds only in the final minutes, and add a one-line 'we'll notify you' note tied to the notifications bell so users feel safe leaving the page.
  • Live 'nurse is on site' presence for in-progress bookings (impact: medium, effort: small) — The customer already receives EVV banners per session — elevate this into a headline presence state on the detail (and list row): 'پرستار در محل است · ورود ۰۹:۰۲' while checked-in. It converts the EVV plumbing into the platform's most visceral trust cue for the family member who is not at home with the patient.
  • Cancellation off-ramps before the kill switch (impact: medium, effort: medium) — Before the fee disclosure, offer alternatives: 'تغییر زمان' (reschedule request via support ticket until real rescheduling exists) and 'گفتگو با پشتیبانی'. Add a one-line human note about nurse impact. The existing disclosure is excellent; giving an exit that isn't destruction both reduces cancellations and reads as fair.
  • Post-completion review nudge on the list (impact: medium, effort: small) — Completed bookings without a review should show a compact star-strip CTA directly on the bookings-list row (the eligibility + my-review hooks already exist), instead of relying on the user opening the detail and finding the button below the money summary.
  • Terminal-state cards with smarter recovery (impact: medium, effort: medium) — C5's rejected/expired cards all funnel to generic search. Offer 'درخواست دوباره از همین پرستار با زمان دیگر' (prefilled C4) when the rejection reason isn't gender/coverage, and 'پرستاران مشابه' (same service + area) otherwise — recovering the booking intent rather than restarting discovery from zero.
  • Soft-tint status system + status-differentiated list rows (impact: medium, effort: small) — Introduce a soft chip variant (token -soft backgrounds + strong text, reserving solid fills for EVV banners) and give list rows a status-colored inline-start border accent so the eye can rank a page of bookings without reading every chip — aligning the whole flow with the calm/warm brand direction.

Keep (do not regress)

  • Token discipline is genuinely excellent in this area: zero hard-coded hexes across all audited files — every color resolves through --bal-* semantic tokens with both light/dark schemes defined (StatusChip, EvvStatusBanner, RatingInput even document the rule in comments).
  • Server-truth discipline: CountdownTimer never computes deadlines client-side (renders diff vs server-frozen instant, LTR-forced Persian digits); BookingStatusTimeline never advances a step client-side; money is display-only IRR digit-strings through formatIrrToToman — never summed or re-split in the UI (BookingMoneySummary doc + props contract).
  • Two-stage disclosure as a hard UI gate: the customer's care-instructions query never fires (enabled only for nurse on confirmed+), replaced by a designed lock affordance with honest copy ('visible to your assigned nurse and support only') — BookingDetailView CareSection.
  • The cancellation flow's trust architecture: full pre-submit disclosure of policy tier, refund %/fee %, concrete Toman split via PriceBreakdown, per-session refundable/locked breakdown, admin-approval explainer, and an explicit acknowledgement checkbox gating confirm (CancellationPolicyDisclosure + cancel page).
  • Honest refund UX: a failed refund suppresses ALL success-framed progress/amount/ETA (explicit comment in RefundStatusCard), BNPL's ~710-business-day window is surfaced plainly in RefundEtaBanner, and retry is deliberately absent (admin-only).
  • EVV advisory semantics done right: out-of-range is warning-toned (never error), GPS-unavailable is neutral, and a mismatch never blocks the flow — tri-state handled end-to-end (EvvStatusBanner styleFor + SessionCard).
  • Every screen in the flow has real skeleton loading states shaped like the final layout (FormSkeleton, StatusSkeleton, DetailSkeleton, review skeletons) plus distinct empty/error branches — no spinner-only pages.
  • RTL/Persian craft in most components: dir="ltr" isolation on countdown digits, session time ranges and refund references; borderInlineStart logical accents; textAlign:'end'; Shamsi dates via fa-IR-u-ca-persian and Persian digits via Intl everywhere; the AddressMapPicker's documented inline-style workaround against stylis RTL flipping.
  • First-class caregiver-gender preference with culturally-informed hint copy, never silently defaulted, and gender-mismatch blocked inline before the round-trip with the server staying authoritative (request form lines 109113).
  • The single terracotta-accent rule holds: --bal-secondary appears only at money/urgency moments (payment countdown, payment CTA, payout rows, nurse-view chip) — exactly the sparing-accent brand intent.