ui phase 5

This commit is contained in:
hamid
2026-07-18 09:51:03 +03:30
parent 53b4e1b0a4
commit 4c70d8e424
42 changed files with 2834 additions and 548 deletions
+22 -19
View File
@@ -125,23 +125,23 @@ client/
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
│ │ │ ├── loading.tsx # Route-group loading skeleton (header + search bar + category-tile row + card stack)
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (shell.customer_app) + renders HomeScreen
│ │ │ ├── HomeScreen.tsx # 'use client' — the actual A5 home body (moved out of page.tsx for the metadata pattern; see "Per-page metadata" below)
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│ │ │ ├── HomeScreen.tsx # 'use client' — ui-phase-4: A5 home body — tappable search entry (routes to C1; the free-text field never worked, upgrade path noted for REQ-041's `q` param), a quiet TrustStrip, the data-driven category grid, a completeness-gated + session-dismissible patient-record nudge, and a rebook shortcut row (useBookingList + per-card useBookingDetail → deep-links to the nurse's C3 profile)
│ │ │ ├── search/ # /search — f6 discovery, ui-phase-4 redesign: C1 filter screen (page.tsx: reused category grid + f3 region picker + the shared GenderToggle `allowAny` + a Jalali day-chip date-intent strip (JalaliDatePicker `chips` variant + a full-grid popover) + Toman price + a sticky live-count CTA (StickyActionBar) that turns into a non-CTA "no matches" message at zero results; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (search.title) + renders SearchScreen
│ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; reads ?category_id preselect; pushes filter set to C2 as URL query params
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── bookings/
│ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; hydrates from the FULL carried URL (searchParamsToFilters + a client-only `province_id` convenience param for the cascading-select prefill), not just `category_id`; pushes the filter set + `province_id`/`date` to C2 as URL query params
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller — seeds every field from the initial URL (category/region/gender/price/date), not just category; derives the canonical NurseSearchFilters (debounced Toman price → IRR)
│ │ │ │ ├── results/page.tsx # C2 results, ui-phase-4 redesign — a tappable filter-recap chip row (category/region/gender/price, each deep-linking back to C1 with the ENTIRE carried query string) + a static "مرتب‌شده بر اساس امتیاز" caption (the dead one-option sort dropdown is gone) + NurseResultCard.Skeleton twins; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile, ui-phase-4 dossier redesign — header now shows completed-visits count; a tappable TrustBadge (nurseId prop) + the shared VerificationPanel section (fed by useNurseTrustBadge) + attribute chips; a f13 tab strip: «خدمات» (ServicePriceRow list + an optional latest-review snippet) / «نظرات» (ReviewsPanel — published-only fractional aggregate+count + infinite list via services/reviews); a sticky bottom CTA bar (StickyActionBar, price-from beside "درخواست رزرو") survives the infinite reviews list and hands off to /bookings/request (f7); no gender chip (the public profile DTO doesn't serve `nurseGender` yet — REQ-042, never render the client's placeholder stub)
│ │ │ ├── bookings/ # ui-phase-5 lifecycle redesign — the /bookings tabs are now the lifecycle home (no request is orphaned once left)
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen
│ │ │ │ ├── BookingsScreen.tsx # 'use client' — f8 رزروها list body (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── BookingsScreen.tsx # 'use client' — three tabs («در انتظار پاسخ»/«فعال»/«گذشته»): pending wires useCustomerRequests (mini-countdown per row, deep-links to C5); active/past split useBookingList('customer') client-side by status over one growing-pageSize "load more" query (the C2 pattern); AccentCard rows (status-tone borderInlineStart) are fully tappable (role=button+keyboard); a completed row without a review shows a compact star-strip CTA (useReviewEligibility, gated to completed rows)
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
│ │ │ │ ├── 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; converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── request/page.tsx # /bookings/request — C4 request form, ui-phase-5 redesign: sticky nurse-identity bar (avatar/name/rating/TrustBadge/gender) + a «چه اتفاقی می‌افتد؟» 3-step strip (reuses C5's StepperHeader); JalaliDateIntentPicker date + tappable morning/afternoon/evening/custom time-window chips (kills the end≤start error class); touched-on-blur field errors + a disabled-CTA "what's missing" caption (the old submit-gated `attempted` dead code is gone); a compact address row with a «تغییر» toggle back to the select (no more fake-map preview); accepts `patient_id`/`address_id` recovery params from C5's re-request handoff
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — C5 tracker, ui-phase-5 redesign: CountdownTimer progress ring (`windowStart=createdAt`) + humanized «حدود N ساعت/دقیقه» framing above the coarse threshold; the cancel-request dialog is now `ConfirmDialog` with the destructive/dismiss labels fixed («نه، نگه دار» vs «بله، انصراف از درخواست» — the old defect had them swapped); rejected/expired terminal cards offer «درخواست دوباره با زمان دیگر» (reopens C4 prefilled) + «پرستاران مشابه» (region+gender-carried search), gated by a keyword heuristic over the freeform `nurseRejectionReason` (REQ-044 proposes a real code); converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14), ui-phase-5 added a context recap (service/Shamsi date/nurse avatar off the cached booking) + the moderation-expectation note up front (not only post-submit); `useMyReviewForBooking` now gated `{ enabled: reviewable }` like the detail page; RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute)
@@ -232,8 +232,10 @@ client/
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); imports next-intl (see jest.config.ts transformIgnorePatterns note below)
│ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion)
│ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants, RTL-aware keyboard nav
│ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
│ │ ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested)
│ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)`
│ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
@@ -241,7 +243,7 @@ client/
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
│ ├── StepperHeader/ # Progress header for onboarding/verification flows
│ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens
│ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching
│ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching; ui-phase-4 added an opt-in `allowAny` mode (discriminated-union props) adding a third «فرقی ندارد» option for C1's search facet — the default booking-context contract is unchanged
│ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels)
│ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self)
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
@@ -250,12 +252,13 @@ client/
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
│ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (tested)
│ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile; ui-phase-4 added an opt-in `nurseId` prop that makes the badge tappable, opening a bottom-sheet/dialog explainer (the shared VerificationPanel, fed by a LAZY useNurseTrustBadge(nurseId) fetch) — split into an inner `InteractiveTrustBadge` so the default (no `nurseId`) mode calls no query hook at all and needs no QueryClientProvider in its callers' tests (tested)
│ ├── 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)
│ ├── VerificationPanel/ # ui-phase-4 — shared "what Balinyaar verified" explainer (`src/components/VerificationPanel/`): one row per TrustBadge.credentialTypes[] (i18n off the verification namespace's step_* codes) + the approval date, fed by useNurseTrustBadge; used standalone on the C3 profile AND inside TrustBadge's tap-to-explain dialog; reused unchanged by phase 8's public-profile preview (tested)
│ ├── NurseResultCard/ # f6 C2 result card, ui-phase-4 v2 anatomy: avatar+name, tappable verified TrustBadge, a service/variant label (category name until REQ-040's `variantDisplayName` lands), a quiet nurse-gender chip + completed-visits count, rating+review count, optional distance chip, an optional one-line top-review tag (REQ-040), and "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)
│ ├── 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; v2 (ui-phase-1) progress ring (`windowStart`) + urgency tiers + humanized coarse mode — C5's response countdown (ui-phase-5) is the ring's first live consumer (`windowStart=createdAt`) (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail; ui-phase-5 bidi-isolated the date·time-range label (`dir="ltr"` span, matching SessionCard's precedent) (tested)
│ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested)
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
@@ -272,7 +275,7 @@ client/
│ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested)
│ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested)
│ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, the frozen visit address, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
+71 -8
View File
@@ -89,7 +89,11 @@
"nudge_profile_title": "Complete your profile",
"nudge_profile_body": "Add an emergency contact to speed up bookings.",
"nudge_profile_cta": "Go to profile",
"patients_error": "Couldn't load your patients."
"patients_error": "Couldn't load your patients.",
"trust_escrow": "Secure escrow payment",
"trust_verified_nurses": "Verified nurses",
"trust_support": "Support",
"rebook_with": "Book again with {name}"
},
"onboarding": {
"step_relation": "Who for",
@@ -366,30 +370,41 @@
"gender_any": "No preference",
"gender_hint": "For personal and bodily care, many families prefer a same-gender caregiver. Your choice is carried into the booking request.",
"date_hint": "We pass your preferred date to the nurse — it does not remove nurses from the results.",
"date_today": "Today",
"date_tomorrow": "Tomorrow",
"date_pick_other": "Pick another date",
"price_hint": "Leave blank to see every price.",
"price_min": "From",
"price_max": "To",
"toman": "Toman",
"whole_city": "Whole city",
"categories_error": "Couldn't load categories.",
"cta_choose_category_city": "Choose a category and city",
"cta_loading": "Counting nurses…",
"cta_view_results": "View {count, plural, =0 {no nurses} one {# nurse} other {# nurses}}",
"cta_zero_title": "No nurses match these filters",
"cta_zero_hint": "Loosen a filter to see more nurses.",
"results_loading_title": "Searching…",
"results_count": "{count, plural, =0 {No nurses} one {# nurse} other {# nurses}}",
"sort_label": "Sort",
"sort_rating": "Rating",
"sort_static": "Sorted by rating",
"results_error": "Something went wrong loading results.",
"retry": "Try again",
"load_more": "Load more",
"empty_title": "No nurses match your filters",
"empty_suggest_gender": "Try removing the gender filter.",
"empty_suggest_district": "Clear the district to search the whole city.",
"empty_suggest_city": "Try a nearby city like Mashhad, Isfahan, or Shiraz.",
"empty_suggest_date": "Try a different date.",
"empty_cta": "Adjust filters",
"unnamed_nurse": "Nurse",
"unnamed_service": "Service",
"completed_visits": "{count, number} successful visits",
"reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"distance_km": "{km} km",
"price_from": "from",
"price_chip_range": "{min} to {max} Toman",
"price_chip_min": "From {min} Toman",
"price_chip_max": "Up to {max} Toman",
"latest_review_title": "Latest review",
"profile_not_found_title": "Nurse unavailable",
"profile_not_found_body": "This nurse is no longer available.",
"profile_not_found_cta": "Back to search",
@@ -418,6 +433,7 @@
"summary_when": "Time",
"request_title": "Booking request",
"form_subtitle": "Send a request to this nurse. No payment is taken yet — the nurse reviews it first.",
"whathappens_title": "What happens next?",
"missing_nurse_title": "No nurse selected",
"missing_nurse_body": "Choose a nurse from search, then send a request.",
"missing_nurse_cta": "Find a nurse",
@@ -433,9 +449,18 @@
"address_empty": "You haven't added an address yet.",
"address_add_cta": "Add an address",
"address_whole_city": "Whole city",
"address_change_cta": "Change",
"date_label": "Date",
"date_today": "Today",
"date_tomorrow": "Tomorrow",
"date_pick_other": "Pick another date",
"time_start_label": "From",
"time_end_label": "To",
"time_window_label": "Visit time window",
"window_morning": "Morning 812",
"window_afternoon": "Afternoon 1216",
"window_evening": "Evening 1620",
"window_custom": "Custom time",
"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",
@@ -457,6 +482,13 @@
"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.",
"cta_missing_patient": "choosing a patient",
"cta_missing_service": "choosing a service",
"cta_missing_address": "choosing an address",
"cta_missing_date": "a date",
"cta_missing_time": "a time window",
"cta_missing_gender": "caregiver gender",
"cta_missing_caption": "To continue: {fields}",
"awaiting_title": "Request sent to the nurse",
"awaiting_subtitle": "Awaiting the nurse's response",
"step_submitted": "Request submitted",
@@ -464,6 +496,9 @@
"step_payment": "Payment & final confirmation",
"response_countdown_label": "Nurse response window",
"response_elapsed": "Awaiting server confirmation…",
"response_notify_note": "We'll let you know the result",
"countdown_about_hours": "About {hours, plural, one {# hour} other {# hours}}",
"countdown_about_minutes": "About {minutes, plural, one {# minute} other {# minutes}}",
"accepted_badge": "The nurse accepted",
"accepted_body": "Pay within the window below to confirm the booking.",
"payment_countdown_label": "Payment window",
@@ -473,7 +508,8 @@
"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",
"cancel_confirm_keep": "No, keep it",
"cancel_confirm_destructive": "Yes, cancel the request",
"rejected_title": "The nurse declined the request",
"rejected_reason_label": "Nurse's reason",
"expired_title": "The nurse didn't respond in time",
@@ -482,6 +518,8 @@
"converted_title": "Your booking is confirmed",
"converted_cta": "View booking",
"terminal_rerequest": "Request another nurse",
"terminal_rerequest_same_nurse": "Request again with a different time",
"terminal_similar_nurses": "Similar nurses",
"not_found_title": "Request not found",
"not_found_body": "This request may have been removed.",
"error_title": "Something went wrong",
@@ -519,6 +557,8 @@
"status_cancelled_by_customer": "Cancelled",
"bd_title": "Booking",
"bd_ref": "Booking #{id}",
"bd_nurse_label": "Nurse",
"bd_add_to_calendar": "Add to calendar",
"bd_not_found_title": "Booking not found",
"bd_not_found_body": "This booking doesn't exist or isn't yours.",
"bd_my_bookings": "My bookings",
@@ -563,6 +603,7 @@
"evv_banner_in_range": "Checked in {time} · location confirmed (EVV)",
"evv_banner_out_of_range": "Checked in {time} · location out of range (under review)",
"evv_banner_no_gps": "Checked in {time} · location not captured",
"evv_presence_headline": "The nurse is on site · Checked in {time}",
"evv_checked_out_at": "Checked out {time}",
"evv_gps_denied_note": "Location permission was denied — you can still record the visit; it's flagged for review.",
"evv_no_open_check_in": "There's no open check-in to close.",
@@ -587,11 +628,19 @@
"care_locked_body": "The full care record is visible only to your assigned nurse and support.",
"list_title": "My bookings",
"list_subtitle": "Your confirmed engagements.",
"list_empty_title": "No bookings yet",
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
"list_error": "Couldn't load your bookings.",
"list_total": "Total",
"inbox_error": "Couldn't load your requests."
"inbox_error": "Couldn't load your requests.",
"tab_pending": "Awaiting response",
"tab_active": "Active",
"tab_past": "Past",
"pending_empty_title": "No pending requests",
"pending_empty_body": "Requests awaiting the nurse's response or your payment appear here.",
"active_empty_title": "No active bookings",
"active_empty_body": "Your ongoing bookings appear here.",
"past_empty_title": "No past bookings",
"past_empty_body": "Your completed or cancelled bookings appear here.",
"load_more": "Show more"
},
"payment": {
"title_checkout": "Confirm & pay",
@@ -788,6 +837,13 @@
"badge_verified": "Verified",
"badge_unverified": "Not verified",
"badge_expired": "Expired",
"explainer_title": "What Balinyaar verified",
"explainer_open_label": "View verification details",
"explainer_close": "Close",
"explainer_intro": "This nurse has successfully passed these steps:",
"explainer_approved_at": "Verified on {date}",
"explainer_not_verified": "This nurse is not verified yet.",
"explainer_error": "Couldn't load the verification details.",
"publish_ready_title": "Ready to publish",
"publish_ready_body": "Your verification is complete. Your services are visible and bookable.",
"publish_blocked_title": "Complete verification to publish",
@@ -815,6 +871,9 @@
"cancel_title": "Cancel booking",
"step_review": "Review",
"step_confirm": "Confirm",
"offramp_note": "Before cancelling, consider these options — cancelling may cost your nurse a booked slot.",
"reschedule_cta": "Change the time",
"contact_support_cta": "Chat with support",
"policy_free_24h": "Free cancellation",
"policy_partial_under_24h": "Partial refund",
"policy_customer_no_show": "No refund",
@@ -835,6 +894,7 @@
"reason_cancelled": "Cancelled",
"admin_approval_explainer": "Your cancellation request is submitted and the refund is reviewed and processed by our team — you never issue the refund yourself.",
"reason_field_label": "Reason for cancelling",
"reason_placeholder": "Choose a reason",
"reason_cat_changed_mind": "Changed my mind",
"reason_cat_schedule_conflict": "Schedule conflict",
"reason_cat_found_other_care": "Found other care",
@@ -1061,6 +1121,9 @@
"title": "Leave a review",
"subtitle": "Share your experience to help other families.",
"for_nurse": "About {name}",
"moderation_note": "Your review will be published after moderation",
"recap_fallback_service": "Care visit",
"recap_fallback_nurse": "Nurse",
"rating_label": "Your rating",
"body_label": "Comments (optional)",
"body_placeholder": "How was the care? Anything worth sharing…",
+71 -8
View File
@@ -89,7 +89,11 @@
"nudge_profile_title": "تکمیل پروفایل",
"nudge_profile_body": "برای رزرو سریع‌تر، تماس اضطراری را اضافه کنید.",
"nudge_profile_cta": "رفتن به پروفایل",
"patients_error": "بیماران شما بارگذاری نشد."
"patients_error": "بیماران شما بارگذاری نشد.",
"trust_escrow": "پرداخت امن امانی",
"trust_verified_nurses": "پرستاران تاییدشده",
"trust_support": "پشتیبانی",
"rebook_with": "رزرو دوباره با {name}"
},
"onboarding": {
"step_relation": "برای چه کسی",
@@ -366,30 +370,41 @@
"gender_any": "فرقی ندارد",
"gender_hint": "برای مراقبت‌های شخصی و بدنی، بسیاری از خانواده‌ها پرستار هم‌جنس را ترجیح می‌دهند. انتخاب شما به درخواست رزرو منتقل می‌شود.",
"date_hint": "تاریخ موردنظر شما به پرستار اطلاع داده می‌شود و پرستاری را از نتایج حذف نمی‌کند.",
"date_today": "امروز",
"date_tomorrow": "فردا",
"date_pick_other": "انتخاب تاریخ دیگر",
"price_hint": "برای دیدن همهٔ قیمت‌ها خالی بگذارید.",
"price_min": "از",
"price_max": "تا",
"toman": "تومان",
"whole_city": "کل شهر",
"categories_error": "بارگذاری دسته‌ها ممکن نشد.",
"cta_choose_category_city": "یک دسته و شهر انتخاب کنید",
"cta_loading": "در حال شمارش پرستاران…",
"cta_view_results": "مشاهده {count} پرستار",
"cta_zero_title": "پرستاری با این فیلترها یافت نشد",
"cta_zero_hint": "فیلترها را کمی باز کنید تا پرستاران بیشتری ببینید.",
"results_loading_title": "در حال جستجو…",
"results_count": "{count} پرستار",
"sort_label": "مرتب‌سازی",
"sort_rating": "امتیاز",
"sort_static": "مرتب‌شده بر اساس امتیاز",
"results_error": "در بارگذاری نتایج مشکلی پیش آمد.",
"retry": "تلاش دوباره",
"load_more": "نمایش بیشتر",
"empty_title": "پرستاری با فیلترهای شما پیدا نشد",
"empty_suggest_gender": "فیلتر جنسیت را بردارید.",
"empty_suggest_district": "برای جستجوی کل شهر، منطقه را خالی کنید.",
"empty_suggest_city": "شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز را امتحان کنید.",
"empty_suggest_date": "تاریخ دیگری را امتحان کنید.",
"empty_cta": "تغییر فیلترها",
"unnamed_nurse": "پرستار",
"unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق",
"reviews_count": "({count} نظر)",
"distance_km": "{km} کیلومتر",
"price_from": "از",
"price_chip_range": "از {min} تا {max} تومان",
"price_chip_min": "از {min} تومان",
"price_chip_max": "تا {max} تومان",
"latest_review_title": "آخرین نظر",
"profile_not_found_title": "پرستار در دسترس نیست",
"profile_not_found_body": "این پرستار دیگر در دسترس نیست.",
"profile_not_found_cta": "بازگشت به جستجو",
@@ -418,6 +433,7 @@
"summary_when": "زمان",
"request_title": "درخواست رزرو",
"form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمی‌شود؛ ابتدا پرستار درخواست را بررسی می‌کند.",
"whathappens_title": "چه اتفاقی می‌افتد؟",
"missing_nurse_title": "پرستاری انتخاب نشده است",
"missing_nurse_body": "ابتدا از جست‌وجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
"missing_nurse_cta": "یافتن پرستار",
@@ -433,9 +449,18 @@
"address_empty": "هنوز آدرسی ثبت نکرده‌اید.",
"address_add_cta": "افزودن آدرس",
"address_whole_city": "کل شهر",
"address_change_cta": "تغییر",
"date_label": "تاریخ",
"date_today": "امروز",
"date_tomorrow": "فردا",
"date_pick_other": "انتخاب تاریخ دیگر",
"time_start_label": "از ساعت",
"time_end_label": "تا ساعت",
"time_window_label": "بازهٔ زمانی مراجعه",
"window_morning": "صبح ۸–۱۲",
"window_afternoon": "بعدازظهر ۱۲–۱۶",
"window_evening": "عصر ۱۶–۲۰",
"window_custom": "زمان دلخواه",
"gender_label": "جنسیت مراقب",
"gender_hint": "برای مراقبت‌های بدنی، هم‌جنس بودن مراقب اهمیت دارد. انتخاب شما همراه درخواست ارسال می‌شود.",
"notes_label": "توضیحات برای پرستار",
@@ -457,6 +482,13 @@
"error_not_bookable": "این خدمت در حال حاضر قابل رزرو نیست.",
"error_tenancy": "بیمار یا آدرس یافت نشد.",
"error_generic": "ارسال درخواست ناموفق بود. دوباره تلاش کنید.",
"cta_missing_patient": "انتخاب بیمار",
"cta_missing_service": "انتخاب خدمت",
"cta_missing_address": "انتخاب آدرس",
"cta_missing_date": "تاریخ",
"cta_missing_time": "بازهٔ زمانی",
"cta_missing_gender": "جنسیت مراقب",
"cta_missing_caption": "برای ادامه: {fields}",
"awaiting_title": "درخواست برای پرستار ارسال شد",
"awaiting_subtitle": "در انتظار پاسخ پرستار",
"step_submitted": "درخواست ثبت شد",
@@ -464,6 +496,9 @@
"step_payment": "پرداخت و تایید نهایی",
"response_countdown_label": "مهلت پاسخ پرستار",
"response_elapsed": "در انتظار تایید سرور…",
"response_notify_note": "نتیجه را به شما اطلاع می‌دهیم",
"countdown_about_hours": "حدود {hours} ساعت",
"countdown_about_minutes": "حدود {minutes} دقیقه",
"accepted_badge": "پرستار تایید کرد",
"accepted_body": "برای نهایی‌شدن رزرو، در مهلت زیر پرداخت را انجام دهید.",
"payment_countdown_label": "مهلت پرداخت",
@@ -473,7 +508,8 @@
"cancelling": "در حال لغو…",
"cancel_confirm_title": "این درخواست لغو شود؟",
"cancel_confirm_body": "پرستار دیگر آن را نمی‌بیند. هر زمان می‌توانید پرستار دیگری انتخاب کنید.",
"cancel_confirm_yes": "بله، لغو کن",
"cancel_confirm_keep": "نه، نگه دار",
"cancel_confirm_destructive": "بله، انصراف از درخواست",
"rejected_title": "پرستار درخواست را رد کرد",
"rejected_reason_label": "دلیل پرستار",
"expired_title": "پرستار در مهلت مقرر پاسخ نداد",
@@ -482,6 +518,8 @@
"converted_title": "رزرو شما ثبت شد",
"converted_cta": "مشاهده رزرو",
"terminal_rerequest": "انتخاب پرستار دیگر",
"terminal_rerequest_same_nurse": "درخواست دوباره با زمان دیگر",
"terminal_similar_nurses": "پرستاران مشابه",
"not_found_title": "درخواست یافت نشد",
"not_found_body": "ممکن است این درخواست حذف شده باشد.",
"error_title": "خطایی رخ داد",
@@ -519,6 +557,8 @@
"status_cancelled_by_customer": "لغوشده",
"bd_title": "رزرو",
"bd_ref": "رزرو #{id}",
"bd_nurse_label": "پرستار",
"bd_add_to_calendar": "افزودن به تقویم",
"bd_not_found_title": "رزرو یافت نشد",
"bd_not_found_body": "این رزرو وجود ندارد یا متعلق به شما نیست.",
"bd_my_bookings": "رزروهای من",
@@ -563,6 +603,7 @@
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تایید شد (EVV)",
"evv_banner_out_of_range": "ورود ثبت شد {time} · موقعیت خارج از محدوده (در حال بررسی)",
"evv_banner_no_gps": "ورود ثبت شد {time} · موقعیت ثبت نشد",
"evv_presence_headline": "پرستار در محل است · ورود {time}",
"evv_checked_out_at": "خروج ثبت شد {time}",
"evv_gps_denied_note": "دسترسی به موقعیت داده نشد — همچنان می‌توانید ویزیت را ثبت کنید؛ برای بررسی علامت‌گذاری می‌شود.",
"evv_no_open_check_in": "ورود بازی برای ثبت خروج وجود ندارد.",
@@ -587,11 +628,19 @@
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
"list_title": "رزروهای من",
"list_subtitle": "مراقبت‌های تاییدشدهٔ شما.",
"list_empty_title": "هنوز رزروی ندارید",
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده می‌شود.",
"list_error": "بارگذاری رزروها ممکن نشد.",
"list_total": "مبلغ کل",
"inbox_error": "درخواست‌های شما بارگذاری نشد."
"inbox_error": "درخواست‌های شما بارگذاری نشد.",
"tab_pending": "در انتظار پاسخ",
"tab_active": "فعال",
"tab_past": "گذشته",
"pending_empty_title": "درخواست در انتظاری ندارید",
"pending_empty_body": "درخواست‌های در انتظار پاسخ پرستار یا پرداخت شما اینجا نمایش داده می‌شوند.",
"active_empty_title": "رزرو فعالی ندارید",
"active_empty_body": "رزروهای در حال انجام شما اینجا نمایش داده می‌شوند.",
"past_empty_title": "رزرو گذشته‌ای ندارید",
"past_empty_body": "رزروهای تکمیل‌شده یا لغوشدهٔ شما اینجا نمایش داده می‌شوند.",
"load_more": "نمایش بیشتر"
},
"payment": {
"title_checkout": "تایید و پرداخت",
@@ -788,6 +837,13 @@
"badge_verified": "تاییدشده",
"badge_unverified": "احراز نشده",
"badge_expired": "منقضی شده",
"explainer_title": "بالین‌یار چه چیزی را تایید کرده است",
"explainer_open_label": "مشاهدهٔ جزئیات تاییدیه",
"explainer_close": "بستن",
"explainer_intro": "این پرستار این مراحل را با موفقیت گذرانده است:",
"explainer_approved_at": "تاریخ تایید: {date}",
"explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.",
"explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.",
"publish_ready_title": "آمادهٔ انتشار",
"publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.",
"publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید",
@@ -815,6 +871,9 @@
"cancel_title": "لغو رزرو",
"step_review": "بازبینی",
"step_confirm": "تایید",
"offramp_note": "پیش از لغو، این گزینه‌ها را هم در نظر بگیرید — لغو ممکن است زمان پرستار شما را از دست بدهد.",
"reschedule_cta": "تغییر زمان",
"contact_support_cta": "گفتگو با پشتیبانی",
"policy_free_24h": "لغو رایگان",
"policy_partial_under_24h": "بازپرداخت جزئی",
"policy_customer_no_show": "بدون بازپرداخت",
@@ -835,6 +894,7 @@
"reason_cancelled": "لغوشده",
"admin_approval_explainer": "درخواست لغو شما ثبت می‌شود و بازپرداخت توسط تیم بالین‌یار بررسی و انجام می‌شود؛ بازپرداخت را هرگز خودتان انجام نمی‌دهید.",
"reason_field_label": "دلیل لغو",
"reason_placeholder": "دلیل را انتخاب کنید",
"reason_cat_changed_mind": "نظرم عوض شد",
"reason_cat_schedule_conflict": "تداخل زمانی",
"reason_cat_found_other_care": "مراقب دیگری پیدا کردم",
@@ -1061,6 +1121,9 @@
"title": "ثبت نظر",
"subtitle": "تجربه‌تان را با دیگر خانواده‌ها به اشتراک بگذارید.",
"for_nurse": "دربارهٔ {name}",
"moderation_note": "نظر شما پس از بررسی منتشر می‌شود",
"recap_fallback_service": "دریافت خدمت",
"recap_fallback_nurse": "پرستار",
"rating_label": "امتیاز شما",
"body_label": "توضیحات (اختیاری)",
"body_placeholder": "مراقبت چطور بود؟ اگر نکته‌ای هست بنویسید…",
@@ -1,14 +1,25 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
CategoryTile,
EmptyState,
ErrorState,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useBookingDetail, useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
interface NudgeCardProps {
icon: string;
@@ -16,17 +27,20 @@ interface NudgeCardProps {
body: string;
ctaLabel: string;
to: string;
/** Optional dismiss affordance (session-scoped) — omit for the always-relevant profile nudge. */
onDismiss?: () => void;
dismissLabel?: string;
}
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
{ctaLabel}
</AppButton>
</Stack>
{onDismiss ? (
<AppIconButton
icon="close"
title={dismissLabel}
onClick={onDismiss}
size="small"
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
/>
) : null}
</Paper>
);
// Session-scoped dismiss: a plain module variable (not a cookie/localStorage — this is ephemeral UI
// state, not app/auth state) survives client-side navigation within the same page load and resets on a
// hard reload, matching "dismissible for this session, not permanently".
let patientNudgeDismissedInSession = false;
/**
* A5 the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
* query / chosen `service_category_id` toward the f6 search flow results are not built here), the
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
* complete-patient-record nudge (derived from the f2 patient cache no extra fetch).
* A5 the family Home: the front door of the app. Greeting + avatar, a compact ambient trust strip, a
* tappable search entry point (routes to C1 see `HomeSearchBar`), the **data-driven** service-category
* grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
* and a "رزرو دوباره" (rebook) shortcut row sourced from recent bookings.
*
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
* a settled list so a post-create refetch never bounces the user back to onboarding.
@@ -57,6 +85,7 @@ export default function HomeScreen() {
const { data: me } = useMe();
const { data, isError, refetch } = usePatients();
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
const isEmpty = data?.total === 0;
@@ -78,6 +107,16 @@ export default function HomeScreen() {
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
// Completeness signal derived from the cached patients data (no extra fetch): a patient with no
// conditions recorded yet is an incomplete record — never a forever-nudge once every record is filled.
const hasIncompletePatient = data.items.some((patient) => patient.conditions.length === 0);
const showPatientNudge = hasIncompletePatient && !nudgeDismissed;
const dismissPatientNudge = () => {
patientNudgeDismissedInSession = true;
setNudgeDismissed(true);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
@@ -94,17 +133,25 @@ export default function HomeScreen() {
</Box>
</Stack>
<TrustStrip />
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
/>
<RebookRow />
{showPatientNudge ? (
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
onDismiss={dismissPatientNudge}
dismissLabel={tc('close')}
/>
) : null}
{!profileComplete ? (
<NudgeCard
icon="profile"
@@ -119,41 +166,63 @@ export default function HomeScreen() {
}
/**
* The Home search field. Rendering + query capture live here; **execution is f6** submitting
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
* frontend-phase-6-b7.
* Quiet, one-line ambient reassurance under the greeting not a hero. Three icon+label items: escrow
* payment, verified nurses, support. Purely presentational; tokens only.
*/
const TrustStrip: FunctionComponent = () => {
const t = useTranslations('home');
const items: Array<{ icon: string; label: string }> = [
{ icon: 'lock', label: t('trust_escrow') },
{ icon: 'verification', label: t('trust_verified_nurses') },
{ icon: 'support', label: t('trust_support') },
];
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
{items.map((item) => (
<Stack key={item.icon} direction="row" sx={{ gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon={item.icon} size={16} color="var(--bal-primary)" />
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{item.label}
</Typography>
</Stack>
))}
</Stack>
);
};
/**
* The Home search entry point a tappable faux-input (never a half-working free-text field: the search
* index has no text column, variant names aren't client-queryable, and the only matchable dataset 56
* cached category names is already better served by the category grid directly below). Routes straight
* to C1 (`/search`). **Upgrade path**: once the backend serves a `q` param on `search/nurses` (REQ-041,
* matching nurse/variant/category names), this can become a real typeahead the placeholder copy is
* already written for that future, so only the tap target need change, not the copy/i18n keys.
*/
const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
};
return (
<Box component="form" onSubmit={submit} role="search">
<TextField
fullWidth
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_action')}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</InputAdornment>
),
},
}}
/>
</Box>
<ButtonBase
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
aria-label={t('search_action')}
sx={{
justifyContent: 'flex-start',
gap: 1,
width: '100%',
px: 2,
py: 1.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
color: 'text.secondary',
}}
>
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
<Typography variant="body1">{t('search_placeholder')}</Typography>
</ButtonBase>
);
};
@@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
</Stack>
);
};
/**
* The "رزرو دوباره" shortcut row repeat care is the dominant pattern in home nursing. Sourced from the
* existing `useBookingList('customer')` cache (no extra list fetch); renders up to 2 cards, deduplicated
* by nurse, deep-linking to the nurse's C3 profile. Renders nothing (no empty state) when there is no
* past-bookings history.
*/
const RebookRow: FunctionComponent = () => {
const { data, isLoading, isError } = useBookingList('customer', { pageSize: 5 });
const items = data?.items ?? [];
if (isLoading || isError || items.length === 0) return null;
const seen = new Set<string>();
const candidates: BookingListItemDto[] = [];
for (const item of items) {
if (seen.has(item.counterpartyName)) continue;
seen.add(item.counterpartyName);
candidates.push(item);
if (candidates.length === 2) break;
}
if (candidates.length === 0) return null;
return (
<Stack sx={{ gap: 1 }}>
{candidates.map((booking) => (
<RebookCard key={booking.id} booking={booking} />
))}
</Stack>
);
};
/** One rebook card resolves the booking's `nurseId` (not on the list row) via the cached booking
* detail, then deep-links to the nurse's C3 profile. Renders nothing while resolving. */
const RebookCard: FunctionComponent<{ booking: BookingListItemDto }> = ({ booking }) => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const { data: detail } = useBookingDetail(booking.id, 'customer');
if (!detail) return null;
const open = () => router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${detail.nurseId}`);
return (
<SurfaceCard
padding="sm"
onClick={open}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon="history" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{t('rebook_with', { name: booking.counterpartyName })}
</Typography>
</Stack>
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
</SurfaceCard>
);
};
@@ -1,63 +1,212 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
import type { AccentTone, StatusKind } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { bookingReviewPath, ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils';
import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
import { useCustomerRequests } from '@/services/bookingRequests';
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
import { useReviewEligibility } from '@/services/reviews';
type BookingsTab = 'pending' | 'active' | 'past';
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'pending_nurse_response',
'accepted_awaiting_payment',
];
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
neutral: 'neutral',
info: 'info',
pending: 'primary',
verified: 'success',
active: 'success',
rejected: 'error',
};
/**
* Customer رزروها the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
* `converted` state also lands here). Amounts render in Toman via the money util.
* Customer رزروها the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
* `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
* `pageSize` (the C2 results pattern) booking #21+ stays reachable.
*/
export default function BookingsScreen() {
const t = useTranslations('booking');
const { data, isLoading, isError, refetch } = useBookingList('customer');
const items = data?.items ?? [];
const router = useRouter();
const locale = useLocale();
const [tab, setTab] = useState<BookingsTab>('active');
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
const pendingQuery = useCustomerRequests();
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
PENDING_REQUEST_STATUSES.includes(item.status),
);
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
const allBookings = bookingsQuery.data?.items ?? [];
const total = bookingsQuery.data?.total ?? 0;
const hasMore = allBookings.length < total;
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Stack sx={{ gap: 3 }}>
<Stack>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Box>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={120} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} />
))}
</Stack>
)}
</Box>
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
<Tab
value="pending"
data-tab="pending"
label={
pendingItems.length > 0 ? (
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
{t('tab_pending')}
</Badge>
) : (
t('tab_pending')
)
}
/>
<Tab value="active" data-tab="active" label={t('tab_active')} />
<Tab value="past" data-tab="past" label={t('tab_past')} />
</Tabs>
{tab === 'pending' ? (
pendingQuery.isLoading ? (
<ListSkeleton />
) : pendingQuery.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
) : pendingItems.length === 0 ? (
<EmptyState
icon="pending"
title={t('pending_empty_title')}
body={t('pending_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 2 }}>
{pendingItems.map((item) => (
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
))}
</Stack>
)
) : null}
{tab === 'active' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : activeItems.length === 0 ? (
<EmptyState
icon="bookings"
title={t('active_empty_title')}
body={t('active_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
{tab === 'past' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : pastItems.length === 0 ? (
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
) : (
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
</Stack>
);
}
function BookingRow({ item }: { item: BookingListItemDto }) {
function BookingRows({
items,
locale,
onOpen,
hasMore,
onLoadMore,
loadingMore,
loadMoreLabel,
}: {
items: BookingListItemDto[];
locale: string;
onOpen: (id: number) => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
loadMoreLabel: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
))}
{hasMore ? (
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
{loadMoreLabel}
</AppButton>
) : null}
</Stack>
);
}
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const kind = BOOKING_STATUS_KIND[item.status];
const isCompleted = item.status === 'completed' || item.status === 'closed';
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AccentCard
tone={KIND_TO_ACCENT[kind]}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-booking-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
@@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) {
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
<AppButton
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
>
{t('view_booking')}
</AppButton>
</Stack>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
</Stack>
</Paper>
</AccentCard>
);
}
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
* The eligibility read is gated to completed/closed rows only (`enabled`) an active booking never fires
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
const t = useTranslations('reviews');
const router = useRouter();
const locale = useLocale();
const eligibility = useReviewEligibility(bookingId, { enabled });
if (!eligibility.data?.canReview) return null;
return (
<AppButton
variant="text"
color="primary"
size="small"
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
onClick={(event) => {
event.stopPropagation();
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
}}
sx={{ alignSelf: 'flex-start', px: 0 }}
>
{t('cta_leave')}
</AppButton>
);
}
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
function PendingRequestRow({
item,
locale,
onOpen,
}: {
item: BookingRequestListItem;
locale: string;
onOpen: () => void;
}) {
const t = useTranslations('booking');
const accepted = item.status === 'accepted_awaiting_payment';
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const dateLabel = formatShamsiDate(startDate, locale);
const timeLabel = timeFmt.format(startDate);
return (
<AccentCard
tone={accepted ? 'secondary' : 'primary'}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-request-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeLabel}
</Typography>
</Typography>
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
</Stack>
{deadline ? (
<CountdownTimer
deadlineIso={deadline}
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
urgent={accepted}
size="sm"
/>
) : null}
</Stack>
</AccentCard>
);
}
function ListSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={96} />
))}
</Stack>
);
}
@@ -9,6 +9,8 @@ import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
import type { TicketCategory } from '@/services/tickets/types';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
@@ -55,8 +57,10 @@ export default function CancelBookingPage() {
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
// Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen.
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -109,7 +113,8 @@ export default function CancelBookingPage() {
{
bookingId,
sessionIds: preview.refundableSessionIds,
reasonCategory,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
@@ -124,6 +129,33 @@ export default function CancelBookingPage() {
{step === 0 ? (
<>
{/* Off-ramps before the kill switch exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="primary"
startIcon="schedule"
onClick={() => setSupportDialogCategory('coordination')}
>
{t('reschedule_cta')}
</AppButton>
<AppButton
variant="outlined"
color="primary"
startIcon="support"
onClick={() => setSupportDialogCategory('support')}
>
{t('contact_support_cta')}
</AppButton>
</Stack>
</Stack>
<CancellationPolicyDisclosure preview={preview} />
<TextField
@@ -133,6 +165,9 @@ export default function CancelBookingPage() {
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
@@ -159,12 +194,20 @@ export default function CancelBookingPage() {
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged}
disabled={!acknowledged || reasonCategory === ''}
onClick={() => setStep(1)}
>
{t('continue_cta')}
</AppButton>
</Stack>
<ContactSupportDialog
open={supportDialogCategory !== null}
onClose={() => setSupportDialogCategory(null)}
role="customer"
bookingId={bookingId}
defaultCategory={supportDialogCategory ?? 'support'}
/>
</>
) : (
<>
@@ -3,14 +3,25 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import type { BookingDetailDto } from '@/services/bookings/types';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
const REVIEW_BODY_MAX = 2000;
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
@@ -39,8 +50,11 @@ export default function LeaveReviewPage() {
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const eligibility = useReviewEligibility(bookingId);
const myReview = useMyReviewForBooking(bookingId);
// Gated exactly like the booking-detail page's identical call — a review can only ever exist for a
// completed/closed booking, so an in-flight/active booking never fires this query.
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
@@ -69,6 +83,7 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
@@ -131,6 +146,15 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -180,6 +204,31 @@ export default function LeaveReviewPage() {
);
}
/** "What you're reviewing" recap — service, Shamsi visit date, nurse — off the already-cached booking. */
function ReviewContextRecap({ booking, locale }: { booking: BookingDetailDto; locale: string }) {
const t = useTranslations('reviews');
const service = variantName(booking.variantSnapshotJson);
const name = booking.nurseName.trim();
return (
<SurfaceCard padding="sm">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 40, height: 40, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(name || t('recap_fallback_nurse')).charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{service ?? t('recap_fallback_service')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{name || t('recap_fallback_nurse')} · {formatShamsiDate(booking.scheduledDate, locale)}
</Typography>
</Stack>
</Stack>
</SurfaceCard>
);
}
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack sx={{ gap: 0.5 }}>
@@ -2,21 +2,31 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
AppButton,
AppIcon,
BookingRequestSummaryCard,
ConfirmDialog,
CountdownTimer,
StatusChip,
StepperHeader,
} from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
const MINUTES_PER_HOUR = 60;
/** Freeform-text heuristic (the DTO carries no structured rejection-reason code REQ-044): suppress the
* "same nurse, different time" recovery when the nurse's reason reads like a hard gender/coverage block. */
const RETRY_BLOCK_KEYWORDS = ['gender', 'coverage', 'area', 'جنسیت', 'پوشش', 'منطقه', 'محدوده'];
function rejectionAllowsSameNurseRetry(reason: string | null): boolean {
if (!reason) return true;
const lower = reason.toLowerCase();
return !RETRY_BLOCK_KEYWORDS.some((keyword) => lower.includes(keyword));
}
/**
* 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
@@ -46,14 +56,37 @@ export default function BookingRequestStatusPage() {
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
primary={{ label: t('retry'), onClick: () => refetch() }}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
/** Region + gender-carried search "پرستاران مشابه": same city/district + the same caregiver-gender
* intent, recovering the search context rather than restarting discovery from zero. */
const goToSimilarNurses = () => {
const searchParams = new URLSearchParams();
searchParams.set('city_id', String(request.cityId));
if (request.districtId != null) searchParams.set('district_id', String(request.districtId));
if (request.requiredCaregiverGender === 'male' || request.requiredCaregiverGender === 'female') {
searchParams.set('nurse_gender', request.requiredCaregiverGender);
}
router.push(`/${locale}${ROUTES.SEARCH}?${searchParams.toString()}`);
};
/** Reopens C4 for the SAME nurse/variant/patient/address, only the date/time left to re-pick recovers
* the booking intent instead of restarting from search. */
const goToReRequestSameNurse = () => {
const requestParams = new URLSearchParams();
requestParams.set('nurse_id', String(request.nurseId));
requestParams.set('variant_id', String(request.variantId));
if (request.requiredCaregiverGender) requestParams.set('required_gender', request.requiredCaregiverGender);
requestParams.set('patient_id', String(request.patientId));
requestParams.set('address_id', String(request.customerAddressId));
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${requestParams.toString()}`);
};
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
@@ -73,6 +106,7 @@ export default function BookingRequestStatusPage() {
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
const canRetrySameNurse = rejectionAllowsSameNurseRetry(request.nurseRejectionReason);
return (
<Stack sx={{ gap: 3 }}>
{summary}
@@ -81,8 +115,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
primary={
canRetrySameNurse
? { label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }
: { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }
}
secondary={canRetrySameNurse ? { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses } : undefined}
/>
</Stack>
);
@@ -91,7 +129,13 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('expired_title')}
primary={{ label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }}
secondary={{ label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }}
/>
</Stack>
);
}
@@ -99,7 +143,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('payment_expired_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -107,7 +156,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="rejected"
tone="var(--bal-text-secondary)"
title={t('cancelled_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -119,13 +173,14 @@ export default function BookingRequestStatusPage() {
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
primary={{
label: t('converted_cta'),
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onClick: () =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
),
}}
/>
</Stack>
);
@@ -193,12 +248,19 @@ export default function BookingRequestStatusPage() {
</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()}
/>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
windowStart={request.createdAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
onElapsed={() => refetch()}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('response_notify_note')}
</Typography>
</Stack>
</Paper>
)}
@@ -212,33 +274,32 @@ export default function BookingRequestStatusPage() {
{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>
<ConfirmDialog
open={confirmCancel}
title={t('cancel_confirm_title')}
body={t('cancel_confirm_body')}
cancelLabel={t('cancel_confirm_keep')}
confirmLabel={t('cancel_confirm_destructive')}
confirmColor="error"
loading={cancelRequest.isPending}
onClose={() => setConfirmCancel(false)}
onConfirm={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
/>
</Stack>
);
}
/** Humanized minutes-remaining copy above the coarse threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه»). */
function coarseResponseLabel(minutes: number, t: (key: string, values?: Record<string, number>) => string): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
/** "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;
@@ -247,20 +308,25 @@ function customerAddressLabel(request: BookingRequestDto, locale: string, wholeC
return `${request.addressTitle} · ${city} · ${district}`;
}
interface TerminalAction {
label: string;
onClick: () => void;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
primary,
secondary,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
primary: TerminalAction;
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
@@ -273,9 +339,16 @@ function TerminalCard({
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" onClick={primary.onClick}>
{primary.label}
</AppButton>
{secondary ? (
<AppButton variant="outlined" color="primary" onClick={secondary.onClick}>
{secondary.label}
</AppButton>
) : null}
</Stack>
</Paper>
);
}
@@ -3,9 +3,10 @@ import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Avatar,
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
@@ -13,23 +14,48 @@ import {
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import {
AppButton,
AppIcon,
AppLoading,
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
StepperHeader,
TrustBadge,
} from '@/components';
import { todayIso } from '@/components/common/JalaliDatePicker';
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 type { NurseProfile } from '@/services/search/types';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { formatNumber } from '@/utils';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
interface TimeWindowOption {
key: 'morning' | 'afternoon' | 'evening';
start: string;
end: string;
}
const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'morning', start: '08:00', end: '12:00' },
{ key: 'afternoon', start: '12:00', end: '16:00' },
{ key: 'evening', start: '16:00', end: '20:00' },
];
type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender';
/**
* 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
@@ -48,7 +74,6 @@ export default function BookingRequestFormPage() {
function BookingRequestForm() {
const t = useTranslations('booking');
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
@@ -57,6 +82,10 @@ function BookingRequestForm() {
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
// Recovery hand-off from C5's "request again with another time" — reopens this same nurse/variant
// prefilled with the patient + address of the terminal request, extending the C3 handoff params.
const patientIdParam = Number(query.get('patient_id')) || null;
const addressIdParam = Number(query.get('address_id')) || null;
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
@@ -68,20 +97,32 @@ function BookingRequestForm() {
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [patientId, setPatientId] = useState<number | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
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 [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const markTouched = (field: TouchedField) =>
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
// 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.
@@ -112,20 +153,33 @@ function BookingRequestForm() {
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
const district =
selectedAddress.districtId == null
address.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
? address.districtNameEn
: address.districtNameFa;
return `${address.title} · ${city} · ${district}`;
};
const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key);
setTimeStart(option.start);
setTimeEnd(option.end);
if (pastDateError) setPastDateError(false);
};
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (date === '') missingFieldLabels.push(t('cta_missing_date'));
if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
@@ -208,11 +262,13 @@ function BookingRequestForm() {
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
@@ -222,6 +278,13 @@ function BookingRequestForm() {
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
@@ -235,9 +298,10 @@ function BookingRequestForm() {
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth
>
<MenuItem value="" disabled>
@@ -252,41 +316,42 @@ function BookingRequestForm() {
)}
{/* 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}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
) : null}
</Stack>
{/* Address */}
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
@@ -294,90 +359,142 @@ function BookingRequestForm() {
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')}
) : addressEditing || !selectedAddress ? (
<TextField
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
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>
{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}` : ''}
))}
</TextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{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}
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
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);
onChange={(iso) => {
setDate(iso);
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
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
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>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
@@ -393,7 +510,7 @@ function BookingRequestForm() {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
@@ -406,7 +523,7 @@ function BookingRequestForm() {
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
@@ -419,19 +536,21 @@ function BookingRequestForm() {
</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>
<Stack sx={{ gap: 0.5 }}>
<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' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
@@ -439,17 +558,86 @@ function BookingRequestForm() {
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
<Stack sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
);
}
/** The sticky "who you're inviting home" identity summary — avatar, name, rating, trust badge, gender. */
function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
const t = useTranslations('booking');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const ratingLabel = formatNumber(profile.averageRating, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return (
<Stack
direction="row"
data-nurse-identity-bar
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'background.paper',
}}
>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{name}
</Typography>
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} />
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={15} color="var(--bal-rating)" />
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
({formatNumber(profile.totalReviews, locale)})
</Typography>
</Stack>
<Chip
size="small"
variant="outlined"
label={t(`gender_${profile.nurseGender}`)}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
</Stack>
</Stack>
);
}
@@ -512,7 +700,9 @@ function FieldEmpty({
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="rounded" height={76} />
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={48} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
@@ -2,32 +2,32 @@
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, InputAdornment, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
Box,
InputAdornment,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
AppButton,
AppLoading,
CategoryTile,
ErrorState,
GenderToggle,
JalaliDateIntentPicker,
StickyActionBar,
} from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams';
import type { NurseGender } from '@/services/search/types';
import { useSearchFilters } from './useSearchFilters';
/**
* C1 Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP it is carried to booking,
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
* the **prominent same-gender facet** (the shared `GenderToggle`, `allowAny`), a Jalali date-intent chip
* strip, and an optional Toman price range; a live result count drives the sticky "مشاهده N پرستار" CTA
* into C2. Availability (date) is intent-only at MVP it is carried to booking, never used to hard-filter
* results. `useSearchParams` needs a Suspense boundary under static rendering.
*/
export default function SearchScreen() {
return (
@@ -37,32 +37,24 @@ export default function SearchScreen() {
);
}
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams();
const initialCategoryRaw = Number(params.get('category_id'));
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
const controller = useSearchFilters(initialCategoryId);
const controller = useSearchFilters(params);
const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total;
const goToResults = () => {
const query = filtersToSearchParams(controller.filters);
if (controller.region.provinceId) query.set('province_id', String(controller.region.provinceId));
if (controller.dateIntent) query.set('date', controller.dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
};
const ctaLabel = !controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count });
const zeroResults = controller.isReady && !isFetching && count === 0;
return (
<Stack sx={{ gap: 3 }}>
@@ -82,31 +74,19 @@ function SearchFilterScreen() {
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup
exclusive
fullWidth
color="primary"
<GenderToggle
allowAny
value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => {
if (value != null) controller.setGender(value === 'any' ? undefined : value);
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
onChange={(value) => controller.setGender(value === 'any' ? undefined : value)}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
anyLabel={t('gender_any')}
ariaLabel={t('section_gender')}
/>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<DateIntentFilter value={controller.dateIntent} onChange={controller.setDateIntent} />
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
@@ -126,17 +106,35 @@ function SearchFilterScreen() {
</Stack>
</FilterSection>
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5 }}
>
{ctaLabel}
</AppButton>
<StickyActionBar>
{zeroResults ? (
<Stack sx={{ gap: 0.25 }} data-search-cta="zero">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('cta_zero_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('cta_zero_hint')}
</Typography>
</Stack>
) : (
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5, width: '100%' }}
data-search-cta="view-results"
>
{!controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count })}
</AppButton>
)}
</StickyActionBar>
</Stack>
);
}
@@ -177,6 +175,29 @@ const PriceField: FunctionComponent<{
/>
);
/**
* The Jalali date-intent picker: a horizontal «امروز»/«فردا» + day-chip strip (the next 7 days) plus a
* calendar-icon entry into the full Jalali grid for later dates. Intent-only the value stays the same
* ISO string the flow already carries and never hard-filters results.
*/
const DateIntentFilter: FunctionComponent<{ value: string; onChange: (iso: string) => void }> = ({
value,
onChange,
}) => {
const t = useTranslations('search');
return (
<JalaliDateIntentPicker
value={value}
onChange={onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
);
};
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId,
@@ -3,22 +3,40 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
import {
AppButton,
AppIcon,
EmptyState,
ErrorState,
PriceDisplay,
RatingInput,
ServicePriceRow,
StickyActionBar,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatNumber, formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import type { NurseProfile, NurseProfileServiceRow } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types';
import { useNurseTrustBadge } from '@/services/verification';
type ProfileTab = 'services' | 'reviews';
/**
* C3 Nurse profile (پروفایل پرستار): identity + trust badges ( تاییدشده, نظام پرستاری), attribute
* chips, and a **tabbed** body «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
* C3 Nurse profile (پروفایل پرستار): the trust dossier identity header (completed visits + rating),
* a tappable تاییدشده badge + «نظام پرستاری» chip, the shared `VerificationPanel` (what Balinyaar
* verified, fed by the public trust-badge read), attribute chips, and a **tabbed** body «خدمات» (the
* priced services list + an optional latest-review snippet) and «نظرات» (the f13 published-reviews tab:
* fractional aggregate rating + count + an infinite list). Only `published` reviews are ever
* requested/rendered. The primary "درخواست رزرو" CTA is a **sticky bottom bar** (price-from beside the
* button) so it survives the infinite reviews list, and hands the selected nurse + variant +
* `required_caregiver_gender` to f7. The profile DTO does not yet serve `nurseGender` (REQ-042) the
* header intentionally omits a gender chip rather than render the client's placeholder stub.
*/
export default function NurseProfilePage() {
const t = useTranslations('search');
@@ -54,8 +72,11 @@ export default function NurseProfilePage() {
if (!profile) return null;
const carriedVariant = query.get('variant_id');
const primaryService: NurseProfileServiceRow | undefined =
profile.services.find((service) => String(service.variantId) === carriedVariant) ?? profile.services[0];
const requestBooking = () => {
const carriedVariant = query.get('variant_id');
const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId));
@@ -76,6 +97,7 @@ export default function NurseProfilePage() {
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<VerificationSection nurseId={profile.nurseId} />
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
@@ -84,16 +106,28 @@ export default function NurseProfilePage() {
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5 }}
>
{t('request_booking')}
</AppButton>
<StickyActionBar>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between' }}>
{primaryService ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('price_from')}
</Typography>
<PriceDisplay price={primaryService.priceIrr} priceUnit={primaryService.priceUnit} align="start" />
</Box>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5, flexGrow: 1 }}
>
{t('request_booking')}
</AppButton>
</Stack>
</StickyActionBar>
</Stack>
);
}
@@ -121,7 +155,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
@@ -129,11 +163,14 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -152,6 +189,23 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
);
}
/** "What Balinyaar verified" — the shared `VerificationPanel`, fed by the public trust-badge read. */
function VerificationSection({ nurseId }: { nurseId: number }) {
const t = useTranslations('verification');
const { data: badge, isLoading, isError } = useNurseTrustBadge(nurseId);
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</SurfaceCard>
</Stack>
);
}
function AttributeChips({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
@@ -177,7 +231,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 2 }}>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
@@ -195,10 +249,34 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
))}
</Box>
)}
{profile.latestReview ? <LatestReviewSnippet review={profile.latestReview} /> : null}
</Stack>
);
}
/** The already-fetched latest-review snippet — a small taste of the dossier's reviews tab. */
function LatestReviewSnippet({ review }: { review: NonNullable<NurseProfile['latestReview']> }) {
const t = useTranslations('search');
const tr = useTranslations('reviews');
const locale = useLocale();
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.75 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('latest_review_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={tr('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
</Stack>
</SurfaceCard>
);
}
/**
* The f13 reviews tab the aggregate rating + count and an infinite list of **published** reviews. Never
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
@@ -2,20 +2,28 @@
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
import { Chip, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
import { useNurseSearch } from '@/services/search';
import { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
import type { NurseSearchResult } from '@/services/search/types';
/**
* C2 Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the
* carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key),
* so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` +
* `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry
* / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
* `keepPreviousData`). A tappable **filter-recap chip row** (category · region · gender · price) deep-
* links back to C1 carrying the *entire* current query string every filter C1 set, including the
* client-only `province_id`/`date` params so C1 hydrates fully instead of resetting to just the
* category. Renders all four states (loading skeletons / empty "relax filters" / error-retry /
* populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
*/
export default function SearchResultsPage() {
return (
@@ -37,12 +45,48 @@ function ResultsScreen() {
// The URL is the source of truth for the filter set; grow only the page size for "load more".
const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]);
const dateIntent = params.get('date') ?? undefined;
const provinceIdParam = params.get('province_id');
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const hasMore = items.length < total;
const { data: categoriesData } = useServiceCategories();
const categories = useMemo(() => categoriesData?.items ?? [], [categoriesData]);
const categoryLabelById = useMemo(() => {
const map = new Map<number, string>();
categories.forEach((category) => map.set(category.id, pickCatalogName(category, locale)));
return map;
}, [categories, locale]);
const categoryLabel = categoryLabelById.get(filters.serviceCategoryId);
const { data: cities } = useCities(provinceIdParam ? Number(provinceIdParam) : undefined);
const { data: districts } = useDistricts(filters.cityId || undefined);
const city = cities?.find((candidate) => candidate.id === filters.cityId);
const district = filters.districtId ? districts?.find((candidate) => candidate.id === filters.districtId) : undefined;
const regionLabel = city
? `${pickRegionName(city, locale)} · ${district ? pickRegionName(district, locale) : t('whole_city')}`
: undefined;
const genderLabel = t(`gender_${filters.nurseGender ?? 'any'}`);
const priceLabel = filters.priceMin
? filters.priceMax
? t('price_chip_range', {
min: formatIrrToToman(filters.priceMin, locale),
max: formatIrrToToman(filters.priceMax, locale),
})
: t('price_chip_min', { min: formatIrrToToman(filters.priceMin, locale) })
: filters.priceMax
? t('price_chip_max', { max: formatIrrToToman(filters.priceMax, locale) })
: undefined;
const backToFilters = useCallback(
() => router.push(`/${locale}${ROUTES.SEARCH}?${params.toString()}`),
[router, locale, params],
);
const openProfile = useCallback(
(nurse: NurseSearchResult) => {
const query = new URLSearchParams();
@@ -56,18 +100,26 @@ function ResultsScreen() {
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
);
const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */}
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}>
<MenuItem value="rating">{t('sort_rating')}</MenuItem>
</TextField>
{/* Rating is the only MVP sort a static caption, not a dead-interactive dropdown. Other
sorts are DEFERRED until the API grows them. */}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('sort_static')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{categoryLabel ? (
<Chip label={categoryLabel} onClick={backToFilters} data-recap-chip="category" />
) : null}
{regionLabel ? <Chip label={regionLabel} onClick={backToFilters} data-recap-chip="region" /> : null}
<Chip label={genderLabel} onClick={backToFilters} data-recap-chip="gender" />
{priceLabel ? <Chip label={priceLabel} onClick={backToFilters} data-recap-chip="price" /> : null}
</Stack>
{isLoading ? (
@@ -83,7 +135,12 @@ function ResultsScreen() {
) : (
<Stack sx={{ gap: 1.5 }}>
{items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
<NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
nurse={nurse}
serviceLabel={categoryLabelById.get(nurse.serviceCategoryId) ?? t('unnamed_service')}
onSelect={openProfile}
/>
))}
{hasMore ? (
<AppButton
@@ -118,7 +175,7 @@ function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')}
{t('empty_suggest_date')}
</Typography>
</Stack>
}
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils';
import { rialToToman } from '@/utils/money';
import { useDebouncedValue } from '@/services/search';
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { parsePositiveInt, searchParamsToFilters } from '@/services/search/filterParams';
import type { NurseGender, NurseSearchFilters } from '@/services/search/types';
import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
interface ParamReader {
get(name: string): string | null;
}
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
function tomanInputToIrr(toman: string): string | undefined {
@@ -14,20 +19,41 @@ function tomanInputToIrr(toman: string): string | undefined {
return tomanToRial(digits);
}
/** IRR digit-string (or undefined) → the whole-Toman string the price fields display. */
function irrToTomanInput(irr: string | undefined): string {
return irr ? String(rialToToman(irr)) : '';
}
/**
* The C1 filter controller fast-changing UI state kept **colocated** (not in a high context provider,
* phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and
* derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The
* price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value
* joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id.
*
* `params` seeds the **initial** state only (a lazy `useState` read) either a bare `?category_id=`
* (the Home tile handoff) or a full filter set carried back from a C2 recap chip (`searchParamsToFilters`
* reads every field C2's URL carries). `province_id` is a client-only convenience param (not part of
* `NurseSearchFilters`/the search query key) so `CascadingRegionSelect` can prefill the city dropdown
* without a server round trip; `goToResults` re-carries it so the round trip back to C1 keeps working.
*/
export function useSearchFilters(initialCategoryId?: number) {
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId ?? null);
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [gender, setGender] = useState<NurseGender | undefined>(undefined);
const [priceMinToman, setPriceMinToman] = useState('');
const [priceMaxToman, setPriceMaxToman] = useState('');
const [dateIntent, setDateIntent] = useState('');
export function useSearchFilters(params: ParamReader) {
const [categoryId, setCategoryId] = useState<number | null>(() => {
const raw = searchParamsToFilters(params).serviceCategoryId;
return raw > 0 ? raw : null;
});
const [region, setRegion] = useState<CascadingRegionValue>(() => {
const initial = searchParamsToFilters(params);
return {
provinceId: parsePositiveInt(params.get('province_id')) ?? null,
cityId: initial.cityId > 0 ? initial.cityId : null,
districtId: initial.districtId ?? null,
};
});
const [gender, setGender] = useState<NurseGender | undefined>(() => searchParamsToFilters(params).nurseGender);
const [priceMinToman, setPriceMinToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMin));
const [priceMaxToman, setPriceMaxToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMax));
const [dateIntent, setDateIntent] = useState(() => params.get('date') ?? '');
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
@@ -55,4 +55,11 @@ describe('<BookingRequestSummaryCard/> component', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
it('bidi-isolates the time range in a dir="ltr" span (matches SessionCard\'s convention)', () => {
const { container } = renderCard();
const ltrSpan = container.querySelector('span[dir="ltr"]');
expect(ltrSpan).toBeInTheDocument();
expect(ltrSpan?.textContent).toMatch(//);
});
});
@@ -58,7 +58,8 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const whenDateLabel = formatShamsiDate(startDate, locale);
const whenTimeRangeLabel = `${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
@@ -121,7 +122,10 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'end' }}>
{whenLabel}
{whenDateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{whenTimeRangeLabel}
</Typography>
</Typography>
</SummaryRow>
</Stack>
@@ -40,3 +40,39 @@ describe('<GenderToggle/> component', () => {
expect(onChange).not.toHaveBeenCalled();
});
});
describe('<GenderToggle/> allowAny mode', () => {
function renderAnyToggle(value: 'male' | 'female' | 'any' | null) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<GenderToggle
allowAny
value={value}
onChange={onChange}
maleLabel="Male"
femaleLabel="Female"
anyLabel="Any"
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
it('renders the third "any" option', () => {
renderAnyToggle(null);
expect(screen.getByText('Any')).toBeInTheDocument();
});
it('calls onChange with "any" when picked', async () => {
const user = userEvent.setup();
const { onChange } = renderAnyToggle(null);
await user.click(screen.getByText('Any'));
expect(onChange).toHaveBeenCalledWith('any');
});
it('does not render the "any" option when allowAny is omitted', () => {
renderToggle(null);
expect(screen.queryByText('Any')).not.toBeInTheDocument();
});
});
@@ -4,11 +4,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import type { Gender } from '@/services/patients/types';
export interface GenderToggleProps {
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
interface GenderToggleBaseProps {
maleLabel: string;
femaleLabel: string;
/** Marks the group invalid (e.g. submitted without a choice). */
@@ -17,45 +13,75 @@ export interface GenderToggleProps {
ariaLabel?: string;
}
/** The default, booking-context shape — required male/female only, gender never defaulted. */
export interface GenderToggleRequiredProps extends GenderToggleBaseProps {
allowAny?: false;
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
}
/** The opt-in search-context shape — adds a third "فرقی ندارد" (any) option. */
export interface GenderToggleAnyProps extends GenderToggleBaseProps {
allowAny: true;
/** Label for the "فرقی ندارد" / any-gender option (required when `allowAny`). */
anyLabel: string;
value: Gender | 'any' | null;
onChange: (value: Gender | 'any') => void;
}
export type GenderToggleProps = GenderToggleRequiredProps | GenderToggleAnyProps;
/**
* Required male/female toggle. Gender is **load-bearing** for same-gender caregiver matching
* (search/booking), so it is never defaulted and cannot be deselected back to empty via the UI.
* Labels are translated by the caller (labels are i18n keys off the code).
* Labels are translated by the caller (labels are i18n keys off the code). The opt-in `allowAny`
* mode (search's C1 facet) adds a third "فرقی ندارد" option **without** loosening the default
* booking-context contract omitting `allowAny` keeps the exact required male/female behaviour.
* @component GenderToggle
*/
const GenderToggle: FunctionComponent<GenderToggleProps> = ({
value,
onChange,
maleLabel,
femaleLabel,
error = false,
disabled = false,
ariaLabel,
}) => (
<ToggleButtonGroup
exclusive
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | null) => {
if (next) onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
</ToggleButtonGroup>
);
const GenderToggle: FunctionComponent<GenderToggleProps> = (props) => {
const { maleLabel, femaleLabel, error = false, disabled = false, ariaLabel } = props;
const allowAny = props.allowAny === true;
const handleChange = (next: Gender | 'any' | null) => {
if (!next) return;
if (next === 'any' && !allowAny) return;
// The two prop shapes are a discriminated union on `allowAny`; the runtime guard above already
// enforces the invariant TS can't see through a plain union call, so this cast is safe.
(props.onChange as (value: Gender | 'any') => void)(next);
};
return (
<ToggleButtonGroup
exclusive
value={props.value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | 'any' | null) => handleChange(next)}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
{allowAny ? (
<ToggleButton value="any" data-gender="any">
{(props as GenderToggleAnyProps).anyLabel}
</ToggleButton>
) : null}
</ToggleButtonGroup>
);
};
export default GenderToggle;
@@ -2,12 +2,23 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseSearchResult } from '@/services/search/types';
// next-intl echoes keys; locale = en so the rating/price format with ASCII digits we can assert on.
// next-intl echoes keys so we can assert on them; locale = en so numbers format with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${Object.values(values).join(',')}` : key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
// NurseResultCard renders <TrustBadge nurseId=…>, which calls useNurseTrustBadge — mock it so the
// card doesn't need a real QueryClientProvider in this test.
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: () => ({ data: undefined, isLoading: false, isError: false }),
}));
import NurseResultCard from './NurseResultCard';
const NURSE: NurseSearchResult = {
@@ -26,24 +37,36 @@ const NURSE: NurseSearchResult = {
nurseGender: 'female',
cityId: 101,
districtId: 1003,
topReviewTag: null,
};
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn()) {
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn(), serviceLabel = 'Elderly Care') {
render(
<ThemeProvider>
<NurseResultCard nurse={nurse} onSelect={onSelect} />
<NurseResultCard nurse={nurse} serviceLabel={serviceLabel} onSelect={onSelect} />
</ThemeProvider>,
);
return onSelect;
}
describe('<NurseResultCard/> component', () => {
it('renders the name, the reused verified badge, and the rating', () => {
it('renders the name, service label, the reused verified badge, and the rating', () => {
renderCard(NURSE);
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Elderly Care')).toBeInTheDocument();
expect(screen.getByText('badge_verified')).toBeInTheDocument();
expect(screen.getByText('4.9')).toBeInTheDocument();
expect(screen.getByText('reviews_count')).toBeInTheDocument();
expect(screen.getByText(/reviews_count/)).toBeInTheDocument();
});
it('renders the nurse gender chip and the completed-visits count', () => {
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(container.querySelector('[data-nurse-gender="female"]')).toBeInTheDocument();
expect(screen.getByText(/completed_visits/)).toBeInTheDocument();
});
it('renders the "from" price line as grouped Toman via the money util', () => {
@@ -56,17 +79,37 @@ describe('<NurseResultCard/> component', () => {
it('shows the distance chip only when distanceKm is present', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} onSelect={jest.fn()} />
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.getByText('distance_km')).toBeInTheDocument();
expect(screen.getByText(/distance_km/)).toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} onSelect={jest.fn()} />
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText('distance_km')).not.toBeInTheDocument();
expect(screen.queryByText(/distance_km/)).not.toBeInTheDocument();
});
it('renders the optional top-review tag only when served', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText(/منظم و دقیق/)).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard
nurse={{ ...NURSE, topReviewTag: 'منظم و دقیق' }}
serviceLabel="Elderly Care"
onSelect={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
});
it('falls back to a label when the name is missing (b7 join gap)', () => {
@@ -75,8 +118,13 @@ describe('<NurseResultCard/> component', () => {
});
it('calls onSelect with the nurse row when clicked', () => {
const onSelect = renderCard(NURSE);
fireEvent.click(screen.getByRole('button'));
const onSelect = jest.fn();
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={onSelect} />
</ThemeProvider>,
);
fireEvent.click(container.querySelector('[data-nurse-result-card]') as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(NURSE);
});
@@ -1,6 +1,6 @@
import { FunctionComponent, memo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import TrustBadge from '../TrustBadge';
import PriceDisplay from '../PriceDisplay';
@@ -10,6 +10,14 @@ import type { NurseSearchResult } from '@/services/search/types';
export interface NurseResultCardProps {
/** One search-result row (a bookable variant in a covered area). */
nurse: NurseSearchResult;
/**
* The service/variant label for this row the row **is** a variant, so the card must name what is
* being bought. `NurseSearchResultDto` has no display name yet (REQ-040); until it lands, the page
* passes the row's **category** name (from the cached catalog reference data) so multi-variant nurses
* are at least distinguishable by price row. The card stays data-agnostic swap the caller's label
* source to the served `variantDisplayName` once the REQ lands, no card change needed.
*/
serviceLabel: string;
/** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */
onSelect: (nurse: NurseSearchResult) => void;
}
@@ -19,14 +27,16 @@ function ratingText(rating: number, locale: string): string {
}
/**
* The C2 result card: avatar, name, the reused تاییدشده verified badge, rating + review count, an
* optional distance chip (only when `distanceKm` is present), and the "from X تومان/ساعت" rate (via the
* shared `PriceDisplay` money util). Presentational + memoized so a list of N cards doesn't re-render on
* unrelated state pass a stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the
* search-index invariant, so the badge is always shown.
* The C2 result card the four-second decision unit. Avatar, name, the reused tappable تاییدشده
* verified badge (opens the verification explainer), the service/variant label, a quiet gender chip
* (same-gender matching is load-bearing), completed-visits count, rating + review count, an optional
* distance chip, an optional one-line top-review tag (only when served), and the "from X تومان/ساعت"
* rate. Presentational + memoized so a list of N cards doesn't re-render on unrelated state pass a
* stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the search-index invariant,
* so the badge is always shown.
* @component NurseResultCard
*/
const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps) => {
const t = useTranslations('search');
const locale = useLocale();
@@ -38,6 +48,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
return (
<Paper
elevation={0}
data-nurse-result-card
onClick={() => onSelect(nurse)}
role="button"
tabIndex={0}
@@ -68,12 +79,29 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
{initial}
</Avatar>
<Stack sx={{ gap: 0.75, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.5, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={nurse.nurseId} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
<Chip
size="small"
variant="outlined"
label={t(`gender_${nurse.nurseGender}`)}
data-nurse-gender={nurse.nurseGender}
sx={{ height: 22, fontSize: '0.75rem' }}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(nurse.totalCompletedBookings, locale) })}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
@@ -97,6 +125,12 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
) : null}
</Stack>
{nurse.topReviewTag ? (
<Typography variant="caption" sx={{ color: 'var(--bal-trust)', fontWeight: 500 }}>
«{nurse.topReviewTag}»
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('price_from')}
@@ -108,14 +142,15 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
);
};
/** Matches the real card's anatomy (avatar disc, name/badge row, rating row, price row) so a loading list
* doesn't jump when data lands. */
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />
<Skeleton variant="text" width="40%" height={20} />
<Skeleton variant="text" width="35%" height={20} />
<Skeleton variant="text" width="45%" height={18} />
<Skeleton variant="text" width="30%" height={20} />
</Stack>
</Paper>
@@ -1,23 +1,39 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys so we assert on the label key each state maps to.
// next-intl echoes keys so we assert on the label key each state maps to. `VerificationPanel`
// (rendered inside the explainer dialog) also needs `t.has` + `useLocale`.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string) => key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
const useNurseTrustBadgeMock = jest.fn();
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: (nurseId: number | undefined) => useNurseTrustBadgeMock(nurseId),
}));
import TrustBadge from './TrustBadge';
import type { BadgeState } from '@/services/verification/types';
function renderBadge(state: BadgeState) {
function renderBadge(state: BadgeState, nurseId?: number) {
return render(
<ThemeProvider>
<TrustBadge state={state} />
<TrustBadge state={state} nurseId={nurseId} />
</ThemeProvider>,
);
}
describe('<TrustBadge/> component', () => {
beforeEach(() => {
useNurseTrustBadgeMock.mockReturnValue({ data: undefined, isLoading: false, isError: false });
});
it('renders the verified label + a data attribute for the verified state', () => {
const { container } = renderBadge('verified');
expect(screen.getByText('badge_verified')).toBeInTheDocument();
@@ -35,4 +51,31 @@ describe('<TrustBadge/> component', () => {
expect(screen.getByText('badge_expired')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
});
it('is not clickable by default (no nurseId), and calls no query hook at all', () => {
const { container } = renderBadge('verified');
expect(container.querySelector('[data-trust-explainer]')).not.toBeInTheDocument();
// The default (non-interactive) mode never mounts the query — no QueryClientProvider needed.
expect(useNurseTrustBadgeMock).not.toHaveBeenCalled();
});
it('opens the explainer dialog on tap when a nurseId is provided, fetching the badge lazily', async () => {
useNurseTrustBadgeMock.mockReturnValue({
data: { nurseId: 7, isVerified: true, approvedAt: '2026-01-01T00:00:00Z', credentialTypes: ['ino_membership'] },
isLoading: false,
isError: false,
});
const user = userEvent.setup();
renderBadge('verified', 7);
// Disabled (undefined) until opened.
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(undefined);
await user.click(screen.getByText('badge_verified'));
expect(screen.getByText('explainer_title')).toBeInTheDocument();
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(7);
await user.click(screen.getByLabelText('explainer_close'));
// MUI's Dialog exit transition removes the node asynchronously.
await waitFor(() => expect(screen.queryByText('explainer_title')).not.toBeInTheDocument());
});
});
+90 -11
View File
@@ -1,7 +1,15 @@
import { FunctionComponent } from 'react';
'use client';
import { FunctionComponent, ReactElement, useState } from 'react';
import { useTranslations } from 'next-intl';
import Chip, { ChipProps } from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import AppIcon from '../common/AppIcon';
import VerificationPanel from '../VerificationPanel';
import { useNurseTrustBadge } from '@/services/verification';
import type { BadgeState } from '@/services/verification/types';
interface BadgeStyle {
@@ -20,11 +28,22 @@ const BADGE_STYLE: Record<BadgeState, BadgeStyle> = {
expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' },
};
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label' | 'onClick'> {
/** The trust state — verified / unverified / expired. */
state: BadgeState;
/**
* Opt-in explainer: when provided, the badge becomes tappable and opens a bottom-sheet (mobile) /
* dialog (desktop) narrating what Balinyaar verified the same `VerificationPanel` fed by
* `useNurseTrustBadge(nurseId)` (fetched lazily, only once the explainer is actually opened). The
* default (non-interactive) badge everywhere else is unchanged and calls no query hook at all the
* `useNurseTrustBadge` call lives entirely inside `InteractiveTrustBadge`, mounted only in this mode,
* so a caller that never passes `nurseId` needs no `QueryClientProvider` in its tests.
*/
nurseId?: number;
}
type ChipVisualProps = Pick<ChipProps, 'size' | 'sx'> & { 'data-badge-state': BadgeState };
/**
* The public trust signal (the "✓ تاییدشده" mark) rendered on a nurse's profile and reused unchanged
* in f6 on search results and the public nurse profile. Fed by `GetVerifiedBadgeQuery`; the state is
@@ -32,18 +51,78 @@ export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'lab
* renders when the aggregate is approved; `expired` is visually distinct from never-verified.
* @component TrustBadge
*/
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, size = 'small', sx, ...rest }) => {
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, nurseId, size = 'small', sx, ...rest }) => {
const t = useTranslations('verification');
const style = BADGE_STYLE[state];
const label = t(style.labelKey);
const icon: ReactElement = <AppIcon icon={style.icon} size={16} color={style.fg} />;
const chipSx = { backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx };
const chipProps: ChipVisualProps = { size, sx: chipSx, 'data-badge-state': state };
if (nurseId == null) {
return <Chip label={label} icon={icon} {...chipProps} {...rest} />;
}
return (
<Chip
data-badge-state={state}
size={size}
label={t(style.labelKey)}
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }}
{...rest}
/>
<InteractiveTrustBadge nurseId={nurseId} label={label} icon={icon} chipProps={chipProps} rest={rest} />
);
};
/** Owns the tap-to-open state, the lazily-enabled `useNurseTrustBadge` fetch, and the explainer dialog
* split out so `TrustBadge` itself never calls a query hook in the default (non-interactive) mode. */
const InteractiveTrustBadge: FunctionComponent<{
nurseId: number;
label: string;
icon: ReactElement;
chipProps: ChipVisualProps;
rest: Omit<ChipProps, 'color' | 'icon' | 'label' | 'onClick' | 'size' | 'sx'>;
}> = ({ nurseId, label, icon, chipProps, rest }) => {
const t = useTranslations('verification');
const [open, setOpen] = useState(false);
// Lazy: only fetches once the explainer is opened (undefined disables the query otherwise).
const { data: badge, isLoading, isError } = useNurseTrustBadge(open ? nurseId : undefined);
return (
<>
<Chip
label={label}
icon={icon}
clickable
onClick={() => setOpen(true)}
aria-label={t('explainer_open_label')}
{...chipProps}
{...rest}
/>
<Dialog
open={open}
onClose={() => setOpen(false)}
fullWidth
data-trust-explainer
sx={{ '& .MuiDialog-container': { alignItems: { xs: 'flex-end', sm: 'center' } } }}
slotProps={{
paper: {
sx: {
width: { xs: '100%', sm: 480 },
m: { xs: 0, sm: 'auto' },
borderRadius: { xs: 'var(--bal-radius-lg) var(--bal-radius-lg) 0 0', sm: 'var(--bal-radius-lg)' },
},
},
}}
>
<DialogTitle sx={{ p: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5 }}>
{t('explainer_title')}
<IconButton onClick={() => setOpen(false)} aria-label={t('explainer_close')} size="small">
<AppIcon icon="close" size={20} />
</IconButton>
</Stack>
</DialogTitle>
<DialogContent sx={{ pb: 3 }}>
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => {
const t = (key: string, values?: Record<string, string>) =>
values ? `${key}:${Object.values(values).join(',')}` : key;
t.has = (key: string) => key.startsWith('step_moh') || key.startsWith('step_ino') || key.startsWith('step_criminal');
return t;
},
useLocale: () => 'en',
}));
import VerificationPanel from './VerificationPanel';
import type { TrustBadge } from '@/services/verification/types';
const VERIFIED_BADGE: TrustBadge = {
nurseId: 1,
isVerified: true,
approvedAt: '2026-01-05T00:00:00Z',
credentialTypes: ['moh_competency_license', 'ino_membership'],
};
describe('<VerificationPanel/> component', () => {
it('renders a loading skeleton', () => {
const { container } = render(
<ThemeProvider>
<VerificationPanel badge={undefined} isLoading />
</ThemeProvider>,
);
expect(container.querySelector('[data-verification-panel="loading"]')).toBeInTheDocument();
});
it('renders an error state', () => {
render(
<ThemeProvider>
<VerificationPanel badge={undefined} isError />
</ThemeProvider>,
);
expect(screen.getByText('explainer_error')).toBeInTheDocument();
});
it('renders the not-verified message when the badge is unverified', () => {
render(
<ThemeProvider>
<VerificationPanel badge={{ nurseId: 1, isVerified: false, approvedAt: null, credentialTypes: [] }} />
</ThemeProvider>,
);
expect(screen.getByText('explainer_not_verified')).toBeInTheDocument();
});
it('renders one row per credential type + the approval date, never inventing steps', () => {
const { container } = render(
<ThemeProvider>
<VerificationPanel badge={VERIFIED_BADGE} />
</ThemeProvider>,
);
expect(container.querySelector('[data-verification-row="moh_competency_license"]')).toBeInTheDocument();
expect(container.querySelector('[data-verification-row="ino_membership"]')).toBeInTheDocument();
expect(container.querySelectorAll('[data-verification-row]').length).toBe(2);
expect(screen.getByText(/explainer_approved_at/)).toBeInTheDocument();
});
it('falls back to the raw code for an unmapped credential type', () => {
render(
<ThemeProvider>
<VerificationPanel badge={{ ...VERIFIED_BADGE, credentialTypes: ['some_future_code'] }} />
</ThemeProvider>,
);
expect(screen.getByText('some_future_code')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '../common/AppIcon';
import { formatShamsiDate } from '@/utils';
import type { TrustBadge as TrustBadgeDto } from '@/services/verification/types';
export interface VerificationPanelProps {
/** The public trust-badge payload (`nurses/{id}/trust_badge`); `undefined` while loading/absent. */
badge: TrustBadgeDto | undefined;
isLoading?: boolean;
isError?: boolean;
}
/**
* "What Balinyaar verified" a check-listed explainer fed by the public trust-badge read: one row
* per `credentialTypes[]` entry (stable codes mapped to i18n labels off the `verification` namespace's
* step vocabulary never a raw wire value) plus the approval date. Renders only what is served: no
* invented steps, no fake dates. Shared by the C3 profile and the `TrustBadge` tap-to-explain
* sheet/dialog; phase 8's public-profile preview reuses it unchanged.
* @component VerificationPanel
*/
const VerificationPanel: FunctionComponent<VerificationPanelProps> = ({
badge,
isLoading = false,
isError = false,
}) => {
const t = useTranslations('verification');
const locale = useLocale();
if (isLoading) {
return (
<Stack data-verification-panel="loading" sx={{ gap: 1.5 }}>
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="90%" />
<Skeleton variant="text" width="80%" />
</Stack>
);
}
if (isError || !badge) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }} data-verification-panel="error">
{t('explainer_error')}
</Typography>
);
}
if (!badge.isVerified) {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }} data-verification-panel="unverified">
{t('explainer_not_verified')}
</Typography>
);
}
return (
<Stack data-verification-panel="verified" sx={{ gap: 1.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('explainer_intro')}
</Typography>
<Stack sx={{ gap: 1 }}>
{badge.credentialTypes.map((code) => (
<Stack key={code} direction="row" data-verification-row={code} sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-trust)" />
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{t.has(`step_${code}`) ? t(`step_${code}`) : code}
</Typography>
</Stack>
))}
</Stack>
{badge.approvedAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('explainer_approved_at', { date: formatShamsiDate(badge.approvedAt, locale) })}
</Typography>
) : null}
</Stack>
);
};
export default VerificationPanel;
@@ -0,0 +1,4 @@
import VerificationPanel from './VerificationPanel';
export default VerificationPanel;
export type { VerificationPanelProps } from './VerificationPanel';
@@ -1,14 +1,17 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AccentCard from '@/components/common/AccentCard';
import AppButton from '@/components/common/AppButton';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate } from '@/utils';
import SurfaceCard from '@/components/common/SurfaceCard';
import { formatRelativeTime, formatShamsiDate, localeTag } from '@/utils';
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
import {
isBookingConfirmedOrBeyond,
type BookingDetailDto,
type BookingSessionDto,
type BookingViewerRole,
} from '@/services/bookings/types';
import BookingStatusTimeline from '../BookingStatusTimeline';
@@ -16,6 +19,7 @@ import SessionList from '../SessionList';
import BookingMoneySummary from '../BookingMoneySummary';
import CareInstructionsCard from '../CareInstructionsCard';
import { useEvvController } from '../useEvvController';
import { buildSessionIcs, downloadIcsFile } from '../ics';
export interface BookingDetailViewProps {
bookingId: number;
@@ -33,6 +37,38 @@ function variantName(snapshotJson: string): string | null {
}
}
/**
* Best-effort read of the frozen address snapshot. The wire has no stable schema for this blob
* (confirmed against the seed fixtures field names drift between `city`/`cityName`/`cityNameFa` and
* `line`/`addressLine` across bookings) REQ-045 proposes a typed shape; until then this tries every
* candidate key and joins whatever resolves. `null` for the nurse view (masked server-side) or a
* genuinely empty snapshot.
*/
function addressSnapshotLabel(snapshotJson: string | null, locale: string): string | null {
if (!snapshotJson) return null;
try {
const parsed = JSON.parse(snapshotJson) as Record<string, string | undefined>;
const city = locale === 'en' ? (parsed.cityNameEn ?? parsed.cityName ?? parsed.city) : (parsed.cityNameFa ?? parsed.cityName ?? parsed.city);
const district = locale === 'en' ? (parsed.districtNameEn ?? parsed.district) : (parsed.districtNameFa ?? parsed.district);
const parts = [parsed.title, city, district, parsed.addressLine ?? parsed.line].filter(
(part): part is string => Boolean(part && part.trim()),
);
return parts.length > 0 ? parts.join(' · ') : null;
} catch {
return null;
}
}
/** The earliest not-yet-finished session — the "next visit" the hero headlines. */
function upcomingSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined {
return sessions.find((session) => session.status === 'scheduled' || session.status === 'in_progress');
}
/** The session currently checked in (on-site) — drives the EVV presence headline. */
function checkedInSession(sessions: BookingSessionDto[]): BookingSessionDto | undefined {
return sessions.find((session) => session.evvStatus === 'checked_in');
}
/**
* The both-roles booking detail the hinge screen. Fetches `useBookingDetail`, renders the server-truth
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
@@ -47,6 +83,7 @@ function variantName(snapshotJson: string): string | null {
*/
const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingId, viewerRole }) => {
const t = useTranslations('booking');
const locale = useLocale();
const isNurse = viewerRole === 'nurse';
const { data: booking, isLoading, isError } = useBookingDetail(bookingId, viewerRole);
@@ -58,10 +95,36 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
if (isError || !booking) return <NotFoundCard title={t('bd_not_found_title')} body={t('bd_not_found_body')} />;
const service = variantName(booking.variantSnapshotJson);
const addressLabel = addressSnapshotLabel(booking.addressSnapshotJson, locale);
const nextSession = upcomingSession(booking.sessions);
const onSiteCheckInAt = checkedInSession(booking.sessions)?.checkInAt ?? null;
const clockFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const nextSessionDayLabel = nextSession
? formatRelativeTime(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`, locale, formatShamsiDate)
: null;
const nextSessionTimeLabel = nextSession
? clockFmt.format(new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`))
: null;
const addToCalendar = () => {
if (!nextSession) return;
const start = new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeStart}`);
const end = new Date(`${nextSession.scheduledDate}T${nextSession.scheduledTimeEnd}`);
const ics = buildSessionIcs({
uid: `balinyaar-booking-${booking.id}-session-${nextSession.id}@balinyaar`,
title: `${service ?? t('bd_title')}${t('session_index', { n: nextSession.sessionIndex })}`,
location: addressLabel ?? undefined,
start,
end,
});
downloadIcsFile(`balinyaar-booking-${booking.id}-visit-${nextSession.sessionIndex}.ics`, ics);
};
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }} data-viewer-role={viewerRole}>
{/* Header — carries the terracotta accent + "نمای پرستار" chip on the nurse view. */}
{/* Hero next-visit headline, address, nurse identity, add-to-calendar; terracotta accent + "نمای
پرستار" chip on the nurse view. */}
<Paper
elevation={0}
sx={{
@@ -72,11 +135,25 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
}}
>
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="h6" component="h1">
{service ?? t('bd_title')}
</Typography>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{nextSession ? (
<>
{t('session_index', { n: nextSession.sessionIndex })} · {nextSessionDayLabel}{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{nextSessionTimeLabel}
</Typography>
</>
) : (
(service ?? t('bd_title'))
)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('bd_ref', { id: booking.id })}
</Typography>
</Stack>
{isNurse ? (
<Chip
size="small"
@@ -85,19 +162,54 @@ const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingI
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('bd_ref', { id: booking.id })}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap', mt: 0.5 }}>
{onSiteCheckInAt ? (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1, borderRadius: 2, bgcolor: 'var(--bal-success-soft)' }}
>
<AppIcon icon="location" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ fontWeight: 700, color: 'var(--bal-success)' }}>
{t('evv_presence_headline', { time: clockFmt.format(new Date(onSiteCheckInAt)) })}
</Typography>
</Stack>
) : null}
{addressLabel ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="location" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{addressLabel}
</Typography>
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Avatar sx={{ width: 36, height: 36, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(booking.nurseName.trim() || t('unnamed_nurse')).charAt(0)}
</Avatar>
<HeaderFact label={t('bd_nurse_label')} value={booking.nurseName} />
<HeaderFact label={t('summary_patient')} value={booking.patientName} />
<HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />
</Stack>
{nextSession ? (
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="calendar"
onClick={addToCalendar}
sx={{ alignSelf: 'flex-start' }}
>
{t('bd_add_to_calendar')}
</AppButton>
) : null}
</Stack>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<SurfaceCard>
<BookingStatusTimeline status={booking.status} />
</Paper>
</SurfaceCard>
<StatusNote booking={booking} />
@@ -1,8 +1,8 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, Money } from '@/components/common';
import { Stack, Typography } from '@mui/material';
import { AppButton, Money, SurfaceCard } from '@/components/common';
import StatusChip from '@/components/StatusChip';
import { formatShamsiDate } from '@/utils';
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
@@ -76,11 +76,7 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
const busy = acquiringLocation || evvPending;
return (
<Paper
elevation={0}
data-session-status={status}
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<SurfaceCard padding="sm" data-session-status={status}>
<Stack sx={{ gap: 1.25 }}>
{title ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
@@ -139,7 +135,7 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
</AppButton>
) : null}
</Stack>
</Paper>
</SurfaceCard>
);
};
+55
View File
@@ -0,0 +1,55 @@
/**
* A minimal client-side `.ics` (iCalendar) generator for the booking-detail "add to calendar"
* affordance no backend seam, the file is built entirely in the browser from already-served session
* data. Times are emitted in **Gregorian UTC** (the iCalendar spec's wire format); the UI around this
* stays Shamsi this is purely the download artifact.
*/
function toIcsUtc(date: Date): string {
return `${date.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`;
}
function escapeIcsText(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
}
export interface IcsEventInput {
uid: string;
title: string;
description?: string;
location?: string;
start: Date;
end: Date;
}
/** Builds a single-event `VCALENDAR` document as a CRLF-joined string, per RFC 5545. */
export function buildSessionIcs({ uid, title, description, location, start, end }: IcsEventInput): string {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Balinyaar//Booking//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${toIcsUtc(new Date())}`,
`DTSTART:${toIcsUtc(start)}`,
`DTEND:${toIcsUtc(end)}`,
`SUMMARY:${escapeIcsText(title)}`,
];
if (description) lines.push(`DESCRIPTION:${escapeIcsText(description)}`);
if (location) lines.push(`LOCATION:${escapeIcsText(location)}`);
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.join('\r\n');
}
/** Triggers a browser download of the `.ics` content — call only from a client event handler. */
export function downloadIcsFile(filename: string, content: string): void {
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
jest.mock('next-intl', () => ({
useLocale: () => 'en',
}));
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import { todayIso } from '../JalaliDatePicker/calendarEngine';
const LABELS = { todayLabel: 'Today', tomorrowLabel: 'Tomorrow', pickOtherLabel: 'Pick another date' };
function wrap(ui: React.ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('<JalaliDateIntentPicker/> component', () => {
it('renders the near-date chip strip and emits the ISO date on click', () => {
const onChange = jest.fn();
const { container } = wrap(<JalaliDateIntentPicker value="" onChange={onChange} min={todayIso()} {...LABELS} />);
const chips = container.querySelectorAll('[data-day]');
expect(chips.length).toBeGreaterThan(0);
fireEvent.click(chips[0]);
expect(onChange).toHaveBeenCalledWith(todayIso());
});
it('opens a full-grid popover from the calendar-icon button', () => {
wrap(<JalaliDateIntentPicker value="" onChange={jest.fn()} min={todayIso()} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
expect(screen.getByText(/\d{4}/)).toBeInTheDocument();
});
it('closes the popover and emits the picked date when a grid day is clicked', () => {
const onChange = jest.fn();
wrap(<JalaliDateIntentPicker value={todayIso()} onChange={onChange} {...LABELS} />);
fireEvent.click(screen.getByLabelText('Pick another date'));
const gridDay = screen.getAllByText('15')[0];
fireEvent.click(gridDay);
expect(onChange).toHaveBeenCalled();
});
});
@@ -0,0 +1,83 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover';
import Stack from '@mui/material/Stack';
import AppIconButton from '../AppIconButton';
import JalaliDatePicker from '../JalaliDatePicker';
export interface JalaliDateIntentPickerProps {
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `''` for no selection. */
value: string;
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
onChange: (iso: string) => void;
/** Inclusive ISO lower bound (typically `todayIso()`). */
min?: string;
/** Number of day-chips in the near strip (default 7). */
chipDayCount?: number;
/** Already-translated relative labels for the first two chips when they land on today/tomorrow. */
todayLabel: string;
tomorrowLabel: string;
/** Already-translated accessible label for the "pick another date" calendar-icon entry. */
pickOtherLabel: string;
}
/**
* A horizontal near-date chip strip (`JalaliDatePicker` `chips` variant) plus a calendar-icon entry into
* the full Jalali grid (a `Popover`) for dates beyond the strip the shape both C1 search (date-intent,
* never hard-filters) and C4 booking-request (a real required field) need. Presentational, caller-owned
* copy, per the `components/common` convention.
* @component JalaliDateIntentPicker
*/
const JalaliDateIntentPicker: FunctionComponent<JalaliDateIntentPickerProps> = ({
value,
onChange,
min,
chipDayCount = 7,
todayLabel,
tomorrowLabel,
pickOtherLabel,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<JalaliDatePicker
variant="chips"
chipDayCount={chipDayCount}
value={value || null}
onChange={onChange}
min={min}
todayLabel={todayLabel}
tomorrowLabel={tomorrowLabel}
/>
</Box>
<AppIconButton
icon="calendar"
title={pickOtherLabel}
onClick={(event) => setAnchorEl(event.currentTarget)}
/>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Box sx={{ p: 2, width: 320 }}>
<JalaliDatePicker
value={value || null}
min={min}
onChange={(iso) => {
onChange(iso);
setAnchorEl(null);
}}
/>
</Box>
</Popover>
</Stack>
);
};
export default JalaliDateIntentPicker;
@@ -0,0 +1,4 @@
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
export default JalaliDateIntentPicker;
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
@@ -26,6 +26,10 @@ export interface JalaliDatePickerProps {
/** Already-translated accessible label for the previous/next-month buttons. */
prevMonthLabel?: string;
nextMonthLabel?: string;
/** `chips` variant only: overrides the weekday label of the first chip with "today"/"tomorrow" copy
* when that chip's date genuinely is today/tomorrow (never forced when a `min` pushes the strip later). */
todayLabel?: string;
tomorrowLabel?: string;
}
const GRID_COLUMNS = 7;
@@ -47,6 +51,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
chipDayCount = 14,
prevMonthLabel,
nextMonthLabel,
todayLabel,
tomorrowLabel,
}) => {
const locale = useLocale();
const engine = useMemo(() => engineForLocale(locale), [locale]);
@@ -77,6 +83,9 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format(
new Date(`${dayIso}T00:00:00`),
);
const relativeLabel =
dayIso === todayIso() ? todayLabel : dayIso === addDaysIso(todayIso(), 1) ? tomorrowLabel : undefined;
const topLabel = relativeLabel ?? weekday;
return (
<Box
key={dayIso}
@@ -104,8 +113,8 @@ const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
opacity: disabled ? 0.5 : 1,
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{weekday}
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: relativeLabel ? 700 : 400 }}>
{topLabel}
</Typography>
<Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}>
{formatNumber(cell.day, locale)}
@@ -0,0 +1,19 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import StickyActionBar from './StickyActionBar';
describe('<StickyActionBar/> component', () => {
it('renders its children inside a sticky-bottom container', () => {
const { container } = render(
<ThemeProvider>
<StickyActionBar>
<button type="button">مشاهده ۱۲ پرستار</button>
</StickyActionBar>
</ThemeProvider>,
);
expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument();
const bar = container.querySelector('[data-sticky-action-bar]');
expect(bar).toBeInTheDocument();
expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' });
});
});
@@ -0,0 +1,35 @@
import { FunctionComponent, ReactNode } from 'react';
import Box from '@mui/material/Box';
export interface StickyActionBarProps {
children: ReactNode;
}
/**
* A bottom-pinned action bar for a scrolling screen the C1 live-count CTA and the C3 booking CTA.
* Rendered as the last child of a page's scrolling content, `position: sticky` pins it to the bottom
* of the nearest scrolling ancestor (the shell's `main`), so on mobile it naturally sits directly above
* `BottomBar` (a separate flex sibling below `main`, which already owns the `env(safe-area-inset-bottom)`
* padding this component does not re-implement it) and on desktop it sits at the viewport bottom.
* @component StickyActionBar
*/
const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children }) => (
<Box
data-sticky-action-bar
sx={{
position: 'sticky',
bottom: 0,
zIndex: 1,
mt: 2,
pt: 1.5,
pb: 1.5,
bgcolor: 'background.default',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{children}
</Box>
);
export default StickyActionBar;
@@ -0,0 +1,4 @@
import StickyActionBar from './StickyActionBar';
export default StickyActionBar;
export type { StickyActionBarProps } from './StickyActionBar';
+6
View File
@@ -16,7 +16,9 @@ import Money from './Money';
import StatusTimeline from './StatusTimeline';
import JalaliDatePicker from './JalaliDatePicker';
import JalaliDateField from './JalaliDateField';
import JalaliDateIntentPicker from './JalaliDateIntentPicker';
import LocaleSwitcher from './LocaleSwitcher';
import StickyActionBar from './StickyActionBar';
export {
ErrorBoundary,
@@ -37,7 +39,9 @@ export {
StatusTimeline,
JalaliDatePicker,
JalaliDateField,
JalaliDateIntentPicker,
LocaleSwitcher,
StickyActionBar,
};
export type { EmptyStateProps } from './EmptyState';
export type { ErrorStateProps } from './ErrorState';
@@ -50,3 +54,5 @@ export type { MoneyProps } from './Money';
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
export type { JalaliDatePickerProps } from './JalaliDatePicker';
export type { JalaliDateFieldProps } from './JalaliDateField';
export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker';
export type { StickyActionBarProps } from './StickyActionBar';
+3
View File
@@ -33,6 +33,7 @@ import RatingInput from './RatingInput';
import ReviewTagSelector from './ReviewTagSelector';
import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader';
import VerificationPanel from './VerificationPanel';
export {
ProfileSummary,
@@ -68,6 +69,7 @@ export {
ReviewTagSelector,
VisitNoteCard,
PatientHeader,
VerificationPanel,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -100,3 +102,4 @@ export type { RatingInputProps } from './RatingInput';
export type { ReviewTagSelectorProps } from './ReviewTagSelector';
export type { VisitNoteCardProps } from './VisitNoteCard';
export type { PatientHeaderProps } from './PatientHeader';
export type { VerificationPanelProps } from './VerificationPanel';
@@ -32,6 +32,8 @@ interface NurseSearchResultDto {
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
/** REQ-040 (proposed) — not yet served; absent until the backend lands it. */
topReviewTag?: string | null;
}
/** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
@@ -106,6 +108,7 @@ export const searchClientApi: SearchApi = {
nurseGender: dto.nurseGender,
cityId: dto.cityId,
districtId: dto.districtId,
topReviewTag: dto.topReviewTag ?? null,
})),
};
},
+3 -1
View File
@@ -16,7 +16,9 @@ interface ParamReader {
const GENDERS: readonly NurseGender[] = ['male', 'female'];
function parsePositiveInt(raw: string | null): number | undefined {
/** Exported so callers that carry client-only params alongside the filter set (e.g. `province_id`,
* a UI-only cascading-select prefill hint not part of `NurseSearchFilters`) can reuse the same parse. */
export function parsePositiveInt(raw: string | null): number | undefined {
if (raw == null) return undefined;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : undefined;
+3
View File
@@ -74,6 +74,9 @@ export interface NurseSearchResult {
cityId: number;
/** `null` = the nurse covers the whole city. */
districtId: number | null;
/** One-line top review tag (e.g. «منظم و دقیق») optional, not yet served (REQ-040); `null`/absent
* hides the card's review-tag line. */
topReviewTag?: string | null;
}
/** One offered variant on the C3 profile — the bookable unit; reused by the ServicePriceRow. */
@@ -602,6 +602,61 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
- **Status:** open
## REQ-040 — Index-row enrichment: `variantDisplayName` + optional `topReviewTag` — filed by ui-phase-4 — 2026-07-18
- **Need:** Two additions to `NurseSearchResultDto` (`GET search/nurses`):
1. `variantDisplayName` (required, string) — the variant's display name, so the C2 result card can
name the exact service being bought (the row **is** a variant).
2. `topReviewTag` (optional, string | null) — a one-line top review tag (e.g. «منظم و دقیق») from the
review-tag vocabulary, for an optional one-line trust cue on the card.
- **Why:** `NurseResultCard` v2 (ui-phase-4) is the four-second decision unit. Today the index row carries
no variant label, so a nurse offering three variants renders as three near-identical cards differing
only by price; the card falls back to the row's **category** name (passed in by the page from the cached
catalog reference data) until this lands — a genuine wire gap, not a display choice. The client type
(`services/search/types.ts` `NurseSearchResult.topReviewTag`) and `NurseResultCard` already render the
tag conditionally so this is a zero-code swap once served.
- **Proposed shape:** `NurseSearchResultDto { …, variantDisplayName: string, topReviewTag?: string | null }`.
- **Status:** open
## REQ-041 — Free-text search (`q`) over nurse/variant/category names — filed by ui-phase-4 — 2026-07-18
- **Need:** A `q` query param on `GET search/nurses` matching against nurse display name, variant display
name, and category name (case-insensitive substring or better).
- **Why:** The Home search bar was a dead affordance — it pushed `?q=` to C1, which silently discarded it
(C1 reads only `category_id`). ui-phase-4 replaced the free-text field with a tappable entry point straight
to C1 (decision: the index has no text column, variant names aren't client-queryable, and the only
matchable dataset client-side — 56 cached category names — is already better served by the category grid
directly below; a half-working text field over-promises exactly where trust matters). This REQ is the
upgrade path noted in the component's JSDoc: once `q` is served, the Home search bar can become a real
typeahead without changing its i18n keys.
- **Proposed shape:** `GET search/nurses?...&q=<string>` — matches folded into the existing filter set
(AND'd with category/city/gender/price), same `Paginated<NurseSearchResultDto>` response shape.
- **Status:** open
## REQ-042 — `nurseGender` on `NursePublicProfileDto` — filed by ui-phase-4 — 2026-07-18
- **Need:** Add `nurseGender` (`'male'|'female'`) to the `GET nurses/{id}/profile` response.
- **Why:** C3's header would show a gender chip alongside years-of-experience/rating (same-gender
matching is load-bearing throughout the product), but the public profile DTO doesn't serve it — the
client's `nurseGender: 'female'` is a **placeholder stub** (explicitly commented "unused by the C3 page").
ui-phase-4 keeps the C3 header gender chip **omitted** rather than render the stub as truth; the C2 card
and the carried `required_gender` query param already cover the matching flow honestly in the meantime.
- **Proposed shape:** `NursePublicProfileDto { …, nurseGender: 'male' | 'female' }`.
- **Status:** open
## REQ-043 — Public per-step verification detail (step codes + decision dates) — filed by ui-phase-4 — 2026-07-18
- **Need:** Extend the public `GET nurses/{id}/trust_badge` read (or a sibling public endpoint) with
per-step passed-check detail: step `code`, `status`, and `decidedAt`/`passedAt` date — not just the
aggregate `credentialTypes[]` + `approvedAt` it serves today.
- **Why:** The new shared `VerificationPanel` component (ui-phase-4, `src/components/VerificationPanel/`)
is the "what Balinyaar verified" explainer — reused by the C3 profile section, the tappable `TrustBadge`
bottom-sheet/dialog, and (per the phase's cross-reference) phase 8's public-profile preview. Today it can
only render one row per `credentialTypes[]` entry (identity/license/INO/background-check are folded into
three generic credential-type codes) plus the single aggregate `approvedAt` — it cannot list identity
check, Shahkar match, and license individually with their own dates. Until this lands the panel stays
honest (renders only what's served: the credential-type rows + the one approval date — no invented steps
or fake dates).
- **Proposed shape:** `TrustBadgeDto { …, steps?: [{ code: string, status: 'passed', decidedAt: string }] }`
(only `passed` steps are ever publicly listed — never a pending/failed step's detail).
- **Status:** open
## REQ-039 — WebOTP-conformant OTP SMS template — filed by ui-phase-3 — 2026-07-17
- **Need:** the OTP SMS body must end with the origin-bound last line `@<domain> #<code>` (the
[WebOTP](https://web.dev/articles/web-otp) / origin-bound one-time-code convention), e.g.:
@@ -620,3 +675,38 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** append `\n@<domain> #<code>` as the SMS's last line, where `<domain>` is the site's own
host (no scheme, no trailing slash) and `<code>` is the exact code the user must enter.
- **Status:** open — client-side WebOTP wiring ships regardless and degrades gracefully until this lands.
## REQ-044 — Structured `nurseRejectionReasonCode` on `BookingRequestDto` — filed by ui-phase-5 — 2026-07-18
- **Need:** A stable, enumerated `nurseRejectionReasonCode` (e.g. `gender_preference` | `outside_coverage` |
`schedule_conflict` | `other`) alongside the existing freeform `nurseRejectionReason` text on
`BookingRequestDto`.
- **Why:** C5's terminal-state recovery (phase §3.2) offers "request again with the same nurse, a different
time" only when the rejection reason **isn't** a hard gender/coverage block — otherwise only "similar
nurses" is offered, since re-requesting the same nurse would just fail again. `nurseRejectionReason` is
nurse-authored free text with no code, so the client can only approximate this with a keyword heuristic
over the (possibly translated/paraphrased) string — implemented in
`bookings/request/[id]/page.tsx`'s `rejectionAllowsSameNurseRetry()`, matching `gender`/`coverage`/`area`
and their Persian equivalents. A real code would replace the heuristic with an exact check.
- **Proposed shape:** `BookingRequestDto { …, nurseRejectionReasonCode: 'gender_preference' |
'outside_coverage' | 'schedule_conflict' | 'other' | null }`, required whenever `nurseRejectionReason` is
set. `reject_dialog`'s reason field would become a category select (+ optional free-text) instead of a
single free-text field.
- **Status:** open — the client ships the keyword heuristic in the meantime (documented as a known
approximation in the phase report), never blocking the recovery UI.
## REQ-045 — Typed `addressSnapshot`/`variantSnapshot` on `BookingDetailDto` — filed by ui-phase-5 — 2026-07-18
- **Need:** Replace (or supplement) `addressSnapshotJson`/`variantSnapshotJson` (opaque JSON strings) with
typed fields: `addressSnapshot: { title, cityNameFa, cityNameEn, districtNameFa, districtNameEn,
addressLine, postalCode } | null` and `variantSnapshot: { displayName, priceUnit }`.
- **Why:** The booking-detail hero (phase §3.4) needed the frozen visit address for its "next visit" card,
but the seed fixtures show the snapshot blob has **no stable schema** — field names drift across bookings
(`city`/`cityName`/`cityNameFa`, `line`/`addressLine`; see `services/bookings/apis/mockApi.ts`'s `addr5001`
vs the booking-5005 fixture). The client (`BookingDetailView.tsx`'s `addressSnapshotLabel()`) currently
tries every candidate key and joins whatever resolves — a best-effort parse, not a contract. The nurse view
already masks this field to `null` server-side (two-stage disclosure); only the shape within the customer's
non-null value is the gap.
- **Proposed shape:** as above — the same shape `BookingRequestDisplayContext`'s address fields already use
elsewhere in the frontend, so the mapping is a straight carry-over from whatever snapshotting logic
produces the JSON blob today.
- **Status:** open — the client keeps the defensive multi-key parse until this lands; no user-facing defect
today (the address still renders when any recognizable key is present), just an unenforced contract.
@@ -0,0 +1,152 @@
# UI Phase 4 — Customer Storefront — Report (2026-07-18)
## What was built
**Home (A5)** — `HomeScreen.tsx`:
- The dead free-text search bar (`?q=` silently discarded by C1) replaced with a tappable faux-input
(`ButtonBase`) that routes straight to C1. Decision: the search index has no text column, variant names
aren't client-queryable, and the only matchable dataset client-side (56 cached category names) is
already better served by the category grid directly below — a half-working text field over-promises
where trust matters most. The upgrade path is documented in the component's JSDoc and filed as REQ-041.
- A compact one-line `TrustStrip` (escrow · verified nurses · support) under the greeting.
- The patient-record `NudgeCard` is now gated on a derived completeness signal (a patient with no
recorded conditions) instead of rendering forever, and is dismissible for the session (a module-scoped
flag — survives client-side navigation, resets on a hard reload; deliberately not a cookie/localStorage
write, since it's ephemeral UI state, not app/auth state).
- A `RebookRow` — up to 2 "رزرو دوباره با …" cards sourced from `useBookingList('customer')`, deduplicated
by nurse, each resolving its `nurseId` via a per-card `useBookingDetail` call (the list row doesn't carry
`nurseId`) and deep-linking to the nurse's C3 profile. Renders nothing when there's no booking history.
- The pre-existing `isError` retry branch on `usePatients()` was found **already correct** in the current
code (the audit's finding predates it) — verified, not re-fixed.
**Search (C1)** — `SearchScreen.tsx` + `useSearchFilters.ts`:
- The native `<input type="date">` replaced by a Jalali day-chip strip (`JalaliDatePicker` `chips` variant,
extended with optional `todayLabel`/`tomorrowLabel` props for «امروز»/«فردا») plus a calendar-icon entry
into the full Jalali grid (a `Popover`) for later dates — intent-only semantics unchanged.
- The inline `ToggleButtonGroup` gender facet replaced by the shared `GenderToggle`, extended with an
opt-in `allowAny` mode (a discriminated-union prop shape — the default booking-context contract is
byte-for-byte unchanged for every other caller).
- The live-count CTA is now a `StickyActionBar` (new shared primitive) — never a tappable "مشاهده ۰
پرستار": at zero results it shows a non-CTA message with a relaxation hint instead of the button.
- `useSearchFilters` now hydrates **every** field from the initial URL (`searchParamsToFilters`), not just
`category_id` — a full filter set carried back from a C2 recap chip rehydrates C1 completely. A
client-only `province_id` query param (not part of `NurseSearchFilters`/the search cache key) carries the
province id so `CascadingRegionSelect`'s city dropdown can prefill without a server round trip.
**Results (C2)** — `results/page.tsx`:
- A tappable filter-recap chip row (category · region · gender · price) under the count — every chip
performs the same navigation: back to C1 with the **entire current query string** (C2's URL is already a
superset of everything C1 set, including `province_id`/`date`), so hydration is exact.
- The dead single-option sort `<TextField select>` replaced with a static "مرتب‌شده بر اساس امتیاز" caption.
- Skeleton twins (`NurseResultCard.Skeleton`) were already wired in the current code; updated to match the
v2 card anatomy.
**`NurseResultCard` v2** (this phase owns it) — rebuilt as the four-second decision unit:
- Added: a service/variant label (falls back to the category name via a page-supplied `serviceLabel` prop
until REQ-040's `variantDisplayName` lands), a quiet nurse-gender chip, the completed-visits count
(«N ویزیت موفق», already-served data that was fetched and never rendered), an optional one-line top-review
tag (`topReviewTag`, REQ-040), and a tappable `TrustBadge` (now passes `nurseId`).
- `NurseResultCardSkeleton` updated to the new anatomy (an extra meta-row).
**Nurse profile (C3)** — `nurse/[nurseId]/page.tsx`:
- Header now shows the completed-visits count (served, previously unrendered). No gender chip — the
public profile DTO doesn't serve `nurseGender` (the client's `'female'` stub is explicitly a
placeholder); filed as REQ-042 rather than rendering it.
- New `VerificationSection` renders the shared `VerificationPanel`, fed by `useNurseTrustBadge(nurseId)`.
- `TrustBadge` now passes `nurseId`, making the badge tappable everywhere it appears (C2 cards + C3).
- The primary CTA is now a `StickyActionBar` (price-from beside "درخواست رزرو") — survives the infinite
reviews list.
- An optional latest-review snippet renders on the services tab when `profile.latestReview` is present.
- The reviews tab's fractional `RatingInput` (no `Math.round`) was found **already correct** in the current
code — verified, not re-fixed (phase 1's RatingInput v2 already replaced the rounding).
**New shared components** (both co-located-tested, both reused by phase 8 per the phase's cross-reference):
- `VerificationPanel` (`src/components/VerificationPanel/`) — "what Balinyaar verified": one row per
`TrustBadge.credentialTypes[]` (i18n off the `verification` namespace's `step_*` codes, never a raw wire
value) + the approval date. Renders only what is served — no invented steps, no fake dates.
- `TrustBadge`'s new opt-in `nurseId` prop — tappable, opens a bottom-sheet (mobile)/dialog (desktop)
rendering `VerificationPanel`, fed by a **lazily-enabled** `useNurseTrustBadge` fetch (only queries once
the explainer is actually opened). Implemented as an inner `InteractiveTrustBadge` subcomponent so the
**default** (no `nurseId`) badge — used everywhere else in the app (`ProfileSummary`, etc.) — calls no
query hook at all and needs no `QueryClientProvider` in its callers' tests.
- `StickyActionBar` (`src/components/common/StickyActionBar/`) — the bottom-pinned action-bar shell shared
by C1's live-count CTA and C3's booking CTA. Composes with the shell's existing `BottomBar` safe-area
handling (a flex sibling below the scrolling `main`) rather than reimplementing
`env(safe-area-inset-bottom)`.
**Copy**: `search.empty_suggest_city` («شهر نزدیک دیگری مانند مشهد، اصفهان یا شیراز») deleted from both
`en.json`/`fa.json`, replaced with `empty_suggest_date` ("try a different date" — an honest relaxation the
family can actually act on). Reused in the C1 zero-count sticky-bar hint too.
## What is now testable (and exactly how)
1. **Home**: log in as the seeded customer (`0912000000x`). Tap the search field on Home → lands on
`/search` with no `?q=` in the URL. Stop the API and reload → an error card with «تلاش مجدد», not an
eternal spinner. Restart → home recovers; the trust strip shows under the greeting; a customer with a
completed booking sees a «رزرو دوباره با …» card that opens the nurse's profile.
2. **C1**: `/search` — the date filter shows «امروز»/«فردا» + Shamsi day chips (no native browser
calendar anywhere); tapping the calendar icon opens the full Jalali grid in a popover. Pick a category +
city → the count CTA is pinned at the bottom while scrolling the page. Pick filters matching nothing
(e.g. an unusually high min price) → the sticky bar shows the non-CTA "no matches" message, not a
tappable button.
3. **C2**: run a search → recap chips show category/region/gender/price; tap any chip → C1 opens with
every filter pre-filled (category, region incl. province prefill, gender, price, date); browser-back
returns to identical results with zero network (cache hit, unchanged from before this phase). The
header reads «مرتب‌شده بر اساس امتیاز» as static text, not a dropdown.
4. **Cards**: a nurse with multiple variants shows distinguishable cards (category/variant label + price);
every card shows the gender chip and «N ویزیت موفق»; tapping the ✓ badge opens the verification
bottom-sheet/dialog (fetches `nurses/{id}/trust_badge` lazily on open).
5. **C3**: open a profile → header shows completed visits + rating; the `VerificationPanel` section lists
the served credential types + approval date (Shamsi); open the reviews tab and scroll deep → «درخواست
رزرو» stays pinned at the bottom; a 4.5 average renders as a fractional star row. Tap the CTA → the C4
request form receives the same nurse/variant/gender/date params as before.
6. Repeat 15 on `/en` (LTR) and in dark mode; on a mobile viewport confirm both sticky bars sit above the
`BottomBar` and the home-indicator safe area (the `BottomBar` already owns
`env(safe-area-inset-bottom)`; `StickyActionBar` is a separate flex sibling above it, inside the
scrolling `main`, so it never overlaps).
## What is mocked / waiting on a real service
Nothing new mocked this phase — search stays real (`USE_SEARCH_MOCK = false`, unchanged) and verification
stays mock-primary (`USE_VERIFICATION_MOCK`, unchanged) exactly as before; this phase only consumes the
existing `useNurseTrustBadge` hook (previously built, never wired into a page) and adds no new fetch code
outside existing service hooks. No mock registry entries added/changed.
## Contracts
- Consumed: `services/search` (b7 + REQ-012, unchanged), `services/verification`'s public trust-badge read
(b6, unchanged — this phase is the first to actually call `useNurseTrustBadge` from a page/component).
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 040043):
- **REQ-040**`variantDisplayName` (required) + optional `topReviewTag` on `NurseSearchResultDto`.
- **REQ-041** — free-text `q` search over nurse/variant/category names on `GET search/nurses` (the
Home search bar's upgrade path).
- **REQ-042**`nurseGender` on `NursePublicProfileDto` (the C3 header gender chip, currently omitted).
- **REQ-043** — public per-step verification detail (step codes + decision dates) so `VerificationPanel`
can list identity/Shahkar/license individually instead of folding to `credentialTypes[]`.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the C1/C2/C3 line items (Jalali chips, sticky CTA, recap chips,
dossier layout), the new `VerificationPanel`/`StickyActionBar` entries, and updated `TrustBadge`/
`GenderToggle`/`NurseResultCard`/`JalaliDatePicker` lines noting their new modes.
## Follow-ups for later phases
- **Multi-variant collapse.** Collapsing a nurse's several variant rows into one card with a price range +
"N خدمت" disclosure needs the `variantDisplayName` REQ (040) served first — noted as a design follow-up,
not built this phase (the category-name fallback keeps rows distinguishable meanwhile).
- **Public/guest storefront and landing page** — deferred to phase 13 (unchanged from the phase brief).
- **Save/favorite/share nurses** — post-MVP product decision (unchanged from the phase brief).
- **Full ICU zero-case copy sweep** (ratings/counts pluralization polish across the whole app) — deferred
to phase 12 per the phase brief; this phase only ensured the C1 zero-count state is never a tappable CTA.
- Phase 8's public-profile preview should reuse `VerificationPanel` and `TrustBadge`'s `nurseId` explainer
mode unchanged, per the phase's explicit cross-reference.
## Memory
Saved a `project`-type memory (`ui_phase_4_customer_storefront.md`, indexed in `MEMORY.md`) covering: the
`NurseResultCard`/`TrustBadge` ownership split with phase 8, the `VerificationPanel` shared-component
contract, the `TrustBadge` split-component gotcha (query hooks must live in a conditionally-**mounted**
subcomponent, not a conditionally-**enabled** hook call, to avoid forcing `QueryClientProvider` on every
caller's tests), the C3 profile DTO's missing `nurseGender` (REQ-042, never render the placeholder stub),
and the `StickyActionBar`/`province_id`-carry patterns for future sticky-CTA or region-prefill needs.
@@ -0,0 +1,189 @@
# UI Phase 5 — Booking Lifecycle — Report (2026-07-18)
## What was built
**C4 request form** — `bookings/request/page.tsx`:
- A sticky nurse-identity bar (`NurseIdentityBar`, page-local) pinned to the top of the scroll: avatar,
name, rating + review count, tappable `TrustBadge`, gender chip — off the already-fetched
`useNurseProfile`. The family always sees who they're inviting home before filling anything in.
- A «چه اتفاقی می‌افتد؟» strip reusing C5's own `StepperHeader` three-step labels (`step_submitted` /
`step_awaiting` / `step_payment`) at `activeStep={0}` — no new one-off stepper, and the copy stays in
sync with the tracker the family lands on next.
- The native `type="date"` field replaced by the new shared `JalaliDateIntentPicker` (extracted from C1's
local date-intent widget — see below) and the free start/end time fields replaced by tappable
morning/afternoon/evening window chips + a «زمان دلخواه» custom option that reveals the time fields —
kills the end≤start error class for the common case.
- **Fixed the dead-validation defect**: `attempted` (a single flag only ever set inside the submit handler
the disabled button could never reach) replaced with a per-field `touched` map set `onBlur`; inline
errors are now reachable. The submit button stays disabled while required fields are missing (unchanged
behavior) but a caption underneath now lists exactly what's missing («برای ادامه: انتخاب بیمار، تاریخ»),
built from `cta_missing_*` keys.
- The fake-map preview (`AddressMapPicker` wrapped in `pointerEvents:'none'`) replaced with a compact
address row (icon + "title · city · district" + street line) and a «تغییر» affordance that swaps the row
back for the select; choosing a new address collapses it back to the compact row automatically.
- Negative-margin stitching (`mt: -1.5`, `mt: -2`) removed — price-under-select and counter-under-notes are
now grouped with real `Stack` containers.
- C4 now also reads `patient_id`/`address_id` query params (extending the existing `nurse_id`/`variant_id`/
`required_gender` handoff from C3) so C5's terminal-state "request again" can reopen it fully prefilled.
**New shared component** — `components/common/JalaliDateIntentPicker/`: extracted from C1's local
`DateIntentFilter` (near-day chip strip + a calendar-icon `Popover` entry into the full Jalali grid) so C4's
**real, required** date field could reuse the identical widget instead of forking a second copy. C1's
`SearchScreen.tsx` was refactored to delegate to it (behavior byte-identical — verified via `npm run
test:ci`); the new component ships its own co-located test.
**C5 tracker** — `bookings/request/[id]/page.tsx`:
- The response countdown now renders `CountdownTimer`'s progress ring (`windowStart={request.createdAt}`,
the exact server-frozen pair — no client-side deadline math) with a humanized coarse label above the
10-minute threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه», new `countdown_about_hours`/`countdown_about_minutes`
keys) that switches to the ticking clock in the final minutes. A one-line «نتیجه را به شما اطلاع می‌دهیم»
note sits under it. The payment countdown is unchanged (no accurate `windowStart` exists on the DTO for
it — never fabricated client-side).
- **Fixed the cancel-dialog label defect**: the hand-rolled `Dialog` replaced with the shared
`ConfirmDialog`. Dismiss now reads «نه، نگه دار» (`cancel_confirm_keep`, neutral text button); the
destructive action reads «بله، انصراف از درخواست» (`cancel_confirm_destructive`, error/contained) — the
dismiss button no longer carries the destructive action's own label. The now-unused `cancel_confirm_yes`
key was removed from both message files.
- **Terminal-state recovery** for `rejected_by_nurse`/`expired_no_response`: a «درخواست دوباره با زمان
دیگر» button reopens C4 prefilled with the same nurse/variant/patient/address, and a «پرستاران مشابه»
button opens `/search` carrying the request's city/district/gender. For rejections, the same-nurse retry
is suppressed when `nurseRejectionReason` (freeform text — no structured code exists, REQ-044) matches a
gender/coverage keyword heuristic (fa+en), in which case only "similar nurses" is offered. Other terminal
states (`payment_deadline_expired`, `cancelled_by_customer`, `converted`) are unchanged.
**Bookings list** — `bookings/page.tsx` + new `BookingsScreen.tsx`:
- Three segmented tabs: «در انتظار پاسخ» / «فعال» / «گذشته». The pending tab wires the
previously-exported-but-unused `useCustomerRequests` (filtered to `pending_nurse_response` +
`accepted_awaiting_payment`), with a live mini `CountdownTimer` per row deep-linking to C5 — the "orphaned
request" defect (money-adjacent deadline the customer could no longer find) is fixed. A badge on the tab
label shows the pending count.
- Active/past split `useBookingList('customer')` client-side by status over **one** query with a growing
`pageSize` ("load more", the exact C2 results pattern) — booking #21+ is reachable via the button.
- Rows are `AccentCard`s with a status-toned `borderInlineStart` + a soft `StatusChip`, `role="button"` +
keyboard-activatable, fully tappable (not just a small nested button).
- A completed/closed row without a review shows a compact star-strip CTA (`RatingInput` read-only decor +
«ثبت نظر» text) via a gated `useReviewEligibility(bookingId, { enabled: isCompleted })` — no extra query
fires for non-completed rows.
**Booking detail** — `BookingDetailView.tsx`:
- The header is now a hero: a next-upcoming-session headline («ویزیت ۲ · فردا ۰۹:۰۰», via
`formatRelativeTime` + a Shamsi fallback past 7 days, bidi-isolated clock digits), the frozen visit
address (best-effort parsed off `addressSnapshotJson` — see REQ-045), a nurse-identity row, and a
client-side **`.ics` add-to-calendar download** (new `components/booking/ics.ts`, no backend seam —
Gregorian UTC in the file, Shamsi in the UI).
- An EVV **presence headline** («پرستار در محل است · ورود ۰۹:۰۲», success-toned) renders above the address
whenever a session is currently checked in — elevates the existing advisory EVV data instead of leaving
it buried per-session. (The equivalent **compact list-row indicator** from the phase brief was **not**
built: `BookingListItemDto` carries no per-row EVV/session state, and fetching it via an extra query per
in-progress row would be an N+1 anti-pattern — filed as a note, not a REQ, since no clean single-field
addition was obvious; flagged for a human product/API call.)
- The vertical `StatusTimeline` swap the phase asked for (in place of `StepperHeader`) was **already done**
in ui-phase-1 (`BookingStatusTimeline` already renders `StatusTimeline`) — verified, not re-built; the
stale in-code comment claiming otherwise is fixed in this pass.
- Fixed the `unnamed_nurse` key-misuse defect: a new `bd_nurse_label` ("پرستار") key is now the nurse
header-fact **label**; `unnamed_nurse` stays exactly the no-name **fallback value** it was written for.
- `SessionCard`'s outer `Paper` swapped for `SurfaceCard` (identical `padding="sm"` visual, now on the
shared radius token) — the "align to phase-1 card anatomy" ask.
**Cancel flow** — `bookings/[id]/cancel/page.tsx`:
- Two off-ramps above the disclosure: «تغییر زمان» and «گفتگو با پشتیبانی», both opening the existing
`ContactSupportDialog` (pre-linked to the booking, categories `coordination`/`support` respectively) plus
a one-line nurse-impact note. Real rescheduling stays DEFERRED (product decision + backend, per the phase
brief — no REQ filed, it was already a known/flagged gap).
- The reason `useState` no longer pre-defaults to `'changed_mind'` — it starts empty with a disabled
placeholder `MenuItem`, and the continue CTA stays disabled until a reason is chosen.
- `CancellationPolicyDisclosure` is untouched (byte-identical behavior, per the keep-list).
**Review flow** — `bookings/[id]/review/page.tsx` + the list row:
- A context-recap card (service name off the variant snapshot, nurse avatar + name, Shamsi visit date —
all off the already-cached `useBookingDetail`, no new fetch) renders above both the eligible-form and the
already-reviewed states.
- The moderation-expectation note («نظر شما پس از بررسی منتشر می‌شود») now renders **before** submit, not
only in the post-submit "under review" state.
- **Fixed the ungated-hook defect**: `useMyReviewForBooking(bookingId)` now passes `{ enabled: reviewable }`
(`booking?.status === 'completed' || 'closed'`), matching the exact gate the booking-detail page already
used for the same hook.
- The list-row star-strip CTA (above) is the "post-completion review nudge" half of this deliverable.
**Misc verified defects**:
- `BookingRequestSummaryCard`'s `whenLabel` bidi-isolated: the date·time-range now wraps the clock-digits
segment in a `dir="ltr"` `tabular-nums` span, matching `SessionCard.tsx`'s existing precedent exactly. A
new co-located test asserts the isolation.
- The «ادامه پرداخت ←» arrow-in-string CTA (`booking.continue_payment`) was **not** touched — confirmed
it's phase 12's catalog-wide sweep, per the phase brief's own scope note; only strings genuinely edited
this phase had their arrows reconsidered (none needed it).
## What is now testable (and exactly how)
1. From a nurse profile (C3) tap «درخواست رزرو» → C4 shows the sticky nurse card and the 3-step strip.
Blur the empty patient select → inline error; the disabled submit lists what's still missing.
2. Pick a date from the Jalali chip strip (or the calendar-icon popover) and tap «صبح ۸–۱۲» → the time
fields fill silently; tap «زمان دلخواه» → the free time fields appear. The address shows a compact text
row with a «تغییر» link, never a grid-canvas stand-in.
3. Submit → C5 shows the countdown ring with «حدود …» framing (switches to ticking digits under 10 min).
Tap «انصراف از درخواست» → the dialog's neutral button reads «نه، نگه دار» and keeps the request; the
red «بله، انصراف از درخواست» button cancels it.
4. Leave C5 → `/bookings` «در انتظار پاسخ» tab shows the pending request with a live mini-countdown and a
tab badge; the row deep-links back to C5. Reject a request with a non-gender/coverage reason (dev nurse
inbox) → C5's terminal card offers both «درخواست دوباره با زمان دیگر» (C4 reopens fully prefilled,
including patient/address) and «پرستاران مشابه» (search prefilled with the same city/gender).
5. Seed >20 bookings → the active/past tabs load 20, then «نمایش بیشتر» reveals the rest; every row is
keyboard-reachable (Tab + Enter) and carries a status-colored accent stripe.
6. Open an active booking with a session scheduled → the hero reads «ویزیت N · <relative/Shamsi> <time>»,
the frozen address, a nurse avatar, and a working «افزودن به تقویم» `.ics` download. Check a nurse in
(dev EVV sim) → the hero shows «پرستار در محل است · ورود …».
7. Start a cancellation → the reason select is empty (continue disabled until chosen); «تغییر زمان»/«گفتگو
با پشتیبانی» open the support dialog pre-linked to the booking; completing the flow shows the unchanged
policy disclosure.
8. Open a completed booking's `/bookings` row → a star-strip CTA (only when un-reviewed) deep-links to the
review page, which shows the service/nurse/date recap and the moderation note before any input.
9. `npm run check` and `npm run test:ci` are green (101/101 suites, 437/437 tests, including the new
`JalaliDateIntentPicker` and `BookingRequestSummaryCard` bidi tests).
## What is mocked / waiting on a real service
Nothing new mocked — `bookingRequests`/`bookings`/`reviews`/`refunds`/`tickets` all run real, unchanged
from refinement-phase-4, per the phase's own "None introduced" note. Every deliverable is client-side over
existing seams; the `.ics` file is generated entirely in the browser.
## Contracts
- Consumed: `services/bookingRequests` (b8), `services/bookings` (b9), `services/refunds` (b11),
`services/reviews` (b14), `services/tickets` (b15) — all unchanged.
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 044045):
- **REQ-044** — a structured `nurseRejectionReasonCode` on `BookingRequestDto`. C5's same-nurse-retry
gate currently approximates this with a keyword heuristic over the freeform `nurseRejectionReason`
text (documented in-code, `rejectionAllowsSameNurseRetry()`).
- **REQ-045** — a typed `addressSnapshot`/`variantSnapshot` shape on `BookingDetailDto` in place of the
opaque `*SnapshotJson` strings, whose field names were confirmed to drift across the seed fixtures
(`city`/`cityName`/`cityNameFa`, `line`/`addressLine`). The client's `addressSnapshotLabel()` tries
every candidate key defensively; no user-facing defect today, just an unenforced contract.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the `/bookings` tree (tabs + wired `useCustomerRequests` +
load-more, C4/C5 redesign notes, the cancel/review off-ramp and gating fixes), the new
`JalaliDateIntentPicker` entry, the `booking/` composite line (hero, `.ics`, EVV presence, the
`StatusTimeline`-not-`StepperHeader` correction — the prior line was already stale before this phase),
`CountdownTimer`'s ring now having a live consumer, and `BookingRequestSummaryCard`'s bidi fix.
## Follow-ups for later phases
- **List-row EVV presence indicator** (phase brief §3.4) was not built — `BookingListItemDto` has no
per-row EVV/session field and an N+1 per-row fetch would be the wrong shape; needs a product/API
decision (a lightweight `currentlyCheckedIn` boolean on the list DTO, or accept the gap) before a future
phase attempts it.
- **REQ-044/045** (rejection-reason code, snapshot typing) — no phase currently owns picking these up;
flagged for whichever backend phase next touches b8/b9.
- The arrow-in-string `continue_payment` CTA sweep stays phase 12's, as scoped.
## Memory
Saved a `project`-type memory (`ui_phase_5_booking_lifecycle.md`, indexed in `MEMORY.md`) covering: the
lifecycle tabs model (client-side status bucketing over one growing-pageSize query, not per-tab server
filters — a deliberate trade-off since the list API takes only a single `status`), the terminal-state
recovery decision tree (same-nurse retry vs similar-nurses, gated by a keyword heuristic pending REQ-044),
the confirm-dialog labeling convention (`ConfirmDialog` cancelLabel=keep/neutral,
confirmLabel=destructive/error — apply to every future confirm dialog), the presence-headline pattern, and
what phase 6 (checkout) should know about the accepted-request → payment handoff surfaces touched here (C5's
payment countdown + `continue_payment` CTA, unchanged in shape).