diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 03bf9cc..938f8b4 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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/ # — 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 diff --git a/client/messages/en.json b/client/messages/en.json index a0c346e..492c8f7 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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…", diff --git a/client/messages/fa.json b/client/messages/fa.json index a301c73..fec4659 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "مراقبت چطور بود؟ اگر نکته‌ای هست بنویسید…", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx index 4f7f73a..90717a0 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx @@ -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 = ({ icon, title, body, ctaLabel, to }) => ( +const NudgeCard: FunctionComponent = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => ( - + - + {title} @@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent = ({ icon, title, body, ctaLa {ctaLabel} + {onDismiss ? ( + + ) : null} ); +// 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 ( @@ -94,17 +133,25 @@ export default function HomeScreen() { + + router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} /> - + + + {showPatientNudge ? ( + + ) : null} {!profileComplete ? ( { + 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 ( + + {items.map((item) => ( + + + + {item.label} + + + ))} + + ); +}; + +/** + * 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 ( - - setQuery(event.target.value)} - placeholder={t('search_placeholder')} - aria-label={t('search_action')} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - /> - + 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', + }} + > + + {t('search_placeholder')} + ); }; @@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void } ); }; + +/** + * 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(); + 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 ( + + {candidates.map((booking) => ( + + ))} + + ); +}; + +/** 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 ( + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }} + sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }} + > + + + + {t('rebook_with', { name: booking.counterpartyName })} + + + + + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx index 7cb7277..8b488f2 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx @@ -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 = { + 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('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 ( - - + + {t('list_title')} {t('list_subtitle')} - + - {isLoading ? ( - - {[0, 1].map((key) => ( - - ))} - - ) : isError ? ( - refetch()} /> - ) : items.length === 0 ? ( - - ) : ( - - {items.map((item) => ( - - ))} - - )} - + setTab(value)} variant="fullWidth"> + 0 ? ( + + {t('tab_pending')} + + ) : ( + t('tab_pending') + ) + } + /> + + + + + {tab === 'pending' ? ( + pendingQuery.isLoading ? ( + + ) : pendingQuery.isError ? ( + pendingQuery.refetch()} /> + ) : pendingItems.length === 0 ? ( + + {t('missing_nurse_cta')} + + } + /> + ) : ( + + {pendingItems.map((item) => ( + openRequest(item.id)} /> + ))} + + ) + ) : null} + + {tab === 'active' ? ( + bookingsQuery.isLoading ? ( + + ) : bookingsQuery.isError ? ( + bookingsQuery.refetch()} /> + ) : activeItems.length === 0 ? ( + + {t('missing_nurse_cta')} + + } + /> + ) : ( + setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} /> + ) + ) : null} + + {tab === 'past' ? ( + bookingsQuery.isLoading ? ( + + ) : bookingsQuery.isError ? ( + bookingsQuery.refetch()} /> + ) : pastItems.length === 0 ? ( + + ) : ( + setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} /> + ) + ) : null} + ); } -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 ( + + {items.map((item) => ( + onOpen(item.id)} /> + ))} + {hasMore ? ( + + {loadMoreLabel} + + ) : null} + + ); +} + +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 ( - + { + 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 } }} + > @@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) { {formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })} - + - - - {t('list_total')}: - - router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)} - > - {t('view_booking')} - - + + {t('list_total')}: + + + {isCompleted ? : null} - + + ); +} + +/** 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 ( + } + onClick={(event) => { + event.stopPropagation(); + router.push(`/${locale}${bookingReviewPath(bookingId)}`); + }} + sx={{ alignSelf: 'flex-start', px: 0 }} + > + {t('cta_leave')} + + ); +} + +/** «در انتظار پاسخ» 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 ( + { + 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 } }} + > + + + + {item.counterpartyName} + + + {dateLabel} ·{' '} + + {timeLabel} + + + + + {deadline ? ( + + ) : null} + + + ); +} + +function ListSkeleton() { + return ( + + {[0, 1].map((key) => ( + + ))} + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx index b3f609f..68d6eae 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx @@ -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('changed_mind'); + // Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen. + const [reasonCategory, setReasonCategory] = useState(''); const [reasonNotes, setReasonNotes] = useState(''); + const [supportDialogCategory, setSupportDialogCategory] = useState(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. */} + + + {t('offramp_note')} + + + setSupportDialogCategory('coordination')} + > + {t('reschedule_cta')} + + setSupportDialogCategory('support')} + > + {t('contact_support_cta')} + + + + setReasonCategory(event.target.value as CancelReasonCategory)} fullWidth > + + {t('reason_placeholder')} + {REASON_CATEGORIES.map((category) => ( {t(`reason_cat_${category}`)} @@ -159,12 +194,20 @@ export default function CancelBookingPage() { setStep(1)} > {t('continue_cta')} + + setSupportDialogCategory(null)} + role="customer" + bookingId={bookingId} + defaultCategory={supportDialogCategory ?? 'support'} + /> ) : ( <> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx index 97fabd7..865124d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx @@ -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 ( + {booking ? : null} @@ -131,6 +146,15 @@ export default function LeaveReviewPage() { return ( + {booking ? : null} + + {/* Moderation expectation, up front — not only after submit. */} + + + + {t('moderation_note')} + + @@ -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 ( + + + + {(name || t('recap_fallback_nurse')).charAt(0)} + + + + {service ?? t('recap_fallback_service')} + + + {name || t('recap_fallback_nurse')} · {formatShamsiDate(booking.scheduledDate, locale)} + + + + + ); +} + function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) { return ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx index 7abe07f..e007727 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx @@ -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 ( {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} /> ); @@ -91,7 +129,13 @@ export default function BookingRequestStatusPage() { return ( {summary} - + ); } @@ -99,7 +143,12 @@ export default function BookingRequestStatusPage() { return ( {summary} - + ); } @@ -107,7 +156,12 @@ export default function BookingRequestStatusPage() { return ( {summary} - + ); } @@ -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}`, + ), + }} /> ); @@ -193,12 +248,19 @@ export default function BookingRequestStatusPage() { ) : ( - refetch()} - /> + + coarseResponseLabel(minutes, t)} + onElapsed={() => refetch()} + /> + + {t('response_notify_note')} + + )} @@ -212,33 +274,32 @@ export default function BookingRequestStatusPage() { {cancelRequest.isPending ? t('cancelling') : t('cancel_request')} - setConfirmCancel(false)}> - {t('cancel_confirm_title')} - - - {t('cancel_confirm_body')} - - - - setConfirmCancel(false)}> - {t('cancel_request')} - - { - setConfirmCancel(false); - cancelRequest.mutate(request.id); - }} - > - {t('cancel_confirm_yes')} - - - + setConfirmCancel(false)} + onConfirm={() => { + setConfirmCancel(false); + cancelRequest.mutate(request.id); + }} + /> ); } +/** Humanized minutes-remaining copy above the coarse threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه»). */ +function coarseResponseLabel(minutes: number, t: (key: string, values?: Record) => 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 ( @@ -273,9 +339,16 @@ function TerminalCard({ {body} ) : null} - - {ctaLabel} - + + + {primary.label} + + {secondary ? ( + + {secondary.label} + + ) : null} + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx index 0697d4d..b11fc91 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx @@ -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(''); + const [patientId, setPatientId] = useState(patientIdParam ?? ''); const [variantSel, setVariantSel] = useState(variantIdParam ?? ''); - const [addressSel, setAddressSel] = useState(''); + const [addressSel, setAddressSel] = useState(addressIdParam ?? ''); + const [addressEditing, setAddressEditing] = useState(false); const [gender, setGender] = useState( 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(null); + const [timeStart, setTimeStart] = useState(''); + const [timeEnd, setTimeEnd] = useState(''); const [notes, setNotes] = useState(''); - const [attempted, setAttempted] = useState(false); + const [touched, setTouched] = useState>({ + patient: false, + service: false, + address: false, + date: false, + time: false, + gender: false, + }); const [pastDateError, setPastDateError] = useState(false); const [formError, setFormError] = useState(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 ; - const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart; + const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart; const pastError = pastDateError; return ( + {profile ? : null} + {t('request_title')} @@ -222,6 +278,13 @@ function BookingRequestForm() { + + + {t('whathappens_title')} + + + + {/* Patient */} {patients.length === 0 ? ( setPatientId(Number(event.target.value))} + onBlur={() => markTouched('patient')} fullWidth > @@ -252,41 +316,42 @@ function BookingRequestForm() { )} {/* Service variant */} - {services.length === 0 ? ( - - {t('service_empty')} - - ) : ( - setVariantSel(Number(event.target.value))} - fullWidth - > - - {t('service_placeholder')} - - {services.map((service) => ( - - {service.displayName} + + {services.length === 0 ? ( + + {t('service_empty')} + + ) : ( + setVariantSel(Number(event.target.value))} + onBlur={() => markTouched('service')} + fullWidth + > + + {t('service_placeholder')} - ))} - - )} - {selectedVariant ? ( - + {services.map((service) => ( + + {service.displayName} + + ))} + + )} + {selectedVariant ? ( - - ) : null} + ) : null} + - {/* Address */} + {/* Address — a compact confirmation row once resolved, with a way back to the select. */} {addresses.length === 0 ? ( router.push(`/${locale}${ROUTES.ADDRESSES}`)} /> - ) : ( - - setAddressSel(Number(event.target.value))} - fullWidth - > - - {t('address_placeholder')} + ) : addressEditing || !selectedAddress ? ( + { + setAddressSel(Number(event.target.value)); + setAddressEditing(false); + }} + onBlur={() => markTouched('address')} + fullWidth + > + + {t('address_placeholder')} + + {addresses.map((address) => ( + + {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} - {addresses.map((address) => ( - - {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} - - ))} - - {selectedAddress ? ( - - - {regionLabel()} - {selectedAddress.addressLine ? ` — ${selectedAddress.addressLine}` : ''} + ))} + + ) : ( + + + + + {regionLabel(selectedAddress)} + + {selectedAddress.addressLine ? ( + + {selectedAddress.addressLine} - {selectedAddress.latitude != null && selectedAddress.longitude != null ? ( - // Read-only preview of the address's stored pin (the pin itself is set in the f3 book). - - undefined} - center={cityCentroid(selectedAddress.cityId)} - helperText={regionLabel()} - latLabel={tAddress('map_lat')} - lngLabel={tAddress('map_lng')} - /> - - ) : null} - - ) : null} + ) : null} + + setAddressEditing(true)} sx={{ flexShrink: 0 }}> + {t('address_change_cta')} + )} - {/* Date + time */} - - markTouched('date')}> + + {t('date_label')} + + { - setDate(event.target.value); + onChange={(iso) => { + setDate(iso); if (pastDateError) setPastDateError(false); }} - slotProps={{ inputLabel: { shrink: true } }} - fullWidth - /> - { - setTimeStart(event.target.value); - if (pastDateError) setPastDateError(false); - }} - slotProps={{ inputLabel: { shrink: true } }} - fullWidth - /> - 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 ? ( + + {t('error_past_date')} + + ) : touched.date && date === '' ? ( + + {t('error_date_required')} + + ) : null} + + + {/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */} + markTouched('time')}> + + {t('time_window_label')} + + + {TIME_WINDOWS.map((option) => ( + selectWindow(option)} + color={windowSel === option.key ? 'primary' : undefined} + variant={windowSel === option.key ? 'filled' : 'outlined'} + data-window={option.key} + /> + ))} + setWindowSel('custom')} + color={windowSel === 'custom' ? 'primary' : undefined} + variant={windowSel === 'custom' ? 'filled' : 'outlined'} + data-window="custom" + /> + + {windowSel === 'custom' ? ( + + setTimeStart(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + setTimeEnd(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + ) : null} + {touched.time && (timeStart === '' || timeEnd === '') ? ( + + {t('error_time_required')} + + ) : null} {/* Caregiver gender — first-class, three-way, never silently defaulted */} - + markTouched('gender')}> {t('gender_label')} @@ -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() { {t('gender_hint')} - {attempted && gender === '' ? ( + {touched.gender && gender === '' ? ( {t('error_gender_required')} @@ -419,19 +536,21 @@ function BookingRequestForm() { {/* Stage-1 notes */} - setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} - multiline - minRows={3} - fullWidth - helperText={t('notes_hint')} - /> - - {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} - + + setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} + multiline + minRows={3} + fullWidth + helperText={t('notes_hint')} + /> + + {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} + + {formError ? ( @@ -439,17 +558,86 @@ function BookingRequestForm() { ) : null} - + + {createRequest.isPending ? t('submitting') : t('submit')} + + {!requiredChosen && missingFieldLabels.length > 0 ? ( + + {t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })} + + ) : null} + + + ); +} + +/** 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 ( + + - {createRequest.isPending ? t('submitting') : t('submit')} - + {name.charAt(0)} + + + + + {name} + + + + + + + + {ratingLabel} + + + ({formatNumber(profile.totalReviews, locale)}) + + + + + ); } @@ -512,7 +700,9 @@ function FieldEmpty({ function FormSkeleton() { return ( + + {[0, 1, 2, 3].map((key) => ( ))} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx index 8448230..9688ad0 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx @@ -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 ( @@ -82,31 +74,19 @@ function SearchFilterScreen() { - { - if (value != null) controller.setGender(value === 'any' ? undefined : value); - }} - > - {GENDER_OPTIONS.map((option) => ( - - {t(`gender_${option}`)} - - ))} - + onChange={(value) => controller.setGender(value === 'any' ? undefined : value)} + maleLabel={t('gender_male')} + femaleLabel={t('gender_female')} + anyLabel={t('gender_any')} + ariaLabel={t('section_gender')} + /> - controller.setDateIntent(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - /> + @@ -126,17 +106,35 @@ function SearchFilterScreen() { - - {ctaLabel} - + + {zeroResults ? ( + + + {t('cta_zero_title')} + + + {t('cta_zero_hint')} + + + ) : ( + + {!controller.isReady + ? t('cta_choose_category_city') + : isFetching || count == null + ? t('cta_loading') + : t('cta_view_results', { count })} + + )} + ); } @@ -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 ( + + ); +}; + /** 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, diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx index 741c37e..3670a95 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx @@ -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() { + setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}> @@ -84,16 +106,28 @@ export default function NurseProfilePage() { {tab === 'services' ? : } - - {t('request_booking')} - + + + {primaryService ? ( + + + {t('price_from')} + + + + ) : null} + + {t('request_booking')} + + + ); } @@ -121,7 +155,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) { {name} - + {rating} @@ -129,11 +163,14 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) { {t('reviews_count', { count: profile.totalReviews })} + + {t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })} + - + {profile.inoMembership ? ( } @@ -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 ( + + + {t('explainer_title')} + + + + + + ); +} + 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 ( - + {profile.services.length === 0 ? ( {t('services_empty')} @@ -195,10 +249,34 @@ function ServicesSection({ profile }: { profile: NurseProfile }) { ))} )} + {profile.latestReview ? : null} ); } +/** The already-fetched latest-review snippet — a small taste of the dossier's reviews tab. */ +function LatestReviewSnippet({ review }: { review: NonNullable }) { + const t = useTranslations('search'); + const tr = useTranslations('reviews'); + const locale = useLocale(); + return ( + + + + {t('latest_review_title')} + + + + + {review.authorMasked} · {formatShamsiDate(review.createdAt, locale)} + + + {review.body ? {review.body} : null} + + + ); +} + /** * 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 diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx index cc6fae6..faffe3d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx @@ -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(); + 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 ( - + {isLoading ? t('results_loading_title') : t('results_count', { count: total })} - {/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */} - - {t('sort_rating')} - + {/* Rating is the only MVP sort — a static caption, not a dead-interactive dropdown. Other + sorts are DEFERRED until the API grows them. */} + + {t('sort_static')} + + + + + {categoryLabel ? ( + + ) : null} + {regionLabel ? : null} + + {priceLabel ? : null} {isLoading ? ( @@ -83,7 +135,12 @@ function ResultsScreen() { ) : ( {items.map((nurse) => ( - + ))} {hasMore ? ( void }) { {t('empty_suggest_district')} - {t('empty_suggest_city')} + {t('empty_suggest_date')} } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts b/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts index 005b1ae..5a33666 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/useSearchFilters.ts @@ -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(initialCategoryId ?? null); - const [region, setRegion] = useState(EMPTY_REGION); - const [gender, setGender] = useState(undefined); - const [priceMinToman, setPriceMinToman] = useState(''); - const [priceMaxToman, setPriceMaxToman] = useState(''); - const [dateIntent, setDateIntent] = useState(''); +export function useSearchFilters(params: ParamReader) { + const [categoryId, setCategoryId] = useState(() => { + const raw = searchParamsToFilters(params).serviceCategoryId; + return raw > 0 ? raw : null; + }); + const [region, setRegion] = useState(() => { + 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(() => 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); diff --git a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx index 96ca36b..61455cf 100644 --- a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx +++ b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx @@ -55,4 +55,11 @@ describe(' 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(/–/); + }); }); diff --git a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx index 6570169..32b8ca6 100644 --- a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx +++ b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx @@ -58,7 +58,8 @@ const BookingRequestSummaryCard: FunctionComponent - {whenLabel} + {whenDateLabel} ·{' '} + + {whenTimeRangeLabel} + diff --git a/client/src/components/GenderToggle/GenderToggle.test.tsx b/client/src/components/GenderToggle/GenderToggle.test.tsx index 5b15877..522897c 100644 --- a/client/src/components/GenderToggle/GenderToggle.test.tsx +++ b/client/src/components/GenderToggle/GenderToggle.test.tsx @@ -40,3 +40,39 @@ describe(' component', () => { expect(onChange).not.toHaveBeenCalled(); }); }); + +describe(' allowAny mode', () => { + function renderAnyToggle(value: 'male' | 'female' | 'any' | null) { + const onChange = jest.fn(); + const utils = render( + + + , + ); + 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(); + }); +}); diff --git a/client/src/components/GenderToggle/GenderToggle.tsx b/client/src/components/GenderToggle/GenderToggle.tsx index 7868042..cc22f06 100644 --- a/client/src/components/GenderToggle/GenderToggle.tsx +++ b/client/src/components/GenderToggle/GenderToggle.tsx @@ -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 = ({ - value, - onChange, - maleLabel, - femaleLabel, - error = false, - disabled = false, - ariaLabel, -}) => ( - { - if (next) onChange(next); - }} - sx={{ - '& .MuiToggleButton-root': { - flex: 1, - py: 1.25, - fontWeight: 700, - borderColor: error ? 'var(--bal-error)' : undefined, - }, - }} - > - - {maleLabel} - - - {femaleLabel} - - -); +const GenderToggle: FunctionComponent = (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 ( + handleChange(next)} + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.25, + fontWeight: 700, + borderColor: error ? 'var(--bal-error)' : undefined, + }, + }} + > + + {maleLabel} + + + {femaleLabel} + + {allowAny ? ( + + {(props as GenderToggleAnyProps).anyLabel} + + ) : null} + + ); +}; export default GenderToggle; diff --git a/client/src/components/NurseResultCard/NurseResultCard.test.tsx b/client/src/components/NurseResultCard/NurseResultCard.test.tsx index eb0305e..792e200 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.test.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.test.tsx @@ -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) => + values ? `${key}:${Object.values(values).join(',')}` : key; + t.has = () => true; + return t; + }, useLocale: () => 'en', })); +// NurseResultCard renders , 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( - + , ); return onSelect; } describe(' 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( + + + , + ); + 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(' component', () => { it('shows the distance chip only when distanceKm is present', () => { const { rerender } = render( - + , ); - expect(screen.getByText('distance_km')).toBeInTheDocument(); + expect(screen.getByText(/distance_km/)).toBeInTheDocument(); rerender( - + , ); - 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( + + + , + ); + expect(screen.queryByText(/منظم و دقیق/)).not.toBeInTheDocument(); + + rerender( + + + , + ); + expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument(); }); it('falls back to a label when the name is missing (b7 join gap)', () => { @@ -75,8 +118,13 @@ describe(' 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( + + + , + ); + fireEvent.click(container.querySelector('[data-nurse-result-card]') as HTMLElement); expect(onSelect).toHaveBeenCalledWith(NURSE); }); diff --git a/client/src/components/NurseResultCard/NurseResultCard.tsx b/client/src/components/NurseResultCard/NurseResultCard.tsx index 106802c..9299476 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.tsx @@ -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 ( onSelect(nurse)} role="button" tabIndex={0} @@ -68,12 +79,29 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => { {initial} - + {name} - + + + + + {serviceLabel} + + + + + + {t('completed_visits', { count: formatNumber(nurse.totalCompletedBookings, locale) })} + @@ -97,6 +125,12 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => { ) : null} + {nurse.topReviewTag ? ( + + «{nurse.topReviewTag}» + + ) : null} + {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 = () => ( - + + diff --git a/client/src/components/TrustBadge/TrustBadge.test.tsx b/client/src/components/TrustBadge/TrustBadge.test.tsx index 2135975..707677b 100644 --- a/client/src/components/TrustBadge/TrustBadge.test.tsx +++ b/client/src/components/TrustBadge/TrustBadge.test.tsx @@ -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( - + , ); } describe(' 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(' 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()); + }); }); diff --git a/client/src/components/TrustBadge/TrustBadge.tsx b/client/src/components/TrustBadge/TrustBadge.tsx index 20cdd99..3004dad 100644 --- a/client/src/components/TrustBadge/TrustBadge.tsx +++ b/client/src/components/TrustBadge/TrustBadge.tsx @@ -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 = { expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' }, }; -export interface TrustBadgeProps extends Omit { +export interface TrustBadgeProps extends Omit { /** 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 & { '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 = ({ state, size = 'small', sx, ...rest }) => { +const TrustBadge: FunctionComponent = ({ state, nurseId, size = 'small', sx, ...rest }) => { const t = useTranslations('verification'); const style = BADGE_STYLE[state]; + const label = t(style.labelKey); + const icon: ReactElement = ; + 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 ; + } + return ( - } - sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }} - {...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; +}> = ({ 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 ( + <> + setOpen(true)} + aria-label={t('explainer_open_label')} + {...chipProps} + {...rest} + /> + 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)' }, + }, + }, + }} + > + + + {t('explainer_title')} + setOpen(false)} aria-label={t('explainer_close')} size="small"> + + + + + + + + + ); }; diff --git a/client/src/components/VerificationPanel/VerificationPanel.test.tsx b/client/src/components/VerificationPanel/VerificationPanel.test.tsx new file mode 100644 index 0000000..2ff4f03 --- /dev/null +++ b/client/src/components/VerificationPanel/VerificationPanel.test.tsx @@ -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) => + 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(' component', () => { + it('renders a loading skeleton', () => { + const { container } = render( + + + , + ); + expect(container.querySelector('[data-verification-panel="loading"]')).toBeInTheDocument(); + }); + + it('renders an error state', () => { + render( + + + , + ); + expect(screen.getByText('explainer_error')).toBeInTheDocument(); + }); + + it('renders the not-verified message when the badge is unverified', () => { + render( + + + , + ); + expect(screen.getByText('explainer_not_verified')).toBeInTheDocument(); + }); + + it('renders one row per credential type + the approval date, never inventing steps', () => { + const { container } = render( + + + , + ); + 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( + + + , + ); + expect(screen.getByText('some_future_code')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/VerificationPanel/VerificationPanel.tsx b/client/src/components/VerificationPanel/VerificationPanel.tsx new file mode 100644 index 0000000..a736aaa --- /dev/null +++ b/client/src/components/VerificationPanel/VerificationPanel.tsx @@ -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 = ({ + badge, + isLoading = false, + isError = false, +}) => { + const t = useTranslations('verification'); + const locale = useLocale(); + + if (isLoading) { + return ( + + + + + + ); + } + + if (isError || !badge) { + return ( + + {t('explainer_error')} + + ); + } + + if (!badge.isVerified) { + return ( + + {t('explainer_not_verified')} + + ); + } + + return ( + + + {t('explainer_intro')} + + + {badge.credentialTypes.map((code) => ( + + + + {t.has(`step_${code}`) ? t(`step_${code}`) : code} + + + ))} + + {badge.approvedAt ? ( + + {t('explainer_approved_at', { date: formatShamsiDate(badge.approvedAt, locale) })} + + ) : null} + + ); +}; + +export default VerificationPanel; diff --git a/client/src/components/VerificationPanel/index.tsx b/client/src/components/VerificationPanel/index.tsx new file mode 100644 index 0000000..8ba0c92 --- /dev/null +++ b/client/src/components/VerificationPanel/index.tsx @@ -0,0 +1,4 @@ +import VerificationPanel from './VerificationPanel'; + +export default VerificationPanel; +export type { VerificationPanelProps } from './VerificationPanel'; diff --git a/client/src/components/booking/BookingDetailView/BookingDetailView.tsx b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx index b11b57f..fb13994 100644 --- a/client/src/components/booking/BookingDetailView/BookingDetailView.tsx +++ b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx @@ -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; + 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 = ({ 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 = ({ bookingI if (isError || !booking) return ; 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 ( - {/* 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. */} = ({ bookingI ...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}), }} > - + - - {service ?? t('bd_title')} - + + + {nextSession ? ( + <> + {t('session_index', { n: nextSession.sessionIndex })} · {nextSessionDayLabel}{' '} + + {nextSessionTimeLabel} + + + ) : ( + (service ?? t('bd_title')) + )} + + + {t('bd_ref', { id: booking.id })} + + {isNurse ? ( = ({ bookingI /> ) : null} - - {t('bd_ref', { id: booking.id })} - - + + {onSiteCheckInAt ? ( + + + + {t('evv_presence_headline', { time: clockFmt.format(new Date(onSiteCheckInAt)) })} + + + ) : null} + + {addressLabel ? ( + + + + {addressLabel} + + + ) : null} + + + + {(booking.nurseName.trim() || t('unnamed_nurse')).charAt(0)} + + - + + {nextSession ? ( + + {t('bd_add_to_calendar')} + + ) : null} - + - + diff --git a/client/src/components/booking/SessionCard/SessionCard.tsx b/client/src/components/booking/SessionCard/SessionCard.tsx index 401d0c6..421d592 100644 --- a/client/src/components/booking/SessionCard/SessionCard.tsx +++ b/client/src/components/booking/SessionCard/SessionCard.tsx @@ -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 = ({ const busy = acquiringLocation || evvPending; return ( - + {title ? ( @@ -139,7 +135,7 @@ const SessionCard: FunctionComponent = ({ ) : null} - + ); }; diff --git a/client/src/components/booking/ics.ts b/client/src/components/booking/ics.ts new file mode 100644 index 0000000..874b130 --- /dev/null +++ b/client/src/components/booking/ics.ts @@ -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); +} diff --git a/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.test.tsx b/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.test.tsx new file mode 100644 index 0000000..0298c60 --- /dev/null +++ b/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.test.tsx @@ -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({ui}); +} + +describe(' component', () => { + it('renders the near-date chip strip and emits the ISO date on click', () => { + const onChange = jest.fn(); + const { container } = wrap(); + 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(); + 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(); + fireEvent.click(screen.getByLabelText('Pick another date')); + const gridDay = screen.getAllByText('15')[0]; + fireEvent.click(gridDay); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.tsx b/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.tsx new file mode 100644 index 0000000..d7e3227 --- /dev/null +++ b/client/src/components/common/JalaliDateIntentPicker/JalaliDateIntentPicker.tsx @@ -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 = ({ + value, + onChange, + min, + chipDayCount = 7, + todayLabel, + tomorrowLabel, + pickOtherLabel, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + + return ( + + + + + setAnchorEl(event.currentTarget)} + /> + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + transformOrigin={{ vertical: 'top', horizontal: 'center' }} + > + + { + onChange(iso); + setAnchorEl(null); + }} + /> + + + + ); +}; + +export default JalaliDateIntentPicker; diff --git a/client/src/components/common/JalaliDateIntentPicker/index.tsx b/client/src/components/common/JalaliDateIntentPicker/index.tsx new file mode 100644 index 0000000..9543664 --- /dev/null +++ b/client/src/components/common/JalaliDateIntentPicker/index.tsx @@ -0,0 +1,4 @@ +import JalaliDateIntentPicker from './JalaliDateIntentPicker'; + +export default JalaliDateIntentPicker; +export type { JalaliDateIntentPickerProps } from './JalaliDateIntentPicker'; diff --git a/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx index 693c24b..a1ab3a7 100644 --- a/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx +++ b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx @@ -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 = ({ chipDayCount = 14, prevMonthLabel, nextMonthLabel, + todayLabel, + tomorrowLabel, }) => { const locale = useLocale(); const engine = useMemo(() => engineForLocale(locale), [locale]); @@ -77,6 +83,9 @@ const JalaliDatePicker: FunctionComponent = ({ 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 ( = ({ opacity: disabled ? 0.5 : 1, }} > - - {weekday} + + {topLabel} {formatNumber(cell.day, locale)} diff --git a/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx b/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx new file mode 100644 index 0000000..90397f7 --- /dev/null +++ b/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import StickyActionBar from './StickyActionBar'; + +describe(' component', () => { + it('renders its children inside a sticky-bottom container', () => { + const { container } = render( + + + + + , + ); + expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument(); + const bar = container.querySelector('[data-sticky-action-bar]'); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' }); + }); +}); diff --git a/client/src/components/common/StickyActionBar/StickyActionBar.tsx b/client/src/components/common/StickyActionBar/StickyActionBar.tsx new file mode 100644 index 0000000..3082a8e --- /dev/null +++ b/client/src/components/common/StickyActionBar/StickyActionBar.tsx @@ -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 = ({ children }) => ( + + {children} + +); + +export default StickyActionBar; diff --git a/client/src/components/common/StickyActionBar/index.tsx b/client/src/components/common/StickyActionBar/index.tsx new file mode 100644 index 0000000..02f3e7d --- /dev/null +++ b/client/src/components/common/StickyActionBar/index.tsx @@ -0,0 +1,4 @@ +import StickyActionBar from './StickyActionBar'; + +export default StickyActionBar; +export type { StickyActionBarProps } from './StickyActionBar'; diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index 5b30e15..2c4c772 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -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'; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 54f4649..438ad5f 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -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'; diff --git a/client/src/services/search/apis/clientApi.ts b/client/src/services/search/apis/clientApi.ts index dfe15be..9dc4ccb 100644 --- a/client/src/services/search/apis/clientApi.ts +++ b/client/src/services/search/apis/clientApi.ts @@ -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, })), }; }, diff --git a/client/src/services/search/filterParams.ts b/client/src/services/search/filterParams.ts index e8a29dd..fb782a8 100644 --- a/client/src/services/search/filterParams.ts +++ b/client/src/services/search/filterParams.ts @@ -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; diff --git a/client/src/services/search/types.ts b/client/src/services/search/types.ts index 8db7fd5..d8bc73a 100644 --- a/client/src/services/search/types.ts +++ b/client/src/services/search/types.ts @@ -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. */ diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index d48f8e0..002ca36 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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=` — matches folded into the existing filter set + (AND'd with category/city/gender/price), same `Paginated` 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 `@ #` (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@ #` as the SMS's last line, where `` is the site's own host (no scheme, no trailing slash) and `` 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. diff --git a/dev/shared-working-context/reports/ui-phase-4-report.md b/dev/shared-working-context/reports/ui-phase-4-report.md new file mode 100644 index 0000000..17ce2f9 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-4-report.md @@ -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 `` 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 `` 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. diff --git a/dev/shared-working-context/reports/ui-phase-5-report.md b/dev/shared-working-context/reports/ui-phase-5-report.md new file mode 100644 index 0000000..2f13867 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-5-report.md @@ -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 ·