ui phase 5
This commit is contained in:
+22
-19
@@ -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 ~7–10-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
@@ -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 8–12",
|
||||
"window_afternoon": "Afternoon 12–16",
|
||||
"window_evening": "Evening 16–20",
|
||||
"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
@@ -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 — 5–6
|
||||
* 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 }}>
|
||||
|
||||
+134
-61
@@ -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,
|
||||
|
||||
+98
-20
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 — 5–6 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 (5–6 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 1–5 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 040–043):
|
||||
- **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 044–045):
|
||||
- **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).
|
||||
Reference in New Issue
Block a user