Compare commits

...

6 Commits

Author SHA1 Message Date
hamid 1ef4feb911 ui phase 8 2026-07-19 13:57:11 +03:30
hamid edc38543fd ui phase 7 2026-07-19 11:56:59 +03:30
hamid a438edeeaa ui phase 6 2026-07-19 09:49:25 +03:30
hamid 4c70d8e424 ui phase 5 2026-07-18 09:51:03 +03:30
hamid 53b4e1b0a4 ui phase 3 2026-07-18 01:56:17 +03:30
hamid 222856d600 ui phase 2 2026-07-17 19:05:17 +03:30
216 changed files with 10530 additions and 2355 deletions
+31 -9
View File
@@ -147,7 +147,7 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
| `AppImage` | images | wrapper around next/image conventions |
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
| `UserInfo` | user avatar/identity block | feature component |
| `ProfileSummary` | the identity card in chrome | avatar+name+masked phone+role label+optional `TrustBadge`; vertical or `compact` horizontal chip |
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
@@ -166,19 +166,41 @@ CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
## 5. Layout & page shells
- **Private (authenticated) screens** render inside `PrivateLayout`
`TopBarAndSideBarLayout` (`src/layout/`): a `TopBar` + a `SideBar` (variant
`sidebarPersistentOnDesktop`: persistent ≥desktop, temporary drawer on mobile) +
a dark-mode toggle. Sidebar nav items are `{ title, path, icon }` arrays built with
`useTranslations('nav')`. Page content is auto-wrapped in `ErrorBoundary`.
- **Public screens** use `PublicLayout`.
- **Four per-actor shells**, each wrapped in `RoleGuard` (don't touch): `CustomerLayout`
(mobile-first — contextual `TopBar`: brand lockup on the 5 root tabs, title + back
chevron on pushed routes, an inline desktop top-nav at `≥md` replacing the mobile
`BottomBar`), `NurseLayout` / `AdminLayout` / `PartnerLayout` (all three share
`TopBarAndSideBarLayout`, `src/layout/`). All chrome navigation goes through
`@/i18n/navigation` (`Link`/`usePathname`/`useRouter`) — never a raw `next/link` or a
manual `` `/${locale}` `` prefix.
- `TopBarAndSideBarLayout`: a fixed `TopBar` (title from `useRouteTitle()`, the
route→title map in `layout/routeTitle.tsx`) + a `SideBar` rendered as **two Drawers
sharing one content tree** — mobile `temporary` and desktop `variant="permanent"` —
switched purely by `sx` breakpoint, not `useIsMobile()`; the permanent Drawer is a
normal flex sibling of the main column, so desktop reserves its own width with no
manual offset math and no post-hydration layout jump. Optional `identity` (TopBar
chip — admin/partner), `sidebarIdentity` (sidebar card — nurse's `ProfileSummary`),
and `mobileBottomBar` slots.
- Sidebar nav items are `{ title, path, icon, group? }` arrays (`@/utils`'s
`LinkToPage`) built with `useTranslations('nav')`; a shared `group` string on
consecutive items renders a `ListSubheader` section (see `NurseLayout`/`AdminLayout`).
Selection is computed once via the shared `matchActivePath` (longest-prefix,
winner-takes-all) helper — reuse it for any new nav list, never hand-roll
`pathname.startsWith`.
- **Public screens** use `PublicLayout` — a minimal corner strip (logo +
`LocaleSwitcher` + dark toggle), no sidebar/bottom bar; the step content (`AuthCard`)
carries its own larger `BrandMark`, so don't duplicate a big lockup in the shell.
- Page content is auto-wrapped in `ErrorBoundary` inside every shell.
- Shell dimensions are constants in `src/layout/config.ts` (`SIDE_BAR_WIDTH = 240px`,
top-bar `56px` mobile / `64px` desktop, anchors). Respect them; don't hard-code.
top-bar `56px` mobile / `64px` desktop). Respect them; don't hard-code.
- A page is `src/app/[locale]/(private|public-routes)/…/page.tsx`. Keep page bodies to
composition + content; push reusable visuals into `src/components/`.
- Constrain reading width with `CONTENT_MAX_WIDTH` (800) for text-heavy views; full-bleed
is fine for dashboards/tables.
- Use `useIsMobile()` (`@/hooks`) for responsive branching, or MUI breakpoints in `sx`.
- Prefer MUI breakpoints in `sx` (`{ xs: …, md: … }`) for responsive branching over
`useIsMobile()` (`@/hooks`) — the latter is JS/post-hydration and is what caused the
desktop SSR flash `TopBarAndSideBarLayout` now avoids; reach for it only for genuinely
non-structural, JS-only behavior.
---
+115 -82
View File
@@ -125,61 +125,64 @@ 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)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── 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)
│ │ │ │ ├── [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
│ │ │ │ ├── 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), ui-phase-6 fiscal-grade pass: number + Shamsi date + buyer/service/visit-date recap (client-joined off useCustomerProfile + useBookingDetail) + payment method/transaction reference + seller fiscal-identity block (all REQ-049), reconciling lines with the VAT-on-commission line, read-only مودیان state; A4 `@page` print stylesheet + print-only footer; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── 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)
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment)
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → approved(ceiling)/declined(+card)
│ │ │ │ ├── [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=), ui-phase-6 trust-forward redesign
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — identity moment (nurse avatar+TrustBadge, REQ-046), prominent `<Money size="xl">` total, served reconciling breakdown (PriceBreakdown), EscrowExplainer, safe-area-aware sticky pay bar (StickyActionBar: total+CTA+secure-gateway trust line), both CTAs disable during initiate, explicit «بازگشت به درخواست» text link, un-baked continue arrow (endIcon="forward")
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → staged 2-node pending progress (StatusTimeline «بازگشت از درگاه ✓ → در انتظار تایید بانک» + duration hint, replacing the old spinner+chip+title stack) → succeeded (invalidate + hand off) / failed retry / window-expired, all via the shared PaymentStateCard (on the real path the PSP redirectUrl is absolute)
│ │ │ │ ├── confirmation/page.tsx # payment success rebuilt as a receipt (ui-phase-6): copyable LTR کد پیگیری + copy-to-clipboard, Shamsi paid-at, method, booking reference (all REQ-046, hidden gracefully on the real path), EscrowExplainer, a "what happens next" 2-step StatusTimeline, real loading/error states (never a silently missing amount); «مشاهده رزرو» + «دانلود فاکتور»; REUSED by f11 (?method=bnpl reads the settled BnplOrderStatus instead of the payment outcome)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=), ui-phase-6 honesty + polish pass
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere; terminal cards via the shared PaymentStateCard
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded); provider mark via BnplProviderLogo (replaces the two-letter glyph stand-in)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group; the «مبلغ کل» header only renders once a plan is selected and **names** it («مبلغ کل با طرح {plan}») + the fee delta, never a silent plans[0] default
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل (readOnly presentation, not disabled) + consent gate → useCheckEligibility (in-progress spinner + «در حال استعلام اعتبار…» label) → approved(ceiling)/declined(+card)
│ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff
│ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card
│ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return; env-gated `notFound()` outside `development` (ui-phase-6 — was reachable in production builds)
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card; the invalid-link CTA label now matches its destination (ui-phase-6 fix)
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13)
│ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later)
│ │ │ ├── wallet/ # /wallet — ui-phase-6 rebuilt into the customer money hub (page.tsx → WalletScreen.tsx: 4 Tabs, one shared `CONTENT_MAX_WIDTH`, no local width override): «پرداخت‌ها» (WalletPaymentHistory — card `usePaymentHistory` (REQ-047) + BNPL down-payment rows merged via the co-located `useWalletHistoryRows`), «اقساط» (WalletInstallments — the unchanged f11 D5 provider-reported outstanding balance + due list + early-pay hand-off, now section-only, no own heading/width), «استردادها» (WalletRefunds — `useMyRefunds`, REQ-048, one RefundStatusCard per refund), «رسیدها» (WalletReceipts — client-derived invoice deep-links off the same merged history rows, no endpoint)
│ │ │ ├── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # /nurse (dashboard) — RSC; generateMetadata (nav.dashboard) inline (no split — already a Server Component)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here
│ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views
│ │ │ ├── page.tsx # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
│ │ │ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar
│ │ │ │ ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
│ │ │ │ ├── review/page.tsx # B6under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ ── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
│ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
│ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
│ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout
│ │ │ ├── DashboardActivationSlot.tsx # ui-phase-8 — the named composition point from ui-phase-7's hand-off, now filled with the shared `ActivationChecklist` (same component as `/nurse/services`) instead of the old single-row verification banner
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/ # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor. ui-phase-8: the separate whole-city/districts scope toggle is gone — `CascadingRegionSelect`'s own district level (its «کل شهر» empty option) is the ONLY control for the whole-city choice; `removeArea` now has an onError toast
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch; `BankStatusPanel` unchanged). ui-phase-8: restructured as an accounts section — a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (never dead-ends a nurse switching banks), an explicit pending-inquiry copy, and a real error state (never the empty-state form) on a failed query
│ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone)
│ │ │ │ ├── page.tsx # B3 hub — grouped step cards (هویت/مدارک حرفه‌ای/بانک, `VerificationChecklist`) + `TrustBadgePreviewPanel` («این نشان را خانواده‌ها می‌بینند», fills per group) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
│ │ │ │ ├── identity/page.tsx # B4national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar; a cheap CSS `CaptureGuideFrame` (viewfinder corners / oval) + static hint per capture, `VerificationJourneyHeader` replaces the old StepperHeader
│ │ │ │ ├── credentials/page.tsx # B5 — hydrates INO/specialties/registry fields from `status.credentialSubmission` (REQ-056, mock-tolerant) so a returning nurse sees a submitted summary, never blank fields; the INO number locks into a "شمارهٔ نظام ثبت شد" row (never re-prompted, never re-sent blank — the raw value is never read back by design); Jalali `JalaliDateField`s replace the native `type="date"` issue/expiry inputs; the submit gate considers server-side document state too, so a returning nurse is never dead-ended on a disabled button with no explanation
│ │ │ │ ── review/page.tsx # B6 — under-review; a Shamsi submitted timestamp (REQ-055, mock-tolerant) + a `StatusTimeline` what-happens-next (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعال‌سازی نشان) + `VerificationJourneyHeader`
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: grouped step cards over `groupedDisplaySteps` (co-located, page-only)
│ │ │ │ ├── TrustBadgePreviewPanel.tsx # B3's payoff — live TrustBadge + a per-group fill indicator, never fakes a state beyond `ownBadgeState`
│ │ │ │ ├── VerificationJourneyHeader.tsx # the ONE progress header B4/B5/B6 share — group name + «بازگشت به مسیر تأیید» (reused across 3 sibling pages)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step + ui-phase-8's group→steps folding (`groupedDisplaySteps`/`groupStatus`/`GROUP_ORDER`) — keeps rendering data-driven
│ │ │ ├── visits/ # /nurse/visits — f8 EVV, ui-phase-7 day-surface + detail pass: page.tsx = Shamsi «امروز، …» date anchor + ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner; 60s refetchInterval; SessionCard's EVV CTA is now the full-width hero action + a confirm step on check-out) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": address card + geo: map link, an in-visit «در حال ویزیت» banner promoting check-out, EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords)
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
@@ -207,12 +210,19 @@ client/
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
│ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
│ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
│ │ └── onboarding/ # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup
│ │ ├── page.tsx # Thin RSC — generateMetadata (onboarding.welcome_title) + renders OnboardingScreen
│ │ └── OnboardingScreen.tsx # 'use client' — welcome moment (not a stepper step) → relation (4 distinct icons: elderly/favorite/infant/account) → patient (StepperHeader 2 steps)
│ └── (public-routes)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ ├── loading.tsx # Auth-card-shaped skeleton (brand mark + a card-sized block)
── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
└── LoginScreen.tsx # 'use client' — the actual LoginFlow body
── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
└── LoginScreen.tsx # 'use client' — the actual LoginFlow body
│ ├── terms/page.tsx # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
│ └── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
│ ├── common/ # Foundational primitives (import from @/components or @/components/common)
│ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4)
@@ -224,17 +234,21 @@ client/
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
│ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); imports next-intl (see jest.config.ts transformIgnorePatterns note below)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; 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)
│ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (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
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
│ ├── 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
@@ -242,21 +256,26 @@ client/
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── 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)
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested)
│ ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (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)
│ ├── 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)
│ ├── 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; ui-phase-6 switched the row amounts to `<Money>` too (every row now carries «تومان», not just the total) (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — the untouchable inner sentence; C6/confirmation now wrap it in `EscrowExplainer` (tested)
│ ├── EscrowExplainer/ # ui-phase-6 — wraps `EscrowNotice` with an optional «چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی) grounded in product/payments/escrow-ledger.md + the cancellation/refund implication; checkout + confirmation (tested)
│ ├── PaymentStateCard/ # ui-phase-6 — the one terminal/wait-state card (icon/tone/title/body/actions) replacing the four copy-pasted private `MessageCard`/`StateCard` functions across the card + BNPL checkout/return flows (tested)
│ ├── BnplProviderLogo/ # ui-phase-6 — providerCode → bundled SVG (none licensed yet) falling back to a designed tinted-monogram roundel, replacing the two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's MethodStep (tested)
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
│ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
│ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + served monthly amount, a plain پیش‌پرداخت amount row, and مجموع بازپرداخت with the fee delta vs. paying in full spelled out in Toman (ui-phase-6 replaced the percent-only label + `LinearProgress` bar — a static fact must not look like loading); single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount (every row carries «تومان» since ui-phase-6) + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
@@ -264,28 +283,36 @@ client/
│ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested)
│ ├── 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)
│ ├── 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)
│ ├── 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; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, 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; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), 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; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), 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
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment)
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
── request.ts # getRequestConfig — loads messages/${locale}.json
├── layout/
── request.ts # getRequestConfig — loads messages/${locale}.json
│ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop
├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: TopBar + BottomBar (5-tab); useTranslations('nav')
│ ├── NurseLayout.tsx # 'use client' — nurse shell via TopBarAndSideBarLayout; useTranslations('nav')
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout (persistent sidebar)
│ ├── PublicLayout.tsx # unauthenticated shell
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — TopBar + SideBar composition (nurse/admin engine)
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer)
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
│ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot (area phases feed real names in later) + `useRouteTitle`; `isCustomerRootTab`/`CUSTOMER_ROOT_TABS` for the customer header's brand-lockup-vs-title branch
│ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — used by SideBarNavList and BottomBar so a nested route still lights up its parent tab, never a sibling
│ ├── config.ts
│ ├── index.ts
│ └── components/
│ ├── TopBar.tsx
│ ├── SideBar.tsx
│ ├── SideBarNavList.tsx
│ ├── SideBarNavItem.tsx
│ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
│ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot
│ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop
│ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header
│ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested)
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
│ └── index.tsx
├── lib/
@@ -308,9 +335,9 @@ client/
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam). ui-phase-8 added `setAcceptingBookings` (`useSetAcceptingBookings`) — the real, previously-unwired `POST nurse_profiles/set_accepting_bookings` go-live switch (mock mirrors the flip; real+mock both invalidate `profileKeys.nurse()`)
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
│ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
@@ -320,9 +347,9 @@ client/
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error)/usePaymentHistory(ui-phase-6, wallet «پرداخت‌ها», REQ-047); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast. ui-phase-6 added `nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` + `trackingCode`/`paidAt` on `PaymentOutcomeDto` (REQ-046) and `paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` (REQ-049) — mock-populated, `null` on the real path until served
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund/getMyRefunds(ui-phase-6, wallet «استردادها», REQ-048). useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal)/useMyRefunds; invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider. ui-phase-6: no API-shape change, UI-only honesty/polish pass (BnplPlanCard Toman rows + fee delta, PlanStep names the selected plan, BnplProviderLogo, eligibility progress feedback, gateway harness env-gated)
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
@@ -448,19 +475,20 @@ async function MyServerComponent() {
- `'common'``DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …)
- `'shell'` — actor-shell titles + the not-yet-built placeholder body
- `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive)
- `'onboarding'` — the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels)
- `'onboarding'` — the A3→A4 wizard (ui-phase-3 added `welcome_*` for the pre-wizard welcome moment) + the shared enum labels (relation/condition/gender codes → labels)
- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
- `'profile'` — the customer profile + emergency contact
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder); ui-phase-8 added editable education level/field + specialties (`education_*`, `specializations_label`), the public-profile preview (`preview_*`), and the avatar/save error copy
- `'activation'` — ui-phase-8 — the shared `ActivationChecklist` row labels (`row_identity`/`row_profile`/`row_services`/`row_coverage`/`row_bank` + the "getting paid, not search-visibility" hint) and the collapsed «فعال در جستجو» state; consumed by `ActivationChecklist` wherever it's mounted (services page, dashboard slot)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states); ui-phase-8 added the accounts-section copy (`add_another`, `load_error`) and an explicit pending-inquiry duration hint
- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints)
- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link
- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'coverage'` — the nurse coverage-area editor (chips, duplicate + "won't appear in search" warnings); ui-phase-8 dropped the now-unused `scope_*`/`district_required` keys once the separate scope toggle was removed, added `remove_error`
- `'catalog'`**shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm); ui-phase-8 added the live-preview (`preview_heading`/`preview_untitled`) and 409-recovery (`duplicate_edit_existing`) copy, replaced `publish_*` with the state-driven go-live copy (`publish_start_accepting`/`publish_pause_accepting`/`publish_live_*`/`publish_unmet_intro` — the old no-op `publish_done`/`publish_cta` are gone)
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, shared-SIM/mismatch messages; ui-phase-8 replaced `journey_identity`/`journey_credentials`/`journey_review` with the journey-group labels (`group_identity`/`group_credentials`/`group_bank`/`group_review`, shared by `VerificationJourneyHeader`), added the payoff panel copy (`payoff_*`), the B4 capture hints (`capture_hint_card`/`capture_hint_selfie`), the B5 hydration copy (`ino_number_submitted`/`ino_number_change`/`credentials_needs_document`), and the B6 timeline labels (`review_timeline_*`)
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*``bnpl_revert` 710-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'bnpl'` — the f11 BNPL installment checkout (D1D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
@@ -469,7 +497,8 @@ async function MyServerComponent() {
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note`
- `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
@@ -854,7 +883,11 @@ private-routes layout) hydrates `currentUser.roles` from `/me` — the single so
on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes
the session) clears tokens and redirects to `/login`. The refresh/OTP endpoints are excluded from this retry.
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render.
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render. On
redirect it appends the attempted locale-stripped path + query as `?next=` (`RETURN_URL_PARAM`) so a deep
link survives the round trip; `LoginFlow` reads it and `RoleRouter` resolves it via
`resolvePostLoginDestination` (`services/auth/routing.ts`) — same-origin-relative + role-permitting only,
else it falls back to `resolveRoleDestination` (never an open redirect).
**Security posture — current limits and best-practice follow-ups.** The flow above is the intended
client design, but some hardening needs *server* coordination — don't silently "fix" it client-only:
+305 -41
View File
@@ -32,7 +32,17 @@
"partner_home": "Center",
"partner_nurses": "Sponsored nurses",
"partner_bookings": "Bookings",
"partner_settlement": "Settlement"
"partner_settlement": "Settlement",
"search": "Search",
"checkout": "Checkout",
"addresses": "Addresses",
"more": "More",
"group_today": "Today",
"group_profession": "My profession",
"group_finance": "Finance",
"group_support": "Support",
"group_trust": "Trust",
"group_system": "System"
},
"common": {
"dark_mode": "Dark mode",
@@ -51,14 +61,21 @@
"optional": "Optional",
"currency_toman": "Toman",
"brand": "Balinyaar",
"brand_tagline": "Home care you can trust"
"brand_tagline": "Home care you can trust",
"open_sidebar": "Open menu",
"switch_locale": "Switch to {locale}",
"page_prev": "Previous",
"page_next": "Next",
"page_indicator": "Page {page} of {total}"
},
"shell": {
"customer_app": "Family app",
"nurse_app": "Nurse view",
"admin_console": "Admin console",
"placeholder_body": "This area will be built in a later phase.",
"partner_console": "Partner portal"
"partner_console": "Partner portal",
"switch_to_nurse": "Nurse view",
"switch_to_customer": "Family app"
},
"home": {
"greeting_named": "Hi, {name}",
@@ -75,7 +92,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",
@@ -105,7 +126,10 @@
"condition_dementia": "Dementia",
"continue": "Continue",
"save_continue": "Save and continue",
"saved": "Patient saved"
"saved": "Patient saved",
"welcome_title": "Welcome — who's this care for?",
"welcome_subtitle": "A couple of quick steps and you're ready to book a nurse.",
"welcome_cta": "Let's start"
},
"patients": {
"title": "Patients",
@@ -144,7 +168,9 @@
"saved": "Profile saved",
"completion_done": "Your profile is complete.",
"completion_todo": "Complete your profile to speed up bookings.",
"load_error": "Couldn't load your profile."
"load_error": "Couldn't load your profile.",
"app_language": "App language",
"sign_out": "Sign out"
},
"nurseProfile": {
"title": "Nurse profile",
@@ -164,7 +190,35 @@
"unverified_cta": "Complete verification",
"deferred_services": "Manage your services & prices in the Services section; available days come in a later step.",
"save_error": "Couldn't save your profile. Please try again.",
"avatar_upload_error": "Couldn't upload your photo. Please try again."
"avatar_upload_error": "Couldn't upload your photo. Please try again.",
"education_level_label": "Education level",
"education_level_diploma": "Diploma",
"education_level_associate": "Associate degree",
"education_level_bachelor": "Bachelor's degree",
"education_level_master": "Master's degree",
"education_level_doctorate": "Doctorate",
"education_field_label": "Field of study",
"education_field_nursing": "Nursing",
"education_field_midwifery": "Midwifery",
"education_field_anesthesia": "Anesthesia",
"education_field_operating_room": "Operating room technology",
"education_field_public_health": "Public health",
"education_other": "Other",
"education_level_other_label": "Enter your education level",
"education_field_other_label": "Enter your field of study",
"specializations_label": "Specialties",
"preview_cta": "Preview my public profile",
"preview_title": "My public profile",
"preview_subtitle": "This is what families see when they find you.",
"back_to_profile": "Back to profile",
"preview_reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"preview_completed_visits": "{count, number} successful visits",
"preview_years_experience": "{years} years' experience",
"preview_verified_title": "What Balinyaar verified",
"preview_services_title": "Services",
"preview_services_empty": "No active services yet — add one in the Services section.",
"preview_coverage_title": "Coverage areas",
"preview_coverage_empty": "No coverage areas yet."
},
"bank": {
"title": "Bank account",
@@ -179,9 +233,11 @@
"submitting": "Submitting…",
"added": "Account submitted — ownership inquiry started",
"add_error": "This account couldn't be added. Check the IBAN and try again.",
"add_another": "Add another account",
"load_error": "Couldn't load your bank accounts.",
"status_pending_chip": "Checking",
"status_pending_title": "Verifying account ownership",
"status_pending_body": "The Sheba ownership inquiry is running; this can take a moment.",
"status_pending_body": "Checking the IBAN's ownership — this usually takes a few minutes.",
"status_verified_chip": "Verified",
"status_verified_title": "Account verified",
"status_verified_body": "This account is ready to receive your payouts.",
@@ -191,6 +247,7 @@
"reenter": "Enter another account",
"make_primary": "Make primary",
"primary_set": "Primary account updated",
"primary_set_error": "Couldn't update the primary account. Please try again.",
"iban_masked_label": "IBAN",
"primary": "Primary",
"empty_title": "No bank account yet",
@@ -247,9 +304,6 @@
"subtitle": "The cities and districts you'll travel to.",
"areas_heading": "Your areas",
"add_title": "Add a coverage area",
"scope_label": "Coverage",
"scope_whole_city": "Whole city",
"scope_districts": "Specific districts",
"add": "Add area",
"adding": "Adding…",
"whole_city_chip": "Whole city",
@@ -257,13 +311,13 @@
"empty_warning": "You won't appear in search until you add at least one coverage area.",
"duplicate": "You already cover this area.",
"city_required": "Select a city",
"district_required": "Select a district, or switch to whole city.",
"added": "Coverage area added",
"add_error": "This area couldn't be added. Please try again.",
"remove_title": "Remove coverage area?",
"remove_body": "You'll no longer be matched for visits in this area.",
"remove_confirm": "Remove",
"removed": "Coverage area removed"
"removed": "Coverage area removed",
"remove_error": "This area couldn't be removed. Please try again."
},
"catalog": {
"unit_per_hour": "hourly",
@@ -323,6 +377,9 @@
"display_name_label": "Display name",
"display_name_hint": "Auto-generated from your options — you can edit it.",
"duplicate_warning": "You already have a service with these exact details. Change an option or the category.",
"duplicate_edit_existing": "Edit that existing listing instead",
"preview_heading": "This is what appears in search",
"preview_untitled": "New service",
"summary_options": "Options",
"summary_none": "No options",
"next": "Next",
@@ -334,6 +391,21 @@
"list_error": "Couldn't load your services.",
"options_error": "Couldn't load this category's options."
},
"activation": {
"title": "Get set up",
"subtitle_visibility": "These control whether you appear in search.",
"row_identity": "Identity & document verification",
"row_profile": "Complete your profile",
"row_services": "At least one active service",
"row_coverage": "A coverage area",
"row_bank": "A verified bank account",
"row_bank_hint": "For getting paid — not required to appear in search.",
"row_fix": "Complete",
"live_title": "Active in search",
"live_body": "Setup is complete and you're accepting bookings — families can find and book you.",
"load_error": "Couldn't load your setup status.",
"retry": "Retry"
},
"search": {
"title": "Find a nurse",
"subtitle": "Only verified, background-checked nurses appear here.",
@@ -347,30 +419,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",
@@ -399,6 +482,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",
@@ -414,9 +498,18 @@
"address_empty": "You haven't added an address yet.",
"address_add_cta": "Add an address",
"address_whole_city": "Whole city",
"address_change_cta": "Change",
"date_label": "Date",
"date_today": "Today",
"date_tomorrow": "Tomorrow",
"date_pick_other": "Pick another date",
"time_start_label": "From",
"time_end_label": "To",
"time_window_label": "Visit time window",
"window_morning": "Morning 812",
"window_afternoon": "Afternoon 1216",
"window_evening": "Evening 1620",
"window_custom": "Custom time",
"gender_label": "Caregiver gender",
"gender_hint": "For personal/bodily care, a same-gender caregiver matters. Your choice is sent with the request.",
"notes_label": "Notes for the nurse",
@@ -438,6 +531,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",
@@ -445,6 +545,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",
@@ -454,7 +557,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",
@@ -463,6 +567,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",
@@ -500,6 +606,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",
@@ -544,6 +652,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.",
@@ -568,11 +677,58 @@
"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",
"address_title": "Address",
"address_open_map": "Directions",
"address_open_web_map": "Show on map",
"address_pending_note": "The address will be available once the booking is confirmed.",
"in_visit_title": "Visit in progress",
"in_visit_elapsed": "On site {duration}",
"evv_checkout_confirm_title": "End this visit?",
"evv_checkout_confirm_body": "Checking out ends this visit and starts the payout clock.",
"evv_checkout_confirm_cta": "Yes, check out",
"evv_checkout_confirm_cancel": "Cancel",
"today_date_prefix": "Today, {date}",
"evv_visits_error": "Couldn't load today's visits.",
"nurse_inbox_tab_pending": "Awaiting response",
"nurse_inbox_tab_answered": "Answered",
"nurse_inbox_tab_expired": "Expired",
"inbox_countdown_pill_label": "Respond by",
"accept_confirm_title": "Accept this request?",
"accept_confirm_body": "Accepting invites the family to pay; the booking is confirmed once they do.",
"accept_confirm_cta": "Yes, accept the request"
},
"dashboard": {
"greeting": "Hi, {name}",
"retry": "Retry",
"next_visit_title": "Next visit",
"next_visit_empty": "No visits scheduled for today.",
"next_visit_starts_in": "starts {relative}",
"next_visit_cta": "View and check in",
"next_visit_error": "Couldn't load today's visits.",
"requests_strip_title": "{count, plural, =0 {No pending requests} one {# request awaiting your response} other {# requests awaiting your response}}",
"requests_strip_empty": "You have no new requests.",
"requests_strip_cta": "View all",
"requests_strip_open": "View",
"requests_strip_error": "Couldn't load your requests.",
"earnings_snapshot_title": "Earnings",
"earnings_snapshot_cta": "View details",
"earnings_snapshot_error": "Couldn't load your balance.",
"notifications_entry_title": "Notifications",
"notifications_entry_unread": "{count} unread",
"notifications_entry_empty": "You're all caught up"
},
"payment": {
"title_checkout": "Confirm & pay",
@@ -582,12 +738,22 @@
"row_vat": "VAT",
"row_total": "Total",
"escrow_notice": "The amount is held in escrow with Balinyaar and released after the visit ends",
"cta_pay": "Continue to payment →",
"escrow_explainer_toggle": "How does this work?",
"escrow_step_pay": "Payment",
"escrow_step_hold": "Held in escrow with Balinyaar",
"escrow_step_release": "Released once the visit is confirmed complete",
"escrow_cancellation_note": "If you cancel before the visit, the amount is refunded per the cancellation policy.",
"total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment",
"bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…",
"state_pending_title": "Confirming your payment…",
"state_pending_hint": "The gateway is confirming your payment; this usually takes a few moments.",
"stage_returned": "Returned from the gateway",
"stage_confirming": "Awaiting bank confirmation",
"state_pending_duration_hint": "This usually takes less than a minute.",
"check_again": "Check again",
"state_failed_title": "Payment failed",
"state_failed_hint": "Nothing was charged. You can try again.",
@@ -604,10 +770,31 @@
"view_booking": "View booking",
"download_invoice": "Download invoice",
"total_paid_label": "Amount paid",
"receipt_tracking_code_label": "Tracking code",
"receipt_paid_at_label": "Paid on",
"receipt_method_label": "Payment method",
"receipt_booking_ref_label": "Booking reference",
"copy_tracking_code": "Copy tracking code",
"tracking_code_copied": "Tracking code copied",
"method_card": "Bank card",
"method_bnpl_provider": "Installments — {provider}",
"next_steps_title": "What happens next",
"next_step_nurse_notified": "The nurse has been notified",
"next_step_visit_checkin": "Visit day check-in",
"view_invoice_cta": "View invoice",
"invoice_title": "Invoice",
"invoice_number_label": "Invoice number",
"invoice_issued_at": "Issued on",
"invoice_buyer_label": "Buyer",
"invoice_service_label": "Service",
"invoice_visit_dates_label": "Visit date",
"invoice_visit_dates_multi": "{date} ({count, plural, one {# visit} other {# visits}})",
"invoice_transaction_ref_label": "Transaction reference",
"invoice_method_bnpl": "Installments",
"invoice_vat_on_commission": "VAT (on the Balinyaar fee)",
"invoice_seller_economic_code_label": "Economic code",
"invoice_footer_reference": "Invoice {number} · issued {date}",
"invoice_footer_moadian": "Moadian reference: {ref}",
"invoice_not_issued_title": "The invoice has not been issued yet",
"invoice_not_issued_body": "The invoice for this booking will appear here once issued.",
"print_invoice": "Print invoice",
@@ -619,11 +806,6 @@
"pstatus_pending": "Awaiting confirmation",
"pstatus_succeeded": "Paid",
"pstatus_failed": "Failed",
"gateway_title": "Test payment gateway",
"gateway_hint": "This page stands in for the real gateway in development.",
"gateway_reference_label": "Reference",
"gateway_pay_success": "Pay successfully",
"gateway_pay_fail": "Simulate a failed payment",
"error_title": "Something went wrong",
"error_body": "We couldn't load the payment summary.",
"invalid_link_body": "This payment link is not valid.",
@@ -656,11 +838,16 @@
"role_customer_desc": "Book nurses and home care",
"role_nurse": "Nurse",
"role_nurse_desc": "Offer nursing services",
"role_add_later_note": "You can add the other role from your profile anytime.",
"continue": "Continue",
"guard_denied": "You don't have access to that area.",
"account_error_title": "Couldn't load your account",
"account_error_body": "We couldn't reach Balinyaar to load your account. Check your connection and try again.",
"account_error_retry": "Try again"
"account_error_retry": "Try again",
"trust_verified_nurses": "Licensed, identity-verified nurses",
"trust_escrow_payment": "Your payment stays in escrow until the visit is confirmed",
"trust_support": "Support at every step of care",
"consent_line": "By continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>."
},
"verification": {
"title": "Verification",
@@ -706,9 +893,13 @@
"reason_kyc_no_match": "Your identity details didn't match the civil registry.",
"reason_national_id_mismatch": "The national ID didn't match.",
"reason_blurry_scan": "The document image wasn't clear. Please upload a sharper copy.",
"journey_identity": "Identity",
"journey_credentials": "Credentials",
"journey_review": "Review",
"journey_back": "Back to the verification journey",
"group_identity": "Identity",
"group_credentials": "Professional credentials",
"group_bank": "Bank",
"group_review": "Review",
"payoff_title": "This is the badge families will see",
"payoff_subtitle": "It fills in as you complete each group below.",
"identity_title": "Verify identity",
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
"national_id_label": "National ID",
@@ -717,8 +908,10 @@
"card_label": "National-ID card image",
"card_hint": "Photograph your ID card in good light, clearly readable.",
"card_recommended": "Uploading the ID card image is recommended.",
"capture_hint_card": "Good lighting, no blur, all four corners of the card inside the frame.",
"selfie_label": "Liveness selfie",
"selfie_hint": "Take a selfie for face verification.",
"capture_hint_selfie": "Good lighting, face centered and unobstructed.",
"auto_registry_note": "An automatic civil-registry check is performed.",
"error_national_id_mismatch": "The national ID didn't match the civil registry. Please check and try again.",
"error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
@@ -733,6 +926,9 @@
"ino_number_label": "Nursing-council number",
"ino_number_hint": "Enter your nursing-council membership number.",
"ino_number_required": "Enter your nursing-council number.",
"ino_number_submitted": "Nursing-council number on file",
"ino_number_change": "Change",
"credentials_needs_document": "Upload at least one document to submit.",
"doc_uploaded": "Document uploaded",
"education_label": "Education certificate",
"education_hint": "Optional — an image of your latest qualification.",
@@ -757,6 +953,9 @@
"review_title": "Under review",
"review_body": "Your documents were submitted and are being reviewed by our team.",
"review_eta": "This usually takes 2448 hours.",
"review_timeline_submitted": "Documents submitted",
"review_timeline_review": "Reviewed by our team",
"review_timeline_activation": "Trust badge activated",
"review_approved_title": "Verification complete",
"review_approved_body": "Every step is verified. You can now publish your profile.",
"review_summary_title": "Status summary",
@@ -764,13 +963,25 @@
"badge_verified": "Verified",
"badge_unverified": "Not verified",
"badge_expired": "Expired",
"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",
"publish_blocked_body": "Until verification is complete, your services won't appear in search and can't be booked.",
"publish_cta": "Publish profile",
"publish_complete_verification": "Complete verification",
"publish_done": "Your profile has been published",
"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 go live",
"publish_ready_body": "Setup is complete. Turn on bookings to appear in search and start receiving requests.",
"publish_blocked_title": "Finish setup to go live",
"publish_unmet_intro": "To appear in search: {items}.",
"publish_start_accepting": "Start accepting bookings",
"publish_pause_accepting": "Pause bookings",
"publish_toggling": "Updating…",
"publish_live_title": "Live in search",
"publish_live_body": "Families can find and book you right now.",
"publish_accepting_success": "You're now accepting bookings",
"publish_paused_success": "Bookings paused — you won't appear in search until you resume",
"publish_toggle_error": "Couldn't update your booking status. Please try again.",
"mock_admin_title": "Simulate admin review (demo)",
"mock_admin_approve": "Approve all steps",
"mock_admin_reject": "Reject a document",
@@ -791,6 +1002,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",
@@ -811,6 +1025,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",
@@ -858,13 +1073,18 @@
"eta_business_days": "About 710 business days",
"eta_expected_label": "estimated by {date}",
"cancel_booking_cta": "Cancel booking",
"refund_section_title": "Refund"
"refund_section_title": "Refund",
"wallet_empty_title": "No refunds yet",
"wallet_empty_body": "Refunds for cancelled bookings will appear here.",
"wallet_booking_label": "Booking #{id}"
},
"bnpl": {
"title": "Installment payment",
"ownership_note": "The provider pays Balinyaar the full amount at once and bears 100% of the customer's default risk; installment repayment is between you and the provider.",
"payable_amount": "Payable amount",
"total_amount": "Total amount",
"total_amount_named": "Total with the {plan} plan",
"total_repayment": "Total repayment",
"plan_fee_amount_suffix": "fee",
"monthly": "Monthly",
"continue": "Continue",
"error_title": "Something went wrong",
@@ -903,6 +1123,7 @@
"mobile_label": "Mobile number",
"consent_label": "I agree to a credit and eligibility inquiry by {provider}.",
"check_eligibility": "Check eligibility",
"checking_eligibility": "Checking eligibility…",
"approved_title": "You're approved",
"credit_ceiling_label": "Available credit ceiling",
"approve_continue": "Confirm & continue",
@@ -947,7 +1168,17 @@
"step_provider": "Provider",
"step_plan": "Plan",
"step_eligibility": "Eligibility",
"step_schedule": "Schedule"
"step_schedule": "Schedule",
"wallet_hub_title": "Wallet",
"tab_payments": "Payments",
"tab_installments": "Installments",
"tab_refunds": "Refunds",
"tab_receipts": "Receipts",
"history_empty_title": "No payments yet",
"history_empty_body": "Your card and installment payments will appear here.",
"history_view_booking": "View booking",
"receipts_empty_title": "No receipts yet",
"receipts_empty_body": "Receipts for your completed payments will appear here."
},
"payouts": {
"title": "Earnings",
@@ -1031,12 +1262,19 @@
"net_amount_label": "Net amount",
"amount_transferred_label": "Transferred",
"detail_bookings_title": "Bookings covered",
"detail_bookings_hint": "This transfer paid for these visits."
"detail_bookings_hint": "This transfer paid for these visits.",
"forecast_label": "Next payout",
"forecast_on_date": "on {date}",
"failure_code_invalid_sheba": "The account (Sheba) number is invalid.",
"failure_code_unknown": "The transfer failed."
},
"reviews": {
"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…",
@@ -1594,5 +1832,31 @@
"not_found_title": "Page not found",
"not_found_body": "The page you are looking for does not exist or has moved.",
"go_home": "Go home"
},
"legal": {
"terms_title": "Terms of Service",
"privacy_title": "Privacy Policy",
"draft_banner": "This is placeholder legal copy — it has not yet been reviewed by counsel and must not be relied on before launch.",
"terms_intro": "These draft terms describe how Balinyaar connects families with independent, verified home-nursing professionals. By creating an account you agree to the terms below.",
"terms_sections": [
{ "title": "The service", "body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform." },
{ "title": "Bookings and payment", "body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes." },
{ "title": "Cancellations and refunds", "body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider." },
{ "title": "Nurse verification", "body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile." },
{ "title": "Your responsibilities", "body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully." },
{ "title": "Liability", "body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system." },
{ "title": "Changes to these terms", "body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about these terms can be sent through the support ticket system in the app." }
],
"privacy_intro": "This draft policy explains what personal data Balinyaar collects to run the service, and how it is used.",
"privacy_sections": [
{ "title": "Information we collect", "body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider." },
{ "title": "How we use it", "body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support." },
{ "title": "Who we share it with", "body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more." },
{ "title": "Data security", "body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse." },
{ "title": "Your rights", "body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data." },
{ "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." }
]
}
}
+305 -41
View File
@@ -32,7 +32,17 @@
"partner_home": "مرکز",
"partner_nurses": "پرستاران تحت پوشش",
"partner_bookings": "رزروها",
"partner_settlement": "تسویه"
"partner_settlement": "تسویه",
"search": "جستجو",
"checkout": "پرداخت",
"addresses": "آدرس‌ها",
"more": "بیشتر",
"group_today": "امروز",
"group_profession": "حرفهٔ من",
"group_finance": "مالی",
"group_support": "پشتیبانی",
"group_trust": "اعتماد",
"group_system": "سیستم"
},
"common": {
"dark_mode": "حالت تاریک",
@@ -51,14 +61,21 @@
"optional": "اختیاری",
"currency_toman": "تومان",
"brand": "بالین یار",
"brand_tagline": "مراقبت مطمئن در خانه"
"brand_tagline": "مراقبت مطمئن در خانه",
"open_sidebar": "باز کردن منو",
"switch_locale": "تغییر به {locale}",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}"
},
"shell": {
"customer_app": "اپلیکیشن خانواده",
"nurse_app": "نمای پرستار",
"admin_console": "کنسول مدیریت",
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود.",
"partner_console": "پرتال همکار"
"partner_console": "پرتال همکار",
"switch_to_nurse": "نمای پرستار",
"switch_to_customer": "اپلیکیشن خانواده"
},
"home": {
"greeting_named": "سلام، {name}",
@@ -75,7 +92,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": "برای چه کسی",
@@ -105,7 +126,10 @@
"condition_dementia": "آلزایمر/دمانس",
"continue": "ادامه",
"save_continue": "ذخیره و ادامه",
"saved": "بیمار ثبت شد"
"saved": "بیمار ثبت شد",
"welcome_title": "خوش آمدید — مراقبت برای چه کسی است؟",
"welcome_subtitle": "با چند مرحله کوتاه، آماده رزرو پرستار می‌شوید.",
"welcome_cta": "شروع می‌کنیم"
},
"patients": {
"title": "بیماران",
@@ -144,7 +168,9 @@
"saved": "پروفایل ذخیره شد",
"completion_done": "پروفایل شما کامل است.",
"completion_todo": "برای رزرو سریع‌تر، پروفایل خود را کامل کنید.",
"load_error": "پروفایل شما بارگذاری نشد."
"load_error": "پروفایل شما بارگذاری نشد.",
"app_language": "زبان برنامه",
"sign_out": "خروج از حساب"
},
"nurseProfile": {
"title": "پروفایل پرستار",
@@ -164,7 +190,35 @@
"unverified_cta": "تکمیل احراز هویت",
"deferred_services": "خدمات و قیمت‌های خود را در بخش «خدمات و قیمت‌ها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه می‌شود.",
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید."
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید.",
"education_level_label": "مقطع تحصیلی",
"education_level_diploma": "دیپلم",
"education_level_associate": "کاردانی",
"education_level_bachelor": "کارشناسی",
"education_level_master": "کارشناسی ارشد",
"education_level_doctorate": "دکترا",
"education_field_label": "رشتهٔ تحصیلی",
"education_field_nursing": "پرستاری",
"education_field_midwifery": "مامایی",
"education_field_anesthesia": "هوشبری",
"education_field_operating_room": "اتاق عمل",
"education_field_public_health": "بهداشت عمومی",
"education_other": "سایر",
"education_level_other_label": "مقطع تحصیلی خود را وارد کنید",
"education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید",
"specializations_label": "تخصص‌ها",
"preview_cta": "پیش‌نمایش نمایهٔ عمومی من",
"preview_title": "نمایهٔ عمومی من",
"preview_subtitle": "این چیزی است که خانواده‌ها هنگام یافتن شما می‌بینند.",
"back_to_profile": "بازگشت به پروفایل",
"preview_reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"preview_completed_visits": "{count, number} ویزیت موفق",
"preview_years_experience": "{years} سال سابقه",
"preview_verified_title": "بالین‌یار چه چیزی را تایید کرده است",
"preview_services_title": "خدمات",
"preview_services_empty": "هنوز خدمت فعالی ندارید — یک خدمت در بخش «خدمات و قیمت‌ها» اضافه کنید.",
"preview_coverage_title": "مناطق تحت پوشش",
"preview_coverage_empty": "هنوز منطقه‌ای تحت پوشش ثبت نشده."
},
"bank": {
"title": "حساب بانکی",
@@ -179,9 +233,11 @@
"submitting": "در حال ثبت…",
"added": "حساب ثبت شد — استعلام مالکیت آغاز شد",
"add_error": "ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.",
"add_another": "افزودن حساب دیگر",
"load_error": "بارگذاری حساب‌های بانکی شما ممکن نشد.",
"status_pending_chip": "در حال استعلام",
"status_pending_title": "در حال استعلام مالکیت حساب",
"status_pending_body": "استعلام شبا در حال انجام است؛ ممکن است چند لحظه طول بکشد.",
"status_pending_body": "در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول می‌کشد",
"status_verified_chip": "تاییدشد",
"status_verified_title": "حساب تایید شد",
"status_verified_body": "این حساب برای واریز درآمد شما آماده است.",
@@ -191,6 +247,7 @@
"reenter": "ثبت حساب دیگر",
"make_primary": "انتخاب به‌عنوان حساب اصلی",
"primary_set": "حساب اصلی به‌روزرسانی شد",
"primary_set_error": "به‌روزرسانی حساب اصلی ممکن نشد. دوباره تلاش کنید.",
"iban_masked_label": "شبا",
"primary": "حساب اصلی",
"empty_title": "هنوز حسابی ثبت نشده",
@@ -247,9 +304,6 @@
"subtitle": "شهرها و مناطقی که به آن‌ها می‌روید.",
"areas_heading": "مناطق شما",
"add_title": "افزودن منطقهٔ تحت پوشش",
"scope_label": "گستره",
"scope_whole_city": "کل شهر",
"scope_districts": "مناطق خاص",
"add": "افزودن منطقه",
"adding": "در حال افزودن…",
"whole_city_chip": "کل شهر",
@@ -257,13 +311,13 @@
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جست‌وجو نمایش داده نمی‌شوید.",
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
"city_required": "شهر را انتخاب کنید",
"district_required": "یک منطقه انتخاب کنید یا به کل شهر تغییر دهید.",
"added": "منطقهٔ تحت پوشش اضافه شد",
"add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.",
"remove_title": "حذف منطقهٔ تحت پوشش؟",
"remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید.",
"remove_confirm": "حذف",
"removed": "منطقهٔ تحت پوشش حذف شد"
"removed": "منطقهٔ تحت پوشش حذف شد",
"remove_error": "حذف این منطقه ممکن نشد. دوباره تلاش کنید."
},
"catalog": {
"unit_per_hour": "ساعتی",
@@ -323,6 +377,9 @@
"display_name_label": "نام نمایشی",
"display_name_hint": "به‌صورت خودکار از گزینه‌ها ساخته می‌شود؛ می‌توانید تغییرش دهید.",
"duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.",
"duplicate_edit_existing": "به‌جای آن، همان خدمت موجود را ویرایش کنید",
"preview_heading": "این‌گونه در جستجو دیده می‌شوید",
"preview_untitled": "خدمت جدید",
"summary_options": "گزینه‌ها",
"summary_none": "بدون گزینه",
"next": "بعدی",
@@ -334,6 +391,21 @@
"list_error": "خدمات شما بارگذاری نشد.",
"options_error": "گزینه‌های این دسته بارگذاری نشد."
},
"activation": {
"title": "راه‌اندازی حساب",
"subtitle_visibility": "این موارد نمایش شما در جستجو را کنترل می‌کنند.",
"row_identity": "احراز هویت و مدارک",
"row_profile": "تکمیل نمایه",
"row_services": "حداقل یک خدمت فعال",
"row_coverage": "محدودهٔ پوشش",
"row_bank": "شبای تاییدشده",
"row_bank_hint": "برای دریافت درآمد — برای نمایش در جستجو لازم نیست.",
"row_fix": "تکمیل",
"live_title": "فعال در جستجو",
"live_body": "راه‌اندازی کامل است و پذیرش رزرو فعال است — خانواده‌ها می‌توانند شما را پیدا و رزرو کنند.",
"load_error": "بارگذاری وضعیت راه‌اندازی ممکن نشد.",
"retry": "تلاش مجدد"
},
"search": {
"title": "یافتن پرستار",
"subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.",
@@ -347,30 +419,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": "بازگشت به جستجو",
@@ -399,6 +482,7 @@
"summary_when": "زمان",
"request_title": "درخواست رزرو",
"form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمی‌شود؛ ابتدا پرستار درخواست را بررسی می‌کند.",
"whathappens_title": "چه اتفاقی می‌افتد؟",
"missing_nurse_title": "پرستاری انتخاب نشده است",
"missing_nurse_body": "ابتدا از جست‌وجو یک پرستار انتخاب کنید و سپس درخواست دهید.",
"missing_nurse_cta": "یافتن پرستار",
@@ -414,9 +498,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": "توضیحات برای پرستار",
@@ -438,6 +531,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": "درخواست ثبت شد",
@@ -445,6 +545,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": "مهلت پرداخت",
@@ -454,7 +557,8 @@
"cancelling": "در حال لغو…",
"cancel_confirm_title": "این درخواست لغو شود؟",
"cancel_confirm_body": "پرستار دیگر آن را نمی‌بیند. هر زمان می‌توانید پرستار دیگری انتخاب کنید.",
"cancel_confirm_yes": "بله، لغو کن",
"cancel_confirm_keep": "نه، نگه دار",
"cancel_confirm_destructive": "بله، انصراف از درخواست",
"rejected_title": "پرستار درخواست را رد کرد",
"rejected_reason_label": "دلیل پرستار",
"expired_title": "پرستار در مهلت مقرر پاسخ نداد",
@@ -463,6 +567,8 @@
"converted_title": "رزرو شما ثبت شد",
"converted_cta": "مشاهده رزرو",
"terminal_rerequest": "انتخاب پرستار دیگر",
"terminal_rerequest_same_nurse": "درخواست دوباره با زمان دیگر",
"terminal_similar_nurses": "پرستاران مشابه",
"not_found_title": "درخواست یافت نشد",
"not_found_body": "ممکن است این درخواست حذف شده باشد.",
"error_title": "خطایی رخ داد",
@@ -500,6 +606,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": "رزروهای من",
@@ -544,6 +652,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": "ورود بازی برای ثبت خروج وجود ندارد.",
@@ -568,11 +677,58 @@
"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": "نمایش بیشتر",
"address_title": "آدرس",
"address_open_map": "مسیریابی",
"address_open_web_map": "نمایش روی نقشه",
"address_pending_note": "آدرس پس از تایید رزرو در دسترس قرار می‌گیرد.",
"in_visit_title": "در حال ویزیت",
"in_visit_elapsed": "{duration} در محل",
"evv_checkout_confirm_title": "اتمام این ویزیت؟",
"evv_checkout_confirm_body": "با ثبت خروج، این ویزیت پایان می‌یابد و فرآیند واریز به حساب شما آغاز می‌شود.",
"evv_checkout_confirm_cta": "بله، ثبت خروج",
"evv_checkout_confirm_cancel": "انصراف",
"today_date_prefix": "امروز، {date}",
"evv_visits_error": "بارگذاری ویزیت‌های امروز ممکن نشد.",
"nurse_inbox_tab_pending": "در انتظار پاسخ",
"nurse_inbox_tab_answered": "پاسخ‌داده",
"nurse_inbox_tab_expired": "منقضی‌شده",
"inbox_countdown_pill_label": "پاسخ تا",
"accept_confirm_title": "پذیرش این درخواست؟",
"accept_confirm_body": "با پذیرش، خانواده برای پرداخت دعوت می‌شود؛ پس از پرداخت، رزرو قطعی می‌شود.",
"accept_confirm_cta": "بله، پذیرش درخواست"
},
"dashboard": {
"greeting": "سلام، {name}",
"retry": "تلاش مجدد",
"next_visit_title": "ویزیت بعدی",
"next_visit_empty": "امروز ویزیتی ندارید.",
"next_visit_starts_in": "شروع {relative}",
"next_visit_cta": "مشاهده و ثبت ورود",
"next_visit_error": "بارگذاری ویزیت‌های امروز ممکن نشد.",
"requests_strip_title": "{count, plural, =0 {درخواست در انتظاری ندارید} other {# درخواست منتظر پاسخ شما}}",
"requests_strip_empty": "درخواست جدیدی ندارید.",
"requests_strip_cta": "مشاهده همه",
"requests_strip_open": "مشاهده",
"requests_strip_error": "بارگذاری درخواست‌های شما ممکن نشد.",
"earnings_snapshot_title": "درآمد",
"earnings_snapshot_cta": "مشاهده جزئیات",
"earnings_snapshot_error": "بارگذاری موجودی شما ممکن نشد.",
"notifications_entry_title": "اعلان‌ها",
"notifications_entry_unread": "{count} خوانده‌نشده",
"notifications_entry_empty": "اعلان جدیدی ندارید"
},
"payment": {
"title_checkout": "تایید و پرداخت",
@@ -582,12 +738,22 @@
"row_vat": "مالیات بر ارزش افزوده",
"row_total": "مبلغ کل",
"escrow_notice": "مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود",
"cta_pay": "ادامه پرداخت ←",
"escrow_explainer_toggle": "چطور کار می‌کند؟",
"escrow_step_pay": "پرداخت",
"escrow_step_hold": "امانت نزد بالین‌یار",
"escrow_step_release": "آزادسازی پس از تایید پایان ویزیت",
"escrow_cancellation_note": "در صورت لغو رزرو پیش از انجام ویزیت، مبلغ طبق سیاست لغو بازپرداخت می‌شود.",
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت",
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
"state_pending_title": "در حال تایید پرداخت…",
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.",
"stage_returned": "بازگشت از درگاه",
"stage_confirming": "در انتظار تایید بانک",
"state_pending_duration_hint": "معمولاً کمتر از یک دقیقه طول می‌کشد.",
"check_again": "بررسی دوباره",
"state_failed_title": "پرداخت ناموفق بود",
"state_failed_hint": "مبلغی از حساب شما کسر نشده است. می‌توانید دوباره تلاش کنید.",
@@ -604,10 +770,31 @@
"view_booking": "مشاهده رزرو",
"download_invoice": "دانلود فاکتور",
"total_paid_label": "مبلغ پرداخت‌شده",
"receipt_tracking_code_label": "کد پیگیری",
"receipt_paid_at_label": "تاریخ پرداخت",
"receipt_method_label": "روش پرداخت",
"receipt_booking_ref_label": "شماره رزرو",
"copy_tracking_code": "کپی کد پیگیری",
"tracking_code_copied": "کد پیگیری کپی شد",
"method_card": "کارت بانکی",
"method_bnpl_provider": "اقساطی — {provider}",
"next_steps_title": "مراحل بعدی",
"next_step_nurse_notified": "اطلاع‌رسانی به پرستار",
"next_step_visit_checkin": "ویزیت و ثبت ورود",
"view_invoice_cta": "مشاهده فاکتور",
"invoice_title": "فاکتور",
"invoice_number_label": "شماره فاکتور",
"invoice_issued_at": "تاریخ صدور",
"invoice_buyer_label": "خریدار",
"invoice_service_label": "خدمت",
"invoice_visit_dates_label": "تاریخ ویزیت",
"invoice_visit_dates_multi": "{date} ({count, plural, one {# ویزیت} other {# ویزیت}})",
"invoice_transaction_ref_label": "کد پیگیری تراکنش",
"invoice_method_bnpl": "اقساطی",
"invoice_vat_on_commission": "مالیات بر ارزش افزوده (بر کارمزد بالین‌یار)",
"invoice_seller_economic_code_label": "کد اقتصادی",
"invoice_footer_reference": "فاکتور {number} · صادرشده در {date}",
"invoice_footer_moadian": "کد مرجع مودیان: {ref}",
"invoice_not_issued_title": "فاکتور هنوز صادر نشده است",
"invoice_not_issued_body": "فاکتور این رزرو پس از صدور در همین‌جا در دسترس خواهد بود.",
"print_invoice": "چاپ فاکتور",
@@ -619,11 +806,6 @@
"pstatus_pending": "در انتظار تایید",
"pstatus_succeeded": "موفق",
"pstatus_failed": "ناموفق",
"gateway_title": "درگاه پرداخت آزمایشی",
"gateway_hint": "این صفحه در محیط توسعه جایگزین درگاه واقعی است.",
"gateway_reference_label": "کد پیگیری",
"gateway_pay_success": "پرداخت موفق",
"gateway_pay_fail": "شبیه‌سازی پرداخت ناموفق",
"error_title": "مشکلی پیش آمد",
"error_body": "بارگذاری خلاصه پرداخت ممکن نشد.",
"invalid_link_body": "پیوند پرداخت معتبر نیست.",
@@ -656,11 +838,16 @@
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
"role_nurse": "پرستار",
"role_nurse_desc": "برای ارائه خدمات پرستاری",
"role_add_later_note": "هر زمان می‌توانید نقش دیگر را هم از پروفایل خود اضافه کنید.",
"continue": "ادامه",
"guard_denied": "شما به این بخش دسترسی ندارید.",
"account_error_title": "حساب شما بارگذاری نشد",
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_retry": "تلاش مجدد"
"account_error_retry": "تلاش مجدد",
"trust_verified_nurses": "پرستاران دارای پروانه صلاحیت حرفه‌ای و احراز هویت‌شده",
"trust_escrow_payment": "پرداخت شما تا تایید انجام خدمت، امانی نزد بالین‌یار می‌ماند",
"trust_support": "پشتیبانی در تمام مراحل مراقبت",
"consent_line": "با ورود، <terms>شرایط استفاده</terms> و <privacy>حریم خصوصی</privacy> را می‌پذیرید."
},
"verification": {
"title": "احراز هویت",
@@ -706,9 +893,13 @@
"reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.",
"reason_national_id_mismatch": "کد ملی مطابقت نداشت.",
"reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.",
"journey_identity": "هویت",
"journey_credentials": "مدارک",
"journey_review": "بررسی",
"journey_back": "بازگشت به مسیر تأیید",
"group_identity": "هویت",
"group_credentials": "مدارک حرفه‌ای",
"group_bank": "بانک",
"group_review": "بررسی",
"payoff_title": "این نشان را خانواده‌ها می‌بینند",
"payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.",
"identity_title": "تأیید هویت",
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
"national_id_label": "کد ملی",
@@ -717,8 +908,10 @@
"card_label": "تصویر کارت ملی",
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
"card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.",
"capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.",
"selfie_label": "سلفی زنده",
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
"capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.",
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.",
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
"error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.",
@@ -733,6 +926,9 @@
"ino_number_label": "شماره نظام پرستاری",
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
"ino_number_submitted": "شمارهٔ نظام پرستاری ثبت شد",
"ino_number_change": "تغییر",
"credentials_needs_document": "برای ثبت، حداقل یک مدرک بارگذاری کنید.",
"doc_uploaded": "مدرک بارگذاری شد",
"education_label": "مدرک تحصیلی",
"education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.",
@@ -757,6 +953,9 @@
"review_title": "در حال بررسی",
"review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.",
"review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان می‌برد.",
"review_timeline_submitted": "مدارک ثبت شد",
"review_timeline_review": "بررسی توسط کارشناس",
"review_timeline_activation": "فعال‌سازی نشان",
"review_approved_title": "احراز هویت تکمیل شد",
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون می‌توانید پروفایل خود را منتشر کنید.",
"review_summary_title": "خلاصهٔ وضعیت",
@@ -764,13 +963,25 @@
"badge_verified": "تاییدشده",
"badge_unverified": "احراز نشده",
"badge_expired": "منقضی شده",
"publish_ready_title": "آمادهٔ انتشار",
"publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.",
"publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید",
"publish_blocked_body": "تا تکمیل احراز هویت، خدمات شما در جستجو نمایش داده نمی‌شود و قابل رزرو نیست.",
"publish_cta": "انتشار پروفایل",
"publish_complete_verification": "تکمیل احراز هویت",
"publish_done": "پروفایل شما منتشر شد",
"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": "برای فعال‌سازی، راه‌اندازی را کامل کنید",
"publish_unmet_intro": "برای نمایش در جستجو: {items}.",
"publish_start_accepting": "شروع پذیرش رزرو",
"publish_pause_accepting": "توقف موقت پذیرش",
"publish_toggling": "در حال به‌روزرسانی…",
"publish_live_title": "فعال در جستجو",
"publish_live_body": "هم‌اکنون خانواده‌ها می‌توانند شما را پیدا کرده و رزرو کنند.",
"publish_accepting_success": "اکنون رزرو دریافت می‌کنید",
"publish_paused_success": "پذیرش رزرو متوقف شد — تا از سرگیری، در جستجو نمایش داده نمی‌شوید",
"publish_toggle_error": "به‌روزرسانی وضعیت پذیرش رزرو ممکن نشد. دوباره تلاش کنید.",
"mock_admin_title": "شبیه‌سازی بررسی مدیر (نمایشی)",
"mock_admin_approve": "تأیید همهٔ مراحل",
"mock_admin_reject": "رد یک مدرک",
@@ -791,6 +1002,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": "بدون بازپرداخت",
@@ -811,6 +1025,7 @@
"reason_cancelled": "لغوشده",
"admin_approval_explainer": "درخواست لغو شما ثبت می‌شود و بازپرداخت توسط تیم بالین‌یار بررسی و انجام می‌شود؛ بازپرداخت را هرگز خودتان انجام نمی‌دهید.",
"reason_field_label": "دلیل لغو",
"reason_placeholder": "دلیل را انتخاب کنید",
"reason_cat_changed_mind": "نظرم عوض شد",
"reason_cat_schedule_conflict": "تداخل زمانی",
"reason_cat_found_other_care": "مراقب دیگری پیدا کردم",
@@ -858,13 +1073,18 @@
"eta_business_days": "حدود ۷ تا ۱۰ روز کاری",
"eta_expected_label": "تا حدود {date}",
"cancel_booking_cta": "لغو رزرو",
"refund_section_title": "بازپرداخت"
"refund_section_title": "بازپرداخت",
"wallet_empty_title": "استردادی موجود نیست",
"wallet_empty_body": "استرداد رزروهای لغوشده شما اینجا نمایش داده می‌شود.",
"wallet_booking_label": "رزرو شماره {id}"
},
"bnpl": {
"title": "پرداخت اقساطی",
"ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.",
"payable_amount": "مبلغ قابل پرداخت",
"total_amount": "مبلغ کل",
"total_amount_named": "مبلغ کل با طرح {plan}",
"total_repayment": "مجموع بازپرداخت",
"plan_fee_amount_suffix": "کارمزد",
"monthly": "ماهانه",
"continue": "ادامه",
"error_title": "خطایی رخ داد",
@@ -903,6 +1123,7 @@
"mobile_label": "شماره موبایل",
"consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.",
"check_eligibility": "استعلام اعتبار",
"checking_eligibility": "در حال استعلام اعتبار…",
"approved_title": "اعتبار شما تایید شد",
"credit_ceiling_label": "سقف اعتبار قابل استفاده",
"approve_continue": "تایید و ادامه",
@@ -947,7 +1168,17 @@
"step_provider": "ارائه‌دهنده",
"step_plan": "طرح",
"step_eligibility": "اعتبارسنجی",
"step_schedule": "جدول"
"step_schedule": "جدول",
"wallet_hub_title": "کیف‌پول",
"tab_payments": "پرداخت‌ها",
"tab_installments": "اقساط",
"tab_refunds": "استردادها",
"tab_receipts": "رسیدها",
"history_empty_title": "هنوز پرداختی نداشته‌اید",
"history_empty_body": "پرداخت‌های کارتی و اقساطی شما اینجا نمایش داده می‌شود.",
"history_view_booking": "مشاهده رزرو",
"receipts_empty_title": "رسیدی موجود نیست",
"receipts_empty_body": "رسید پرداخت‌های تکمیل‌شده شما اینجا نمایش داده می‌شود."
},
"payouts": {
"title": "درآمدها",
@@ -1031,12 +1262,19 @@
"net_amount_label": "مبلغ خالص",
"amount_transferred_label": "واریزشده",
"detail_bookings_title": "رزروهای دربرگرفته",
"detail_bookings_hint": "این واریز بابت این ویزیت‌ها پرداخت شده است."
"detail_bookings_hint": "این واریز بابت این ویزیت‌ها پرداخت شده است.",
"forecast_label": "برداشت بعدی",
"forecast_on_date": "در {date}",
"failure_code_invalid_sheba": "شماره شبای حساب نامعتبر است.",
"failure_code_unknown": "این انتقال ناموفق بود."
},
"reviews": {
"title": "ثبت نظر",
"subtitle": "تجربه‌تان را با دیگر خانواده‌ها به اشتراک بگذارید.",
"for_nurse": "دربارهٔ {name}",
"moderation_note": "نظر شما پس از بررسی منتشر می‌شود",
"recap_fallback_service": "دریافت خدمت",
"recap_fallback_nurse": "پرستار",
"rating_label": "امتیاز شما",
"body_label": "توضیحات (اختیاری)",
"body_placeholder": "مراقبت چطور بود؟ اگر نکته‌ای هست بنویسید…",
@@ -1594,5 +1832,31 @@
"not_found_title": "صفحه پیدا نشد",
"not_found_body": "صفحه‌ای که به دنبال آن هستید وجود ندارد یا جابه‌جا شده است.",
"go_home": "بازگشت به خانه"
},
"legal": {
"terms_title": "شرایط استفاده",
"privacy_title": "حریم خصوصی",
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تاییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جست‌وجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تایید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تاییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تایید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تاییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تایید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." },
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
],
"privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.",
"privacy_sections": [
{ "title": "اطلاعاتی که جمع‌آوری می‌کنیم", "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت." },
{ "title": "نحوه استفاده", "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی." },
{ "title": "اشتراک‌گذاری اطلاعات", "body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند." },
{ "title": "امنیت اطلاعات", "body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است." },
{ "title": "حقوق شما", "body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید." },
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
]
}
}
+11 -2
View File
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server';
import { routing } from './src/i18n/routing';
import { COOKIE_NAMES } from './src/lib/cookies';
import { isTokenAlive } from './src/lib/auth/token';
import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants';
import { HEADER_NAMES, PUBLIC_PATHS, RETURN_URL_PARAM, ROUTES } from './src/constants';
const intlMiddleware = createMiddleware(routing);
@@ -26,7 +26,16 @@ export default function middleware(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value;
if (!isTokenAlive(token)) {
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale;
return NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url));
const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url);
// Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a
// shared nurse profile — survives the round trip through login instead of dumping the user
// on their role home. Validated same-origin + role-permitting on the way back out
// (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway.
const next = pathWithoutLocale + request.nextUrl.search;
if (next && next !== '/') {
loginUrl.searchParams.set(RETURN_URL_PARAM, next);
}
return NextResponse.redirect(loginUrl);
}
}
@@ -1,14 +1,25 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { FunctionComponent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
CategoryTile,
EmptyState,
ErrorState,
SurfaceCard,
} from '@/components';
import { ROUTES } from '@/constants';
import { useMe } from '@/services/auth';
import { usePatients } from '@/services/patients';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useBookingDetail, useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
interface NudgeCardProps {
icon: string;
@@ -16,17 +27,20 @@ interface NudgeCardProps {
body: string;
ctaLabel: string;
to: string;
/** Optional dismiss affordance (session-scoped) — omit for the always-relevant profile nudge. */
onDismiss?: () => void;
dismissLabel?: string;
}
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
{ctaLabel}
</AppButton>
</Stack>
{onDismiss ? (
<AppIconButton
icon="close"
title={dismissLabel}
onClick={onDismiss}
size="small"
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
/>
) : null}
</Paper>
);
// Session-scoped dismiss: a plain module variable (not a cookie/localStorage — this is ephemeral UI
// state, not app/auth state) survives client-side navigation within the same page load and resets on a
// hard reload, matching "dismissible for this session, not permanently".
let patientNudgeDismissedInSession = false;
/**
* A5 the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
* query / chosen `service_category_id` toward the f6 search flow results are not built here), the
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
* complete-patient-record nudge (derived from the f2 patient cache no extra fetch).
* A5 the family Home: the front door of the app. Greeting + avatar, a compact ambient trust strip, a
* tappable search entry point (routes to C1 see `HomeSearchBar`), the **data-driven** service-category
* grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
* and a "رزرو دوباره" (rebook) shortcut row sourced from recent bookings.
*
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
* a settled list so a post-create refetch never bounces the user back to onboarding.
@@ -57,6 +85,7 @@ export default function HomeScreen() {
const { data: me } = useMe();
const { data, isError, refetch } = usePatients();
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
const isEmpty = data?.total === 0;
@@ -78,6 +107,16 @@ export default function HomeScreen() {
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
// Completeness signal derived from the cached patients data (no extra fetch): a patient with no
// conditions recorded yet is an incomplete record — never a forever-nudge once every record is filled.
const hasIncompletePatient = data.items.some((patient) => patient.conditions.length === 0);
const showPatientNudge = hasIncompletePatient && !nudgeDismissed;
const dismissPatientNudge = () => {
patientNudgeDismissedInSession = true;
setNudgeDismissed(true);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
@@ -94,17 +133,25 @@ export default function HomeScreen() {
</Box>
</Stack>
<TrustStrip />
<HomeSearchBar />
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
/>
<RebookRow />
{showPatientNudge ? (
<NudgeCard
icon="patients"
title={t('nudge_patient_title')}
body={t('nudge_patient_body')}
ctaLabel={t('nudge_patient_cta')}
to={href(ROUTES.PATIENTS)}
onDismiss={dismissPatientNudge}
dismissLabel={tc('close')}
/>
) : null}
{!profileComplete ? (
<NudgeCard
icon="profile"
@@ -119,41 +166,63 @@ export default function HomeScreen() {
}
/**
* The Home search field. Rendering + query capture live here; **execution is f6** submitting
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
* frontend-phase-6-b7.
* Quiet, one-line ambient reassurance under the greeting not a hero. Three icon+label items: escrow
* payment, verified nurses, support. Purely presentational; tokens only.
*/
const TrustStrip: FunctionComponent = () => {
const t = useTranslations('home');
const items: Array<{ icon: string; label: string }> = [
{ icon: 'lock', label: t('trust_escrow') },
{ icon: 'verification', label: t('trust_verified_nurses') },
{ icon: 'support', label: t('trust_support') },
];
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
{items.map((item) => (
<Stack key={item.icon} direction="row" sx={{ gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon={item.icon} size={16} color="var(--bal-primary)" />
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
{item.label}
</Typography>
</Stack>
))}
</Stack>
);
};
/**
* The Home search entry point a tappable faux-input (never a half-working free-text field: the search
* index has no text column, variant names aren't client-queryable, and the only matchable dataset 56
* cached category names is already better served by the category grid directly below). Routes straight
* to C1 (`/search`). **Upgrade path**: once the backend serves a `q` param on `search/nurses` (REQ-041,
* matching nurse/variant/category names), this can become a real typeahead the placeholder copy is
* already written for that future, so only the tap target need change, not the copy/i18n keys.
*/
const HomeSearchBar: FunctionComponent = () => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const [query, setQuery] = useState('');
const submit = (event: FormEvent) => {
event.preventDefault();
const q = query.trim();
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
};
return (
<Box component="form" onSubmit={submit} role="search">
<TextField
fullWidth
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('search_placeholder')}
aria-label={t('search_action')}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
</InputAdornment>
),
},
}}
/>
</Box>
<ButtonBase
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
aria-label={t('search_action')}
sx={{
justifyContent: 'flex-start',
gap: 1,
width: '100%',
px: 2,
py: 1.5,
borderRadius: 'var(--bal-radius-md)',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
color: 'text.secondary',
}}
>
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
<Typography variant="body1">{t('search_placeholder')}</Typography>
</ButtonBase>
);
};
@@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
</Stack>
);
};
/**
* The "رزرو دوباره" shortcut row repeat care is the dominant pattern in home nursing. Sourced from the
* existing `useBookingList('customer')` cache (no extra list fetch); renders up to 2 cards, deduplicated
* by nurse, deep-linking to the nurse's C3 profile. Renders nothing (no empty state) when there is no
* past-bookings history.
*/
const RebookRow: FunctionComponent = () => {
const { data, isLoading, isError } = useBookingList('customer', { pageSize: 5 });
const items = data?.items ?? [];
if (isLoading || isError || items.length === 0) return null;
const seen = new Set<string>();
const candidates: BookingListItemDto[] = [];
for (const item of items) {
if (seen.has(item.counterpartyName)) continue;
seen.add(item.counterpartyName);
candidates.push(item);
if (candidates.length === 2) break;
}
if (candidates.length === 0) return null;
return (
<Stack sx={{ gap: 1 }}>
{candidates.map((booking) => (
<RebookCard key={booking.id} booking={booking} />
))}
</Stack>
);
};
/** One rebook card resolves the booking's `nurseId` (not on the list row) via the cached booking
* detail, then deep-links to the nurse's C3 profile. Renders nothing while resolving. */
const RebookCard: FunctionComponent<{ booking: BookingListItemDto }> = ({ booking }) => {
const t = useTranslations('home');
const router = useRouter();
const locale = useLocale();
const { data: detail } = useBookingDetail(booking.id, 'customer');
if (!detail) return null;
const open = () => router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${detail.nurseId}`);
return (
<SurfaceCard
padding="sm"
onClick={open}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<AppIcon icon="history" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{t('rebook_with', { name: booking.counterpartyName })}
</Typography>
</Stack>
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
</SurfaceCard>
);
};
@@ -1,63 +1,212 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
import { Badge, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AccentCard, AppButton, CountdownTimer, EmptyState, ErrorState, Money, RatingInput, StatusChip } from '@/components';
import type { AccentTone, StatusKind } from '@/components';
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { bookingReviewPath, ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils';
import { useBookingList } from '@/services/bookings';
import type { BookingListItemDto } from '@/services/bookings/types';
import { BOOKINGS_PAGE_SIZE } from '@/services/bookings/constants';
import type { BookingListItemDto, BookingStatus } from '@/services/bookings/types';
import { useCustomerRequests } from '@/services/bookingRequests';
import type { BookingRequestListItem, BookingRequestStatus } from '@/services/bookingRequests/types';
import { useReviewEligibility } from '@/services/reviews';
type BookingsTab = 'pending' | 'active' | 'past';
/** `pending_payment`/`confirmed`/`in_progress` are still unfolding; the rest are resolved. */
const ACTIVE_BOOKING_STATUSES: readonly BookingStatus[] = ['pending_payment', 'confirmed', 'in_progress'];
const PAST_BOOKING_STATUSES: readonly BookingStatus[] = ['completed', 'disputed', 'closed', 'cancelled'];
const PENDING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'pending_nurse_response',
'accepted_awaiting_payment',
];
const KIND_TO_ACCENT: Record<StatusKind, AccentTone> = {
neutral: 'neutral',
info: 'info',
pending: 'primary',
verified: 'success',
active: 'success',
rejected: 'error',
};
/**
* Customer رزروها the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
* `converted` state also lands here). Amounts render in Toman via the money util.
* Customer رزروها the lifecycle home. Three tabs so a money-adjacent pending request is never orphaned
* once the user leaves C5: **در انتظار پاسخ** wires the exported-but-previously-unused
* `useCustomerRequests` (live mini-countdown per row, deep-linking back to C5); **فعال** / **گذشته** split
* `useBookingList('customer')` by status. Rows carry a soft status chip + a matching `borderInlineStart`
* accent and are fully tappable (keyboard-focusable). Pagination is a "load more" over a single growing
* `pageSize` (the C2 results pattern) booking #21+ stays reachable.
*/
export default function BookingsScreen() {
const t = useTranslations('booking');
const { data, isLoading, isError, refetch } = useBookingList('customer');
const items = data?.items ?? [];
const router = useRouter();
const locale = useLocale();
const [tab, setTab] = useState<BookingsTab>('active');
const [pageSize, setPageSize] = useState(BOOKINGS_PAGE_SIZE);
const pendingQuery = useCustomerRequests();
const pendingItems = (pendingQuery.data?.items ?? []).filter((item) =>
PENDING_REQUEST_STATUSES.includes(item.status),
);
const bookingsQuery = useBookingList('customer', { page: 1, pageSize });
const allBookings = bookingsQuery.data?.items ?? [];
const total = bookingsQuery.data?.total ?? 0;
const hasMore = allBookings.length < total;
const activeItems = allBookings.filter((item) => ACTIVE_BOOKING_STATUSES.includes(item.status));
const pastItems = allBookings.filter((item) => PAST_BOOKING_STATUSES.includes(item.status));
const openBooking = (id: number) => router.push(`/${locale}${ROUTES.BOOKINGS}/${id}`);
const openRequest = (id: number) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${id}`);
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Stack sx={{ gap: 3 }}>
<Stack>
<Typography variant="h5" component="h1">
{t('list_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('list_subtitle')}
</Typography>
</Box>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={120} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} />
))}
</Stack>
)}
</Box>
<Tabs value={tab} onChange={(_event, value: BookingsTab) => setTab(value)} variant="fullWidth">
<Tab
value="pending"
data-tab="pending"
label={
pendingItems.length > 0 ? (
<Badge badgeContent={pendingItems.length} color="secondary" sx={{ '& .MuiBadge-badge': { insetInlineEnd: -12 } }}>
{t('tab_pending')}
</Badge>
) : (
t('tab_pending')
)
}
/>
<Tab value="active" data-tab="active" label={t('tab_active')} />
<Tab value="past" data-tab="past" label={t('tab_past')} />
</Tabs>
{tab === 'pending' ? (
pendingQuery.isLoading ? (
<ListSkeleton />
) : pendingQuery.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => pendingQuery.refetch()} />
) : pendingItems.length === 0 ? (
<EmptyState
icon="pending"
title={t('pending_empty_title')}
body={t('pending_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<Stack sx={{ gap: 2 }}>
{pendingItems.map((item) => (
<PendingRequestRow key={item.id} item={item} locale={locale} onOpen={() => openRequest(item.id)} />
))}
</Stack>
)
) : null}
{tab === 'active' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : activeItems.length === 0 ? (
<EmptyState
icon="bookings"
title={t('active_empty_title')}
body={t('active_empty_body')}
action={
<AppButton variant="outlined" color="primary" startIcon="search" onClick={goToSearch}>
{t('missing_nurse_cta')}
</AppButton>
}
/>
) : (
<BookingRows items={activeItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
{tab === 'past' ? (
bookingsQuery.isLoading ? (
<ListSkeleton />
) : bookingsQuery.isError ? (
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => bookingsQuery.refetch()} />
) : pastItems.length === 0 ? (
<EmptyState icon="bookings" title={t('past_empty_title')} body={t('past_empty_body')} />
) : (
<BookingRows items={pastItems} locale={locale} onOpen={openBooking} hasMore={hasMore} onLoadMore={() => setPageSize((size) => size + BOOKINGS_PAGE_SIZE)} loadingMore={bookingsQuery.isFetching} loadMoreLabel={t('load_more')} />
)
) : null}
</Stack>
);
}
function BookingRow({ item }: { item: BookingListItemDto }) {
function BookingRows({
items,
locale,
onOpen,
hasMore,
onLoadMore,
loadingMore,
loadMoreLabel,
}: {
items: BookingListItemDto[];
locale: string;
onOpen: (id: number) => void;
hasMore: boolean;
onLoadMore: () => void;
loadingMore: boolean;
loadMoreLabel: string;
}) {
return (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<BookingRow key={item.id} item={item} locale={locale} onOpen={() => onOpen(item.id)} />
))}
{hasMore ? (
<AppButton variant="outlined" color="primary" onClick={onLoadMore} disabled={loadingMore} sx={{ alignSelf: 'center' }}>
{loadMoreLabel}
</AppButton>
) : null}
</Stack>
);
}
function BookingRow({ item, locale, onOpen }: { item: BookingListItemDto; locale: string; onOpen: () => void }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const kind = BOOKING_STATUS_KIND[item.status];
const isCompleted = item.status === 'completed' || item.status === 'closed';
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AccentCard
tone={KIND_TO_ACCENT[kind]}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-booking-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
@@ -68,23 +217,112 @@ function BookingRow({ item }: { item: BookingListItemDto }) {
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
</Typography>
</Stack>
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
<StatusChip status={kind} label={t(`bstatus_${item.status}`)} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
<AppButton
variant="outlined"
color="primary"
endIcon="bookings"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
>
{t('view_booking')}
</AppButton>
</Stack>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
</Typography>
{isCompleted ? <CompletedReviewStrip bookingId={item.id} enabled={isCompleted} /> : null}
</Stack>
</Paper>
</AccentCard>
);
}
/** A completed booking without a review gets a compact star-strip CTA deep-linking into the review page.
* The eligibility read is gated to completed/closed rows only (`enabled`) an active booking never fires
* it, and `canReview: false` (already reviewed or otherwise ineligible) renders nothing extra. */
function CompletedReviewStrip({ bookingId, enabled }: { bookingId: number; enabled: boolean }) {
const t = useTranslations('reviews');
const router = useRouter();
const locale = useLocale();
const eligibility = useReviewEligibility(bookingId, { enabled });
if (!eligibility.data?.canReview) return null;
return (
<AppButton
variant="text"
color="primary"
size="small"
startIcon={<RatingInput value={0} readOnly size={16} ariaLabel={t('cta_leave')} />}
onClick={(event) => {
event.stopPropagation();
router.push(`/${locale}${bookingReviewPath(bookingId)}`);
}}
sx={{ alignSelf: 'flex-start', px: 0 }}
>
{t('cta_leave')}
</AppButton>
);
}
/** «در انتظار پاسخ» row — a pending or accepted-awaiting-payment request, with a live mini-countdown. */
function PendingRequestRow({
item,
locale,
onOpen,
}: {
item: BookingRequestListItem;
locale: string;
onOpen: () => void;
}) {
const t = useTranslations('booking');
const accepted = item.status === 'accepted_awaiting_payment';
const deadline = accepted ? item.paymentDeadlineAt : item.nurseResponseDeadlineAt;
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const dateLabel = formatShamsiDate(startDate, locale);
const timeLabel = timeFmt.format(startDate);
return (
<AccentCard
tone={accepted ? 'secondary' : 'primary'}
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
}}
data-request-row={item.id}
sx={{ cursor: 'pointer', '&:focus-visible': { outline: '2px solid var(--bal-primary)', outlineOffset: 2 } }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeLabel}
</Typography>
</Typography>
<StatusChip status={accepted ? 'active' : 'pending'} label={t(`status_${item.status}`)} />
</Stack>
{deadline ? (
<CountdownTimer
deadlineIso={deadline}
elapsedText={t(accepted ? 'payment_elapsed' : 'response_elapsed')}
urgent={accepted}
size="sm"
/>
) : null}
</Stack>
</AccentCard>
);
}
function ListSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={96} />
))}
</Stack>
);
}
@@ -9,6 +9,8 @@ import AppLoading from '@/components/common/AppLoading';
import Money from '@/components/common/Money';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ContactSupportDialog } from '@/components/messaging';
import type { TicketCategory } from '@/services/tickets/types';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
@@ -55,8 +57,10 @@ export default function CancelBookingPage() {
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
// Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen.
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(null);
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
@@ -109,7 +113,8 @@ export default function CancelBookingPage() {
{
bookingId,
sessionIds: preview.refundableSessionIds,
reasonCategory,
// Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate).
reasonCategory: reasonCategory as CancelReasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
@@ -124,6 +129,33 @@ export default function CancelBookingPage() {
{step === 0 ? (
<>
{/* Off-ramps before the kill switch exits, not obstacles; the destructive path stays fully
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
coordination ticket instead. */}
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('offramp_note')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="primary"
startIcon="schedule"
onClick={() => setSupportDialogCategory('coordination')}
>
{t('reschedule_cta')}
</AppButton>
<AppButton
variant="outlined"
color="primary"
startIcon="support"
onClick={() => setSupportDialogCategory('support')}
>
{t('contact_support_cta')}
</AppButton>
</Stack>
</Stack>
<CancellationPolicyDisclosure preview={preview} />
<TextField
@@ -133,6 +165,9 @@ export default function CancelBookingPage() {
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
@@ -159,12 +194,20 @@ export default function CancelBookingPage() {
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged}
disabled={!acknowledged || reasonCategory === ''}
onClick={() => setStep(1)}
>
{t('continue_cta')}
</AppButton>
</Stack>
<ContactSupportDialog
open={supportDialogCategory !== null}
onClose={() => setSupportDialogCategory(null)}
role="customer"
bookingId={bookingId}
defaultCategory={supportDialogCategory ?? 'support'}
/>
</>
) : (
<>
@@ -1,12 +1,14 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Box, Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment';
import { useBookingDetail } from '@/services/bookings';
import { useCustomerProfile } from '@/services/profiles';
import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */
@@ -20,13 +22,26 @@ const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
failed: 'rejected',
};
/** Best-effort read of the frozen variant display name from the booking's variant snapshot (mirrors the
* same tolerant parse `BookingDetailView`/the review page use REQ-045 proposes a typed shape). */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util no float math;
* the service line is the exact integer remainder of served amounts (gross commission VAT).
* issue date), a buyer/service/visit-date recap (composed client-side from the customer's own profile +
* the booking detail read a UI join, not money math), the reconciling lines with the **VAT-on-commission**
* line explicitly labelled (product rule: VAT is on Balinyaar's commission the taxable supply never the
* nurse's earnings), the payment method + transaction reference, a seller fiscal-identity block, and the
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean A4 receipt
* (`window.print()` + a print-scoped visibility rule + `@page` sizing). Every figure via the money util
* no float math; the service line is the exact integer remainder of served amounts (gross commission VAT).
*/
export default function BookingInvoicePage() {
const t = useTranslations('payment');
@@ -38,6 +53,8 @@ export default function BookingInvoicePage() {
const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
const { data: booking } = useBookingDetail(validId ? bookingId : undefined, 'customer');
const { data: customerProfile } = useCustomerProfile();
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
@@ -123,10 +140,21 @@ export default function BookingInvoicePage() {
maximumFractionDigits: 2,
}).format(invoice.vatRate);
const buyerName = [customerProfile?.firstName, customerProfile?.lastName].filter(Boolean).join(' ').trim();
const serviceLabel = booking ? variantName(booking.variantSnapshotJson) : null;
const visitDatesLabel = booking
? booking.sessionCount > 1
? t('invoice_visit_dates_multi', { date: formatShamsiDate(booking.scheduledDate, locale), count: booking.sessionCount })
: formatShamsiDate(booking.scheduledDate, locale)
: null;
const methodLabel =
invoice.paymentMethod === 'card' ? t('method_card') : invoice.paymentMethod === 'bnpl' ? t('invoice_method_bnpl') : null;
return (
<Stack sx={{ gap: 2 }}>
<GlobalStyles
styles={{
'@page': { size: 'A4', margin: '16mm' },
'@media print': {
'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
@@ -145,8 +173,6 @@ export default function BookingInvoicePage() {
<Typography variant="h6" component="h1">
{t('invoice_title')}
</Typography>
{/* The issuer line uses the product spelling «بالینیار» fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')}
</Typography>
@@ -156,6 +182,10 @@ export default function BookingInvoicePage() {
<Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
{buyerName ? <MetaRow label={t('invoice_buyer_label')} value={buyerName} /> : null}
{serviceLabel ? <MetaRow label={t('invoice_service_label')} value={serviceLabel} /> : null}
{visitDatesLabel ? <MetaRow label={t('invoice_visit_dates_label')} value={visitDatesLabel} /> : null}
<MetaRow label={t('receipt_booking_ref_label')} value={String(invoice.bookingId)} ltr />
</Stack>
<PriceBreakdown
@@ -172,6 +202,15 @@ export default function BookingInvoicePage() {
totalAmountIrr={invoice.grossIrr}
/>
{methodLabel || invoice.transactionReference ? (
<Stack sx={{ gap: 0.75 }}>
{methodLabel ? <MetaRow label={t('receipt_method_label')} value={methodLabel} /> : null}
{invoice.transactionReference ? (
<MetaRow label={t('invoice_transaction_ref_label')} value={invoice.transactionReference} ltr />
) : null}
</Stack>
) : null}
{invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -180,6 +219,39 @@ export default function BookingInvoicePage() {
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack>
) : null}
{invoice.sellerFiscalIdentity ? (
<>
<Divider />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{invoice.sellerFiscalIdentity.legalName}
</Typography>
{invoice.sellerFiscalIdentity.economicCode ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('invoice_seller_economic_code_label')}: {invoice.sellerFiscalIdentity.economicCode}
</Typography>
) : null}
{invoice.sellerFiscalIdentity.address ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{invoice.sellerFiscalIdentity.address}
</Typography>
) : null}
</Stack>
</>
) : null}
{/* Print-only document footer — invoice number + issue date (+ مودیان reference when present). */}
<Box sx={{ display: 'none', '@media print': { display: 'block', mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' } }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('invoice_footer_reference', { number: invoice.invoiceNumber, date: formatShamsiDate(invoice.issuedAt, locale) })}
</Typography>
{invoice.moadianReferenceNumber ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }} dir="ltr">
{t('invoice_footer_moadian', { ref: invoice.moadianReferenceNumber })}
</Typography>
) : null}
</Box>
</Stack>
</Paper>
@@ -3,14 +3,25 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components';
import type { StatusKind } from '@/components';
import { formatShamsiDate } from '@/utils';
import { useBookingDetail } from '@/services/bookings';
import type { BookingDetailDto } from '@/services/bookings/types';
import { useReviewEligibility, useMyReviewForBooking, useCreateReview } from '@/services/reviews';
import { REVIEW_TAG_CODES, type ModerationStatus } from '@/services/reviews/types';
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
const REVIEW_BODY_MAX = 2000;
/** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */
@@ -39,8 +50,11 @@ export default function LeaveReviewPage() {
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : -1;
const { data: booking } = useBookingDetail(bookingId, 'customer');
const reviewable = booking?.status === 'completed' || booking?.status === 'closed';
const eligibility = useReviewEligibility(bookingId);
const myReview = useMyReviewForBooking(bookingId);
// Gated exactly like the booking-detail page's identical call — a review can only ever exist for a
// completed/closed booking, so an in-flight/active booking never fires this query.
const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable });
const createReview = useCreateReview();
const [rating, setRating] = useState(0);
@@ -69,6 +83,7 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
@@ -131,6 +146,15 @@ export default function LeaveReviewPage() {
return (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
{/* Moderation expectation, up front — not only after submit. */}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
{t('moderation_note')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -180,6 +204,31 @@ export default function LeaveReviewPage() {
);
}
/** "What you're reviewing" recap — service, Shamsi visit date, nurse — off the already-cached booking. */
function ReviewContextRecap({ booking, locale }: { booking: BookingDetailDto; locale: string }) {
const t = useTranslations('reviews');
const service = variantName(booking.variantSnapshotJson);
const name = booking.nurseName.trim();
return (
<SurfaceCard padding="sm">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar sx={{ width: 40, height: 40, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(name || t('recap_fallback_nurse')).charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{service ?? t('recap_fallback_service')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{name || t('recap_fallback_nurse')} · {formatShamsiDate(booking.scheduledDate, locale)}
</Typography>
</Stack>
</Stack>
</SurfaceCard>
);
}
function PageHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<Stack sx={{ gap: 0.5 }}>
@@ -1,7 +1,7 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl';
@@ -138,7 +138,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
disabled
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
@@ -159,8 +159,9 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{t('check_eligibility')}
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
@@ -2,7 +2,7 @@
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, Money } from '@/components';
import { AppButton, AppIcon, BnplProviderLogo, EmptyState, Money } from '@/components';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps {
@@ -13,15 +13,6 @@ interface MethodStepProps {
onPayWithCard: () => void;
}
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
digipay: 'DG',
snapppay: 'SP',
balinyaar: 'ب',
tara: 'TA',
torobpay: 'TP',
};
/**
* D1 · روش پرداخت the branch off C6. Shows the payable amount, the full-card option (returns to the f9
* card flow never rebuilt here), and the installment providers loaded **from the contract/mock** (never
@@ -146,22 +137,7 @@ function ProviderOption({
}}
>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 28,
borderRadius: 1,
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: 11,
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{PROVIDER_GLYPH[provider.providerCode]}
</Box>
<BnplProviderLogo providerCode={provider.providerCode} size={40} />
<Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)}
@@ -3,10 +3,13 @@ import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
import { parseIrr } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps {
providerCode: ProviderCode;
/** D1's payable gross — the interest-free baseline `BnplPlanCard`'s fee delta compares against. */
orderAmountIrr: string;
plans: BnplPlanOption[];
selectedPlanId: string | null;
onSelectPlan: (planId: string) => void;
@@ -14,6 +17,13 @@ interface PlanStepProps {
onBack: () => void;
}
/** The same term/installment-count label `BnplPlanCard` shows — reused here to name the header's total. */
function termLabelFor(plan: BnplPlanOption, t: ReturnType<typeof useTranslations>): string {
return plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
}
/**
* D2 · انتخاب طرح اقساط the plan selector for the chosen provider. Shows the total amount and the plan
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
@@ -22,6 +32,7 @@ interface PlanStepProps {
*/
const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode,
orderAmountIrr,
plans,
selectedPlanId,
onSelectPlan,
@@ -43,8 +54,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
}
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// Use the selected plan's total, falling back to the first plan's for the header before any selection.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0];
// No default fallback to plans[0] — the header only shows a total once a plan is actually selected, so
// it never silently morphs before the user has chosen anything.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? null;
const feeIrr = shownPlan ? (parseIrr(shownPlan.totalIrr) - parseIrr(orderAmountIrr)).toString() : null;
const hasFee = shownPlan != null && shownPlan.feePercent > 0 && feeIrr != null && parseIrr(feeIrr) > BigInt(0);
return (
<Stack sx={{ gap: 2 }}>
@@ -52,23 +66,39 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_amount')}
</Typography>
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
</Stack>
</Paper>
{shownPlan ? (
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_amount_named', { plan: termLabelFor(shownPlan, t) })}
</Typography>
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
</Stack>
{hasFee && feeIrr != null ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline', justifyContent: 'flex-end' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
+
</Typography>
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)' }} />
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
{t('plan_fee_amount_suffix')}
</Typography>
</Stack>
) : null}
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1 }}>
{plans.map((plan) => (
<BnplPlanCard
key={plan.planId}
plan={plan}
orderAmountIrr={orderAmountIrr}
selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan}
/>
@@ -1,7 +1,7 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Stack } from '@mui/material';
import { AppButton, AppLoading, EmptyState } from '@/components';
import { ROUTES } from '@/constants';
@@ -22,6 +22,12 @@ import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/
export default function BnplGatewayPage() {
// A test harness must never be reachable in a production build — mirrors how the card-gateway harness
// was retired (refinement-phase-4). Unlike the card path, BNPL stays mock-primary, so this one is
// env-gated rather than deleted: still reachable in `next dev`, a clean 404 everywhere else.
if (process.env.NODE_ENV !== 'development') {
notFound();
}
return (
<Suspense fallback={<AppLoading />}>
<BnplGatewayScreen />
@@ -2,8 +2,8 @@
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StepperHeader } from '@/components';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard, StepperHeader } from '@/components';
import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl';
@@ -57,38 +57,47 @@ function BnplCheckoutScreen() {
if (!validId) {
return (
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} />
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" color="primary" onClick={toBookings}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
);
}
if (isError) {
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />;
return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
</PaymentStateCard>
);
}
if (isLoading || !options) return <WizardSkeleton />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={tp('already_paid_title')}
body={tp('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={toRequest}
/>
<PaymentStateCard icon="verified" tone="var(--bal-success)" title={tp('already_paid_title')} body={tp('already_paid_body')}>
<AppButton variant="contained" color="primary" onClick={toRequest}>
{tb('converted_cta')}
</AppButton>
</PaymentStateCard>
);
}
if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined}
ctaLabel={t('pay_with_card')}
onCta={toCard}
/>
>
<AppButton variant="contained" color="primary" onClick={toCard}>
{t('pay_with_card')}
</AppButton>
</PaymentStateCard>
);
}
@@ -139,6 +148,7 @@ function BnplCheckoutScreen() {
{step === 'plan' && providerCode && activeProvider ? (
<PlanStep
providerCode={providerCode}
orderAmountIrr={options.orderAmountIrr}
plans={activeProvider.plans}
selectedPlanId={planId}
onSelectPlan={setPlanId}
@@ -175,39 +185,6 @@ function BnplCheckoutScreen() {
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function WizardSkeleton() {
return (
<Stack sx={{ gap: 2 }}>
@@ -1,10 +1,10 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { CircularProgress } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
@@ -41,6 +41,7 @@ export default function BnplReturnPage() {
function BnplReturnScreen() {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
@@ -97,11 +98,13 @@ function BnplReturnScreen() {
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
{/* No recoverable request id the label must match the destination (the bookings list), never
promise a card-payment action the click can't perform. */}
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('pay_with_card')}
{tb('bd_my_bookings')}
</AppButton>
</StateCard>
</PaymentStateCard>
);
}
@@ -109,20 +112,20 @@ function BnplReturnScreen() {
// The payment window lapsed during the handoff — card payment is impossible now, so route to the
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{tp('back_to_request')}
</AppButton>
</StateCard>
</PaymentStateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton
color="secondary"
variant="contained"
@@ -137,48 +140,17 @@ function BnplReturnScreen() {
>
{t('pay_with_card')}
</AppButton>
</StateCard>
</PaymentStateCard>
);
}
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<PaymentStateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
</PaymentStateCard>
);
}
@@ -1,12 +1,26 @@
'use client';
import { Suspense } from 'react';
import { Suspense, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, Money } from '@/components';
import { useSnackbar } from 'notistack';
import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
ErrorState,
EscrowExplainer,
Money,
StatusTimeline,
SurfaceCard,
type TimelineNode,
} from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants';
import { useCheckoutSummary } from '@/services/payment';
import { formatShamsiDateTime } from '@/utils';
import { useCheckoutSummary, usePaymentOutcome } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import { useBnplOrder } from '@/services/bnpl';
import {
BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL,
@@ -14,12 +28,13 @@ import {
} from '@/services/bnpl/constants';
/**
* Post-payment confirmation the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached
* with `?method=bnpl` it also renders a «پرداختشده با اقساط» line (a settled BNPL order is, to
* Balinyaar, a card payment net-of-fee there is no separate BNPL confirmation). Without a `booking_id`
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list.
* Post-payment confirmation a screenshot-worthy receipt (Iranian users screenshot payment receipts): the
* paid total, a copyable LTR کد پیگیری, the Shamsi payment date-time, the payment method, the booking
* reference, the escrow reassurance, and a "what happens next" 2-step strip. The booking is now
* **confirmed** (flipped by cache invalidation on the return surface, never a blanket refetch). Reused by
* both the f9 card flow and the f11 BNPL branch: reached with `?method=bnpl` it reads the settled BNPL
* order instead of the payment outcome for the tracking reference + paid-at timestamp. Real loading/error
* states a failed fetch must never silently erase the paid amount.
*/
export default function CheckoutConfirmationPage() {
return (
@@ -31,20 +46,51 @@ export default function CheckoutConfirmationPage() {
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const { enqueueSnackbar } = useSnackbar();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validRequestId = Number.isInteger(requestId) && requestId > 0;
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
const {
data: summary,
isLoading,
isError,
refetch,
} = useCheckoutSummary(validRequestId ? requestId : undefined);
// The receipt reference/timestamp come from whichever leg actually settled this request — the card
// outcome or the BNPL order — never fabricated when the real path hasn't served them yet (REQ-046).
const outcomeQuery = usePaymentOutcome(validRequestId && !isBnpl ? requestId : undefined);
const orderQuery = useBnplOrder(validRequestId && isBnpl ? requestId : undefined);
const trackingCode = isBnpl
? (orderQuery.data?.id != null ? String(orderQuery.data.id) : null)
: (outcomeQuery.data?.trackingCode ?? null);
const paidAt = isBnpl ? (orderQuery.data?.settledAt ?? null) : (outcomeQuery.data?.paidAt ?? null);
const methodLabel = isBnpl
? t('method_bnpl_provider', { provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading') })
: t('method_card');
const nextStepsNodes: TimelineNode[] = [
{ key: 'nurse_notified', label: t('next_step_nurse_notified'), state: 'completed' },
{ key: 'visit_checkin', label: t('next_step_visit_checkin'), state: 'pending' },
];
const handleCopy = () => {
if (!trackingCode) return;
navigator.clipboard.writeText(trackingCode).then(() => {
enqueueSnackbar(t('tracking_code_copied'), { variant: 'success' });
});
};
return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
@@ -58,29 +104,62 @@ function ConfirmationScreen() {
</Typography>
</Stack>
{summary ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
{isLoading ? (
<ReceiptSkeleton />
) : isError || !summary ? (
<ErrorState message={t('error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : (
<SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" />
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
{isBnpl ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 500, mt: 0.5 }}>
{tBnpl('paid_via_installments', {
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
})}
</Typography>
) : null}
<Divider sx={{ width: '100%', my: 0.5 }} />
<Stack sx={{ width: '100%', gap: 1 }}>
{trackingCode ? (
<ReceiptRow label={t('receipt_tracking_code_label')}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{trackingCode}
</Box>
<AppIconButton icon="copy" size="small" title={t('copy_tracking_code')} onClick={handleCopy} />
</Stack>
</ReceiptRow>
) : null}
{paidAt ? (
<ReceiptRow label={t('receipt_paid_at_label')} value={formatShamsiDateTime(paidAt, locale)} />
) : null}
<ReceiptRow label={t('receipt_method_label')} value={methodLabel} />
{bookingId != null ? (
<ReceiptRow label={t('receipt_booking_ref_label')}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{bookingId}
</Box>
</ReceiptRow>
) : null}
</Stack>
</Stack>
</Paper>
) : null}
</SurfaceCard>
)}
<Stack sx={{ width: '100%' }}>
<EscrowExplainer />
</Stack>
<SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('next_steps_title')}
</Typography>
<StatusTimeline nodes={nextStepsNodes} />
</Stack>
</SurfaceCard>
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
@@ -107,3 +186,28 @@ function ConfirmationScreen() {
</Stack>
);
}
function ReceiptRow({ label, value, children }: { label: string; value?: string; children?: ReactNode }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{children ?? (
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{value}
</Typography>
)}
</Stack>
);
}
function ReceiptSkeleton() {
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
<Skeleton variant="text" width="40%" height={24} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width="60%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={140} />
</Stack>
);
}
@@ -2,17 +2,21 @@
import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
CountdownTimer,
EscrowNotice,
EscrowExplainer,
Money,
PaymentStateCard,
PriceBreakdown,
StatusChip,
TrustBadge,
} from '@/components';
import AppAlert from '@/components/common/AppAlert';
import StickyActionBar from '@/components/common/StickyActionBar';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils';
@@ -25,10 +29,11 @@ import {
import type { CheckoutSummaryDto } from '@/services/payment/types';
/**
* C6 خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the
* «ادامه پرداخت » CTA that initiates the card payment and follows the gateway redirect. Reached from
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary.
* C6 خلاصه و پرداخت (summary & pay). The acceptance badge, the identity moment (nurse avatar + verified
* badge), a prominent total, the served & reconciling service-cost / commission / VAT / total breakdown,
* the load-bearing escrow trust notice, and a safe-area-aware sticky pay bar that initiates the card
* payment and follows the gateway redirect. Reached from C5's accept CTA with `?request_id=`.
* `useSearchParams` needs a Suspense boundary.
*/
export default function CheckoutPage() {
return (
@@ -60,26 +65,20 @@ function CheckoutScreen() {
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('invalid_link_body')}
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('invalid_link_body')}>
<AppButton variant="contained" color="primary" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
);
}
if (isError) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
</PaymentStateCard>
);
}
if (isLoading || !summary) return <CheckoutSkeleton />;
@@ -92,27 +91,35 @@ function CheckoutScreen() {
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') {
return (
<MessageCard
<PaymentStateCard
icon="verified"
tone="var(--bal-success)"
title={t('already_paid_title')}
body={t('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={() => router.replace(returnUrl())}
/>
>
<AppButton variant="contained" color="primary" onClick={() => router.replace(returnUrl())}>
{tb('converted_cta')}
</AppButton>
</PaymentStateCard>
);
}
if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
/>
>
<AppButton
variant="contained"
color="primary"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</PaymentStateCard>
);
}
@@ -159,10 +166,29 @@ function CheckoutScreen() {
<Typography variant="h6" component="h1">
{t('title_checkout')}
</Typography>
<AppButton
variant="text"
color="primary"
size="small"
startIcon="chevron_start"
onClick={() => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ px: 0.5 }}
>
{t('back_to_request')}
</AppButton>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{/* The prominent total the single most important figure on a payment screen, never buried in the
breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_payable_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
</Stack>
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
@@ -191,42 +217,66 @@ function CheckoutScreen() {
totalAmountIrr={summary.totalIrr}
/>
<EscrowNotice />
<EscrowExplainer />
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
{/* Spacer so the sticky bar never overlaps the last scrollable content on a short viewport. */}
<Stack sx={{ pb: 1 }} />
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
sx={{ py: 1.25 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
<StickyActionBar>
<Stack sx={{ gap: 1 }}>
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
<Stack sx={{ gap: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_total')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
</Stack>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
endIcon="forward"
sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
</Stack>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('secure_gateway_notice')}
</Typography>
</Stack>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
disabled={busy}
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
</StickyActionBar>
</Stack>
);
}
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
/** Nurse/service/schedule mini-summary the C6 identity moment: avatar + verified badge answer "who am
* I paying for" at the moment of payment. */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
@@ -236,54 +286,32 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.nurseName} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Avatar
src={summary.nurseAvatarUrl ?? undefined}
sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{summary.nurseName.trim().charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5, flex: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.nurseName}
</Typography>
<TrustBadge state={summary.nurseVerified ? 'verified' : 'unverified'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Stack>
</Paper>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
@@ -292,6 +320,7 @@ function CheckoutSkeleton() {
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="text" width="40%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} />
@@ -1,10 +1,10 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard, StatusTimeline, type TimelineNode } from '@/components';
import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
@@ -18,10 +18,12 @@ import {
/**
* Return-from-gateway surface drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" pending is
* normal, reflected calmly) until a terminal outcome: succeeded invalidate the booking/request caches
* and hand off to the confirmation screen; failed a retry affordance (a fresh C6 mount = a new attempt
* with a NEW idempotency key); window lapsed back to the request's terminal card.
* **pending-callback** state a staged 2-node progress («بازگشت از درگاه » «در انتظار تایید بانک»,
* the calm animated `StatusTimeline` `current` pulse) with an expected-duration hint, backed by the
* backoff poll ("PSP received ≠ cash in bank" pending is normal, reflected calmly) until a terminal
* outcome: succeeded invalidate the booking/request caches and hand off to the confirmation screen;
* failed a retry affordance (a fresh C6 mount = a new attempt with a NEW idempotency key); window
* lapsed back to the request's terminal card.
*/
export default function CheckoutReturnPage() {
return (
@@ -31,6 +33,11 @@ export default function CheckoutReturnPage() {
);
}
const PENDING_NODES = (returnedLabel: string, confirmingLabel: string): TimelineNode[] => [
{ key: 'returned', label: returnedLabel, state: 'completed' },
{ key: 'confirming', label: confirmingLabel, state: 'current' },
];
function ReturnScreen() {
const t = useTranslations('payment');
const locale = useLocale();
@@ -87,90 +94,75 @@ function ReturnScreen() {
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('view_booking')}
</AppButton>
</StateCard>
</PaymentStateCard>
);
}
if (windowExpired) {
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
<PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={t('window_expired_title')}
body={t('window_expired_body')}
>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</StateCard>
</PaymentStateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" />
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</StateCard>
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</Stack>
</PaymentStateCard>
);
}
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
<CircularProgress color="primary" size="2.5rem" />
<PaymentStatusBadge status="pending" />
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t('state_pending_title')}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
<Stack sx={{ alignSelf: 'stretch', maxWidth: 320, mx: 'auto' }}>
<StatusTimeline nodes={PENDING_NODES(t('stage_returned'), t('stage_confirming'))} />
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('state_pending_duration_hint')}
</Typography>
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</Stack>
</Paper>
);
@@ -2,21 +2,31 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components';
AppButton,
AppIcon,
BookingRequestSummaryCard,
ConfirmDialog,
CountdownTimer,
StatusChip,
StepperHeader,
} from '@/components';
import { ROUTES } from '@/constants';
import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
const MINUTES_PER_HOUR = 60;
/** Freeform-text heuristic (the DTO carries no structured rejection-reason code REQ-044): suppress the
* "same nurse, different time" recovery when the nurse's reason reads like a hard gender/coverage block. */
const RETRY_BLOCK_KEYWORDS = ['gender', 'coverage', 'area', 'جنسیت', 'پوشش', 'منطقه', 'محدوده'];
function rejectionAllowsSameNurseRetry(reason: string | null): boolean {
if (!reason) return true;
const lower = reason.toLowerCase();
return !RETRY_BLOCK_KEYWORDS.some((keyword) => lower.includes(keyword));
}
/**
* C5 Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the
* request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition
@@ -46,14 +56,37 @@ export default function BookingRequestStatusPage() {
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
primary={{ label: t('retry'), onClick: () => refetch() }}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
/** Region + gender-carried search "پرستاران مشابه": same city/district + the same caregiver-gender
* intent, recovering the search context rather than restarting discovery from zero. */
const goToSimilarNurses = () => {
const searchParams = new URLSearchParams();
searchParams.set('city_id', String(request.cityId));
if (request.districtId != null) searchParams.set('district_id', String(request.districtId));
if (request.requiredCaregiverGender === 'male' || request.requiredCaregiverGender === 'female') {
searchParams.set('nurse_gender', request.requiredCaregiverGender);
}
router.push(`/${locale}${ROUTES.SEARCH}?${searchParams.toString()}`);
};
/** Reopens C4 for the SAME nurse/variant/patient/address, only the date/time left to re-pick recovers
* the booking intent instead of restarting from search. */
const goToReRequestSameNurse = () => {
const requestParams = new URLSearchParams();
requestParams.set('nurse_id', String(request.nurseId));
requestParams.set('variant_id', String(request.variantId));
if (request.requiredCaregiverGender) requestParams.set('required_gender', request.requiredCaregiverGender);
requestParams.set('patient_id', String(request.patientId));
requestParams.set('address_id', String(request.customerAddressId));
router.push(`/${locale}${ROUTES.BOOKING_REQUEST}?${requestParams.toString()}`);
};
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
@@ -73,6 +106,7 @@ export default function BookingRequestStatusPage() {
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
const canRetrySameNurse = rejectionAllowsSameNurseRetry(request.nurseRejectionReason);
return (
<Stack sx={{ gap: 3 }}>
{summary}
@@ -81,8 +115,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
primary={
canRetrySameNurse
? { label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }
: { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }
}
secondary={canRetrySameNurse ? { label: t('terminal_similar_nurses'), onClick: goToSimilarNurses } : undefined}
/>
</Stack>
);
@@ -91,7 +129,13 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('expired_title')}
primary={{ label: t('terminal_rerequest_same_nurse'), onClick: goToReRequestSameNurse }}
secondary={{ label: t('terminal_similar_nurses'), onClick: goToSimilarNurses }}
/>
</Stack>
);
}
@@ -99,7 +143,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="pending"
tone="var(--bal-warning)"
title={t('payment_expired_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -107,7 +156,12 @@ export default function BookingRequestStatusPage() {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
<TerminalCard
icon="rejected"
tone="var(--bal-text-secondary)"
title={t('cancelled_title')}
primary={{ label: t('terminal_rerequest'), onClick: goToSearch }}
/>
</Stack>
);
}
@@ -119,13 +173,14 @@ export default function BookingRequestStatusPage() {
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
primary={{
label: t('converted_cta'),
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onClick: () =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
),
}}
/>
</Stack>
);
@@ -193,12 +248,19 @@ export default function BookingRequestStatusPage() {
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
windowStart={request.createdAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
onElapsed={() => refetch()}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('response_notify_note')}
</Typography>
</Stack>
</Paper>
)}
@@ -212,33 +274,32 @@ export default function BookingRequestStatusPage() {
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
<ConfirmDialog
open={confirmCancel}
title={t('cancel_confirm_title')}
body={t('cancel_confirm_body')}
cancelLabel={t('cancel_confirm_keep')}
confirmLabel={t('cancel_confirm_destructive')}
confirmColor="error"
loading={cancelRequest.isPending}
onClose={() => setConfirmCancel(false)}
onConfirm={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
/>
</Stack>
);
}
/** Humanized minutes-remaining copy above the coarse threshold («حدود ۳ ساعت» / «حدود ۲۵ دقیقه»). */
function coarseResponseLabel(minutes: number, t: (key: string, values?: Record<string, number>) => string): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */
function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string {
const city = locale === 'en' ? request.cityNameEn : request.cityNameFa;
@@ -247,20 +308,25 @@ function customerAddressLabel(request: BookingRequestDto, locale: string, wholeC
return `${request.addressTitle} · ${city} · ${district}`;
}
interface TerminalAction {
label: string;
onClick: () => void;
}
function TerminalCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
primary,
secondary,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
primary: TerminalAction;
secondary?: TerminalAction;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
@@ -273,9 +339,16 @@ function TerminalCard({
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" onClick={primary.onClick}>
{primary.label}
</AppButton>
{secondary ? (
<AppButton variant="outlined" color="primary" onClick={secondary.onClick}>
{secondary.label}
</AppButton>
) : null}
</Stack>
</Paper>
);
}
@@ -3,9 +3,10 @@ import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Avatar,
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
@@ -13,23 +14,48 @@ import {
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
import { AddressMapPicker } from '@/components/geography';
import {
AppButton,
AppIcon,
AppLoading,
EmptyState,
JalaliDateIntentPicker,
PriceDisplay,
StepperHeader,
TrustBadge,
} from '@/components';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { cityCentroid } from '@/services/geography/constants';
import { usePatients } from '@/services/patients';
import { useAddresses } from '@/services/addresses';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import { useCreateBookingRequest } from '@/services/bookingRequests';
import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants';
import { formatNumber } from '@/utils';
import type {
BookingRequestDisplayContext,
RequiredCaregiverGender,
} from '@/services/bookingRequests/types';
import type { CustomerAddress } from '@/services/addresses/types';
const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any'];
interface TimeWindowOption {
key: 'morning' | 'afternoon' | 'evening';
start: string;
end: string;
}
const TIME_WINDOWS: TimeWindowOption[] = [
{ key: 'morning', start: '08:00', end: '12:00' },
{ key: 'afternoon', start: '12:00', end: '16:00' },
{ key: 'evening', start: '16:00', end: '20:00' },
];
type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender';
/**
* C4 Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
@@ -48,7 +74,6 @@ export default function BookingRequestFormPage() {
function BookingRequestForm() {
const t = useTranslations('booking');
const tAddress = useTranslations('address');
const locale = useLocale();
const router = useRouter();
const query = useSearchParams();
@@ -57,6 +82,10 @@ function BookingRequestForm() {
const hasNurse = Number.isInteger(nurseId) && nurseId > 0;
const variantIdParam = Number(query.get('variant_id')) || null;
const genderParam = query.get('required_gender');
// Recovery hand-off from C5's "request again with another time" — reopens this same nurse/variant
// prefilled with the patient + address of the terminal request, extending the C3 handoff params.
const patientIdParam = Number(query.get('patient_id')) || null;
const addressIdParam = Number(query.get('address_id')) || null;
const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined);
const patientsQuery = usePatients();
@@ -68,20 +97,32 @@ function BookingRequestForm() {
const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]);
const services = useMemo(() => profile?.services ?? [], [profile]);
const [patientId, setPatientId] = useState<number | ''>('');
const [patientId, setPatientId] = useState<number | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [timeStart, setTimeStart] = useState('09:00');
const [timeEnd, setTimeEnd] = useState('13:00');
const [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [attempted, setAttempted] = useState(false);
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const markTouched = (field: TouchedField) =>
setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true }));
// Effective selection = the user's explicit choice, else a sensible default derived from the loaded
// data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the
// first offered, the address to the primary / first.
@@ -112,20 +153,33 @@ function BookingRequestForm() {
const requiredChosen =
patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== '';
const regionLabel = (): string => {
if (!selectedAddress) return '';
const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa;
const regionLabel = (address: CustomerAddress): string => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
const district =
selectedAddress.districtId == null
address.districtId == null
? t('address_whole_city')
: locale === 'en'
? selectedAddress.districtNameEn
: selectedAddress.districtNameFa;
return `${selectedAddress.title} · ${city} · ${district}`;
? address.districtNameEn
: address.districtNameFa;
return `${address.title} · ${city} · ${district}`;
};
const selectWindow = (option: TimeWindowOption) => {
setWindowSel(option.key);
setTimeStart(option.start);
setTimeEnd(option.end);
if (pastDateError) setPastDateError(false);
};
const missingFieldLabels: string[] = [];
if (patientId === '') missingFieldLabels.push(t('cta_missing_patient'));
if (variantId === '') missingFieldLabels.push(t('cta_missing_service'));
if (addressId === '') missingFieldLabels.push(t('cta_missing_address'));
if (date === '') missingFieldLabels.push(t('cta_missing_date'));
if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time'));
if (gender === '') missingFieldLabels.push(t('cta_missing_gender'));
const handleSubmit = () => {
setAttempted(true);
setFormError(null);
if (!requiredChosen) return;
if (timeEnd <= timeStart) return;
@@ -208,11 +262,13 @@ function BookingRequestForm() {
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
@@ -222,6 +278,13 @@ function BookingRequestForm() {
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
@@ -235,9 +298,10 @@ function BookingRequestForm() {
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth
>
<MenuItem value="" disabled>
@@ -252,41 +316,42 @@ function BookingRequestForm() {
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={touched.service && variantId === ''}
helperText={touched.service && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
onBlur={() => markTouched('service')}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
) : null}
</Stack>
{/* Address */}
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
@@ -294,90 +359,142 @@ function BookingRequestForm() {
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
) : addressEditing || !selectedAddress ? (
<TextField
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
))}
</TextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
onChange={(iso) => {
setDate(iso);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
@@ -393,7 +510,7 @@ function BookingRequestForm() {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined,
},
}}
>
@@ -406,7 +523,7 @@ function BookingRequestForm() {
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
@@ -419,19 +536,21 @@ function BookingRequestForm() {
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
<Stack sx={{ gap: 0.5 }}>
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
@@ -439,17 +558,86 @@ function BookingRequestForm() {
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
<Stack sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
);
}
/** The sticky "who you're inviting home" identity summary — avatar, name, rating, trust badge, gender. */
function NurseIdentityBar({ profile }: { profile: NurseProfile }) {
const t = useTranslations('booking');
const locale = useLocale();
const name = profile.nurseName.trim() || t('unnamed_nurse');
const ratingLabel = formatNumber(profile.averageRating, locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return (
<Stack
direction="row"
data-nurse-identity-bar
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
bgcolor: 'background.paper',
}}
>
<Avatar
src={profile.avatarUrl ?? undefined}
sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }} noWrap>
{name}
</Typography>
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} />
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={15} color="var(--bal-rating)" />
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
({formatNumber(profile.totalReviews, locale)})
</Typography>
</Stack>
<Chip
size="small"
variant="outlined"
label={t(`gender_${profile.nurseGender}`)}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Stack>
</Stack>
</Stack>
);
}
@@ -512,7 +700,9 @@ function FieldEmpty({
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="rounded" height={76} />
<Skeleton variant="text" width="50%" height={36} />
<Skeleton variant="rounded" height={48} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
@@ -4,11 +4,13 @@ import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils';
import { ActorSwitcher } from '@/layout';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth';
import { useMe, useLogout } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
@@ -167,6 +169,31 @@ const CustomerProfileForm: FunctionComponent<{
</AppButton>
</Stack>
</Paper>
<Divider />
{/* Account affordances: sign-out has no home anywhere in the customer shell yet (a full hub
redesign is deferred to phase 9) one labeled row is enough for now. */}
<Stack sx={{ gap: 1.5 }}>
<ActorSwitcher target="nurse" />
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('app_language')}
</Typography>
<LocaleSwitcher />
</Stack>
<SignOutRow />
</Stack>
</Box>
);
};
const SignOutRow: FunctionComponent = () => {
const t = useTranslations('profile');
const { mutate: logout, isPending } = useLogout();
return (
<AppButton variant="text" color="error" startIcon="logout" onClick={() => logout()} disabled={isPending} sx={{ alignSelf: 'flex-start' }}>
{t('sign_out')}
</AppButton>
);
};
@@ -2,32 +2,32 @@
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, InputAdornment, Skeleton, Stack, TextField, Typography } from '@mui/material';
import {
Box,
InputAdornment,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
AppButton,
AppLoading,
CategoryTile,
ErrorState,
GenderToggle,
JalaliDateIntentPicker,
StickyActionBar,
} from '@/components';
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
import { todayIso } from '@/components/common/JalaliDatePicker';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useNurseSearch } from '@/services/search';
import { filtersToSearchParams } from '@/services/search/filterParams';
import type { NurseGender } from '@/services/search/types';
import { useSearchFilters } from './useSearchFilters';
/**
* C1 Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP it is carried to booking,
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
* the **prominent same-gender facet** (the shared `GenderToggle`, `allowAny`), a Jalali date-intent chip
* strip, and an optional Toman price range; a live result count drives the sticky "مشاهده N پرستار" CTA
* into C2. Availability (date) is intent-only at MVP it is carried to booking, never used to hard-filter
* results. `useSearchParams` needs a Suspense boundary under static rendering.
*/
export default function SearchScreen() {
return (
@@ -37,32 +37,24 @@ export default function SearchScreen() {
);
}
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
function SearchFilterScreen() {
const t = useTranslations('search');
const router = useRouter();
const locale = useLocale();
const params = useSearchParams();
const initialCategoryRaw = Number(params.get('category_id'));
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
const controller = useSearchFilters(initialCategoryId);
const controller = useSearchFilters(params);
const { data, isFetching } = useNurseSearch(controller.filters);
const count = data?.total;
const goToResults = () => {
const query = filtersToSearchParams(controller.filters);
if (controller.region.provinceId) query.set('province_id', String(controller.region.provinceId));
if (controller.dateIntent) query.set('date', controller.dateIntent);
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
};
const ctaLabel = !controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count });
const zeroResults = controller.isReady && !isFetching && count === 0;
return (
<Stack sx={{ gap: 3 }}>
@@ -82,31 +74,19 @@ function SearchFilterScreen() {
</FilterSection>
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
<ToggleButtonGroup
exclusive
fullWidth
color="primary"
<GenderToggle
allowAny
value={controller.gender ?? 'any'}
onChange={(_event, value: NurseGender | 'any' | null) => {
if (value != null) controller.setGender(value === 'any' ? undefined : value);
}}
>
{GENDER_OPTIONS.map((option) => (
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
onChange={(value) => controller.setGender(value === 'any' ? undefined : value)}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
anyLabel={t('gender_any')}
ariaLabel={t('section_gender')}
/>
</FilterSection>
<FilterSection title={t('section_date')} hint={t('date_hint')}>
<TextField
type="date"
fullWidth
value={controller.dateIntent}
onChange={(event) => controller.setDateIntent(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<DateIntentFilter value={controller.dateIntent} onChange={controller.setDateIntent} />
</FilterSection>
<FilterSection title={t('section_price')} hint={t('price_hint')}>
@@ -126,17 +106,35 @@ function SearchFilterScreen() {
</Stack>
</FilterSection>
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5 }}
>
{ctaLabel}
</AppButton>
<StickyActionBar>
{zeroResults ? (
<Stack sx={{ gap: 0.25 }} data-search-cta="zero">
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('cta_zero_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('cta_zero_hint')}
</Typography>
</Stack>
) : (
<AppButton
color="primary"
variant="contained"
size="large"
disabled={!controller.isReady}
onClick={goToResults}
startIcon="search"
sx={{ py: 1.5, width: '100%' }}
data-search-cta="view-results"
>
{!controller.isReady
? t('cta_choose_category_city')
: isFetching || count == null
? t('cta_loading')
: t('cta_view_results', { count })}
</AppButton>
)}
</StickyActionBar>
</Stack>
);
}
@@ -177,6 +175,29 @@ const PriceField: FunctionComponent<{
/>
);
/**
* The Jalali date-intent picker: a horizontal «امروز»/«فردا» + day-chip strip (the next 7 days) plus a
* calendar-icon entry into the full Jalali grid for later dates. Intent-only the value stays the same
* ISO string the flow already carries and never hard-filters results.
*/
const DateIntentFilter: FunctionComponent<{ value: string; onChange: (iso: string) => void }> = ({
value,
onChange,
}) => {
const t = useTranslations('search');
return (
<JalaliDateIntentPicker
value={value}
onChange={onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
);
};
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
selectedId,
@@ -3,22 +3,40 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
import {
AppButton,
AppIcon,
EmptyState,
ErrorState,
PriceDisplay,
RatingInput,
ServicePriceRow,
StickyActionBar,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatNumber, formatShamsiDate } from '@/utils';
import { useNurseProfile } from '@/services/search';
import type { NurseProfile } from '@/services/search/types';
import type { NurseProfile, NurseProfileServiceRow } from '@/services/search/types';
import { useNurseReviews } from '@/services/reviews';
import type { ReviewListItem } from '@/services/reviews/types';
import { useNurseTrustBadge } from '@/services/verification';
type ProfileTab = 'services' | 'reviews';
/**
* C3 Nurse profile (پروفایل پرستار): identity + trust badges ( تاییدشده, نظام پرستاری), attribute
* chips, and a **tabbed** body «خدمات» (the priced services list) and «نظرات» (the f13 published-reviews
* tab: aggregate rating + count + an infinite list). Only `published` reviews are ever requested/rendered.
* The primary CTA "درخواست رزرو" hands the selected nurse + variant + `required_caregiver_gender` to f7.
* C3 Nurse profile (پروفایل پرستار): the trust dossier identity header (completed visits + rating),
* a tappable تاییدشده badge + «نظام پرستاری» chip, the shared `VerificationPanel` (what Balinyaar
* verified, fed by the public trust-badge read), attribute chips, and a **tabbed** body «خدمات» (the
* priced services list + an optional latest-review snippet) and «نظرات» (the f13 published-reviews tab:
* fractional aggregate rating + count + an infinite list). Only `published` reviews are ever
* requested/rendered. The primary "درخواست رزرو" CTA is a **sticky bottom bar** (price-from beside the
* button) so it survives the infinite reviews list, and hands the selected nurse + variant +
* `required_caregiver_gender` to f7. The profile DTO does not yet serve `nurseGender` (REQ-042) the
* header intentionally omits a gender chip rather than render the client's placeholder stub.
*/
export default function NurseProfilePage() {
const t = useTranslations('search');
@@ -54,8 +72,11 @@ export default function NurseProfilePage() {
if (!profile) return null;
const carriedVariant = query.get('variant_id');
const primaryService: NurseProfileServiceRow | undefined =
profile.services.find((service) => String(service.variantId) === carriedVariant) ?? profile.services[0];
const requestBooking = () => {
const carriedVariant = query.get('variant_id');
const variantId = carriedVariant ?? String(profile.services[0]?.variantId ?? '');
const params = new URLSearchParams();
params.set('nurse_id', String(profile.nurseId));
@@ -76,6 +97,7 @@ export default function NurseProfilePage() {
<Stack sx={{ gap: 3 }}>
<ProfileHeader profile={profile} />
<AttributeChips profile={profile} />
<VerificationSection nurseId={profile.nurseId} />
<Tabs value={tab} onChange={(_, next: ProfileTab) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tab value="services" label={t('tab_services')} sx={{ textTransform: 'none', fontWeight: 700 }} />
@@ -84,16 +106,28 @@ export default function NurseProfilePage() {
{tab === 'services' ? <ServicesSection profile={profile} /> : <ReviewsPanel nurseId={profile.nurseId} />}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5 }}
>
{t('request_booking')}
</AppButton>
<StickyActionBar>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between' }}>
{primaryService ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('price_from')}
</Typography>
<PriceDisplay price={primaryService.priceIrr} priceUnit={primaryService.priceUnit} align="start" />
</Box>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
onClick={requestBooking}
startIcon="bookings"
sx={{ py: 1.5, flexGrow: 1 }}
>
{t('request_booking')}
</AppButton>
</Stack>
</StickyActionBar>
</Stack>
);
}
@@ -121,7 +155,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{name}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-warning)" />
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{rating}
</Typography>
@@ -129,11 +163,14 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
{t('reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -152,6 +189,23 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
);
}
/** "What Balinyaar verified" — the shared `VerificationPanel`, fed by the public trust-badge read. */
function VerificationSection({ nurseId }: { nurseId: number }) {
const t = useTranslations('verification');
const { data: badge, isLoading, isError } = useNurseTrustBadge(nurseId);
return (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={badge} isLoading={isLoading} isError={isError} />
</SurfaceCard>
</Stack>
);
}
function AttributeChips({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
const locale = useLocale();
@@ -177,7 +231,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
function ServicesSection({ profile }: { profile: NurseProfile }) {
const t = useTranslations('search');
return (
<Stack sx={{ gap: 1 }}>
<Stack sx={{ gap: 2 }}>
{profile.services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('services_empty')}
@@ -195,10 +249,34 @@ function ServicesSection({ profile }: { profile: NurseProfile }) {
))}
</Box>
)}
{profile.latestReview ? <LatestReviewSnippet review={profile.latestReview} /> : null}
</Stack>
);
}
/** The already-fetched latest-review snippet — a small taste of the dossier's reviews tab. */
function LatestReviewSnippet({ review }: { review: NonNullable<NurseProfile['latestReview']> }) {
const t = useTranslations('search');
const tr = useTranslations('reviews');
const locale = useLocale();
return (
<SurfaceCard padding="sm">
<Stack sx={{ gap: 0.75 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('latest_review_title')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={review.rating} readOnly size={16} ariaLabel={tr('rating_label')} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{review.authorMasked} · {formatShamsiDate(review.createdAt, locale)}
</Typography>
</Stack>
{review.body ? <Typography variant="body2">{review.body}</Typography> : null}
</Stack>
</SurfaceCard>
);
}
/**
* The f13 reviews tab the aggregate rating + count and an infinite list of **published** reviews. Never
* requests or renders `pending_moderation`/`hidden`/`rejected` content; the aggregate is the server's
@@ -2,20 +2,28 @@
import { Suspense, useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
import { Chip, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
import { ROUTES } from '@/constants';
import { useServiceCategories } from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import { useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
import { useNurseSearch } from '@/services/search';
import { searchParamsToFilters } from '@/services/search/filterParams';
import { SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { formatIrrToToman } from '@/utils';
import type { NurseSearchResult } from '@/services/search/types';
/**
* C2 Results (نتایج جستجو): the rating-sorted list of **only verified, accepting** nurses for the
* carried filter set. The filter set lives in the URL (the deep-linkable, back/forward-safe cache key),
* so returning to a prior filter URL is a cache hit with zero network calls (`useNurseSearch` +
* `keepPreviousData`). Renders all four states (loading skeletons / empty "relax filters" / error-retry
* / populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
* `keepPreviousData`). A tappable **filter-recap chip row** (category · region · gender · price) deep-
* links back to C1 carrying the *entire* current query string every filter C1 set, including the
* client-only `province_id`/`date` params so C1 hydrates fully instead of resetting to just the
* category. Renders all four states (loading skeletons / empty "relax filters" / error-retry /
* populated). Tapping a card opens C3, carrying the nurse + variant + gender intent.
*/
export default function SearchResultsPage() {
return (
@@ -37,12 +45,48 @@ function ResultsScreen() {
// The URL is the source of truth for the filter set; grow only the page size for "load more".
const filters = useMemo(() => ({ ...searchParamsToFilters(params), pageSize }), [params, pageSize]);
const dateIntent = params.get('date') ?? undefined;
const provinceIdParam = params.get('province_id');
const { data, isLoading, isError, isFetching, refetch } = useNurseSearch(filters);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const hasMore = items.length < total;
const { data: categoriesData } = useServiceCategories();
const categories = useMemo(() => categoriesData?.items ?? [], [categoriesData]);
const categoryLabelById = useMemo(() => {
const map = new Map<number, string>();
categories.forEach((category) => map.set(category.id, pickCatalogName(category, locale)));
return map;
}, [categories, locale]);
const categoryLabel = categoryLabelById.get(filters.serviceCategoryId);
const { data: cities } = useCities(provinceIdParam ? Number(provinceIdParam) : undefined);
const { data: districts } = useDistricts(filters.cityId || undefined);
const city = cities?.find((candidate) => candidate.id === filters.cityId);
const district = filters.districtId ? districts?.find((candidate) => candidate.id === filters.districtId) : undefined;
const regionLabel = city
? `${pickRegionName(city, locale)} · ${district ? pickRegionName(district, locale) : t('whole_city')}`
: undefined;
const genderLabel = t(`gender_${filters.nurseGender ?? 'any'}`);
const priceLabel = filters.priceMin
? filters.priceMax
? t('price_chip_range', {
min: formatIrrToToman(filters.priceMin, locale),
max: formatIrrToToman(filters.priceMax, locale),
})
: t('price_chip_min', { min: formatIrrToToman(filters.priceMin, locale) })
: filters.priceMax
? t('price_chip_max', { max: formatIrrToToman(filters.priceMax, locale) })
: undefined;
const backToFilters = useCallback(
() => router.push(`/${locale}${ROUTES.SEARCH}?${params.toString()}`),
[router, locale, params],
);
const openProfile = useCallback(
(nurse: NurseSearchResult) => {
const query = new URLSearchParams();
@@ -56,18 +100,26 @@ function ResultsScreen() {
[router, locale, filters.serviceCategoryId, filters.cityId, filters.nurseGender, dateIntent],
);
const backToFilters = () => router.push(`/${locale}${ROUTES.SEARCH}`);
return (
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="h6" component="h1">
{isLoading ? t('results_loading_title') : t('results_count', { count: total })}
</Typography>
{/* Rating is the only MVP sort; rendered as a control with a single option. Other sorts DEFERRED. */}
<TextField select size="small" label={t('sort_label')} value="rating" sx={{ minWidth: 160 }}>
<MenuItem value="rating">{t('sort_rating')}</MenuItem>
</TextField>
{/* Rating is the only MVP sort a static caption, not a dead-interactive dropdown. Other
sorts are DEFERRED until the API grows them. */}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('sort_static')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{categoryLabel ? (
<Chip label={categoryLabel} onClick={backToFilters} data-recap-chip="category" />
) : null}
{regionLabel ? <Chip label={regionLabel} onClick={backToFilters} data-recap-chip="region" /> : null}
<Chip label={genderLabel} onClick={backToFilters} data-recap-chip="gender" />
{priceLabel ? <Chip label={priceLabel} onClick={backToFilters} data-recap-chip="price" /> : null}
</Stack>
{isLoading ? (
@@ -83,7 +135,12 @@ function ResultsScreen() {
) : (
<Stack sx={{ gap: 1.5 }}>
{items.map((nurse) => (
<NurseResultCard key={`${nurse.nurseId}-${nurse.variantId}`} nurse={nurse} onSelect={openProfile} />
<NurseResultCard
key={`${nurse.nurseId}-${nurse.variantId}`}
nurse={nurse}
serviceLabel={categoryLabelById.get(nurse.serviceCategoryId) ?? t('unnamed_service')}
onSelect={openProfile}
/>
))}
{hasMore ? (
<AppButton
@@ -118,7 +175,7 @@ function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
{t('empty_suggest_district')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_suggest_city')}
{t('empty_suggest_date')}
</Typography>
</Stack>
}
@@ -1,11 +1,16 @@
import { useMemo, useState } from 'react';
import { toEnglishDigits, tomanToRial } from '@/utils';
import { rialToToman } from '@/utils/money';
import { useDebouncedValue } from '@/services/search';
import { SEARCH_FILTER_DEBOUNCE_MS, SEARCH_PAGE_SIZE } from '@/services/search/constants';
import { parsePositiveInt, searchParamsToFilters } from '@/services/search/filterParams';
import type { NurseGender, NurseSearchFilters } from '@/services/search/types';
import type { CascadingRegionValue } from '@/components/geography/CascadingRegionSelect';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/** Minimal read surface shared by `URLSearchParams` and Next's `ReadonlyURLSearchParams`. */
interface ParamReader {
get(name: string): string | null;
}
/** Toman input → IRR-Rial digit-string at the field boundary; undefined for blank/invalid input. */
function tomanInputToIrr(toman: string): string | undefined {
@@ -14,20 +19,41 @@ function tomanInputToIrr(toman: string): string | undefined {
return tomanToRial(digits);
}
/** IRR digit-string (or undefined) → the whole-Toman string the price fields display. */
function irrToTomanInput(irr: string | undefined): string {
return irr ? String(rialToToman(irr)) : '';
}
/**
* The C1 filter controller fast-changing UI state kept **colocated** (not in a high context provider,
* phase §5). Holds the category, cascading region, same-gender facet, and Toman price inputs, and
* derives the canonical `NurseSearchFilters` that becomes the live-count query key and the C2 URL. The
* price inputs are **debounced** so typing doesn't fan out one search per keystroke before the value
* joins the query key. `districtId = null` (whole city) is carried as an omitted filter, never a bogus id.
*
* `params` seeds the **initial** state only (a lazy `useState` read) either a bare `?category_id=`
* (the Home tile handoff) or a full filter set carried back from a C2 recap chip (`searchParamsToFilters`
* reads every field C2's URL carries). `province_id` is a client-only convenience param (not part of
* `NurseSearchFilters`/the search query key) so `CascadingRegionSelect` can prefill the city dropdown
* without a server round trip; `goToResults` re-carries it so the round trip back to C1 keeps working.
*/
export function useSearchFilters(initialCategoryId?: number) {
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId ?? null);
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [gender, setGender] = useState<NurseGender | undefined>(undefined);
const [priceMinToman, setPriceMinToman] = useState('');
const [priceMaxToman, setPriceMaxToman] = useState('');
const [dateIntent, setDateIntent] = useState('');
export function useSearchFilters(params: ParamReader) {
const [categoryId, setCategoryId] = useState<number | null>(() => {
const raw = searchParamsToFilters(params).serviceCategoryId;
return raw > 0 ? raw : null;
});
const [region, setRegion] = useState<CascadingRegionValue>(() => {
const initial = searchParamsToFilters(params);
return {
provinceId: parsePositiveInt(params.get('province_id')) ?? null,
cityId: initial.cityId > 0 ? initial.cityId : null,
districtId: initial.districtId ?? null,
};
});
const [gender, setGender] = useState<NurseGender | undefined>(() => searchParamsToFilters(params).nurseGender);
const [priceMinToman, setPriceMinToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMin));
const [priceMaxToman, setPriceMaxToman] = useState(() => irrToTomanInput(searchParamsToFilters(params).priceMax));
const [dateIntent, setDateIntent] = useState(() => params.get('date') ?? '');
const debouncedMin = useDebouncedValue(priceMinToman, SEARCH_FILTER_DEBOUNCE_MS);
const debouncedMax = useDebouncedValue(priceMaxToman, SEARCH_FILTER_DEBOUNCE_MS);
@@ -8,12 +8,12 @@ import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/**
* D5 · پیگیری اقساط the Wallet view of active installment plans. It reads `useWalletInstallments` and
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays,
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content
* can land beside it later.
* D5 · پیگیری اقساط the Wallet «اقساط» section (active installment plans). It reads
* `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
* (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
* *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
* the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only the
* page-level heading + tab strip live in `WalletScreen`.
*/
const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl');
@@ -21,11 +21,7 @@ const WalletInstallments: FunctionComponent = () => {
const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}>
<Typography variant="h6" component="h1">
{t('wallet_title')}
</Typography>
<Stack sx={{ gap: 2, width: '100%' }}>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} />
@@ -0,0 +1,62 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState, Money, PaymentStatusBadge, SurfaceCard } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «پرداختها» section every card + BNPL payment (down-payment) the customer made, newest
* first, with a deep-link to the booking. For a card-paying customer (the default path) this is what
* finally fills the previously permanently-empty Wallet tab.
*/
const WalletPaymentHistory: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
if (rows.length === 0) {
return <EmptyState icon="payment" title={t('history_empty_title')} body={t('history_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{rows.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<PaymentStatusBadge status={row.status} />
{row.bookingId != null ? (
<AppLink to={`/${locale}${ROUTES.BOOKINGS}/${row.bookingId}`} sx={{ fontSize: '0.75rem' }}>
{t('history_view_booking')}
</AppLink>
) : null}
</Stack>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletPaymentHistory;
@@ -0,0 +1,66 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, SurfaceCard } from '@/components';
import { bookingInvoicePath } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «رسیدها» section no receipts endpoint exists; every succeeded, booking-linked payment
* (card or BNPL down-payment) derives its invoice deep-link client-side (a UI join over the same rows the
* «پرداختها» tab renders, filtered to `succeeded` + a known `bookingId` no money math).
*/
const WalletReceipts: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
const receipts = rows.filter((row) => row.status === 'succeeded' && row.bookingId != null);
if (receipts.length === 0) {
return <EmptyState icon="document" title={t('receipts_empty_title')} body={t('receipts_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{receipts.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="document"
to={`/${locale}${bookingInvoicePath(row.bookingId as number)}`}
>
{tp('view_invoice_cta')}
</AppButton>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletReceipts;
@@ -0,0 +1,54 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState } from '@/components';
import RefundStatusCard from '@/components/RefundStatusCard';
import { bookingRefundStatusPath } from '@/constants';
import { useMyRefunds } from '@/services/refunds';
/**
* The wallet «استردادها» section every refund the customer owns (REQ-048), each rendered via the shared
* `RefundStatusCard` (step timeline + amount + per-channel ETA) with a link back to its booking.
*/
const WalletRefunds: FunctionComponent = () => {
const t = useTranslations('refunds');
const tw = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { data: refunds, isLoading, isError, refetch } = useMyRefunds();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={160} />
</Stack>
);
}
if (isError) {
return <ErrorState message={tw('wallet_error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
}
if (!refunds || refunds.length === 0) {
return <EmptyState icon="refunds" title={t('wallet_empty_title')} body={t('wallet_empty_body')} />;
}
return (
<Stack sx={{ gap: 3 }}>
{refunds.map((refund) => (
<Stack key={refund.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('wallet_booking_label', { id: refund.bookingId })}
</Typography>
<AppLink to={`/${locale}${bookingRefundStatusPath(refund.bookingId)}`} sx={{ fontSize: '0.75rem' }}>
{t('view_refund_status')}
</AppLink>
</Stack>
<RefundStatusCard refund={refund} />
</Stack>
))}
</Stack>
);
};
export default WalletRefunds;
@@ -0,0 +1,57 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack, Tab, Tabs } from '@mui/material';
import { AppIcon, PageHeader } from '@/components';
import WalletPaymentHistory from './WalletPaymentHistory';
import WalletInstallments from './WalletInstallments';
import WalletRefunds from './WalletRefunds';
import WalletReceipts from './WalletReceipts';
type WalletTab = 'payments' | 'installments' | 'refunds' | 'receipts';
/**
* /wallet the customer money hub (ui-phase-6). Four sections replace the old installments-only shell so
* a card-paying customer (the default path) finally sees something other than a permanently empty tab:
* «پرداختها» (payment history), «اقساط» (the unchanged f11 D5 installment tracker), «استردادها» (refunds,
* REQ-048), «رسیدها» (client-derived invoice links). All four read at the shell's shared `CONTENT_MAX_WIDTH`
* no local width override.
*/
const WalletScreen: FunctionComponent = () => {
const t = useTranslations('bnpl');
const [tab, setTab] = useState<WalletTab>('payments');
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('wallet_hub_title')} />
<Tabs
value={tab}
onChange={(_event, value: WalletTab) => setTab(value)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab value="payments" label={t('tab_payments')} icon={<AppIcon icon="payment" size={18} />} iconPosition="start" />
<Tab
value="installments"
label={t('tab_installments')}
icon={<AppIcon icon="installments" size={18} />}
iconPosition="start"
/>
<Tab value="refunds" label={t('tab_refunds')} icon={<AppIcon icon="refunds" size={18} />} iconPosition="start" />
<Tab value="receipts" label={t('tab_receipts')} icon={<AppIcon icon="document" size={18} />} iconPosition="start" />
</Tabs>
<Box role="tabpanel">
{tab === 'payments' ? <WalletPaymentHistory /> : null}
{tab === 'installments' ? <WalletInstallments /> : null}
{tab === 'refunds' ? <WalletRefunds /> : null}
{tab === 'receipts' ? <WalletReceipts /> : null}
</Box>
</Stack>
);
};
export default WalletScreen;
@@ -1,10 +1,9 @@
import WalletInstallments from './WalletInstallments';
import WalletScreen from './WalletScreen';
/**
* /wallet the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported,
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client
* component (TanStack Query); this page is the thin route shell.
* /wallet — the customer money hub (ui-phase-6): پرداخت‌ها / اقساط / استردادها / رسیدها. Thin route shell;
* the tabbed body is a client component (TanStack Query).
*/
export default function WalletPage() {
return <WalletInstallments />;
return <WalletScreen />;
}
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { usePaymentHistory } from '@/services/payment';
import { useWalletInstallments } from '@/services/bnpl';
import type { PaymentTransactionStatus } from '@/services/payment/types';
export interface WalletHistoryRow {
key: string;
amountIrr: string;
createdAt: string;
status: PaymentTransactionStatus;
bookingId: number | null;
}
/**
* Merges the two independent seams a wallet history/receipt row can come from card transactions
* (`services/payment`, REQ-047) and each settled BNPL plan's own down-payment leg (`services/bnpl`) into
* one newest-first list. Shared by the wallet «پرداختها» and «رسیدها» tabs so the merge logic lives once.
* Degrades gracefully: either source failing alone still renders the other's rows.
*/
export function useWalletHistoryRows() {
const paymentHistory = usePaymentHistory();
const walletInstallments = useWalletInstallments();
const rows = useMemo<WalletHistoryRow[]>(() => {
const cardRows: WalletHistoryRow[] = (paymentHistory.data ?? []).map((row) => ({
key: `card-${row.transactionId}`,
amountIrr: row.amountIrr,
createdAt: row.createdAt,
status: row.status,
bookingId: row.bookingId,
}));
const bnplRows: WalletHistoryRow[] = (walletInstallments.data ?? []).map((plan) => {
const downPayment = plan.installments.find((i) => i.kind === 'down_payment');
return {
key: `bnpl-${plan.bnplTransactionId}`,
amountIrr: downPayment?.amountIrr ?? '0',
createdAt: plan.createdAt,
status: 'succeeded' as const,
bookingId: plan.bookingId,
};
});
return [...cardRows, ...bnplRows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [paymentHistory.data, walletInstallments.data]);
return {
rows,
isLoading: paymentHistory.isLoading || walletInstallments.isLoading,
bothErrored: paymentHistory.isError && walletInstallments.isError,
refetch: () => {
paymentHistory.refetch();
walletInstallments.refetch();
},
};
}
@@ -0,0 +1,19 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Customer-focused route group a chrome-free counterpart to `(customer)` for flows the user
* should not tab away from mid-task (today only first-run onboarding, ui-phase-3 §3.5). A route
* group adds chrome without adding a URL segment, so `/onboarding` is unchanged. RoleGuard still
* gates it on a resolved customer role, identically to the full `(customer)` shell.
*/
export default function CustomerFocusedRouteLayout({ children }: { children: ReactNode }) {
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<FocusedLayout>{children}</FocusedLayout>
</RoleGuard>
);
}
@@ -4,7 +4,8 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components';
import { AppButton, AppIcon, PatientForm, RelationSelect, StepperHeader } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
import { ROUTES } from '@/constants';
import { useCreatePatient } from '@/services/patients';
import { RELATION_CODES } from '@/services/patients/constants';
@@ -12,12 +13,26 @@ import type { CreatePatientInput, Relation } from '@/services/patients/types';
const ONBOARDING_MAX_WIDTH = 520;
// Distinct per-option glyph (the prior defect: all four relations shared the generic 'account'
// icon) — 'elderly' fits a parent, 'favorite' a spouse, 'infant' a child, 'account' one's self.
const RELATION_ICONS: Record<string, string> = {
parent: 'elderly',
spouse: 'favorite',
child: 'infant',
self: 'account',
};
type Phase = 'welcome' | 'relation' | 'patient';
/**
* A3 A4 onboarding wizard: pick who care is for, then register the first patient. The
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already
* chosen here). On save it creates the patient and lands on Home (A5).
* The chrome-free A3 A4 first-run journey: a one-screen welcome moment, then pick who care is
* for, then register the first patient. `FocusedLayout` (the route group above this) strips the
* bottom nav/bell so there's nothing to tab away to mid-setup. The welcome screen doesn't count
* as a stepper step; relation patient does. The chosen relation pre-shapes the patient (hidden
* on the A4 form since it's already chosen here). On save it creates the patient and lands on
* Home (A5).
*/
export default function OnboardingPage() {
export default function OnboardingScreen() {
const t = useTranslations('onboarding');
const tc = useTranslations('common');
const router = useRouter();
@@ -25,10 +40,14 @@ export default function OnboardingPage() {
const { enqueueSnackbar } = useSnackbar();
const createPatient = useCreatePatient();
const [step, setStep] = useState(0);
const [phase, setPhase] = useState<Phase>('welcome');
const [relation, setRelation] = useState<Relation | null>(null);
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }));
const relationOptions = RELATION_CODES.map((code) => ({
code,
label: t(`relation_${code}`),
icon: RELATION_ICONS[code] ?? 'account',
}));
const handleCreate = (input: CreatePatientInput) => {
createPatient.mutate(
@@ -42,11 +61,32 @@ export default function OnboardingPage() {
);
};
if (phase === 'welcome') {
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 3, textAlign: 'center' }}>
<BrandMark />
<Stack sx={{ gap: 1, maxWidth: ONBOARDING_MAX_WIDTH }}>
<Typography variant="h5" component="h1">
{t('welcome_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('welcome_subtitle')}
</Typography>
</Stack>
<AppButton color="primary" variant="contained" onClick={() => setPhase('relation')}>
{t('welcome_cta')}
</AppButton>
</Stack>
);
}
const activeStep = phase === 'relation' ? 0 : 1;
return (
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} />
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={activeStep} />
{step === 0 ? (
{phase === 'relation' ? (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
@@ -66,7 +106,8 @@ export default function OnboardingPage() {
variant="contained"
fullWidth
disabled={!relation}
onClick={() => setStep(1)}
onClick={() => setPhase('patient')}
endIcon={<AppIcon icon="forward" size={18} aria-hidden="true" />}
>
{t('continue')}
</AppButton>
@@ -86,7 +127,7 @@ export default function OnboardingPage() {
submitLabel={t('save_continue')}
submitting={createPatient.isPending}
onSubmit={handleCreate}
onCancel={() => setStep(0)}
onCancel={() => setPhase('relation')}
cancelLabel={tc('back')}
/>
</Stack>
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import OnboardingScreen from './OnboardingScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'onboarding' });
return { title: t('welcome_title') };
}
export default function Page() {
return <OnboardingScreen />;
}
@@ -0,0 +1,13 @@
'use client';
import { ActivationChecklist } from '@/components';
/**
* The dashboard's activation/go-live composition point **named and exported so a later phase can
* find it** (ui-phase-7's hand-off note). ui-phase-8 fills it with the real `ActivationChecklist`
* (the same shared component mounted on `/nurse/services`) replacing the placeholder single-row
* verification banner phase 7 left here. Extend this component in place, don't add a second slot.
* @component DashboardActivationSlot
*/
export default function DashboardActivationSlot() {
return <ActivationChecklist />;
}
@@ -0,0 +1,294 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLink,
CountdownTimer,
EmptyState,
ErrorState,
Money,
SurfaceCard,
TrustBadge,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useMe } from '@/services/auth';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { useTodaySessions } from '@/services/bookings';
import { useNurseEarningsBalance } from '@/services/payouts';
import { useUnreadCount } from '@/services/notifications';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import DashboardActivationSlot from './DashboardActivationSlot';
const DASHBOARD_MAX_WIDTH = 960;
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* The nurse "امروز" dashboard (ui-phase-7 §3.1) the operational home replacing the `PlaceholderScreen`.
* Pure assembly: every widget reads an already-cached query. Order matters the pending-requests strip
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
*/
export default function NurseDashboardScreen() {
const t = useTranslations('dashboard');
const { data: me, isLoading: meLoading } = useMe();
const verification = useVerificationStatus();
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
return (
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{meLoading ? (
<>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={160} height={32} />
</>
) : (
<>
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
{(displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
</Stack>
</>
)}
</Stack>
<NextVisitCard />
<RequestsStrip />
<EarningsSnapshotCard />
<DashboardActivationSlot />
<NotificationsEntryRow />
</Stack>
);
}
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
function NextVisitCard() {
const t = useTranslations('dashboard');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useTodaySessions();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isError) {
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
if (!next) {
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
}
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const timeRangeLabel = `${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeStart}`))} ${timeFmt.format(new Date(`${next.scheduledDate}T${next.scheduledTimeEnd}`))}`;
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
return (
<SurfaceCard data-widget="next-visit">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('next_visit_title')}
</Typography>
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{next.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{timeRangeLabel}
</Typography>
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
</Typography>
</Stack>
<AppButton
variant="contained"
color="secondary"
startIcon="check_in"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('next_visit_cta')}
</AppButton>
</Stack>
</SurfaceCard>
);
}
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
function RequestsStrip() {
const t = useTranslations('dashboard');
const tb = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
if (isLoading) return <Skeleton variant="rounded" height={140} />;
if (isError) {
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
const items = data?.items ?? [];
const total = data?.total ?? 0;
if (items.length === 0) {
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
}
const mostUrgent = items[0];
return (
<SurfaceCard data-widget="requests-strip">
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('requests_strip_title', { count: total })}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
>
{t('requests_strip_cta')}
</AppButton>
</Stack>
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{mostUrgent.counterpartyName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(mostUrgent.requestedDate, locale)}
</Typography>
</Stack>
<CountdownTimer
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
elapsedText={tb('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
size="sm"
/>
</Stack>
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('requests_strip_open')}
</AppButton>
</Stack>
</SurfaceCard>
);
}
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
function EarningsSnapshotCard() {
const t = useTranslations('dashboard');
const tp = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
if (isLoading) return <Skeleton variant="rounded" height={120} />;
if (isError) {
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
}
if (!data) return null;
const net = parseIrr(data.netPayableBalanceIrr);
const isOwed = net < BigInt(0);
const magnitude = isOwed ? -net : net;
return (
<SurfaceCard data-widget="earnings-snapshot">
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('earnings_snapshot_title')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
>
{t('earnings_snapshot_cta')}
</AppButton>
</Stack>
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
</Typography>
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
</Stack>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{tp('bucket_eligible')}
</Typography>
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
</Stack>
</Box>
</Stack>
</SurfaceCard>
);
}
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
function NotificationsEntryRow() {
const t = useTranslations('dashboard');
const locale = useLocale();
const unread = useUnreadCount();
return (
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-widget="notifications-entry">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{t('notifications_entry_title')}
</Typography>
</Stack>
<Typography
variant="body2"
sx={{ color: unread > 0 ? 'var(--bal-secondary)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
>
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
</Typography>
</Stack>
</SurfaceCard>
</AppLink>
);
}
@@ -3,21 +3,26 @@ import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components';
import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components';
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
import { isValidSheba } from '@/services/nurse/iban';
import { deriveBankStatus } from '@/services/nurse/types';
/**
* Nurse payout bank settings submit an IBAN (شبا) + account-holder name, then watch the
* ownership inquiry resolve through its three states (pending verified / mismatch). The list
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter.
* Nurse payout bank settings an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
* an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
* states (pending verified / mismatch, `BankStatusPanel` unchanged). Once at least one account
* exists, a persistent «افزودن حساب دیگر» CTA replaces the old form-only-when-empty gate, so a nurse
* switching banks is never dead-ended the old account stays listed until the nurse makes the new
* one primary. A failed accounts query renders the error state with retry, **never** the empty-state
* form (which would invite a duplicate-IBAN submission blind).
*/
export default function NurseBankPage() {
const t = useTranslations('bank');
const tc = useTranslations('common');
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useNurseBankAccounts();
const { data, isLoading, isError, refetch } = useNurseBankAccounts();
const addAccount = useAddNurseBankAccount();
const setPrimary = useSetPrimaryBankAccount();
@@ -28,7 +33,7 @@ export default function NurseBankPage() {
const [showForm, setShowForm] = useState(false);
const accounts = data ?? [];
const showFormNow = !isLoading && (accounts.length === 0 || showForm);
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
const submit = () => {
const ibanInvalid = !isValidSheba(iban);
@@ -64,47 +69,67 @@ export default function NurseBankPage() {
{isLoading ? <AppLoading /> : null}
{accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})}
{isError ? <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> : null}
{!isLoading && accounts.length === 0 ? (
{!isError
? accounts.map((account) => {
const status = deriveBankStatus(account);
return (
<Stack key={account.id} sx={{ gap: 1 }}>
<BankStatusPanel
status={status}
chipLabel={t(`status_${status}_chip`)}
title={t(`status_${status}_title`)}
body={t(`status_${status}_body`)}
ibanMasked={status === 'verified' ? account.ibanMasked : undefined}
ibanLabel={t('iban_masked_label')}
bankName={account.bankName || undefined}
isPrimary={account.isPrimary}
primaryLabel={t('primary')}
onReenter={status === 'mismatch' ? () => setShowForm(true) : undefined}
reenterLabel={t('reenter')}
/>
{/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */}
{status === 'verified' && !account.isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={setPrimary.isPending}
onClick={() =>
setPrimary.mutate(account.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('primary_set_error'), { variant: 'error' }),
})
}
sx={{ alignSelf: 'flex-start' }}
>
{t('make_primary')}
</AppButton>
) : null}
</Stack>
);
})
: null}
{!isLoading && !isError && accounts.length === 0 ? (
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
) : null}
{/* An accounts section, not a one-shot form: once at least one account exists, a persistent CTA
(rather than "no account yet") lets a nurse switching banks add another the old account
stays listed until they make the new one primary. */}
{!isLoading && !isError && accounts.length > 0 && !showForm ? (
<AppButton
variant="outlined"
color="primary"
startIcon="add"
onClick={() => setShowForm(true)}
sx={{ alignSelf: 'flex-start' }}
>
{t('add_another')}
</AppButton>
) : null}
{showFormNow ? (
<Stack sx={{ gap: 2 }}>
<TextField
@@ -130,16 +155,26 @@ export default function NurseBankPage() {
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<AppButton
color="primary"
variant="contained"
startIcon="bank"
onClick={submit}
disabled={addAccount.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
setIban('');
setHolder('');
setIbanError(false);
setHolderError(false);
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
</AppButton>
) : null}
</Stack>
</Stack>
) : null}
</Box>
@@ -2,36 +2,25 @@
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
import { ApiError } from '@/lib/api/errors';
import { useDistricts } from '@/services/geography';
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
type Scope = 'whole_city' | 'districts';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/**
* The nurse coverage-area editor the cities/districts a nurse will travel to, so search (f6)
* can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add
* control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A
* duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to
* the same message). Empty a warning that the nurse won't appear in search.
* can fan them out geographically. Areas render as chips (whole-city shown explicitly).
*
* ui-phase-8: **one control owns the whole-city choice** `CascadingRegionSelect`'s own district
* level, whose "کل شهر" empty option *is* the choice (`districtId = null`, matching the serviceAreas
* contract both ways). The separate scope toggle this page used to render is gone it let a nurse
* pick "specific districts" and then still land on the district select's own whole-city option,
* tripping a "district required" error the UI itself had offered. City is the only required field;
* leaving the district unset is a complete, valid whole-city submission, never an error state.
*/
export default function NurseCoveragePage() {
const t = useTranslations('coverage');
@@ -44,22 +33,12 @@ export default function NurseCoveragePage() {
const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [scope, setScope] = useState<Scope>('whole_city');
const [cityError, setCityError] = useState(false);
const [districtError, setDistrictError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
const areas = data?.items ?? [];
// A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the
// same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a
// district that cannot exist.
const districtsQuery = useDistricts(region.cityId);
const cityHasNoDistricts =
region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0;
const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope;
const chipLabel = (area: NurseServiceArea) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
@@ -67,33 +46,21 @@ export default function NurseCoveragePage() {
return `${city} · ${district}`;
};
const changeScope = (next: Scope | null) => {
if (!next) return;
setScope(next);
setDistrictError(false);
setDuplicate(false);
// Whole-city ignores any picked district — clear it so the submitted pair is unambiguous.
if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null }));
};
const resetForm = () => {
setRegion(EMPTY_REGION);
setScope('whole_city');
setCityError(false);
setDistrictError(false);
setDuplicate(false);
};
const handleAdd = () => {
const cityInvalid = region.cityId == null;
const districtInvalid = effectiveScope === 'districts' && region.districtId == null;
setCityError(cityInvalid);
setDistrictError(districtInvalid);
setDuplicate(false);
if (cityInvalid || districtInvalid) return;
if (cityInvalid) return;
const cityId = region.cityId as number;
const districtId = effectiveScope === 'whole_city' ? null : region.districtId;
// Whatever the district select currently holds is the complete choice — `null` = whole city.
const districtId = region.districtId;
// Fast path: block a duplicate before firing (null district treated as a real value).
if (areaExists(areas, cityId, districtId)) {
@@ -123,6 +90,7 @@ export default function NurseCoveragePage() {
setRemoveTarget(null);
removeArea.mutate(id, {
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
});
};
@@ -191,38 +159,15 @@ export default function NurseCoveragePage() {
{t('add_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('scope_label')}
</Typography>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={effectiveScope}
onChange={(_event, next: Scope | null) => changeScope(next)}
>
<ToggleButton value="whole_city">{t('scope_whole_city')}</ToggleButton>
{/* A district-less city forces whole-city — disable the option rather than dead-end on it. */}
<ToggleButton value="districts" disabled={cityHasNoDistricts}>
{t('scope_districts')}
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
if (districtError && next.districtId != null) setDistrictError(false);
setDuplicate(false);
}}
includeDistrict={effectiveScope === 'districts'}
cityError={cityError}
cityErrorText={t('city_required')}
districtError={districtError}
districtErrorText={t('district_required')}
/>
{duplicate ? (
@@ -2,10 +2,11 @@
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components';
import { Box, ButtonBase, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState, Money, Pager, SurfaceCard } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { formatNumber } from '@/utils';
import { formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
@@ -46,7 +47,7 @@ export default function NurseEarningsPage() {
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
@@ -61,7 +62,10 @@ export default function NurseEarningsPage() {
) : balance.isError ? (
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
) : balance.data ? (
<EarningsBalanceHeader summary={balance.data} />
<>
<EarningsBalanceHeader summary={balance.data} />
<ForecastLine nextPayoutDate={balance.data.nextPayoutDate} nextPayoutEligibleAmountIrr={balance.data.nextPayoutEligibleAmountIrr} />
</>
) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
@@ -108,7 +112,42 @@ export default function NurseEarningsPage() {
);
}
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
/** The «برداشت بعدی» forecast server-served only (REQ-053); renders nothing until the earnings read
* serves both fields (never computed client-side holiday shifting + eligibility are backend truth). */
function ForecastLine({
nextPayoutDate,
nextPayoutEligibleAmountIrr,
}: {
nextPayoutDate: string | null | undefined;
nextPayoutEligibleAmountIrr: string | null | undefined;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (!nextPayoutDate || !nextPayoutEligibleAmountIrr) return null;
return (
<SurfaceCard padding="sm" data-widget="payout-forecast">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="calendar" size={18} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('forecast_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('forecast_on_date', { date: formatShamsiDate(nextPayoutDate, locale) })}
</Typography>
</Stack>
<Money amountIrr={nextPayoutEligibleAmountIrr} size="md" tone="emphasis" sx={{ fontWeight: 700 }} />
</Stack>
</SurfaceCard>
);
}
const EXPLAINER_CONTENT_ID = 'nurse-earnings-explainer-content';
/** Collapsible "how payouts work" the cadence + dispute-window + method-invariant copy (both locales).
* A real `ButtonBase` toggle (`aria-expanded` + `aria-controls`) replaces the bare `onClick` Stack, and the
* registered `expand` chevron (rotated when open) replaces the eye icons. */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
@@ -125,10 +164,11 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
borderInlineStartColor: 'var(--bal-info)',
}}
>
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
<ButtonBase
onClick={onToggle}
aria-expanded={open}
aria-controls={EXPLAINER_CONTENT_ID}
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
@@ -136,9 +176,14 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
{t('explainer_title')}
</Typography>
</Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
</Stack>
<Collapse in={open}>
<AppIcon
icon="expand"
size={18}
color="var(--bal-text-secondary)"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
/>
</ButtonBase>
<Collapse in={open} id={EXPLAINER_CONTENT_ID}>
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
@@ -150,35 +195,3 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
</Paper>
);
}
/** Prev/next pager — rendered only when there is more than one page. */
function Pager({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => formatNumber(n, locale);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -5,8 +5,10 @@ import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatShamsiDate, parseIrr } from '@/utils';
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
@@ -42,7 +44,7 @@ export default function NursePayoutDetailPage() {
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
@@ -123,9 +125,12 @@ export default function NursePayoutDetailPage() {
{t('failure_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(failureReasonLabelKey(data.failureReason))}
</Typography>
{data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason}
{data.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
@@ -3,9 +3,9 @@ import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components';
import { AppButton, EmptyState, ErrorState, Pager, PayoutHistoryRow } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { formatNumber } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts';
@@ -26,10 +26,9 @@ export default function NursePayoutHistoryPage() {
const items = history.data?.items ?? [];
const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => formatNumber(n, locale);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
@@ -70,19 +69,12 @@ export default function NursePayoutHistoryPage() {
</Stack>
)}
{pageCount > 1 ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
<Pager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</Box>
);
}
@@ -1,6 +1,6 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
import NurseDashboardScreen from './NurseDashboardScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
@@ -8,8 +8,6 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
return { title: t('dashboard') };
}
export default async function NurseDashboardPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="dashboard" title={t('dashboard')} description={tShell('placeholder_body')} />;
export default function NurseDashboardPage() {
return <NurseDashboardScreen />;
}
@@ -1,18 +1,30 @@
'use client';
import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
import type { NurseProfile } from '@/services/profiles/types';
import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
const MAX_YEARS = 80;
const OTHER_CODE = '__other';
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
export default function NurseProfilePage() {
const { data: profile, isLoading } = useNurseProfile();
if (isLoading) return <AppLoading />;
@@ -21,6 +33,7 @@ export default function NurseProfilePage() {
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
@@ -35,6 +48,37 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
useEffect(() => {
if (!avatarDirty) return;
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Chrome (and most engines) only show the native confirm dialog when returnValue is set.
event.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
@@ -47,6 +91,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
@@ -54,13 +101,16 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
upsert.mutate(
{
bio: bio.trim(),
yearsOfExperience: yearsNum,
educationLevel: initial?.educationLevel ?? '',
educationField: initial?.educationField ?? '',
specializationsJson: initial?.specializationsJson ?? '[]',
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
},
{
@@ -163,10 +213,81 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
@@ -0,0 +1,182 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, Chip, Skeleton, Stack, Typography } from '@mui/material';
import {
AppIcon,
EmptyState,
PageHeader,
ServicePriceRow,
SurfaceCard,
TrustBadge,
VerificationPanel,
} from '@/components';
import { ROUTES } from '@/constants';
import { formatNumber } from '@/utils';
import { useMe } from '@/services/auth';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseProfile } from '@/services/profiles';
import { useNurseTrustBadge, useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types';
function parseSpecializations(json: string): string[] {
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
}
/**
* "نمایهٔ عمومی من" how families see this nurse. Composes the same C3 trust-dossier pieces
* (`TrustBadge`, `VerificationPanel`, `ServicePriceRow`) but **entirely from the nurse's own cached
* data** (own profile + `useMyVariants` + `useServiceAreas` + own badge) no dependency on the search
* index, so it renders truthfully even pre-publish (before `is_searchable` can ever be true). Linked
* from the profile and services pages the strongest motivator to finish bio/photo/credentials.
*/
export default function NursePublicProfilePreviewPage() {
const t = useTranslations('nurseProfile');
const tv = useTranslations('verification');
const locale = useLocale();
const { data: me, isLoading: meLoading } = useMe();
const { data: profile, isLoading: profileLoading } = useNurseProfile();
const verification = useVerificationStatus();
const trustBadge = useNurseTrustBadge(me?.id);
const variantsQuery = useMyVariants();
const areasQuery = useServiceAreas();
const isLoading = meLoading || profileLoading || verification.isLoading;
if (isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<PageHeader title={t('preview_title')} backTo={`/${locale}${ROUTES.NURSE_PROFILE}`} backLabel={t('back_to_profile')} />
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Skeleton variant="circular" width={72} height={72} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={140} />
</Box>
);
}
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
const specializations = parseSpecializations(profile?.specializationsJson ?? '[]');
const activeVariants = (variantsQuery.data?.items ?? []).filter((variant) => variant.isActive);
const areas = areasQuery.data?.items ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<PageHeader title={t('preview_title')} subtitle={t('preview_subtitle')} backTo={`/${locale}${ROUTES.NURSE_PROFILE}`} backLabel={t('back_to_profile')} />
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar
src={profile?.avatarUrl ?? undefined}
sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700, fontSize: 28 }}
>
{profile?.avatarUrl ? null : (displayName || '؟').charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h5" component="h2">
{displayName}
</Typography>
{profile && profile.totalReviews > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={18} color="var(--bal-rating)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(profile.averageRating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('preview_reviews_count', { count: profile.totalReviews })}
</Typography>
</Stack>
) : null}
{profile ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('preview_completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })}
</Typography>
) : null}
</Stack>
</Stack>
<TrustBadge state={ownBadgeState(verification.data)} sx={{ alignSelf: 'flex-start' }} />
{profile?.bio ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{profile.bio}
</Typography>
) : null}
</Stack>
{profile?.yearsOfExperience || specializations.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{profile?.yearsOfExperience ? (
<Chip variant="outlined" label={t('preview_years_experience', { years: formatNumber(profile.yearsOfExperience, locale) })} />
) : null}
{specializations.map((code) => (
<Chip key={code} variant="outlined" label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code} />
))}
</Stack>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_verified_title')}
</Typography>
<SurfaceCard padding="md">
<VerificationPanel badge={trustBadge.data} isLoading={trustBadge.isLoading} isError={trustBadge.isError} />
</SurfaceCard>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_services_title')}
</Typography>
{activeVariants.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('preview_services_empty')}
</Typography>
) : (
<Box>
{activeVariants.map((variant) => (
<ServicePriceRow
key={variant.id}
displayName={variant.displayName}
priceIrr={variant.price}
priceUnit={variant.priceUnit}
sessionCount={variant.sessionCount}
/>
))}
</Box>
)}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('preview_coverage_title')}
</Typography>
{areas.length === 0 ? (
<EmptyState icon="coverage" title={t('preview_coverage_empty')} />
) : (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{areas.map((area) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
const district = locale === 'en' ? area.districtNameEn : area.districtNameFa;
return (
<Chip
key={area.id}
label={area.isWholeCity ? city : `${city} · ${district}`}
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }}
/>
);
})}
</Stack>
)}
</Stack>
</Box>
);
}
@@ -17,7 +17,8 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { AppButton, AppIcon, ConfirmDialog, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils';
@@ -54,12 +55,13 @@ export default function NurseRequestDetailPage() {
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
const [reasonError, setReasonError] = useState(false);
const [acceptConfirmOpen, setAcceptConfirmOpen] = useState(false);
if (isLoading) return <DetailSkeleton />;
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
@@ -121,7 +123,7 @@ export default function NurseRequestDetailPage() {
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('detail_title')}
@@ -201,7 +203,7 @@ export default function NurseRequestDetailPage() {
variant="contained"
startIcon="verified"
disabled={acceptRequest.isPending}
onClick={handleAccept}
onClick={() => setAcceptConfirmOpen(true)}
sx={{ flex: 1, py: 1.25 }}
>
{acceptRequest.isPending ? t('accepting') : t('accept')}
@@ -230,12 +232,36 @@ export default function NurseRequestDetailPage() {
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
{request.status === 'accepted_awaiting_payment' && request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
/>
) : null}
</Stack>
</Paper>
)}
<ConfirmDialog
open={acceptConfirmOpen}
title={t('accept_confirm_title')}
body={t('accept_confirm_body')}
confirmLabel={t('accept_confirm_cta')}
cancelLabel={t('cancel_request')}
loading={acceptRequest.isPending}
onClose={() => setAcceptConfirmOpen(false)}
onConfirm={() => {
setAcceptConfirmOpen(false);
handleAccept();
}}
/>
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent>
@@ -307,7 +333,7 @@ function DetailRow({ caption, children }: { caption: string; children: React.Rea
function DetailSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} />
@@ -1,53 +1,125 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components';
import { Box, Chip, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppLink, CountdownTimer, EmptyState, ErrorState, Pager, PageHeader, SurfaceCard } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate, localeTag } from '@/utils';
import { useNurseRequestInbox } from '@/services/bookingRequests';
import { coarseResponseLabel } from '@/services/bookingRequests/format';
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
type InboxTab = 'pending' | 'answered' | 'expired';
/** The pill's urgency tiers (§3.4): teal >2h · amber <2h · terracotta <30min. */
const URGENT_THRESHOLD_SECONDS = 30 * 60;
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
/**
* Nurse incoming-requests inbox (نمای پرستار). Lists pending requests each a card with the family's
* patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and
* a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows
* only `customerNotes` never an address or any clinical field. Lightly polled so new requests appear.
* Nurse incoming-requests inbox (نمای پرستار), ui-phase-7 redesign. Decision-first cards (service + price
* headline when the list DTO serves them REQ-050, mock-tolerant), an urgency-tinted countdown pill, and
* three tabs: «در انتظار» (a single `pending_nurse_response` query) / «پاسخداده» (merged, page-1-only
* the API filters by a *single* status and there's no status-group filter yet, so this tab concatenates
* three page-1 queries; a documented limitation until REQ-050's status-group filter lands) / «منقضی»
* (`expired_no_response`). Two-stage disclosure unchanged: the row shows only `customerNotes` never an
* address or any clinical field.
*/
export default function NurseRequestsPage() {
const t = useTranslations('booking');
const tc = useTranslations('common');
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
const items = data?.items ?? [];
const [tab, setTab] = useState<InboxTab>('pending');
const [pendingPage, setPendingPage] = useState(1);
const [expiredPage, setExpiredPage] = useState(1);
const pendingQuery = useNurseRequestInbox('pending_nurse_response', pendingPage, { enabled: tab === 'pending' });
const expiredQuery = useNurseRequestInbox('expired_no_response', expiredPage, { enabled: tab === 'expired' });
const acceptedQuery = useNurseRequestInbox('accepted_awaiting_payment', 1, { enabled: tab === 'answered' });
const convertedQuery = useNurseRequestInbox('converted', 1, { enabled: tab === 'answered' });
const rejectedQuery = useNurseRequestInbox('rejected_by_nurse', 1, { enabled: tab === 'answered' });
const onTabChange = (_event: React.SyntheticEvent, next: InboxTab) => setTab(next);
const answeredLoading = acceptedQuery.isLoading || convertedQuery.isLoading || rejectedQuery.isLoading;
const answeredError = acceptedQuery.isError || convertedQuery.isError || rejectedQuery.isError;
const answeredItems = [
...(acceptedQuery.data?.items ?? []),
...(convertedQuery.data?.items ?? []),
...(rejectedQuery.data?.items ?? []),
];
const retryAnswered = () => {
acceptedQuery.refetch();
convertedQuery.refetch();
rejectedQuery.refetch();
};
const active =
tab === 'pending'
? {
items: pendingQuery.data?.items ?? [],
isLoading: pendingQuery.isLoading,
isError: pendingQuery.isError,
refetch: pendingQuery.refetch,
page: pendingPage,
pageCount: Math.max(1, Math.ceil((pendingQuery.data?.total ?? 0) / (pendingQuery.data?.pageSize || 1))),
onPageChange: setPendingPage,
}
: tab === 'expired'
? {
items: expiredQuery.data?.items ?? [],
isLoading: expiredQuery.isLoading,
isError: expiredQuery.isError,
refetch: expiredQuery.refetch,
page: expiredPage,
pageCount: Math.max(1, Math.ceil((expiredQuery.data?.total ?? 0) / (expiredQuery.data?.pageSize || 1))),
onPageChange: setExpiredPage,
}
: {
items: answeredItems,
isLoading: answeredLoading,
isError: answeredError,
refetch: retryAnswered,
page: 1,
pageCount: 1,
onPageChange: () => {},
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('inbox_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('inbox_subtitle')}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('inbox_title')} subtitle={t('inbox_subtitle')} />
{isLoading ? (
<Tabs value={tab} onChange={onTabChange} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
<Tab value="pending" label={t('nurse_inbox_tab_pending')} sx={{ textTransform: 'none' }} />
<Tab value="answered" label={t('nurse_inbox_tab_answered')} sx={{ textTransform: 'none' }} />
<Tab value="expired" label={t('nurse_inbox_tab_expired')} sx={{ textTransform: 'none' }} />
</Tabs>
{active.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
) : active.isError ? (
<ErrorState message={t('inbox_error')} retryLabel={t('retry')} onRetry={() => active.refetch()} />
) : active.items.length === 0 ? (
<EmptyState icon="requests" title={t('inbox_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
{active.items.map((item) => (
<InboxCard key={item.id} item={item} />
))}
</Stack>
)}
{tab !== 'answered' ? (
<Pager
page={active.page}
pageCount={active.pageCount}
onPrev={() => active.onPageChange(Math.max(1, active.page - 1))}
onNext={() => active.onPageChange(Math.min(active.pageCount, active.page + 1))}
/>
) : null}
</Box>
);
}
@@ -55,59 +127,72 @@ export default function NurseRequestsPage() {
function InboxCard({ item }: { item: BookingRequestListItem }) {
const t = useTranslations('booking');
const locale = useLocale();
const router = useRouter();
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const hasPricedService = Boolean(item.variantLabel);
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel}
</Typography>
<AppLink to={`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`} color="inherit" underline="none" sx={{ display: 'block' }}>
<SurfaceCard data-request-status={item.status}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
{hasPricedService ? (
<>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.counterpartyName}
</Typography>
</>
) : (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
)}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel}
</Typography>
</Stack>
{item.status === 'pending_nurse_response' ? (
<CountdownTimer
deadlineIso={item.nurseResponseDeadlineAt}
label={t('inbox_countdown_pill_label')}
elapsedText={t('response_elapsed')}
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
coarseLabel={(minutes) => coarseResponseLabel(minutes, t)}
size="sm"
/>
) : null}
</Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{item.requiredCaregiverGender ? (
<Chip
size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
/>
) : null}
</Stack>
{item.customerNotes ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
{item.customerNotes}
</Typography>
</Box>
) : null}
</Stack>
{item.requiredCaregiverGender ? (
<Box>
<Chip
size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
/>
</Box>
) : null}
{item.customerNotes ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
{item.customerNotes}
</Typography>
</Box>
) : null}
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack>
</Paper>
</SurfaceCard>
</AppLink>
);
}
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
@@ -12,7 +12,8 @@ import {
Stack,
Typography,
} from '@mui/material';
import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
import { ActivationChecklist, AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
import { ROUTES } from '@/constants';
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
import type { NurseServiceVariant } from '@/services/catalog/types';
import PublishGate from './PublishGate';
@@ -31,6 +32,8 @@ interface MyServicesListProps {
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
const t = useTranslations('services');
const tc = useTranslations('common');
const tNurseProfile = useTranslations('nurseProfile');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useMyVariants();
@@ -86,8 +89,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
) : null}
</Stack>
<ActivationChecklist />
<PublishGate />
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{tNurseProfile('preview_cta')}
</AppButton>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
@@ -1,81 +1,99 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { ROUTES } from '@/constants';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon } from '@/components';
import { useActivationChecklist } from '@/components/ActivationChecklist';
import { useSetAcceptingBookings } from '@/services/profiles';
/**
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
* approved it enables. Mirrors the server's guarded `is_verified` flip the UI never implies a nurse is
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
* The go-live gate for the nurse's services **the real switch**, not a snackbar. Reads the same
* `useActivationChecklist` state `ActivationChecklist` renders (one source of truth, no duplicated
* search-visibility logic): unmet conditions render guidance naming exactly what's missing; met but
* paused renders the real `set_accepting_bookings` CTA; live renders the on state + a pause action.
* Success copy fires only after the mutation actually succeeds this replaces the old
* `enqueueSnackbar('published')` no-op.
*/
const PublishGate: FunctionComponent = () => {
const t = useTranslations('verification');
const locale = useLocale();
const tActivation = useTranslations('activation');
const { enqueueSnackbar } = useSnackbar();
const { data: status, isLoading } = useVerificationStatus();
const state = useActivationChecklist();
const setAccepting = useSetAcceptingBookings();
if (isLoading) return null;
const approved = isApproved(status);
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
const toggle = (accepting: boolean) => {
setAccepting.mutate(accepting, {
onSuccess: () =>
enqueueSnackbar(accepting ? t('publish_accepting_success') : t('publish_paused_success'), {
variant: 'success',
}),
onError: () => enqueueSnackbar(t('publish_toggle_error'), { variant: 'error' }),
});
};
const live = state.isSearchVisible && state.isAcceptingBookings;
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={approved ? 'verified' : 'warning'}
size={24}
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
</Typography>
<AccentCard tone={live ? 'success' : state.isSearchVisible ? 'primary' : 'warning'} padding="md">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={live ? 'verified' : state.isSearchVisible ? 'publish' : 'warning'}
size={24}
color={live ? 'var(--bal-success)' : state.isSearchVisible ? 'var(--bal-primary)' : 'var(--bal-warning)'}
/>
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{live
? t('publish_live_title')
: state.isSearchVisible
? t('publish_ready_title')
: t('publish_blocked_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{live
? t('publish_live_body')
: state.isSearchVisible
? t('publish_ready_body')
: t('publish_unmet_intro', {
items: state.searchRows
.filter((row) => !row.passed)
.map((row) => tActivation(row.labelKey))
.join('),
})}
</Typography>
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{live ? (
<AppButton
color="inherit"
variant="outlined"
startIcon="pending"
onClick={() => toggle(false)}
disabled={setAccepting.isPending}
>
{setAccepting.isPending ? t('publish_toggling') : t('publish_pause_accepting')}
</AppButton>
) : state.isSearchVisible ? (
<AppButton
color="primary"
variant="contained"
startIcon="publish"
onClick={() => toggle(true)}
disabled={setAccepting.isPending}
>
{setAccepting.isPending ? t('publish_toggling') : t('publish_start_accepting')}
</AppButton>
) : null}
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
color="primary"
variant="contained"
startIcon="publish"
disabled={!approved}
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
>
{t('publish_cta')}
</AppButton>
{!approved ? (
<AppButton
variant="outlined"
color="primary"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
>
{t('publish_complete_verification')}
</AppButton>
) : null}
</Stack>
</Paper>
</AccentCard>
);
};
@@ -2,30 +2,21 @@
import { FunctionComponent, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
MenuItem,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
import { ApiError } from '@/lib/api/errors';
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
import {
useCategoryOptionGroups,
useCreateVariant,
useMyVariants,
useServiceCategories,
useUpdateVariant,
} from '@/services/catalog';
import { pickCatalogName } from '@/services/catalog/names';
import {
PRICE_UNITS,
optionSetSignature,
type NurseServiceVariant,
type PriceUnit,
type VariantOptionSelection,
@@ -36,6 +27,8 @@ interface VariantBuilderProps {
initial: NurseServiceVariant | null;
onDone: () => void;
onCancel: () => void;
/** Jump straight into editing an already-existing listing — the create step's 409-duplicate recovery. */
onEditExisting: (variant: NurseServiceVariant) => void;
}
const DEFAULT_UNIT: PriceUnit = 'per_hour';
@@ -55,7 +48,7 @@ const MAX_DURATION_DIGITS = 4;
* **Edit** locks the category + option-set (changing them would change identity) and edits only
* price/unit/duration/display via `update`.
*/
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel }) => {
const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDone, onCancel, onEditExisting }) => {
const t = useTranslations('services');
const tCatalog = useTranslations('catalog');
const tc = useTranslations('common');
@@ -92,6 +85,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null);
// Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which
// existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly.
const myVariantsQuery = useMyVariants();
const existingMatch = useMemo(() => {
if (isEdit || categoryId == null) return null;
const signature = optionSetSignature(categoryId, Object.values(selectedOptions));
return (
(myVariantsQuery.data?.items ?? []).find(
(variant) =>
variant.serviceCategoryId === categoryId &&
optionSetSignature(categoryId, variant.options.map((option) => option.optionValueId)) === signature,
) ?? null
);
}, [isEdit, categoryId, selectedOptions, myVariantsQuery.data]);
// Auto-generated display name preview (create): category + chosen value labels, in the active locale.
const autoName = useMemo(() => {
if (isEdit) return initial.displayName;
@@ -188,6 +196,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
);
};
// Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing,
// not just an abstract price readout.
const previewVariant: NurseServiceVariant = {
id: 0,
serviceCategoryId: categoryId ?? 0,
categoryNameFa: selectedCategory?.nameFa ?? '',
categoryNameEn: selectedCategory?.nameEn ?? '',
price: irr ?? '0',
priceUnit,
sessionCount,
displayName: displayValue || t('preview_untitled'),
isActive: true,
options: [],
};
const priceStep = (
<Stack sx={{ gap: 2.5 }}>
<TextField
@@ -230,14 +253,17 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
{irr ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Paper>
</Stack>
) : null}
<TextField
@@ -249,21 +275,28 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
/>
{duplicate ? (
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Paper>
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
{/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */}
<Typography variant="body2" sx={{ color: 'text.primary', fontWeight: 500 }}>
{t('duplicate_warning')}
</Typography>
</Stack>
{existingMatch ? (
<AppButton
variant="text"
color="primary"
startIcon="edit"
onClick={() => onEditExisting(existingMatch)}
sx={{ alignSelf: 'flex-start' }}
>
{t('duplicate_edit_existing')}
</AppButton>
) : null}
</Stack>
</AccentCard>
) : null}
</Stack>
);
@@ -423,20 +456,25 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
}}
/>
</Stack>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={selectedOptions[group.id] ?? null}
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
sx={{ flexWrap: 'wrap' }}
>
{group.values.map((value) => (
<ToggleButton key={value.id} value={value.id}>
{pickCatalogName(value, locale)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{group.values.map((value) => {
const selected = selectedOptions[group.id] === value.id;
return (
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => changeOption(group.id, selected ? null : value.id)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
);
})
@@ -22,6 +22,7 @@ export default function NurseServicesPage() {
initial={builder.editing}
onDone={() => setBuilder({ open: false })}
onCancel={() => setBuilder({ open: false })}
onEditExisting={(variant) => setBuilder({ open: true, editing: variant })}
/>
);
}
@@ -0,0 +1,60 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { AppIcon, SurfaceCard, TrustBadge } from '@/components';
import { ownBadgeState, type VerificationStatus } from '@/services/verification/types';
import { groupLabelKey, groupStatus, groupedDisplaySteps } from './verificationSteps';
export interface TrustBadgePreviewPanelProps {
status: VerificationStatus | undefined;
}
/**
* The hub's payoff moment (ui-phase-8 §3.2) a live preview of the badge families actually see,
* with a per-group fill indicator so progress on this screen visibly maps to the trust signal it
* earns. Never invents a "verified" state: the chip is the same `ownBadgeState` derivation the
* profile page and dashboard use, so it only ever shows what the approved aggregate supports.
* @component TrustBadgePreviewPanel
*/
const TrustBadgePreviewPanel: FunctionComponent<TrustBadgePreviewPanelProps> = ({ status }) => {
const t = useTranslations('verification');
const groups = groupedDisplaySteps(status);
return (
<SurfaceCard padding="md" data-trust-badge-preview>
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('payoff_title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payoff_subtitle')}
</Typography>
</Stack>
<TrustBadge state={ownBadgeState(status)} sx={{ alignSelf: 'flex-start' }} />
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
{groups.map(({ group, steps }) => {
const passed = groupStatus(steps) === 'passed';
return (
<Stack key={group} direction="row" sx={{ gap: 0.5, alignItems: 'center' }} data-payoff-group={group}>
<AppIcon
icon={passed ? 'verified' : 'pending'}
size={16}
color={passed ? 'var(--bal-success)' : 'var(--bal-text-secondary)'}
/>
<Typography variant="caption" sx={{ color: passed ? 'text.primary' : 'text.secondary' }}>
{t(groupLabelKey(group))}
</Typography>
</Stack>
);
})}
</Stack>
</Stack>
</SurfaceCard>
);
};
export default TrustBadgePreviewPanel;
@@ -1,138 +1,133 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip } from '@/components';
import { formatNumber } from '@/utils';
import { Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, StatusChip, SurfaceCard } from '@/components';
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
import {
displaySteps,
progressCounts,
routeForStep,
groupLabelKey,
groupRoute,
groupStatus,
groupedDisplaySteps,
stepDescriptionKey,
stepLabelKey,
stepStatusChip,
type StepGroupKey,
} from './verificationSteps';
interface VerificationChecklistProps {
status: VerificationStatus;
}
const GROUP_ICON: Record<StepGroupKey, string> = { identity: 'identity', credentials: 'license', bank: 'bank' };
/**
* The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` a new step type appears without a
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
* automated/manual step gets an inline "go" link to its screen.
* The B3 journey body (ui-phase-8 §3.2) ONE vertical spine of grouped step cards (هویت / مدارک
* حرفهای / بانک), replacing the old flat "X از Y" + 7-row list. Each card folds the data-driven
* steps `verificationSteps.ts` already catalogs (regrouped presentation only the catalog/synthetic
* mobile-step architecture is unchanged) and links to the one screen that owns that group's submission.
*/
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
const { passed, total } = progressCounts(status);
const steps = displaySteps(status);
const firstActionableId = steps.find(
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
)?.id;
const groups = groupedDisplaySteps(status);
return (
<Stack sx={{ gap: 2 }}>
<ProgressMeter passed={passed} total={total} />
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
))}
</Stack>
{groups.map(({ group, steps }) => (
<GroupCard key={group} group={group} steps={steps} />
))}
</Stack>
);
};
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
const GroupCard: FunctionComponent<{ group: StepGroupKey; steps: VerificationStep[] }> = ({ group, steps }) => {
const t = useTranslations('verification');
const locale = useLocale();
const percent = total === 0 ? 0 : (passed / total) * 100;
const format = (value: number) => formatNumber(value, locale);
const aggregate = groupStatus(steps);
const chip = stepStatusChip(aggregate);
const route = groupRoute(group);
const actionable = aggregate !== 'passed' && aggregate !== 'in_review';
return (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('progress_title')}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
{t('progress_count', { passed: format(passed), total: format(total) })}
</Typography>
<SurfaceCard
padding="md"
data-journey-group={group}
data-journey-group-status={aggregate}
sx={{
borderInlineStart: '4px solid',
borderInlineStartColor:
aggregate === 'failed' ? 'var(--bal-error)' : aggregate === 'passed' ? 'var(--bal-success)' : 'divider',
}}
>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon={GROUP_ICON[group]} size={22} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t(groupLabelKey(group))}
</Typography>
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
</Stack>
<Stack sx={{ gap: 1 }}>
{steps.map((step) => (
<StepLine key={step.code} step={step} />
))}
</Stack>
{actionable ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ alignSelf: 'flex-start' }}
>
{aggregate === 'failed' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Stack>
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
</Paper>
</SurfaceCard>
);
};
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
const StepLine: FunctionComponent<{ step: VerificationStep }> = ({ step }) => {
const t = useTranslations('verification');
const locale = useLocale();
const chip = stepStatusChip(step.status);
const labelKey = stepLabelKey(step.code);
const descKey = stepDescriptionKey(step.code);
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
const description = t.has(descKey) ? t(descKey) : '';
const route = routeForStep(step.code);
const showReason = step.status === 'failed' || step.status === 'expired';
const description = step.status !== 'passed' && t.has(descKey) ? t(descKey) : null;
const reason = step.failureReason
? t.has(`reason_${step.failureReason}`)
? t(`reason_${step.failureReason}`)
: step.failureReason
: null;
const showReason = (step.status === 'failed' || step.status === 'expired') && reason;
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
const showAutoNote = step.isAutomated && step.status === 'not_started';
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
</Stack>
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
<Stack sx={{ gap: 0.25 }} data-step-code={step.code}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2">{label}</Typography>
<StatusChip status={chip.kind} label={t(chip.labelKey)} size="small" sx={{ flexShrink: 0 }} />
</Stack>
{showReason && reason ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{description ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
{showReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{reason}
</Typography>
) : null}
{showAutoNote ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_query_note')}
</Typography>
) : null}
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
<AppButton
variant="text"
color="primary"
endIcon="edit"
to={`/${locale}${route}`}
sx={{ alignSelf: 'flex-start' }}
>
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
</AppButton>
) : null}
</Paper>
</Stack>
);
};
@@ -0,0 +1,33 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { PageHeader } from '@/components';
import { ROUTES } from '@/constants';
import type { StepGroupKey } from './verificationSteps';
import { groupLabelKey } from './verificationSteps';
export interface VerificationJourneyHeaderProps {
/** Which journey group this screen submits (or `'review'` for the B6 status screen) — drives the title. */
group: StepGroupKey | 'review';
}
/**
* The ONE progress answer B4/B5/B6 share (ui-phase-8 §3.2) replaces the competing bare 3-step
* `StepperHeader` each of those screens used to render alongside the B3 hub's own "X از Y" meter.
* Just the group name + a "بازگشت به مسیر تأیید" back link into the B3 hub, which is the single
* place progress is now shown.
* @component VerificationJourneyHeader
*/
const VerificationJourneyHeader: FunctionComponent<VerificationJourneyHeaderProps> = ({ group }) => {
const t = useTranslations('verification');
const locale = useLocale();
return (
<PageHeader
title={t(groupLabelKey(group))}
backTo={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
backLabel={t('journey_back')}
/>
);
};
export default VerificationJourneyHeader;
@@ -4,9 +4,11 @@ import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components';
import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components';
import type { UploadedDocInfo } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import {
useSubmitCredentials,
useUploadVerificationDocument,
@@ -15,14 +17,22 @@ import {
import { SPECIALTY_PRESETS } from '@/services/verification/types';
import type { VerificationStep } from '@/services/verification/types';
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
/**
* B5 professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
* status** (data-driven a new manual step renders without a code change); each upload moves its step to
* `in_review` (manual admin review copy never claims an automated authority check). Collects the INO
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
* status** (data-driven a new manual step renders without a code change); each upload moves its step
* to `in_review` (manual admin review copy never claims an automated authority check).
*
* Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been
* recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary never re-prompted as if lost, and
* never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is
* never read back by design, so re-submitting it isn't possible without the nurse re-entering it via
* "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each
* upload takes effect immediately, no re-submit needed) and simply returns to the journey no dead
* disabled button, because there is no button to be dead.
*/
export default function CredentialsSubmitPage() {
const t = useTranslations('verification');
@@ -34,14 +44,29 @@ export default function CredentialsSubmitPage() {
const uploadDocument = useUploadVerificationDocument();
const submitCredentials = useSubmitCredentials();
const submission = status?.credentialSubmission;
const [inoNumber, setInoNumber] = useState('');
const [inoError, setInoError] = useState(false);
const [editingIno, setEditingIno] = useState(true);
const [specialties, setSpecialties] = useState<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [issuedAt, setIssuedAt] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
const [hydrated, setHydrated] = useState(false);
// Hydrate once from the server's read-back, adjusted directly during render (never in an effect —
// that would cascade an extra render) — and never overwrite what the nurse is actively editing.
if (!hydrated && submission) {
setHydrated(true);
setEditingIno(!submission.inoNumberSubmitted);
setSpecialties(submission.specialties);
setIssuingAuthority(submission.issuingAuthority ?? '');
setIssuedAt(submission.issuedAt ?? null);
setExpiresAt(submission.expiresAt ?? null);
}
const manualSteps = useMemo(
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
@@ -72,8 +97,8 @@ export default function CredentialsSubmitPage() {
inoNumber: inoNumber.trim(),
specialties,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
issuedAt: issuedAt ?? undefined,
expiresAt: expiresAt ?? undefined,
},
{
onSuccess: () => {
@@ -89,10 +114,8 @@ export default function CredentialsSubmitPage() {
if (!status || manualSteps.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('credentials_title')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_start')}
</Typography>
@@ -103,12 +126,20 @@ export default function CredentialsSubmitPage() {
);
}
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
// A returning nurse with any manual step already on file (server truth) is never dead-ended: while
// actively (re-)entering the INO number, the gate also counts server-side documents, not only this
// session's uploads.
const hasAnyDocument =
Object.values(uploadedSteps).some(Boolean) ||
manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed');
const canSubmit = hasAnyDocument && !submitCredentials.isPending;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h5" component="h1">
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -116,24 +147,36 @@ export default function CredentialsSubmitPage() {
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
</Box>
{editingIno ? (
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
setInoNumber(event.target.value);
if (inoError) setInoError(false);
}}
error={inoError}
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
{/* One uploader per manual credential step — data-driven from the status. */}
{/* One uploader per manual credential step data-driven from the status. Re-uploading a
rejected document always takes effect immediately, whether or not the INO number is locked. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
@@ -144,97 +187,134 @@ export default function CredentialsSubmitPage() {
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
rejected={step.status === 'failed'}
rejectionReason={step.failureReason ?? undefined}
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null}
/>
))}
</Stack>
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
{editingIno ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : undefined}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
);
})}
{specialties
.filter((value) => !SPECIALTY_PRESETS.includes(value))
.map((value) => (
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Stack>
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
{/* Optional registry details the admin cross-checks issue/expiry feed the credential-expiry sweep, so
wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */}
{editingIno ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issued_at_label')}
type="date"
value={issuedAt}
onChange={(event) => setIssuedAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
/>
<TextField
label={t('expires_at_label')}
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ flex: 1, minWidth: 160 }}
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField
label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<JalaliDateField
label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
</Stack>
) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</Stack>
) : null}
<Paper
elevation={0}
@@ -246,20 +326,27 @@ export default function CredentialsSubmitPage() {
</Typography>
</Paper>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="license"
onClick={handleSubmit}
disabled={submitCredentials.isPending || !anyUploaded}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{editingIno ? (
<>
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : (
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
</Stack>
)}
</Box>
);
}
@@ -4,13 +4,15 @@ import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components';
import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { toEnglishDigits } from '@/utils';
import { useSubmitIdentity } from '@/services/verification';
import { isValidNationalId } from '@/services/verification/validation';
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
@@ -66,9 +68,11 @@ export default function IdentitySubmitPage() {
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h5" component="h1">
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -76,13 +80,6 @@ export default function IdentitySubmitPage() {
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
activeStep={0}
/>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
@@ -96,23 +93,29 @@ export default function IdentitySubmitPage() {
fullWidth
/>
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
</Stack>
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
</Stack>
<Paper
elevation={0}
@@ -153,3 +156,48 @@ export default function IdentitySubmitPage() {
</Box>
);
}
const CORNER_SIZE = 18;
/** The four viewfinder-style corner brackets on the card guide (no-op for the round selfie guide). */
const CORNER_POSITIONS = [
{ insetBlockStart: -1, insetInlineStart: -1, borderBlockStart: '3px solid', borderInlineStart: '3px solid' },
{ insetBlockStart: -1, insetInlineEnd: -1, borderBlockStart: '3px solid', borderInlineEnd: '3px solid' },
{ insetBlockEnd: -1, insetInlineStart: -1, borderBlockEnd: '3px solid', borderInlineStart: '3px solid' },
{ insetBlockEnd: -1, insetInlineEnd: -1, borderBlockEnd: '3px solid', borderInlineEnd: '3px solid' },
] as const;
/**
* A cheap, dependency-free capture guide a dashed frame (viewfinder corners for the card, an oval
* for the selfie) plus a static hint line, shown above the corresponding `DocumentUpload`. There is no
* live camera preview to overlay (the native camera app owns capture via the file input's `capture`
* attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo.
*/
function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) {
const t = useTranslations('verification');
const isCard = variant === 'card';
return (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 220 }}>
{t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')}
</Typography>
</Stack>
);
}
@@ -3,12 +3,14 @@ import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppAlert, AppButton, AppIcon, EmptyState } from '@/components';
import { AppAlert, AppButton, AppIcon, EmptyState, PageHeader } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useStartVerification, useVerificationStatus } from '@/services/verification';
import { verificationKeys } from '@/services/verification/keys';
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
import TrustBadgePreviewPanel from './TrustBadgePreviewPanel';
import VerificationChecklist from './VerificationChecklist';
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
@@ -44,15 +46,8 @@ export default function NurseVerificationPage() {
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('title')} subtitle={t('subtitle')} />
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
@@ -78,6 +73,7 @@ export default function NurseVerificationPage() {
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
) : (
<>
<TrustBadgePreviewPanel status={status} />
<VerificationChecklist status={status} />
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
<ContinueCta status={status} onContinue={handleContinue} />
@@ -1,16 +1,21 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip, StatusTimeline, SurfaceCard } from '@/components';
import type { TimelineNode } from '@/components';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useVerificationStatus } from '@/services/verification';
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
import VerificationJourneyHeader from '../VerificationJourneyHeader';
/**
* B6 under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two
* views never a second fetch). Shows the waiting message + the 2448h expectation + a condensed
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
* وضعیت" returns to the canonical B3 hub.
* B6 under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query,
* two views never a second fetch). ui-phase-8: the same journey header as B4/B5, a Shamsi submitted
* timestamp when the server serves one (REQ-055, mock-tolerant omitted rather than faked), a
* what-happens-next `StatusTimeline` (بررسی توسط کارشناس نتیجه در ۲۴۴۸ ساعت فعالسازی نشان), and
* the condensed mini-checklist of what is passed vs in-review vs pending.
*/
export default function UnderReviewPage() {
const t = useTranslations('verification');
@@ -22,41 +27,53 @@ export default function UnderReviewPage() {
const steps = displaySteps(status);
const isApproved = status?.status === 'approved';
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box sx={{ overflowX: 'auto' }}>
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
</Box>
const timelineNodes: TimelineNode[] = [
{
key: 'submitted',
label: t('review_timeline_submitted'),
timestamp: status?.submittedAt ? formatShamsiDate(status.submittedAt, locale) : undefined,
state: 'completed',
},
{
key: 'review',
label: t('review_timeline_review'),
note: isApproved ? undefined : t('review_eta'),
state: isApproved ? 'completed' : 'current',
},
{
key: 'activation',
label: t('review_timeline_activation'),
state: isApproved ? 'completed' : 'pending',
},
];
<Paper
elevation={0}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="review" />
<SurfaceCard
padding="md"
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStart: '4px solid',
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h1">
{isApproved ? t('review_approved_title') : t('review_title')}
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
<Typography variant="h6" component="h2">
{isApproved ? t('review_approved_title') : t('review_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isApproved ? t('review_approved_body') : t('review_body')}
</Typography>
{!isApproved ? (
<Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--bal-warning)' }}>
{t('review_eta')}
</Typography>
) : null}
</Paper>
</SurfaceCard>
<SurfaceCard padding="md">
<StatusTimeline nodes={timelineNodes} />
</SurfaceCard>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
@@ -115,3 +115,62 @@ export function nextActionableIndex(status: VerificationStatus | undefined): num
const index = steps.findIndex(isActionable);
return index === -1 ? null : index + 1;
}
/**
* The unified vertical journey (ui-phase-8 §3.2) groups the flat step list into three spines
* هویت (mobile + identity KYC + Shahkar), مدارک حرفهای (the three manual credential steps), بانک
* (IBAN-owner match) matching the three submission screens (B4/B5/bank). This is presentation
* grouping only; the underlying data-driven `steps[]` catalog is unchanged.
*/
export type StepGroupKey = 'identity' | 'credentials' | 'bank';
export const GROUP_ORDER: readonly StepGroupKey[] = ['identity', 'credentials', 'bank'] as const;
const GROUP_BY_CODE: Record<string, StepGroupKey> = {
mobile_verified: 'identity',
identity_kyc: 'identity',
shahkar_match: 'identity',
moh_competency_license: 'credentials',
ino_membership: 'credentials',
criminal_record: 'credentials',
bank_account_verification: 'bank',
};
/** Which journey group a step code belongs to (unrecognised codes fall into `credentials`). */
export function stepGroup(code: string): StepGroupKey {
return GROUP_BY_CODE[code] ?? 'credentials';
}
/** The i18n label key for a journey group (also accepts `'review'`, the B6 status screen). */
export function groupLabelKey(group: StepGroupKey | 'review'): string {
return `group_${group}`;
}
/** The screen that owns a journey group's submission (identity/credentials pages, or the bank page). */
export function groupRoute(group: StepGroupKey): string {
switch (group) {
case 'identity':
return ROUTES.NURSE_VERIFICATION_IDENTITY;
case 'credentials':
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
case 'bank':
return ROUTES.NURSE_BANK;
}
}
/** The full checklist folded into ordered `{ group, steps }` buckets — one card per journey group. */
export function groupedDisplaySteps(
status: VerificationStatus | undefined,
): Array<{ group: StepGroupKey; steps: VerificationStep[] }> {
const steps = displaySteps(status);
return GROUP_ORDER.map((group) => ({ group, steps: steps.filter((step) => stepGroup(step.code) === group) }));
}
/** A group's own aggregate status, coarsest-first: failed/expired > in_review > pending > not_started > passed. */
export function groupStatus(steps: VerificationStep[]): VerificationStepStatus {
if (steps.some((step) => step.status === 'failed' || step.status === 'expired')) return 'failed';
if (steps.some((step) => step.status === 'in_review')) return 'in_review';
if (steps.every((step) => step.status === 'passed')) return 'passed';
if (steps.some((step) => step.status === 'pending')) return 'pending';
return 'not_started';
}
@@ -1,36 +1,36 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState } from '@/components';
import { Box, Skeleton, Stack } from '@mui/material';
import { AppButton, EmptyState, ErrorState, PageHeader } from '@/components';
import { SessionCard, useEvvController } from '@/components/booking';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
import type { BookingSessionListItemDto } from '@/services/bookings/types';
const TODAY_HEADER_DATE_OPTIONS: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
/**
* Nurse ویزیت امروز (E3 top) the day's operational surface. Lists today's sessions from
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
* `useTodaySessions` (polled every 60s so a same-day schedule change surfaces without re-navigation);
* each renders the shared `SessionCard` with the per-session EVV check-in/out control (driven by one
* `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is advisory, never a
* block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`), where the gated
* care instructions live. Visit-note authoring + task checklist are deferred to f13.
*/
export default function NurseVisitsPage() {
const t = useTranslations('booking');
const { data, isLoading } = useTodaySessions();
const locale = useLocale();
const { data, isLoading, isError, refetch } = useTodaySessions();
const evv = useEvvController();
const items = data?.items ?? [];
const dateAnchor = t('today_date_prefix', { date: formatShamsiDate(new Date(), locale, TODAY_HEADER_DATE_OPTIONS) });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('evv_visits_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('evv_visits_subtitle')}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={dateAnchor} subtitle={t('evv_visits_subtitle')} />
{isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -38,6 +38,8 @@ export default function NurseVisitsPage() {
<Skeleton key={key} variant="rounded" height={150} />
))}
</Stack>
) : isError ? (
<ErrorState message={t('evv_visits_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : items.length === 0 ? (
<EmptyState icon="visits" title={t('evv_no_visits')} />
) : (
@@ -64,6 +66,7 @@ function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: R
<Stack sx={{ gap: 0.75 }}>
<SessionCard
title={item.patientName}
serviceLabel={item.variantLabel}
sessionIndex={item.sessionIndex}
scheduledDate={item.scheduledDate}
scheduledTimeStart={item.scheduledTimeStart}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('privacy_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Privacy Policy placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function PrivacyPage() {
const t = await getTranslations('legal');
const sections = t.raw('privacy_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('privacy_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('privacy_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('terms_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Terms of Service placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function TermsPage() {
const t = await getTranslations('legal');
const sections = t.raw('terms_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('terms_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('terms_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -0,0 +1,115 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const useNurseProfile = jest.fn();
const useMyVariants = jest.fn();
const useServiceAreas = jest.fn();
const useNurseBankAccounts = jest.fn();
const useVerificationStatus = jest.fn();
jest.mock('@/services/profiles', () => ({ useNurseProfile: () => useNurseProfile() }));
jest.mock('@/services/catalog', () => ({ useMyVariants: () => useMyVariants() }));
jest.mock('@/services/serviceAreas', () => ({ useServiceAreas: () => useServiceAreas() }));
jest.mock('@/services/nurse', () => ({ useNurseBankAccounts: () => useNurseBankAccounts() }));
jest.mock('@/services/verification', () => ({ useVerificationStatus: () => useVerificationStatus() }));
import ActivationChecklist from './ActivationChecklist';
const APPROVED_STATUS = { status: 'approved' as const, isBookable: true, blockingSteps: [], steps: [] };
const NOT_STARTED_STATUS = { status: 'not_started' as const, isBookable: false, blockingSteps: [], steps: [] };
function mockAllPassing(overrides: { accepting?: boolean; bankVerified?: boolean } = {}) {
useVerificationStatus.mockReturnValue({ data: APPROVED_STATUS, isLoading: false, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({
data: { bio: 'Experienced nurse', avatarUrl: 'https://x/y.png', isAcceptingBookings: overrides.accepting ?? true },
isLoading: false,
isError: false,
refetch: jest.fn(),
});
useMyVariants.mockReturnValue({ data: { items: [{ isActive: true }] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [{ id: 1 }] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({
data: overrides.bankVerified === false ? [] : [{ matchedNationalId: true }],
isLoading: false,
isError: false,
refetch: jest.fn(),
});
}
describe('<ActivationChecklist/> component', () => {
it('renders a skeleton while any query is loading', () => {
useVerificationStatus.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: undefined, isLoading: true, isError: false, refetch: jest.fn() });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
it('never renders empty on a failed query — shows the error state with retry', () => {
useVerificationStatus.mockReturnValue({ data: undefined, isLoading: false, isError: true, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: null, isLoading: false, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: [], isLoading: false, isError: false, refetch: jest.fn() });
render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('load_error')).toBeInTheDocument();
expect(screen.getByText('retry')).toBeInTheDocument();
});
it('lists unmet rows with a fix link when nothing has been done yet', () => {
useVerificationStatus.mockReturnValue({ data: NOT_STARTED_STATUS, isLoading: false, isError: false, refetch: jest.fn() });
useNurseProfile.mockReturnValue({ data: { bio: '', avatarUrl: null, isAcceptingBookings: false }, isLoading: false, isError: false, refetch: jest.fn() });
useMyVariants.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useServiceAreas.mockReturnValue({ data: { items: [] }, isLoading: false, isError: false, refetch: jest.fn() });
useNurseBankAccounts.mockReturnValue({ data: [], isLoading: false, isError: false, refetch: jest.fn() });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('title')).toBeInTheDocument();
expect(container.querySelectorAll('[data-passed="false"]')).toHaveLength(5);
expect(screen.getAllByText('row_fix').length).toBeGreaterThan(0);
});
it('collapses to the compact live state once every row passes and accepting is on', () => {
mockAllPassing();
render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.getByText('live_title')).toBeInTheDocument();
expect(screen.queryByText('title')).not.toBeInTheDocument();
});
it('stays expanded when search-visible but not yet accepting bookings (bank still unmet)', () => {
mockAllPassing({ accepting: false });
const { container } = render(
<ThemeProvider>
<ActivationChecklist />
</ThemeProvider>,
);
expect(screen.queryByText('live_title')).not.toBeInTheDocument();
expect(container.querySelectorAll('[data-passed="false"]')).toHaveLength(0);
});
});
@@ -0,0 +1,104 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Divider from '@mui/material/Divider';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AccentCard from '../common/AccentCard';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
import ErrorState from '../common/ErrorState';
import StatusChip from '../StatusChip';
import { useActivationChecklist, type ActivationRow } from './useActivationChecklist';
/**
* The unified "راه‌اندازی" tracker replaces the four scattered go-live nags (the profile
* blocked-until-verified banner, `PublishGate`, the coverage empty warning, the bank empty state)
* with one tracker composing five already-cached queries. **Two-tier honesty**: the first four rows
* are exactly the server's `is_searchable` gate; the bank row is labelled separately as "getting
* paid" — it is never part of search visibility. Collapses to a compact "فعال در جستجو" confirmation
* once every row passes and the nurse is actively accepting bookings. Mounted on `/nurse/services`
* (above the offerings list) and in the dashboard's `DashboardActivationSlot` one shared component,
* never forked.
* @component ActivationChecklist
*/
const ActivationChecklist: FunctionComponent = () => {
const t = useTranslations('activation');
const state = useActivationChecklist();
if (state.isLoading) return <Skeleton variant="rounded" height={220} sx={{ borderRadius: 2 }} />;
if (state.isError) {
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={state.retry} />;
}
if (state.isFullyLive) {
return (
<AccentCard tone="success" padding="sm" data-activation-state="live">
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="verified" size={22} color="var(--bal-success)" />
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('live_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('live_body')}
</Typography>
</Stack>
</Stack>
</AccentCard>
);
}
return (
<AccentCard tone="primary" padding="md" data-activation-state="incomplete">
<Stack sx={{ gap: 1.5 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('title')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('subtitle_visibility')}
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
{state.searchRows.map((row) => (
<ActivationRowItem key={row.key} row={row} />
))}
</Stack>
<Divider />
<Stack sx={{ gap: 0.5 }}>
<ActivationRowItem row={state.bankRow} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('row_bank_hint')}
</Typography>
</Stack>
</Stack>
</AccentCard>
);
};
const ActivationRowItem: FunctionComponent<{ row: ActivationRow }> = ({ row }) => {
const t = useTranslations('activation');
const locale = useLocale();
return (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}
data-activation-row={row.key}
data-passed={row.passed}
>
<StatusChip status={row.passed ? 'verified' : 'pending'} label={t(row.labelKey)} />
{!row.passed ? (
<AppButton variant="text" color="primary" size="small" endIcon="forward" to={`/${locale}${row.href}`}>
{t('row_fix')}
</AppButton>
) : null}
</Stack>
);
};
export default ActivationChecklist;
@@ -0,0 +1,5 @@
import ActivationChecklist from './ActivationChecklist';
export default ActivationChecklist;
export { useActivationChecklist } from './useActivationChecklist';
export type { ActivationRow, ActivationChecklistState } from './useActivationChecklist';
@@ -0,0 +1,101 @@
import { ROUTES } from '@/constants';
import { useNurseProfile } from '@/services/profiles';
import { useMyVariants } from '@/services/catalog';
import { useServiceAreas } from '@/services/serviceAreas';
import { useNurseBankAccounts } from '@/services/nurse';
import { deriveBankStatus } from '@/services/nurse/types';
import { useVerificationStatus } from '@/services/verification';
import { isApproved } from '@/services/verification/types';
export interface ActivationRow {
key: 'identity' | 'profile' | 'services' | 'coverage' | 'bank';
labelKey: string;
passed: boolean;
href: string;
}
export interface ActivationChecklistState {
isLoading: boolean;
isError: boolean;
retry: () => void;
/** The four conditions the server's `is_searchable` gate actually checks. */
searchRows: ActivationRow[];
/** Verified-primary-IBAN — drives getting paid, NOT search visibility. Kept separate on purpose. */
bankRow: ActivationRow;
isSearchVisible: boolean;
isAcceptingBookings: boolean;
/** Nothing left to do at all — every row passes and the nurse is actively accepting bookings. */
isFullyLive: boolean;
}
function isProfileComplete(bio: string, avatarUrl: string | null): boolean {
return bio.trim().length > 0 && avatarUrl != null;
}
/**
* The single source of the nurse's go-live state five already-cached queries folded into the rows
* `ActivationChecklist` renders and the state `PublishGate` gates on, so the two components (and the
* dashboard slot) never compute this twice with drifting logic. **Two-tier honesty**: the first four
* rows are exactly the server's `is_searchable` gate (verification, profile, 1 active service, 1
* coverage area); the bank row is a separate "getting paid" concern the search index never reads.
*/
export function useActivationChecklist(): ActivationChecklistState {
const verification = useVerificationStatus();
const profile = useNurseProfile();
const variants = useMyVariants();
const areas = useServiceAreas();
const bank = useNurseBankAccounts();
const isLoading =
verification.isLoading || profile.isLoading || variants.isLoading || areas.isLoading || bank.isLoading;
const isError = verification.isError || profile.isError || variants.isError || areas.isError || bank.isError;
const retry = () => {
if (verification.isError) verification.refetch();
if (profile.isError) profile.refetch();
if (variants.isError) variants.refetch();
if (areas.isError) areas.refetch();
if (bank.isError) bank.refetch();
};
const hasActiveService = (variants.data?.items ?? []).some((variant) => variant.isActive);
const hasCoverageArea = (areas.data?.items ?? []).length > 0;
const hasVerifiedBank = (bank.data ?? []).some((account) => deriveBankStatus(account) === 'verified');
const searchRows: ActivationRow[] = [
{
key: 'identity',
labelKey: 'row_identity',
passed: isApproved(verification.data),
href: ROUTES.NURSE_VERIFICATION,
},
{
key: 'profile',
labelKey: 'row_profile',
passed: isProfileComplete(profile.data?.bio ?? '', profile.data?.avatarUrl ?? null),
href: ROUTES.NURSE_PROFILE,
},
{ key: 'services', labelKey: 'row_services', passed: hasActiveService, href: ROUTES.NURSE_SERVICES },
{ key: 'coverage', labelKey: 'row_coverage', passed: hasCoverageArea, href: ROUTES.NURSE_COVERAGE },
];
const bankRow: ActivationRow = {
key: 'bank',
labelKey: 'row_bank',
passed: hasVerifiedBank,
href: ROUTES.NURSE_BANK,
};
const isSearchVisible = searchRows.every((row) => row.passed);
const isAcceptingBookings = profile.data?.isAcceptingBookings ?? false;
return {
isLoading,
isError,
retry,
searchRows,
bankRow,
isSearchVisible,
isAcceptingBookings,
isFullyLive: isSearchVisible && bankRow.passed && isAcceptingBookings,
};
}
@@ -12,6 +12,8 @@ jest.mock('next-intl', () => ({
import BnplPlanCard from './BnplPlanCard';
import type { BnplPlanOption } from '@/services/bnpl/types';
const ORDER_AMOUNT_IRR = '20000000'; // 2,000,000 Toman — the card-payable gross
const FEE_PLAN: BnplPlanOption = {
planId: 'digipay_6m',
termMonths: 6,
@@ -20,7 +22,7 @@ const FEE_PLAN: BnplPlanOption = {
downPaymentPercent: 0.2,
monthlyAmountIrr: '4040000', // 404,000 Toman
downPaymentIrr: '4660000',
totalIrr: '23300000',
totalIrr: '23300000', // 2,330,000 Toman — 330,000 Toman fee vs the 2,000,000 gross
};
const INTEREST_FREE_PLAN: BnplPlanOption = {
@@ -31,14 +33,14 @@ const INTEREST_FREE_PLAN: BnplPlanOption = {
downPaymentPercent: 0,
monthlyAmountIrr: '5825000',
downPaymentIrr: '0',
totalIrr: '23300000',
totalIrr: '20000000',
};
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
const onSelect = props.onSelect ?? jest.fn();
render(
<ThemeProvider>
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} />
<BnplPlanCard plan={FEE_PLAN} orderAmountIrr={ORDER_AMOUNT_IRR} selected={false} onSelect={onSelect} {...props} />
</ThemeProvider>,
);
return { onSelect };
@@ -51,18 +53,24 @@ describe('<BnplPlanCard/> component', () => {
expect(screen.getByText('monthly')).toBeInTheDocument();
});
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => {
it('shows the down payment and total repayment as plain Toman rows, no LinearProgress bar', () => {
renderCard();
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4%
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument();
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.getByText(/466,000/)).toBeInTheDocument(); // down payment
expect(screen.getByText(/2,330,000/)).toBeInTheDocument(); // total repayment
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => {
it('shows the fee delta in Toman (total order amount), not a bare percent', () => {
renderCard();
// Anchored so it doesn't also match the "2,330,000" total, which contains "330,000" as a substring.
expect(screen.getByText(/^330,000/)).toBeInTheDocument();
expect(screen.getByText('plan_fee_amount_suffix')).toBeInTheDocument();
});
it('shows "interest-free" and no fee delta for a 0-fee plan', () => {
renderCard({ plan: INTEREST_FREE_PLAN });
expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByText('plan_fee_amount_suffix')).not.toBeInTheDocument();
});
it('marks the selected card via aria-pressed + data-selected', () => {
@@ -1,35 +1,38 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
import { ButtonBase, Divider, Stack, Typography } from '@mui/material';
import Money from '@/components/common/Money';
import { parseIrr } from '@/utils';
import type { BnplPlanOption } from '@/services/bnpl/types';
export interface BnplPlanCardProps {
plan: BnplPlanOption;
/** The order's card-payable gross (D1 «مبلغ قابل پرداخت») the interest-free baseline the fee delta
* below compares against. Both figures are served; the delta is their exact BigInt difference, the same
* "exact remainder of served amounts" pattern the invoice page uses never a computed rate. */
orderAmountIrr: string;
selected: boolean;
onSelect: (planId: string) => void;
}
/** A whole-percent from a 0..1 fraction (display only — never money math). */
const asPercent = (fraction: number): number => Math.round(fraction * 100);
/**
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util never
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only.
* the **served** monthly amount, a plain پیشپرداخت (امروز) amount row, and مجموع بازپرداخت with the fee
* delta vs. paying in full made explicit in Toman (never a percent-only label, never a `LinearProgress` bar
* standing in for a static fact). Single-select: the selected card gets the terracotta `--bal-secondary`
* border + soft tint. Labels are i18n keys off the served fields; money is display-only.
* @component BnplPlanCard
*/
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountIrr, selected, onSelect }) => {
const t = useTranslations('bnpl');
const termLabel =
plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free');
const hasDownPayment = plan.downPaymentPercent > 0;
const feeIrr = (parseIrr(plan.totalIrr) - parseIrr(orderAmountIrr)).toString();
const hasFee = plan.feePercent > 0 && parseIrr(feeIrr) > BigInt(0);
return (
<ButtonBase
@@ -49,19 +52,11 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}}
>
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{termLabel}
</Typography>
<Typography
variant="caption"
sx={{ color: plan.feePercent > 0 ? 'var(--bal-money-emphasis)' : 'text.secondary' }}
>
{feeLabel}
</Typography>
</Stack>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{termLabel}
</Typography>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -70,28 +65,38 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
</Stack>
</Stack>
{hasDownPayment ? (
<Box>
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}>
<Divider />
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')}
</Typography>
<Money amountIrr={plan.downPaymentIrr} size="sm" sx={{ fontWeight: 700 }} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('total_repayment')}
</Typography>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Money amountIrr={plan.totalIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
{hasFee ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
+
</Typography>
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }} />
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
{t('plan_fee_amount_suffix')}
</Typography>
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')}
{t('plan_interest_free')}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })}
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={asPercent(plan.downPaymentPercent)}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'var(--bal-divider)',
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
}}
/>
</Box>
) : null}
)}
</Stack>
</Stack>
</Stack>
</ButtonBase>
);
@@ -0,0 +1,47 @@
import { render } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const NAMES: Record<string, string> = {
provider_digipay: 'Digipay',
provider_snapppay: 'SnappPay',
};
return NAMES[key] ?? key;
},
}));
import BnplProviderLogo from './BnplProviderLogo';
describe('<BnplProviderLogo/> component', () => {
it('falls back to a tinted monogram roundel when no bundled asset exists', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="digipay" />
</ThemeProvider>,
);
const mark = container.querySelector('[data-provider-logo="digipay"]');
expect(mark).toBeInTheDocument();
expect(mark).toHaveTextContent('D');
});
it('derives the monogram from the translated provider name', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="snapppay" />
</ThemeProvider>,
);
expect(container.querySelector('[data-provider-logo="snapppay"]')).toHaveTextContent('S');
});
it('sizes the mark from the size prop', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="digipay" size={60} />
</ThemeProvider>,
);
const mark = container.querySelector('[data-provider-logo="digipay"]') as HTMLElement;
expect(mark.style.width).toBe('60px');
expect(mark.style.height).toBe('42px');
});
});
@@ -0,0 +1,58 @@
import { ComponentType, FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box } from '@mui/material';
import type { ProviderCode } from '@/services/bnpl/types';
export interface BnplProviderLogoProps {
providerCode: ProviderCode;
/** Box width in px — height follows at a 10:7 wordmark ratio. Defaults to the D1 provider-row size. */
size?: number;
}
/**
* Real bundled provider marks empty until licensed assets exist (never fake a provider's logo). A real
* SVG drops in here (`providerCode → ComponentType`) without touching any call-site: `MethodStep` and any
* future provider list keep rendering `<BnplProviderLogo providerCode={…} />` unchanged.
*/
const PROVIDER_LOGO_SVG: Partial<Record<ProviderCode, ComponentType<{ width: number; height: number }>>> = {};
/**
* D1 provider mark a registry component so real logos can drop in without touching call-sites (the
* decision this phase made: no licensed provider assets exist yet, so every provider falls back to a
* *designed* neutral chip a tinted monogram roundel, replacing the old two-letter text-glyph stand-in
* (`DG`/`SP`/) that read as unfinished). The provider's full name is rendered by the caller alongside it
* (unchanged) this component is only the mark.
* @component BnplProviderLogo
*/
const BnplProviderLogo: FunctionComponent<BnplProviderLogoProps> = ({ providerCode, size = 40 }) => {
const t = useTranslations('bnpl');
const height = Math.round(size * 0.7);
const LogoSvg = PROVIDER_LOGO_SVG[providerCode];
if (LogoSvg) return <LogoSvg width={size} height={height} />;
const name = t(`provider_${providerCode}`);
const monogram = name.trim().charAt(0).toUpperCase();
return (
<Box
data-provider-logo={providerCode}
aria-hidden
style={{ width: size, height }}
sx={{
borderRadius: '50%',
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: Math.round(height * 0.55),
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{monogram}
</Box>
);
};
export default BnplProviderLogo;
@@ -0,0 +1,2 @@
export { default } from './BnplProviderLogo';
export type { BnplProviderLogoProps } from './BnplProviderLogo';
@@ -55,4 +55,11 @@ describe('<BookingRequestSummaryCard/> component', () => {
renderCard({ nurseRating: null });
expect(screen.queryByText('4.8')).not.toBeInTheDocument();
});
it('bidi-isolates the time range in a dir="ltr" span (matches SessionCard\'s convention)', () => {
const { container } = renderCard();
const ltrSpan = container.querySelector('span[dir="ltr"]');
expect(ltrSpan).toBeInTheDocument();
expect(ltrSpan?.textContent).toMatch(//);
});
});
@@ -58,7 +58,8 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
hour: '2-digit',
minute: '2-digit',
});
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const whenDateLabel = formatShamsiDate(startDate, locale);
const whenTimeRangeLabel = `${timeFmt.format(startDate)} ${timeFmt.format(endDate)}`;
const ratingLabel =
nurseRating != null
@@ -121,7 +122,10 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'end' }}>
{whenLabel}
{whenDateLabel} ·{' '}
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
{whenTimeRangeLabel}
</Typography>
</Typography>
</SummaryRow>
</Stack>
@@ -71,6 +71,30 @@ describe('<DocumentUpload/> component', () => {
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
});
it('shows live progress (reason still visible, re-upload button gone) when re-uploading a rejected document, then succeeds', async () => {
let resolveUpload: ((doc: { name: string }) => void) | undefined;
const onUpload = jest.fn(
() =>
new Promise<{ name: string }>((resolve) => {
resolveUpload = resolve;
}),
);
const { input, container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan', onUpload });
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
// The precedence fix: `state === 'uploading'` wins over the still-true `rejected` prop.
await waitFor(() => expect(container.querySelector('[data-upload-state="uploading"]')).toBeInTheDocument());
expect(container.querySelector('[data-upload-state="rejected"]')).not.toBeInTheDocument();
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
expect(screen.queryByText('upload_reupload')).not.toBeInTheDocument();
resolveUpload?.({ name: 'license-v2.pdf' });
await waitFor(() => expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument());
expect(screen.getByText('license-v2.pdf')).toBeInTheDocument();
});
it('renders an already-uploaded document from server metadata', () => {
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
@@ -153,32 +153,15 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
onChange={onFileSelected}
/>
{rejected ? (
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{state === 'uploading' ? (
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
{/* A re-upload of a rejected doc must show LIVE progress, not the frozen rejected card the
reason stays visible above the bar so context isn't lost while the new attempt runs. */}
{rejected && rejectionReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</AccentCard>
) : state === 'uploading' ? (
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
@@ -222,6 +205,30 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
</AppButton>
</Stack>
</SurfaceCard>
) : rejected ? (
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('upload_rejected')}
</Typography>
</Stack>
{rejectionReason ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{rejectionReason}
</Typography>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="upload"
onClick={openPicker}
disabled={disabled}
sx={{ alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</AccentCard>
) : state === 'error' ? (
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
@@ -12,10 +12,14 @@ export interface EarningsBalanceHeaderProps {
summary: NurseEarningsSummary;
}
/** The four money-bearing (guaranteed non-null) roll-up fields the bucket grid renders narrower than
* `keyof NurseEarningsSummary` so it excludes the optional ui-phase-7 forecast fields. */
type EarningsBucketAmountKey = 'pendingTotalIrr' | 'eligibleTotalIrr' | 'paidTotalIrr' | 'clawbackOutstandingIrr';
/** The four roll-up buckets — each keyed to a semantic tone + icon so the states read at a glance. */
const BUCKETS: ReadonlyArray<{
key: 'pending' | 'eligible' | 'paid' | 'clawback';
amountKey: keyof NurseEarningsSummary;
amountKey: EarningsBucketAmountKey;
tone: AccentTone;
token: string;
icon: string;
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import EscrowExplainer from './EscrowExplainer';
describe('<EscrowExplainer/> component', () => {
it('always renders the mandated EscrowNotice', () => {
render(
<ThemeProvider>
<EscrowExplainer />
</ThemeProvider>,
);
expect(screen.getByTestId('escrow-notice')).toBeInTheDocument();
});
it('the 3-step explainer is collapsed by default and expands on toggle', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<EscrowExplainer />
</ThemeProvider>,
);
expect(screen.getByText('escrow_step_pay')).not.toBeVisible();
await user.click(screen.getByRole('button', { name: 'escrow_explainer_toggle' }));
expect(screen.getByText('escrow_step_pay')).toBeVisible();
expect(screen.getByText('escrow_step_hold')).toBeVisible();
expect(screen.getByText('escrow_step_release')).toBeVisible();
expect(screen.getByText('escrow_cancellation_note')).toBeVisible();
});
});
@@ -0,0 +1,95 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Collapse, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import AppButton from '@/components/common/AppButton';
import EscrowNotice from '@/components/EscrowNotice';
interface ExplainerStep {
icon: string;
labelKey: string;
}
const STEPS: ExplainerStep[] = [
{ icon: 'payment', labelKey: 'escrow_step_pay' },
{ icon: 'lock', labelKey: 'escrow_step_hold' },
{ icon: 'verified', labelKey: 'escrow_step_release' },
];
/**
* Wraps the product-mandated `EscrowNotice` (never edited) with an optional «چطور کار میکند؟» expander: a
* 3-step visual (پرداخت امانت نزد بالینیار آزادسازی پس از تایید پایان ویزیت) grounded in
* `product/payments/escrow-ledger.md`, plus the cancellation/refund implication. Used from checkout and the
* confirmation receipt so escrow the platform's whole reason to pay on-platform gets more than one
* alert line at the moment of maximum skepticism.
* @component EscrowExplainer
*/
const EscrowExplainer: FunctionComponent = () => {
const t = useTranslations('payment');
const [open, setOpen] = useState(false);
return (
<Stack sx={{ gap: 1 }}>
<EscrowNotice />
<AppButton
variant="text"
color="primary"
size="small"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
endIcon={
<Box
component="span"
sx={{ display: 'inline-flex', transition: 'transform 150ms ease', transform: open ? 'rotate(180deg)' : 'none' }}
>
<AppIcon icon="expand" size={18} />
</Box>
}
sx={{ alignSelf: 'flex-start', px: 0.5 }}
>
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack
direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
>
{STEPS.map((step, index) => (
<Stack key={step.labelKey} direction="row" sx={{ gap: 1, alignItems: 'center', flex: '1 1 auto' }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', minWidth: 72 }}>
<Stack
sx={{
width: 40,
height: 40,
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={step.icon} size={20} color="var(--bal-primary)" />
</Stack>
<Typography variant="caption" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t(step.labelKey)}
</Typography>
</Stack>
{index < STEPS.length - 1 ? (
<Box sx={{ flex: 'none', display: { xs: 'none', sm: 'block' }, pt: 2 }}>
<AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" />
</Box>
) : null}
</Stack>
))}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('escrow_cancellation_note')}
</Typography>
</Stack>
</Collapse>
</Stack>
);
};
export default EscrowExplainer;
@@ -0,0 +1 @@
export { default } from './EscrowExplainer';
@@ -40,3 +40,39 @@ describe('<GenderToggle/> component', () => {
expect(onChange).not.toHaveBeenCalled();
});
});
describe('<GenderToggle/> allowAny mode', () => {
function renderAnyToggle(value: 'male' | 'female' | 'any' | null) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<GenderToggle
allowAny
value={value}
onChange={onChange}
maleLabel="Male"
femaleLabel="Female"
anyLabel="Any"
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
it('renders the third "any" option', () => {
renderAnyToggle(null);
expect(screen.getByText('Any')).toBeInTheDocument();
});
it('calls onChange with "any" when picked', async () => {
const user = userEvent.setup();
const { onChange } = renderAnyToggle(null);
await user.click(screen.getByText('Any'));
expect(onChange).toHaveBeenCalledWith('any');
});
it('does not render the "any" option when allowAny is omitted', () => {
renderToggle(null);
expect(screen.queryByText('Any')).not.toBeInTheDocument();
});
});
@@ -4,11 +4,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import type { Gender } from '@/services/patients/types';
export interface GenderToggleProps {
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
interface GenderToggleBaseProps {
maleLabel: string;
femaleLabel: string;
/** Marks the group invalid (e.g. submitted without a choice). */
@@ -17,45 +13,75 @@ export interface GenderToggleProps {
ariaLabel?: string;
}
/** The default, booking-context shape — required male/female only, gender never defaulted. */
export interface GenderToggleRequiredProps extends GenderToggleBaseProps {
allowAny?: false;
/** Current selection; `null` means nothing chosen yet (gender is never defaulted). */
value: Gender | null;
/** Fires only with a concrete gender — deselecting is ignored so the field stays required. */
onChange: (value: Gender) => void;
}
/** The opt-in search-context shape — adds a third "فرقی ندارد" (any) option. */
export interface GenderToggleAnyProps extends GenderToggleBaseProps {
allowAny: true;
/** Label for the "فرقی ندارد" / any-gender option (required when `allowAny`). */
anyLabel: string;
value: Gender | 'any' | null;
onChange: (value: Gender | 'any') => void;
}
export type GenderToggleProps = GenderToggleRequiredProps | GenderToggleAnyProps;
/**
* Required male/female toggle. Gender is **load-bearing** for same-gender caregiver matching
* (search/booking), so it is never defaulted and cannot be deselected back to empty via the UI.
* Labels are translated by the caller (labels are i18n keys off the code).
* Labels are translated by the caller (labels are i18n keys off the code). The opt-in `allowAny`
* mode (search's C1 facet) adds a third "فرقی ندارد" option **without** loosening the default
* booking-context contract omitting `allowAny` keeps the exact required male/female behaviour.
* @component GenderToggle
*/
const GenderToggle: FunctionComponent<GenderToggleProps> = ({
value,
onChange,
maleLabel,
femaleLabel,
error = false,
disabled = false,
ariaLabel,
}) => (
<ToggleButtonGroup
exclusive
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | null) => {
if (next) onChange(next);
}}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
</ToggleButtonGroup>
);
const GenderToggle: FunctionComponent<GenderToggleProps> = (props) => {
const { maleLabel, femaleLabel, error = false, disabled = false, ariaLabel } = props;
const allowAny = props.allowAny === true;
const handleChange = (next: Gender | 'any' | null) => {
if (!next) return;
if (next === 'any' && !allowAny) return;
// The two prop shapes are a discriminated union on `allowAny`; the runtime guard above already
// enforces the invariant TS can't see through a plain union call, so this cast is safe.
(props.onChange as (value: Gender | 'any') => void)(next);
};
return (
<ToggleButtonGroup
exclusive
value={props.value}
disabled={disabled}
aria-label={ariaLabel}
onChange={(_event, next: Gender | 'any' | null) => handleChange(next)}
sx={{
'& .MuiToggleButton-root': {
flex: 1,
py: 1.25,
fontWeight: 700,
borderColor: error ? 'var(--bal-error)' : undefined,
},
}}
>
<ToggleButton value="male" data-gender="male">
{maleLabel}
</ToggleButton>
<ToggleButton value="female" data-gender="female">
{femaleLabel}
</ToggleButton>
{allowAny ? (
<ToggleButton value="any" data-gender="any">
{(props as GenderToggleAnyProps).anyLabel}
</ToggleButton>
) : null}
</ToggleButtonGroup>
);
};
export default GenderToggle;
@@ -50,7 +50,6 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<Money
amountIrr={row.amountIrr}
hideUnit
size="sm"
tone={row.kind === 'down_payment' ? 'emphasis' : 'default'}
sx={{ fontWeight: 700 }}
@@ -2,12 +2,23 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseSearchResult } from '@/services/search/types';
// next-intl echoes keys; locale = en so the rating/price format with ASCII digits we can assert on.
// next-intl echoes keys so we can assert on them; locale = en so numbers format with ASCII digits.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${Object.values(values).join(',')}` : key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
// NurseResultCard renders <TrustBadge nurseId=…>, which calls useNurseTrustBadge — mock it so the
// card doesn't need a real QueryClientProvider in this test.
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: () => ({ data: undefined, isLoading: false, isError: false }),
}));
import NurseResultCard from './NurseResultCard';
const NURSE: NurseSearchResult = {
@@ -26,24 +37,36 @@ const NURSE: NurseSearchResult = {
nurseGender: 'female',
cityId: 101,
districtId: 1003,
topReviewTag: null,
};
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn()) {
function renderCard(nurse: NurseSearchResult, onSelect = jest.fn(), serviceLabel = 'Elderly Care') {
render(
<ThemeProvider>
<NurseResultCard nurse={nurse} onSelect={onSelect} />
<NurseResultCard nurse={nurse} serviceLabel={serviceLabel} onSelect={onSelect} />
</ThemeProvider>,
);
return onSelect;
}
describe('<NurseResultCard/> component', () => {
it('renders the name, the reused verified badge, and the rating', () => {
it('renders the name, service label, the reused verified badge, and the rating', () => {
renderCard(NURSE);
expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument();
expect(screen.getByText('Elderly Care')).toBeInTheDocument();
expect(screen.getByText('badge_verified')).toBeInTheDocument();
expect(screen.getByText('4.9')).toBeInTheDocument();
expect(screen.getByText('reviews_count')).toBeInTheDocument();
expect(screen.getByText(/reviews_count/)).toBeInTheDocument();
});
it('renders the nurse gender chip and the completed-visits count', () => {
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(container.querySelector('[data-nurse-gender="female"]')).toBeInTheDocument();
expect(screen.getByText(/completed_visits/)).toBeInTheDocument();
});
it('renders the "from" price line as grouped Toman via the money util', () => {
@@ -56,17 +79,37 @@ describe('<NurseResultCard/> component', () => {
it('shows the distance chip only when distanceKm is present', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} onSelect={jest.fn()} />
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.getByText('distance_km')).toBeInTheDocument();
expect(screen.getByText(/distance_km/)).toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} onSelect={jest.fn()} />
<NurseResultCard nurse={{ ...NURSE, distanceKm: null }} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText('distance_km')).not.toBeInTheDocument();
expect(screen.queryByText(/distance_km/)).not.toBeInTheDocument();
});
it('renders the optional top-review tag only when served', () => {
const { rerender } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={jest.fn()} />
</ThemeProvider>,
);
expect(screen.queryByText(/منظم و دقیق/)).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<NurseResultCard
nurse={{ ...NURSE, topReviewTag: 'منظم و دقیق' }}
serviceLabel="Elderly Care"
onSelect={jest.fn()}
/>
</ThemeProvider>,
);
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
});
it('falls back to a label when the name is missing (b7 join gap)', () => {
@@ -75,8 +118,13 @@ describe('<NurseResultCard/> component', () => {
});
it('calls onSelect with the nurse row when clicked', () => {
const onSelect = renderCard(NURSE);
fireEvent.click(screen.getByRole('button'));
const onSelect = jest.fn();
const { container } = render(
<ThemeProvider>
<NurseResultCard nurse={NURSE} serviceLabel="Elderly Care" onSelect={onSelect} />
</ThemeProvider>,
);
fireEvent.click(container.querySelector('[data-nurse-result-card]') as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(NURSE);
});
@@ -1,6 +1,6 @@
import { FunctionComponent, memo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import TrustBadge from '../TrustBadge';
import PriceDisplay from '../PriceDisplay';
@@ -10,6 +10,14 @@ import type { NurseSearchResult } from '@/services/search/types';
export interface NurseResultCardProps {
/** One search-result row (a bookable variant in a covered area). */
nurse: NurseSearchResult;
/**
* The service/variant label for this row the row **is** a variant, so the card must name what is
* being bought. `NurseSearchResultDto` has no display name yet (REQ-040); until it lands, the page
* passes the row's **category** name (from the cached catalog reference data) so multi-variant nurses
* are at least distinguishable by price row. The card stays data-agnostic swap the caller's label
* source to the served `variantDisplayName` once the REQ lands, no card change needed.
*/
serviceLabel: string;
/** Tapping the card opens the nurse profile (C3), carrying the row (nurse + variant + gender intent). */
onSelect: (nurse: NurseSearchResult) => void;
}
@@ -19,14 +27,16 @@ function ratingText(rating: number, locale: string): string {
}
/**
* The C2 result card: avatar, name, the reused تاییدشده verified badge, rating + review count, an
* optional distance chip (only when `distanceKm` is present), and the "from X تومان/ساعت" rate (via the
* shared `PriceDisplay` money util). Presentational + memoized so a list of N cards doesn't re-render on
* unrelated state pass a stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the
* search-index invariant, so the badge is always shown.
* The C2 result card the four-second decision unit. Avatar, name, the reused tappable تاییدشده
* verified badge (opens the verification explainer), the service/variant label, a quiet gender chip
* (same-gender matching is load-bearing), completed-visits count, rating + review count, an optional
* distance chip, an optional one-line top-review tag (only when served), and the "from X تومان/ساعت"
* rate. Presentational + memoized so a list of N cards doesn't re-render on unrelated state pass a
* stable `onSelect` (e.g. `useCallback`). Every returned row is verified by the search-index invariant,
* so the badge is always shown.
* @component NurseResultCard
*/
const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps) => {
const t = useTranslations('search');
const locale = useLocale();
@@ -38,6 +48,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
return (
<Paper
elevation={0}
data-nurse-result-card
onClick={() => onSelect(nurse)}
role="button"
tabIndex={0}
@@ -68,12 +79,29 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
{initial}
</Avatar>
<Stack sx={{ gap: 0.75, flexGrow: 1, minWidth: 0 }}>
<Stack sx={{ gap: 0.5, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" />
<TrustBadge state="verified" nurseId={nurse.nurseId} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
<Chip
size="small"
variant="outlined"
label={t(`gender_${nurse.nurseGender}`)}
data-nurse-gender={nurse.nurseGender}
sx={{ height: 22, fontSize: '0.75rem' }}
/>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('completed_visits', { count: formatNumber(nurse.totalCompletedBookings, locale) })}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
@@ -97,6 +125,12 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
) : null}
</Stack>
{nurse.topReviewTag ? (
<Typography variant="caption" sx={{ color: 'var(--bal-trust)', fontWeight: 500 }}>
«{nurse.topReviewTag}»
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('price_from')}
@@ -108,14 +142,15 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
);
};
/** Matches the real card's anatomy (avatar disc, name/badge row, rating row, price row) so a loading list
* doesn't jump when data lands. */
/** Matches the real card's anatomy (avatar disc, name+badge row, service-label row, gender+visits meta
* row, rating row, price row) so a loading list doesn't jump when data lands. */
const NurseResultCardSkeleton: FunctionComponent = () => (
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Skeleton variant="circular" width={56} height={56} />
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
<Skeleton variant="text" width="55%" height={28} />
<Skeleton variant="text" width="40%" height={20} />
<Skeleton variant="text" width="35%" height={20} />
<Skeleton variant="text" width="45%" height={18} />
<Skeleton variant="text" width="30%" height={20} />
</Stack>
</Paper>
@@ -46,4 +46,22 @@ describe('<OtpInput/> component', () => {
await user.type(boxes[3], '4');
expect(onComplete).toHaveBeenCalledWith('1234');
});
it('carries autoComplete="one-time-code" so the OS can offer the SMS code', () => {
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
boxes.forEach((box) => expect(box).toHaveAttribute('autocomplete', 'one-time-code'));
});
it('backspace on an empty box clears the previous digit and moves focus there in one keypress', async () => {
const user = userEvent.setup();
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
await user.type(boxes[0], '1');
await user.type(boxes[1], '2');
boxes[2].focus();
await user.keyboard('{Backspace}');
expect(boxes[1].value).toBe('');
expect(boxes[1]).toHaveFocus();
});
});
+8 -1
View File
@@ -87,8 +87,13 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
};
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace' && !chars[index]) {
if (event.key === 'Backspace' && !chars[index] && index > 0) {
// Empty box + backspace clears the previous digit too, so one keypress erases one digit
// instead of the first press only moving focus and the second doing the clearing.
const next = [...chars];
next[index - 1] = '';
focusBox(index - 1);
emit(next);
}
};
@@ -122,6 +127,8 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
htmlInput: {
inputMode: 'numeric',
maxLength: 1,
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
autoComplete: 'one-time-code',
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
},
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import PaymentStateCard from './PaymentStateCard';
describe('<PaymentStateCard/> component', () => {
it('renders the title and icon', () => {
const { container } = render(
<ThemeProvider>
<PaymentStateCard icon="error" tone="var(--bal-error)" title="Payment failed" />
</ThemeProvider>,
);
expect(screen.getByText('Payment failed')).toBeInTheDocument();
expect(container.querySelector('[data-icon="error"]')).toBeInTheDocument();
});
it('renders the body only when provided', () => {
const { rerender } = render(
<ThemeProvider>
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" body="Hang tight" />
</ThemeProvider>,
);
expect(screen.getByText('Hang tight')).toBeInTheDocument();
rerender(
<ThemeProvider>
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" />
</ThemeProvider>,
);
expect(screen.queryByText('Hang tight')).not.toBeInTheDocument();
});
it('renders the actions slot', () => {
render(
<ThemeProvider>
<PaymentStateCard icon="verified" tone="var(--bal-success)" title="Done">
<button type="button">Continue</button>
</PaymentStateCard>
</ThemeProvider>,
);
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { FunctionComponent, ReactNode } from 'react';
import { Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
export interface PaymentStateCardProps {
/** AppIcon registry name. */
icon: string;
/** A `--bal-*` token (or MUI palette reference) — never a hard-coded hex. */
tone: string;
/** Already-translated title. */
title: string;
/** Already-translated body. */
body?: string;
/** Actions/badges/spinners below the body (e.g. an `AppButton`, a `PaymentStatusBadge`, a `CircularProgress`). */
children?: ReactNode;
}
/**
* The one terminal/wait-state card for the card and BNPL payment flows replaces the four copy-pasted
* private `MessageCard`/`StateCard` functions (`checkout/page.tsx` + `bnpl/page.tsx`,
* `checkout/return/page.tsx` + `bnpl/return/page.tsx`) so the two flows can no longer visually drift.
* Presentational, caller-owned i18n; `children` is the actions slot (a single CTA or a stack of them).
* @component PaymentStateCard
*/
const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone, title, body, children }) => (
<Paper
elevation={0}
data-payment-state-card
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
export default PaymentStateCard;
@@ -0,0 +1,2 @@
export { default } from './PaymentStateCard';
export type { PaymentStateCardProps } from './PaymentStateCard';
@@ -56,10 +56,13 @@ describe('<PayoutHistoryRow/> component', () => {
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
});
it('surfaces a failed payout reason as a read-only banner with no retry control', () => {
it('surfaces a mapped failure label as the headline and the raw code as a secondary LTR caption', () => {
renderRow(makeItem('failed'));
expect(screen.getByText('failure_title')).toBeInTheDocument();
expect(screen.getByText(/invalid_sheba/)).toBeInTheDocument();
// The headline is the mapped Persian/English label (never the raw vendor string).
expect(screen.getByText('failure_code_invalid_sheba')).toBeInTheDocument();
// The raw code still appears, demoted to a secondary dir="ltr" caption.
expect(screen.getByText('invalid_sheba')).toHaveAttribute('dir', 'ltr');
// No retry affordance for the nurse — only the "view detail" link exists.
expect(screen.queryByText('retry')).not.toBeInTheDocument();
});
@@ -9,6 +9,7 @@ import SurfaceCard from '@/components/common/SurfaceCard';
import AccentCard from '@/components/common/AccentCard';
import Money from '@/components/common/Money';
import { formatShamsiDate } from '@/utils';
import { failureReasonLabelKey } from '@/services/payouts/failureReasons';
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
export interface PayoutHistoryRowProps {
@@ -82,9 +83,12 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
{t('failure_title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(failureReasonLabelKey(item.failureReason))}
</Typography>
{item.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {item.failureReason}
{item.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
@@ -43,4 +43,9 @@ describe('<PhoneNumberField/> component', () => {
expect(isIranianMobile('0912345678')).toBe(false);
expect(isIranianMobile('19123456789')).toBe(false);
});
it('carries autoComplete="tel" so the browser/keyboard can offer the saved number', () => {
render(<Harness />);
expect(screen.getByRole('textbox')).toHaveAttribute('autocomplete', 'tel');
});
});
@@ -47,6 +47,8 @@ const PhoneNumberField: FunctionComponent<PhoneNumberFieldProps> = ({ value, onC
dir: 'ltr',
inputMode: 'numeric',
maxLength: IRAN_MOBILE_LENGTH,
// Offers the user's own number from the browser/keyboard's saved contact info.
autoComplete: 'tel',
style: { textAlign: 'start' },
},
...slotProps,
@@ -3,9 +3,10 @@ import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import { formatIrrToToman, parseIrr } from '@/utils';
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
// next-intl mocked to echo keys (currency_toman resolved so <Money> renders "X Toman"); locale = en so
// money formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key),
useLocale: () => 'en',
}));
@@ -26,12 +27,14 @@ const ROWS = [
const TOTAL = '45000000';
describe('<PriceBreakdown/> component', () => {
it('renders every row label with its Toman-formatted amount', () => {
it('renders every row label with its Toman-formatted amount, unit included', () => {
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
for (const row of ROWS) {
expect(screen.getByText(row.label)).toBeInTheDocument();
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
expect(screen.getByText(new RegExp(formatIrrToToman(row.amountIrr, 'en')))).toBeInTheDocument();
}
// Every row — not just the total — carries the currency unit (the Toman/Rial ambiguity this guards).
expect(screen.getAllByText(/Toman/).length).toBeGreaterThanOrEqual(ROWS.length + 1);
});
it('renders a total equal to the integer sum of the served rows', () => {
@@ -1,10 +1,9 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale } from 'next-intl';
import { Divider, Stack, Typography } from '@mui/material';
import Money from '@/components/common/Money';
import SurfaceCard from '@/components/common/SurfaceCard';
import { formatIrrToToman, parseIrr } from '@/utils';
import { parseIrr } from '@/utils';
export interface PriceBreakdownRow {
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
@@ -31,8 +30,6 @@ export interface PriceBreakdownProps {
* @component PriceBreakdown
*/
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
const locale = useLocale();
if (process.env.NODE_ENV !== 'production') {
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
if (sum !== parseIrr(totalAmountIrr)) {
@@ -50,9 +47,7 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{formatIrrToToman(row.amountIrr, locale)}
</Typography>
<Money amountIrr={row.amountIrr} size="sm" sx={{ fontWeight: 500 }} />
</Stack>
))}
<Divider />
@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import ProfileSummary from './ProfileSummary';
function renderSummary(props: Partial<React.ComponentProps<typeof ProfileSummary>> = {}) {
return render(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" {...props} />
</ThemeProvider>,
);
}
describe('<ProfileSummary/> component', () => {
it('renders the display name, phone, and role label', () => {
renderSummary({ phone: '0912*****33', roleLabel: 'پرستار' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('0912*****33')).toBeInTheDocument();
expect(screen.getByText('پرستار')).toBeInTheDocument();
});
it('renders a TrustBadge only when trustState is set', () => {
const { rerender } = renderSummary();
expect(screen.queryByText('badge_verified')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<ProfileSummary displayName="سارا احمدی" trustState="verified" />
</ThemeProvider>,
);
expect(screen.getByText('badge_verified')).toBeInTheDocument();
});
it('renders skeleton placeholders instead of text while loading', () => {
const { container } = renderSummary({ loading: true, phone: '0912*****33' });
expect(screen.queryByText('سارا احمدی')).not.toBeInTheDocument();
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
});
it('renders the compact horizontal chip variant', () => {
const { container } = renderSummary({ compact: true, roleLabel: 'مالی' });
expect(screen.getByText('سارا احمدی')).toBeInTheDocument();
expect(screen.getByText('مالی')).toBeInTheDocument();
expect(container.querySelector('.MuiAvatar-root')).toHaveStyle({ width: '32px', height: '32px' });
});
});
@@ -0,0 +1,95 @@
import { FunctionComponent } from 'react';
import { Avatar, Skeleton, Stack, Typography } from '@mui/material';
import TrustBadge from '../TrustBadge';
import type { BadgeState } from '@/services/verification/types';
export interface ProfileSummaryProps {
/** Already-resolved display name (caller composes first/last name — i18n-free). */
displayName: string;
/** The server-masked phone (e.g. `0912*****33`) — rendered as a `dir="ltr"` island. */
phone?: string;
/** Already-translated role/fine-grained-role label (e.g. "پرستار", "مالی"). */
roleLabel?: string;
avatarUrl?: string | null;
/** Renders a `TrustBadge` next to the name when set (nurse identity only). */
trustState?: BadgeState;
/** True while the identity is still resolving — renders skeleton placeholders instead of text. */
loading?: boolean;
/** Dense horizontal chip form for the admin/partner TopBar identity slot; default is the vertical card. */
compact?: boolean;
}
/**
* The one identity card for authenticated chrome avatar, name, masked phone, role label, and an
* optional `TrustBadge`. Replaces the starter `UserInfo` (`user?: any`, eternal "Current User").
* Presentational: the caller (each shell) sources data from `useMe`/the profiles domain and passes
* it down, so this component never fetches on its own.
* @component ProfileSummary
*/
const ProfileSummary: FunctionComponent<ProfileSummaryProps> = ({
displayName,
phone,
roleLabel,
avatarUrl,
trustState,
loading = false,
compact = false,
}) => {
const avatarSize = compact ? 32 : 56;
if (loading) {
return (
<Stack direction={compact ? 'row' : 'column'} sx={{ alignItems: 'center', gap: 1.5, width: '100%' }}>
<Skeleton variant="circular" width={avatarSize} height={avatarSize} />
<Stack sx={{ alignItems: compact ? 'flex-start' : 'center', gap: 0.5, minWidth: 0 }}>
<Skeleton variant="text" width={compact ? 80 : 120} />
{!compact && <Skeleton variant="text" width={90} />}
</Stack>
</Stack>
);
}
const avatar = <Avatar src={avatarUrl ?? undefined} alt={displayName} sx={{ width: avatarSize, height: avatarSize }} />;
if (compact) {
return (
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, minWidth: 0 }}>
{avatar}
<Stack sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{displayName}
</Typography>
{roleLabel && (
<Typography variant="caption" noWrap sx={{ color: 'text.secondary', lineHeight: 1.2 }}>
{roleLabel}
</Typography>
)}
</Stack>
</Stack>
);
}
return (
<Stack sx={{ alignItems: 'center', gap: 1, width: '100%', textAlign: 'center' }}>
{avatar}
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap', justifyContent: 'center' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{displayName}
</Typography>
{trustState && <TrustBadge state={trustState} />}
</Stack>
{phone && (
<Typography variant="body2" dir="ltr" sx={{ color: 'text.secondary' }}>
{phone}
</Typography>
)}
{roleLabel && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{roleLabel}
</Typography>
)}
</Stack>
);
};
export default ProfileSummary;
@@ -0,0 +1,4 @@
import ProfileSummary from './ProfileSummary';
export default ProfileSummary;
export type { ProfileSummaryProps } from './ProfileSummary';
@@ -1,23 +1,39 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys so we assert on the label key each state maps to.
// next-intl echoes keys so we assert on the label key each state maps to. `VerificationPanel`
// (rendered inside the explainer dialog) also needs `t.has` + `useLocale`.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => {
const t = (key: string) => key;
t.has = () => true;
return t;
},
useLocale: () => 'en',
}));
const useNurseTrustBadgeMock = jest.fn();
jest.mock('@/services/verification', () => ({
useNurseTrustBadge: (nurseId: number | undefined) => useNurseTrustBadgeMock(nurseId),
}));
import TrustBadge from './TrustBadge';
import type { BadgeState } from '@/services/verification/types';
function renderBadge(state: BadgeState) {
function renderBadge(state: BadgeState, nurseId?: number) {
return render(
<ThemeProvider>
<TrustBadge state={state} />
<TrustBadge state={state} nurseId={nurseId} />
</ThemeProvider>,
);
}
describe('<TrustBadge/> component', () => {
beforeEach(() => {
useNurseTrustBadgeMock.mockReturnValue({ data: undefined, isLoading: false, isError: false });
});
it('renders the verified label + a data attribute for the verified state', () => {
const { container } = renderBadge('verified');
expect(screen.getByText('badge_verified')).toBeInTheDocument();
@@ -35,4 +51,31 @@ describe('<TrustBadge/> component', () => {
expect(screen.getByText('badge_expired')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
});
it('is not clickable by default (no nurseId), and calls no query hook at all', () => {
const { container } = renderBadge('verified');
expect(container.querySelector('[data-trust-explainer]')).not.toBeInTheDocument();
// The default (non-interactive) mode never mounts the query — no QueryClientProvider needed.
expect(useNurseTrustBadgeMock).not.toHaveBeenCalled();
});
it('opens the explainer dialog on tap when a nurseId is provided, fetching the badge lazily', async () => {
useNurseTrustBadgeMock.mockReturnValue({
data: { nurseId: 7, isVerified: true, approvedAt: '2026-01-01T00:00:00Z', credentialTypes: ['ino_membership'] },
isLoading: false,
isError: false,
});
const user = userEvent.setup();
renderBadge('verified', 7);
// Disabled (undefined) until opened.
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(undefined);
await user.click(screen.getByText('badge_verified'));
expect(screen.getByText('explainer_title')).toBeInTheDocument();
expect(useNurseTrustBadgeMock).toHaveBeenLastCalledWith(7);
await user.click(screen.getByLabelText('explainer_close'));
// MUI's Dialog exit transition removes the node asynchronously.
await waitFor(() => expect(screen.queryByText('explainer_title')).not.toBeInTheDocument());
});
});

Some files were not shown because too many files have changed in this diff Show More