ui phase 8
This commit is contained in:
+23
-19
@@ -166,19 +166,21 @@ client/
|
|||||||
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
||||||
│ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx
|
│ │ │ ├── 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
|
│ │ │ ├── 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-7 — the named composition point Phase 8's activation checklist plugs into; filled for now with only the existing verification-status banner (nothing once approved)
|
│ │ │ ├── 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)
|
│ │ │ ├── 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/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
|
│ │ │ ├── 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; PublishGate is the f5 verification-gated go-live)
|
│ │ │ ├── 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 (whole-city/district areas, dup-blocked)
|
│ │ │ ├── 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); the f5 bank_account_verification step deep-links here
|
│ │ │ ├── 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
|
│ │ │ ├── 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 — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim
|
│ │ │ │ ├── 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 # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar
|
│ │ │ │ ├── identity/page.tsx # B4 — national-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 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review
|
│ │ │ │ ├── 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 (same status query, condensed mini-checklist)
|
│ │ │ │ ├── review/page.tsx # B6 — under-review; a Shamsi submitted timestamp (REQ-055, mock-tolerant) + a `StatusTimeline` what-happens-next (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعالسازی نشان) + `VerificationJourneyHeader`
|
||||||
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
|
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: grouped step cards over `groupedDisplaySteps` (co-located, page-only)
|
||||||
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
|
│ │ │ │ ├── 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)
|
│ │ │ ├── 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)
|
│ │ │ ├── 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
|
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
||||||
@@ -254,7 +256,8 @@ client/
|
|||||||
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (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)
|
│ ├── 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)
|
||||||
@@ -334,7 +337,7 @@ client/
|
|||||||
├── services/ # Domain services — no top-level barrel; import directly from the file
|
├── 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: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + 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
|
│ ├── 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
|
│ ├── 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)
|
│ ├── 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)
|
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
|
||||||
@@ -475,16 +478,17 @@ async function MyServerComponent() {
|
|||||||
- `'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)
|
- `'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)
|
- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
|
||||||
- `'profile'` — the customer profile + emergency contact
|
- `'profile'` — the customer profile + emergency contact
|
||||||
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
|
- `'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
|
||||||
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
|
- `'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)
|
- `'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
|
- `'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)
|
- `'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
|
- `'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
|
- `'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`
|
- `'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` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
|
- `'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` 7–10-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 (D1–D5): 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 D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
|
- `'bnpl'` — the f11 BNPL installment checkout (D1–D5): 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 D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
|
||||||
|
|||||||
+80
-20
@@ -190,7 +190,35 @@
|
|||||||
"unverified_cta": "Complete verification",
|
"unverified_cta": "Complete verification",
|
||||||
"deferred_services": "Manage your services & prices in the Services section; available days come in a later step.",
|
"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.",
|
"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": {
|
"bank": {
|
||||||
"title": "Bank account",
|
"title": "Bank account",
|
||||||
@@ -205,9 +233,11 @@
|
|||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
"added": "Account submitted — ownership inquiry started",
|
"added": "Account submitted — ownership inquiry started",
|
||||||
"add_error": "This account couldn't be added. Check the IBAN and try again.",
|
"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_chip": "Checking",
|
||||||
"status_pending_title": "Verifying account ownership",
|
"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_chip": "Verified",
|
||||||
"status_verified_title": "Account verified",
|
"status_verified_title": "Account verified",
|
||||||
"status_verified_body": "This account is ready to receive your payouts.",
|
"status_verified_body": "This account is ready to receive your payouts.",
|
||||||
@@ -217,6 +247,7 @@
|
|||||||
"reenter": "Enter another account",
|
"reenter": "Enter another account",
|
||||||
"make_primary": "Make primary",
|
"make_primary": "Make primary",
|
||||||
"primary_set": "Primary account updated",
|
"primary_set": "Primary account updated",
|
||||||
|
"primary_set_error": "Couldn't update the primary account. Please try again.",
|
||||||
"iban_masked_label": "IBAN",
|
"iban_masked_label": "IBAN",
|
||||||
"primary": "Primary",
|
"primary": "Primary",
|
||||||
"empty_title": "No bank account yet",
|
"empty_title": "No bank account yet",
|
||||||
@@ -273,9 +304,6 @@
|
|||||||
"subtitle": "The cities and districts you'll travel to.",
|
"subtitle": "The cities and districts you'll travel to.",
|
||||||
"areas_heading": "Your areas",
|
"areas_heading": "Your areas",
|
||||||
"add_title": "Add a coverage area",
|
"add_title": "Add a coverage area",
|
||||||
"scope_label": "Coverage",
|
|
||||||
"scope_whole_city": "Whole city",
|
|
||||||
"scope_districts": "Specific districts",
|
|
||||||
"add": "Add area",
|
"add": "Add area",
|
||||||
"adding": "Adding…",
|
"adding": "Adding…",
|
||||||
"whole_city_chip": "Whole city",
|
"whole_city_chip": "Whole city",
|
||||||
@@ -283,13 +311,13 @@
|
|||||||
"empty_warning": "You won't appear in search until you add at least one coverage area.",
|
"empty_warning": "You won't appear in search until you add at least one coverage area.",
|
||||||
"duplicate": "You already cover this area.",
|
"duplicate": "You already cover this area.",
|
||||||
"city_required": "Select a city",
|
"city_required": "Select a city",
|
||||||
"district_required": "Select a district, or switch to whole city.",
|
|
||||||
"added": "Coverage area added",
|
"added": "Coverage area added",
|
||||||
"add_error": "This area couldn't be added. Please try again.",
|
"add_error": "This area couldn't be added. Please try again.",
|
||||||
"remove_title": "Remove coverage area?",
|
"remove_title": "Remove coverage area?",
|
||||||
"remove_body": "You'll no longer be matched for visits in this area.",
|
"remove_body": "You'll no longer be matched for visits in this area.",
|
||||||
"remove_confirm": "Remove",
|
"remove_confirm": "Remove",
|
||||||
"removed": "Coverage area removed"
|
"removed": "Coverage area removed",
|
||||||
|
"remove_error": "This area couldn't be removed. Please try again."
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"unit_per_hour": "hourly",
|
"unit_per_hour": "hourly",
|
||||||
@@ -349,6 +377,9 @@
|
|||||||
"display_name_label": "Display name",
|
"display_name_label": "Display name",
|
||||||
"display_name_hint": "Auto-generated from your options — you can edit it.",
|
"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_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_options": "Options",
|
||||||
"summary_none": "No options",
|
"summary_none": "No options",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
@@ -360,6 +391,21 @@
|
|||||||
"list_error": "Couldn't load your services.",
|
"list_error": "Couldn't load your services.",
|
||||||
"options_error": "Couldn't load this category's options."
|
"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": {
|
"search": {
|
||||||
"title": "Find a nurse",
|
"title": "Find a nurse",
|
||||||
"subtitle": "Only verified, background-checked nurses appear here.",
|
"subtitle": "Only verified, background-checked nurses appear here.",
|
||||||
@@ -680,9 +726,6 @@
|
|||||||
"earnings_snapshot_title": "Earnings",
|
"earnings_snapshot_title": "Earnings",
|
||||||
"earnings_snapshot_cta": "View details",
|
"earnings_snapshot_cta": "View details",
|
||||||
"earnings_snapshot_error": "Couldn't load your balance.",
|
"earnings_snapshot_error": "Couldn't load your balance.",
|
||||||
"activation_title": "Finish activating your profile",
|
|
||||||
"activation_body": "Complete verification to appear in search and receive booking requests.",
|
|
||||||
"activation_cta": "Continue verification",
|
|
||||||
"notifications_entry_title": "Notifications",
|
"notifications_entry_title": "Notifications",
|
||||||
"notifications_entry_unread": "{count} unread",
|
"notifications_entry_unread": "{count} unread",
|
||||||
"notifications_entry_empty": "You're all caught up"
|
"notifications_entry_empty": "You're all caught up"
|
||||||
@@ -850,9 +893,13 @@
|
|||||||
"reason_kyc_no_match": "Your identity details didn't match the civil registry.",
|
"reason_kyc_no_match": "Your identity details didn't match the civil registry.",
|
||||||
"reason_national_id_mismatch": "The national ID didn't match.",
|
"reason_national_id_mismatch": "The national ID didn't match.",
|
||||||
"reason_blurry_scan": "The document image wasn't clear. Please upload a sharper copy.",
|
"reason_blurry_scan": "The document image wasn't clear. Please upload a sharper copy.",
|
||||||
"journey_identity": "Identity",
|
"journey_back": "Back to the verification journey",
|
||||||
"journey_credentials": "Credentials",
|
"group_identity": "Identity",
|
||||||
"journey_review": "Review",
|
"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_title": "Verify identity",
|
||||||
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
|
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
|
||||||
"national_id_label": "National ID",
|
"national_id_label": "National ID",
|
||||||
@@ -861,8 +908,10 @@
|
|||||||
"card_label": "National-ID card image",
|
"card_label": "National-ID card image",
|
||||||
"card_hint": "Photograph your ID card in good light, clearly readable.",
|
"card_hint": "Photograph your ID card in good light, clearly readable.",
|
||||||
"card_recommended": "Uploading the ID card image is recommended.",
|
"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_label": "Liveness selfie",
|
||||||
"selfie_hint": "Take a selfie for face verification.",
|
"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.",
|
"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_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.",
|
"error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
|
||||||
@@ -877,6 +926,9 @@
|
|||||||
"ino_number_label": "Nursing-council number",
|
"ino_number_label": "Nursing-council number",
|
||||||
"ino_number_hint": "Enter your nursing-council membership number.",
|
"ino_number_hint": "Enter your nursing-council membership number.",
|
||||||
"ino_number_required": "Enter your nursing-council 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",
|
"doc_uploaded": "Document uploaded",
|
||||||
"education_label": "Education certificate",
|
"education_label": "Education certificate",
|
||||||
"education_hint": "Optional — an image of your latest qualification.",
|
"education_hint": "Optional — an image of your latest qualification.",
|
||||||
@@ -901,6 +953,9 @@
|
|||||||
"review_title": "Under review",
|
"review_title": "Under review",
|
||||||
"review_body": "Your documents were submitted and are being reviewed by our team.",
|
"review_body": "Your documents were submitted and are being reviewed by our team.",
|
||||||
"review_eta": "This usually takes 24–48 hours.",
|
"review_eta": "This usually takes 24–48 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_title": "Verification complete",
|
||||||
"review_approved_body": "Every step is verified. You can now publish your profile.",
|
"review_approved_body": "Every step is verified. You can now publish your profile.",
|
||||||
"review_summary_title": "Status summary",
|
"review_summary_title": "Status summary",
|
||||||
@@ -915,13 +970,18 @@
|
|||||||
"explainer_approved_at": "Verified on {date}",
|
"explainer_approved_at": "Verified on {date}",
|
||||||
"explainer_not_verified": "This nurse is not verified yet.",
|
"explainer_not_verified": "This nurse is not verified yet.",
|
||||||
"explainer_error": "Couldn't load the verification details.",
|
"explainer_error": "Couldn't load the verification details.",
|
||||||
"publish_ready_title": "Ready to publish",
|
"publish_ready_title": "Ready to go live",
|
||||||
"publish_ready_body": "Your verification is complete. Your services are visible and bookable.",
|
"publish_ready_body": "Setup is complete. Turn on bookings to appear in search and start receiving requests.",
|
||||||
"publish_blocked_title": "Complete verification to publish",
|
"publish_blocked_title": "Finish setup to go live",
|
||||||
"publish_blocked_body": "Until verification is complete, your services won't appear in search and can't be booked.",
|
"publish_unmet_intro": "To appear in search: {items}.",
|
||||||
"publish_cta": "Publish profile",
|
"publish_start_accepting": "Start accepting bookings",
|
||||||
"publish_complete_verification": "Complete verification",
|
"publish_pause_accepting": "Pause bookings",
|
||||||
"publish_done": "Your profile has been published",
|
"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_title": "Simulate admin review (demo)",
|
||||||
"mock_admin_approve": "Approve all steps",
|
"mock_admin_approve": "Approve all steps",
|
||||||
"mock_admin_reject": "Reject a document",
|
"mock_admin_reject": "Reject a document",
|
||||||
|
|||||||
+80
-20
@@ -190,7 +190,35 @@
|
|||||||
"unverified_cta": "تکمیل احراز هویت",
|
"unverified_cta": "تکمیل احراز هویت",
|
||||||
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود.",
|
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود.",
|
||||||
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
|
"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": {
|
"bank": {
|
||||||
"title": "حساب بانکی",
|
"title": "حساب بانکی",
|
||||||
@@ -205,9 +233,11 @@
|
|||||||
"submitting": "در حال ثبت…",
|
"submitting": "در حال ثبت…",
|
||||||
"added": "حساب ثبت شد — استعلام مالکیت آغاز شد",
|
"added": "حساب ثبت شد — استعلام مالکیت آغاز شد",
|
||||||
"add_error": "ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.",
|
"add_error": "ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.",
|
||||||
|
"add_another": "افزودن حساب دیگر",
|
||||||
|
"load_error": "بارگذاری حسابهای بانکی شما ممکن نشد.",
|
||||||
"status_pending_chip": "در حال استعلام",
|
"status_pending_chip": "در حال استعلام",
|
||||||
"status_pending_title": "در حال استعلام مالکیت حساب",
|
"status_pending_title": "در حال استعلام مالکیت حساب",
|
||||||
"status_pending_body": "استعلام شبا در حال انجام است؛ ممکن است چند لحظه طول بکشد.",
|
"status_pending_body": "در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول میکشد…",
|
||||||
"status_verified_chip": "تاییدشد",
|
"status_verified_chip": "تاییدشد",
|
||||||
"status_verified_title": "حساب تایید شد",
|
"status_verified_title": "حساب تایید شد",
|
||||||
"status_verified_body": "این حساب برای واریز درآمد شما آماده است.",
|
"status_verified_body": "این حساب برای واریز درآمد شما آماده است.",
|
||||||
@@ -217,6 +247,7 @@
|
|||||||
"reenter": "ثبت حساب دیگر",
|
"reenter": "ثبت حساب دیگر",
|
||||||
"make_primary": "انتخاب بهعنوان حساب اصلی",
|
"make_primary": "انتخاب بهعنوان حساب اصلی",
|
||||||
"primary_set": "حساب اصلی بهروزرسانی شد",
|
"primary_set": "حساب اصلی بهروزرسانی شد",
|
||||||
|
"primary_set_error": "بهروزرسانی حساب اصلی ممکن نشد. دوباره تلاش کنید.",
|
||||||
"iban_masked_label": "شبا",
|
"iban_masked_label": "شبا",
|
||||||
"primary": "حساب اصلی",
|
"primary": "حساب اصلی",
|
||||||
"empty_title": "هنوز حسابی ثبت نشده",
|
"empty_title": "هنوز حسابی ثبت نشده",
|
||||||
@@ -273,9 +304,6 @@
|
|||||||
"subtitle": "شهرها و مناطقی که به آنها میروید.",
|
"subtitle": "شهرها و مناطقی که به آنها میروید.",
|
||||||
"areas_heading": "مناطق شما",
|
"areas_heading": "مناطق شما",
|
||||||
"add_title": "افزودن منطقهٔ تحت پوشش",
|
"add_title": "افزودن منطقهٔ تحت پوشش",
|
||||||
"scope_label": "گستره",
|
|
||||||
"scope_whole_city": "کل شهر",
|
|
||||||
"scope_districts": "مناطق خاص",
|
|
||||||
"add": "افزودن منطقه",
|
"add": "افزودن منطقه",
|
||||||
"adding": "در حال افزودن…",
|
"adding": "در حال افزودن…",
|
||||||
"whole_city_chip": "کل شهر",
|
"whole_city_chip": "کل شهر",
|
||||||
@@ -283,13 +311,13 @@
|
|||||||
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستوجو نمایش داده نمیشوید.",
|
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستوجو نمایش داده نمیشوید.",
|
||||||
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
|
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
|
||||||
"city_required": "شهر را انتخاب کنید",
|
"city_required": "شهر را انتخاب کنید",
|
||||||
"district_required": "یک منطقه انتخاب کنید یا به کل شهر تغییر دهید.",
|
|
||||||
"added": "منطقهٔ تحت پوشش اضافه شد",
|
"added": "منطقهٔ تحت پوشش اضافه شد",
|
||||||
"add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.",
|
"add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.",
|
||||||
"remove_title": "حذف منطقهٔ تحت پوشش؟",
|
"remove_title": "حذف منطقهٔ تحت پوشش؟",
|
||||||
"remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمیشوید.",
|
"remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمیشوید.",
|
||||||
"remove_confirm": "حذف",
|
"remove_confirm": "حذف",
|
||||||
"removed": "منطقهٔ تحت پوشش حذف شد"
|
"removed": "منطقهٔ تحت پوشش حذف شد",
|
||||||
|
"remove_error": "حذف این منطقه ممکن نشد. دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"unit_per_hour": "ساعتی",
|
"unit_per_hour": "ساعتی",
|
||||||
@@ -349,6 +377,9 @@
|
|||||||
"display_name_label": "نام نمایشی",
|
"display_name_label": "نام نمایشی",
|
||||||
"display_name_hint": "بهصورت خودکار از گزینهها ساخته میشود؛ میتوانید تغییرش دهید.",
|
"display_name_hint": "بهصورت خودکار از گزینهها ساخته میشود؛ میتوانید تغییرش دهید.",
|
||||||
"duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.",
|
"duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.",
|
||||||
|
"duplicate_edit_existing": "بهجای آن، همان خدمت موجود را ویرایش کنید",
|
||||||
|
"preview_heading": "اینگونه در جستجو دیده میشوید",
|
||||||
|
"preview_untitled": "خدمت جدید",
|
||||||
"summary_options": "گزینهها",
|
"summary_options": "گزینهها",
|
||||||
"summary_none": "بدون گزینه",
|
"summary_none": "بدون گزینه",
|
||||||
"next": "بعدی",
|
"next": "بعدی",
|
||||||
@@ -360,6 +391,21 @@
|
|||||||
"list_error": "خدمات شما بارگذاری نشد.",
|
"list_error": "خدمات شما بارگذاری نشد.",
|
||||||
"options_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": {
|
"search": {
|
||||||
"title": "یافتن پرستار",
|
"title": "یافتن پرستار",
|
||||||
"subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده میشوند.",
|
"subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده میشوند.",
|
||||||
@@ -680,9 +726,6 @@
|
|||||||
"earnings_snapshot_title": "درآمد",
|
"earnings_snapshot_title": "درآمد",
|
||||||
"earnings_snapshot_cta": "مشاهده جزئیات",
|
"earnings_snapshot_cta": "مشاهده جزئیات",
|
||||||
"earnings_snapshot_error": "بارگذاری موجودی شما ممکن نشد.",
|
"earnings_snapshot_error": "بارگذاری موجودی شما ممکن نشد.",
|
||||||
"activation_title": "تکمیل فعالسازی پروفایل",
|
|
||||||
"activation_body": "برای دیدهشدن در جستجو و دریافت درخواست رزرو، احراز هویت خود را تکمیل کنید.",
|
|
||||||
"activation_cta": "ادامهٔ احراز هویت",
|
|
||||||
"notifications_entry_title": "اعلانها",
|
"notifications_entry_title": "اعلانها",
|
||||||
"notifications_entry_unread": "{count} خواندهنشده",
|
"notifications_entry_unread": "{count} خواندهنشده",
|
||||||
"notifications_entry_empty": "اعلان جدیدی ندارید"
|
"notifications_entry_empty": "اعلان جدیدی ندارید"
|
||||||
@@ -850,9 +893,13 @@
|
|||||||
"reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.",
|
"reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.",
|
||||||
"reason_national_id_mismatch": "کد ملی مطابقت نداشت.",
|
"reason_national_id_mismatch": "کد ملی مطابقت نداشت.",
|
||||||
"reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.",
|
"reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.",
|
||||||
"journey_identity": "هویت",
|
"journey_back": "بازگشت به مسیر تأیید",
|
||||||
"journey_credentials": "مدارک",
|
"group_identity": "هویت",
|
||||||
"journey_review": "بررسی",
|
"group_credentials": "مدارک حرفهای",
|
||||||
|
"group_bank": "بانک",
|
||||||
|
"group_review": "بررسی",
|
||||||
|
"payoff_title": "این نشان را خانوادهها میبینند",
|
||||||
|
"payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کاملتر میشود.",
|
||||||
"identity_title": "تأیید هویت",
|
"identity_title": "تأیید هویت",
|
||||||
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
|
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
|
||||||
"national_id_label": "کد ملی",
|
"national_id_label": "کد ملی",
|
||||||
@@ -861,8 +908,10 @@
|
|||||||
"card_label": "تصویر کارت ملی",
|
"card_label": "تصویر کارت ملی",
|
||||||
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
|
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
|
||||||
"card_recommended": "بارگذاری تصویر کارت ملی توصیه میشود.",
|
"card_recommended": "بارگذاری تصویر کارت ملی توصیه میشود.",
|
||||||
|
"capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.",
|
||||||
"selfie_label": "سلفی زنده",
|
"selfie_label": "سلفی زنده",
|
||||||
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
|
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
|
||||||
|
"capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.",
|
||||||
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام میشود.",
|
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام میشود.",
|
||||||
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
|
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
|
||||||
"error_shared_sim": "به نظر میرسد این سیمکارت به نام شما نیست. لطفاً با سیمکارتی که به نام خودتان است دوباره تلاش کنید.",
|
"error_shared_sim": "به نظر میرسد این سیمکارت به نام شما نیست. لطفاً با سیمکارتی که به نام خودتان است دوباره تلاش کنید.",
|
||||||
@@ -877,6 +926,9 @@
|
|||||||
"ino_number_label": "شماره نظام پرستاری",
|
"ino_number_label": "شماره نظام پرستاری",
|
||||||
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
|
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
|
||||||
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
|
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
|
||||||
|
"ino_number_submitted": "شمارهٔ نظام پرستاری ثبت شد",
|
||||||
|
"ino_number_change": "تغییر",
|
||||||
|
"credentials_needs_document": "برای ثبت، حداقل یک مدرک بارگذاری کنید.",
|
||||||
"doc_uploaded": "مدرک بارگذاری شد",
|
"doc_uploaded": "مدرک بارگذاری شد",
|
||||||
"education_label": "مدرک تحصیلی",
|
"education_label": "مدرک تحصیلی",
|
||||||
"education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.",
|
"education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.",
|
||||||
@@ -901,6 +953,9 @@
|
|||||||
"review_title": "در حال بررسی",
|
"review_title": "در حال بررسی",
|
||||||
"review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.",
|
"review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.",
|
||||||
"review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان میبرد.",
|
"review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان میبرد.",
|
||||||
|
"review_timeline_submitted": "مدارک ثبت شد",
|
||||||
|
"review_timeline_review": "بررسی توسط کارشناس",
|
||||||
|
"review_timeline_activation": "فعالسازی نشان",
|
||||||
"review_approved_title": "احراز هویت تکمیل شد",
|
"review_approved_title": "احراز هویت تکمیل شد",
|
||||||
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون میتوانید پروفایل خود را منتشر کنید.",
|
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون میتوانید پروفایل خود را منتشر کنید.",
|
||||||
"review_summary_title": "خلاصهٔ وضعیت",
|
"review_summary_title": "خلاصهٔ وضعیت",
|
||||||
@@ -915,13 +970,18 @@
|
|||||||
"explainer_approved_at": "تاریخ تایید: {date}",
|
"explainer_approved_at": "تاریخ تایید: {date}",
|
||||||
"explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.",
|
"explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.",
|
||||||
"explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.",
|
"explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.",
|
||||||
"publish_ready_title": "آمادهٔ انتشار",
|
"publish_ready_title": "آمادهٔ فعالسازی",
|
||||||
"publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.",
|
"publish_ready_body": "راهاندازی کامل است. پذیرش رزرو را روشن کنید تا در جستجو نمایش داده شوید و درخواست دریافت کنید.",
|
||||||
"publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید",
|
"publish_blocked_title": "برای فعالسازی، راهاندازی را کامل کنید",
|
||||||
"publish_blocked_body": "تا تکمیل احراز هویت، خدمات شما در جستجو نمایش داده نمیشود و قابل رزرو نیست.",
|
"publish_unmet_intro": "برای نمایش در جستجو: {items}.",
|
||||||
"publish_cta": "انتشار پروفایل",
|
"publish_start_accepting": "شروع پذیرش رزرو",
|
||||||
"publish_complete_verification": "تکمیل احراز هویت",
|
"publish_pause_accepting": "توقف موقت پذیرش",
|
||||||
"publish_done": "پروفایل شما منتشر شد",
|
"publish_toggling": "در حال بهروزرسانی…",
|
||||||
|
"publish_live_title": "فعال در جستجو",
|
||||||
|
"publish_live_body": "هماکنون خانوادهها میتوانند شما را پیدا کرده و رزرو کنند.",
|
||||||
|
"publish_accepting_success": "اکنون رزرو دریافت میکنید",
|
||||||
|
"publish_paused_success": "پذیرش رزرو متوقف شد — تا از سرگیری، در جستجو نمایش داده نمیشوید",
|
||||||
|
"publish_toggle_error": "بهروزرسانی وضعیت پذیرش رزرو ممکن نشد. دوباره تلاش کنید.",
|
||||||
"mock_admin_title": "شبیهسازی بررسی مدیر (نمایشی)",
|
"mock_admin_title": "شبیهسازی بررسی مدیر (نمایشی)",
|
||||||
"mock_admin_approve": "تأیید همهٔ مراحل",
|
"mock_admin_approve": "تأیید همهٔ مراحل",
|
||||||
"mock_admin_reject": "رد یک مدرک",
|
"mock_admin_reject": "رد یک مدرک",
|
||||||
|
|||||||
@@ -1,56 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { ActivationChecklist } from '@/components';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
|
||||||
import { AccentCard, AppButton, AppIcon } from '@/components';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
import { isApproved, type VerificationStatus } from '@/services/verification/types';
|
|
||||||
|
|
||||||
export interface DashboardActivationSlotProps {
|
|
||||||
status: VerificationStatus | undefined;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dashboard's activation/go-live composition point — **named and exported so a later phase can find
|
* The dashboard's activation/go-live composition point — **named and exported so a later phase can
|
||||||
* it**. This phase fills it only with the existing verification-status banner (rendered while the nurse
|
* find it** (ui-phase-7's hand-off note). ui-phase-8 fills it with the real `ActivationChecklist`
|
||||||
* isn't yet approved; nothing once approved). The fuller "go live" checklist (profile/services/coverage/
|
* (the same shared component mounted on `/nurse/services`) — replacing the placeholder single-row
|
||||||
* bank all done) is **DEFERRED to ui-phase-8**, which owns this slot's content from here — extend this
|
* verification banner phase 7 left here. Extend this component in place, don't add a second slot.
|
||||||
* component in place rather than adding a second slot.
|
|
||||||
* @component DashboardActivationSlot
|
* @component DashboardActivationSlot
|
||||||
*/
|
*/
|
||||||
export default function DashboardActivationSlot({ status, isLoading }: DashboardActivationSlotProps) {
|
export default function DashboardActivationSlot() {
|
||||||
const t = useTranslations('dashboard');
|
return <ActivationChecklist />;
|
||||||
const locale = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (isLoading) return <Skeleton variant="rounded" height={96} />;
|
|
||||||
if (isApproved(status)) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccentCard tone="warning">
|
|
||||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
||||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
|
||||||
<AppIcon icon="verification" size={20} color="var(--bal-warning)" />
|
|
||||||
<Stack sx={{ gap: 0.25 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('activation_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('activation_body')}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<AppButton
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
endIcon="verification"
|
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`)}
|
|
||||||
>
|
|
||||||
{t('activation_cta')}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
</AccentCard>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function NurseDashboardScreen() {
|
|||||||
<NextVisitCard />
|
<NextVisitCard />
|
||||||
<RequestsStrip />
|
<RequestsStrip />
|
||||||
<EarningsSnapshotCard />
|
<EarningsSnapshotCard />
|
||||||
<DashboardActivationSlot status={verification.data} isLoading={verification.isLoading} />
|
<DashboardActivationSlot />
|
||||||
<NotificationsEntryRow />
|
<NotificationsEntryRow />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ import { useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Stack, TextField, Typography } from '@mui/material';
|
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 { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
||||||
import { isValidSheba } from '@/services/nurse/iban';
|
import { isValidSheba } from '@/services/nurse/iban';
|
||||||
import { deriveBankStatus } from '@/services/nurse/types';
|
import { deriveBankStatus } from '@/services/nurse/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the
|
* Nurse payout bank settings — an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit
|
||||||
* ownership inquiry resolve through its three states (pending → verified / mismatch). The list
|
* an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three
|
||||||
* polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter.
|
* 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() {
|
export default function NurseBankPage() {
|
||||||
const t = useTranslations('bank');
|
const t = useTranslations('bank');
|
||||||
|
const tc = useTranslations('common');
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const { data, isLoading } = useNurseBankAccounts();
|
const { data, isLoading, isError, refetch } = useNurseBankAccounts();
|
||||||
const addAccount = useAddNurseBankAccount();
|
const addAccount = useAddNurseBankAccount();
|
||||||
const setPrimary = useSetPrimaryBankAccount();
|
const setPrimary = useSetPrimaryBankAccount();
|
||||||
|
|
||||||
@@ -28,7 +33,7 @@ export default function NurseBankPage() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const accounts = data ?? [];
|
const accounts = data ?? [];
|
||||||
const showFormNow = !isLoading && (accounts.length === 0 || showForm);
|
const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const ibanInvalid = !isValidSheba(iban);
|
const ibanInvalid = !isValidSheba(iban);
|
||||||
@@ -64,7 +69,10 @@ export default function NurseBankPage() {
|
|||||||
|
|
||||||
{isLoading ? <AppLoading /> : null}
|
{isLoading ? <AppLoading /> : null}
|
||||||
|
|
||||||
{accounts.map((account) => {
|
{isError ? <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} /> : null}
|
||||||
|
|
||||||
|
{!isError
|
||||||
|
? accounts.map((account) => {
|
||||||
const status = deriveBankStatus(account);
|
const status = deriveBankStatus(account);
|
||||||
return (
|
return (
|
||||||
<Stack key={account.id} sx={{ gap: 1 }}>
|
<Stack key={account.id} sx={{ gap: 1 }}>
|
||||||
@@ -90,6 +98,7 @@ export default function NurseBankPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPrimary.mutate(account.id, {
|
setPrimary.mutate(account.id, {
|
||||||
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
|
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
|
||||||
|
onError: () => enqueueSnackbar(t('primary_set_error'), { variant: 'error' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
@@ -99,12 +108,28 @@ export default function NurseBankPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
: null}
|
||||||
|
|
||||||
{!isLoading && accounts.length === 0 ? (
|
{!isLoading && !isError && accounts.length === 0 ? (
|
||||||
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
|
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
|
||||||
) : null}
|
) : 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 ? (
|
{showFormNow ? (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -130,16 +155,26 @@ export default function NurseBankPage() {
|
|||||||
helperText={holderError ? t('holder_required') : t('holder_hint')}
|
helperText={holderError ? t('holder_required') : t('holder_hint')}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<AppButton
|
<Stack direction="row" sx={{ gap: 1 }}>
|
||||||
color="primary"
|
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
|
||||||
variant="contained"
|
|
||||||
startIcon="bank"
|
|
||||||
onClick={submit}
|
|
||||||
disabled={addAccount.isPending}
|
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
|
||||||
>
|
|
||||||
{addAccount.isPending ? t('submitting') : t('submit')}
|
{addAccount.isPending ? t('submitting') : t('submit')}
|
||||||
</AppButton>
|
</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>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,36 +2,25 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import {
|
import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { AppButton, AppIcon } from '@/components';
|
import { AppButton, AppIcon } from '@/components';
|
||||||
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
|
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { useDistricts } from '@/services/geography';
|
|
||||||
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
|
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
|
||||||
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
|
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
|
||||||
|
|
||||||
type Scope = 'whole_city' | 'districts';
|
|
||||||
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
|
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)
|
* 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
|
* can fan them out geographically. Areas render as chips (whole-city shown explicitly).
|
||||||
* 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
|
* ui-phase-8: **one control owns the whole-city choice** — `CascadingRegionSelect`'s own district
|
||||||
* the same message). Empty → a warning that the nurse won't appear in search.
|
* 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() {
|
export default function NurseCoveragePage() {
|
||||||
const t = useTranslations('coverage');
|
const t = useTranslations('coverage');
|
||||||
@@ -44,22 +33,12 @@ export default function NurseCoveragePage() {
|
|||||||
const removeArea = useRemoveServiceArea();
|
const removeArea = useRemoveServiceArea();
|
||||||
|
|
||||||
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
|
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
|
||||||
const [scope, setScope] = useState<Scope>('whole_city');
|
|
||||||
const [cityError, setCityError] = useState(false);
|
const [cityError, setCityError] = useState(false);
|
||||||
const [districtError, setDistrictError] = useState(false);
|
|
||||||
const [duplicate, setDuplicate] = useState(false);
|
const [duplicate, setDuplicate] = useState(false);
|
||||||
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
|
||||||
|
|
||||||
const areas = data?.items ?? [];
|
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 chipLabel = (area: NurseServiceArea) => {
|
||||||
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
|
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
|
||||||
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
|
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
|
||||||
@@ -67,33 +46,21 @@ export default function NurseCoveragePage() {
|
|||||||
return `${city} · ${district}`;
|
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 = () => {
|
const resetForm = () => {
|
||||||
setRegion(EMPTY_REGION);
|
setRegion(EMPTY_REGION);
|
||||||
setScope('whole_city');
|
|
||||||
setCityError(false);
|
setCityError(false);
|
||||||
setDistrictError(false);
|
|
||||||
setDuplicate(false);
|
setDuplicate(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
const cityInvalid = region.cityId == null;
|
const cityInvalid = region.cityId == null;
|
||||||
const districtInvalid = effectiveScope === 'districts' && region.districtId == null;
|
|
||||||
setCityError(cityInvalid);
|
setCityError(cityInvalid);
|
||||||
setDistrictError(districtInvalid);
|
|
||||||
setDuplicate(false);
|
setDuplicate(false);
|
||||||
if (cityInvalid || districtInvalid) return;
|
if (cityInvalid) return;
|
||||||
|
|
||||||
const cityId = region.cityId as number;
|
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).
|
// Fast path: block a duplicate before firing (null district treated as a real value).
|
||||||
if (areaExists(areas, cityId, districtId)) {
|
if (areaExists(areas, cityId, districtId)) {
|
||||||
@@ -123,6 +90,7 @@ export default function NurseCoveragePage() {
|
|||||||
setRemoveTarget(null);
|
setRemoveTarget(null);
|
||||||
removeArea.mutate(id, {
|
removeArea.mutate(id, {
|
||||||
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
|
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
|
||||||
|
onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,38 +159,15 @@ export default function NurseCoveragePage() {
|
|||||||
{t('add_title')}
|
{t('add_title')}
|
||||||
</Typography>
|
</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
|
<CascadingRegionSelect
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setRegion(next);
|
setRegion(next);
|
||||||
if (cityError && next.cityId != null) setCityError(false);
|
if (cityError && next.cityId != null) setCityError(false);
|
||||||
if (districtError && next.districtId != null) setDistrictError(false);
|
|
||||||
setDuplicate(false);
|
setDuplicate(false);
|
||||||
}}
|
}}
|
||||||
includeDistrict={effectiveScope === 'districts'}
|
|
||||||
cityError={cityError}
|
cityError={cityError}
|
||||||
cityErrorText={t('city_required')}
|
cityErrorText={t('city_required')}
|
||||||
districtError={districtError}
|
|
||||||
districtErrorText={t('district_required')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{duplicate ? (
|
{duplicate ? (
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { ChangeEvent, FunctionComponent, useRef, useState } from 'react';
|
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
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 { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
|
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
|
||||||
import type { NurseProfile } from '@/services/profiles/types';
|
import type { NurseProfile } from '@/services/profiles/types';
|
||||||
import { useVerificationStatus } from '@/services/verification';
|
import { useVerificationStatus } from '@/services/verification';
|
||||||
import { ownBadgeState } from '@/services/verification/types';
|
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
|
||||||
|
|
||||||
const MAX_YEARS = 80;
|
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() {
|
export default function NurseProfilePage() {
|
||||||
const { data: profile, isLoading } = useNurseProfile();
|
const { data: profile, isLoading } = useNurseProfile();
|
||||||
if (isLoading) return <AppLoading />;
|
if (isLoading) return <AppLoading />;
|
||||||
@@ -21,6 +33,7 @@ export default function NurseProfilePage() {
|
|||||||
|
|
||||||
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
|
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
|
||||||
const t = useTranslations('nurseProfile');
|
const t = useTranslations('nurseProfile');
|
||||||
|
const tv = useTranslations('verification');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
@@ -35,6 +48,37 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
|
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
|
||||||
const [yearsError, setYearsError] = useState(false);
|
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 pickFile = () => fileInputRef.current?.click();
|
||||||
|
|
||||||
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
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 handleSave = () => {
|
||||||
const trimmed = years.trim();
|
const trimmed = years.trim();
|
||||||
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
|
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
|
||||||
@@ -54,13 +101,16 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
setYearsError(yearsInvalid);
|
setYearsError(yearsInvalid);
|
||||||
if (yearsInvalid) return;
|
if (yearsInvalid) return;
|
||||||
|
|
||||||
|
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
|
||||||
|
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
|
||||||
|
|
||||||
upsert.mutate(
|
upsert.mutate(
|
||||||
{
|
{
|
||||||
bio: bio.trim(),
|
bio: bio.trim(),
|
||||||
yearsOfExperience: yearsNum,
|
yearsOfExperience: yearsNum,
|
||||||
educationLevel: initial?.educationLevel ?? '',
|
educationLevel: resolvedLevel,
|
||||||
educationField: initial?.educationField ?? '',
|
educationField: resolvedField,
|
||||||
specializationsJson: initial?.specializationsJson ?? '[]',
|
specializationsJson: JSON.stringify(specializations),
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -163,10 +213,81 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
sx={{ maxWidth: 200 }}
|
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' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('deferred_services')}
|
{t('deferred_services')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon="account"
|
||||||
|
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('preview_cta')}
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
<AppButton
|
<AppButton
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, useState } from 'react';
|
import { FunctionComponent, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} 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 { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||||
import PublishGate from './PublishGate';
|
import PublishGate from './PublishGate';
|
||||||
@@ -31,6 +32,8 @@ interface MyServicesListProps {
|
|||||||
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
|
const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit }) => {
|
||||||
const t = useTranslations('services');
|
const t = useTranslations('services');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
|
const tNurseProfile = useTranslations('nurseProfile');
|
||||||
|
const locale = useLocale();
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useMyVariants();
|
const { data, isLoading, isError, refetch } = useMyVariants();
|
||||||
@@ -86,8 +89,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<ActivationChecklist />
|
||||||
<PublishGate />
|
<PublishGate />
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon="account"
|
||||||
|
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{tNurseProfile('preview_cta')}
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{[0, 1].map((key) => (
|
{[0, 1].map((key) => (
|
||||||
|
|||||||
@@ -1,81 +1,99 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Paper, Stack, Typography } from '@mui/material';
|
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon } from '@/components';
|
import { AccentCard, AppButton, AppIcon } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { useActivationChecklist } from '@/components/ActivationChecklist';
|
||||||
import { useVerificationStatus } from '@/services/verification';
|
import { useSetAcceptingBookings } from '@/services/profiles';
|
||||||
import { isApproved } from '@/services/verification/types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse
|
* The go-live gate for the nurse's services — **the real switch**, not a snackbar. Reads the same
|
||||||
* is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish
|
* `useActivationChecklist` state `ActivationChecklist` renders (one source of truth, no duplicated
|
||||||
* CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once
|
* search-visibility logic): unmet conditions render guidance naming exactly what's missing; met but
|
||||||
* approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is
|
* paused renders the real `set_accepting_bookings` CTA; live renders the on state + a pause action.
|
||||||
* live before verification completes. Reads the shared `VerificationStatus` query (cached across the app).
|
* Success copy fires only after the mutation actually succeeds — this replaces the old
|
||||||
|
* `enqueueSnackbar('published')` no-op.
|
||||||
*/
|
*/
|
||||||
const PublishGate: FunctionComponent = () => {
|
const PublishGate: FunctionComponent = () => {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
const locale = useLocale();
|
const tActivation = useTranslations('activation');
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
const { data: status, isLoading } = useVerificationStatus();
|
const state = useActivationChecklist();
|
||||||
|
const setAccepting = useSetAcceptingBookings();
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
|
||||||
const approved = isApproved(status);
|
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 (
|
return (
|
||||||
<Paper
|
<AccentCard tone={live ? 'success' : state.isSearchVisible ? 'primary' : 'warning'} padding="md">
|
||||||
elevation={0}
|
<Stack sx={{ gap: 1 }}>
|
||||||
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' }}>
|
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||||
<AppIcon
|
<AppIcon
|
||||||
icon={approved ? 'verified' : 'warning'}
|
icon={live ? 'verified' : state.isSearchVisible ? 'publish' : 'warning'}
|
||||||
size={24}
|
size={24}
|
||||||
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
|
color={live ? 'var(--bal-success)' : state.isSearchVisible ? 'var(--bal-primary)' : 'var(--bal-warning)'}
|
||||||
/>
|
/>
|
||||||
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
|
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
|
{live
|
||||||
|
? t('publish_live_title')
|
||||||
|
: state.isSearchVisible
|
||||||
|
? t('publish_ready_title')
|
||||||
|
: t('publish_blocked_title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
|
{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>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
<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
|
<AppButton
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon="publish"
|
startIcon="publish"
|
||||||
disabled={!approved}
|
onClick={() => toggle(true)}
|
||||||
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
|
disabled={setAccepting.isPending}
|
||||||
>
|
>
|
||||||
{t('publish_cta')}
|
{setAccepting.isPending ? t('publish_toggling') : t('publish_start_accepting')}
|
||||||
</AppButton>
|
|
||||||
{!approved ? (
|
|
||||||
<AppButton
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
|
||||||
>
|
|
||||||
{t('publish_complete_verification')}
|
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Stack>
|
||||||
|
</AccentCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,21 @@
|
|||||||
import { FunctionComponent, useMemo, useState } from 'react';
|
import { FunctionComponent, useMemo, useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import {
|
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||||
Box,
|
import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components';
|
||||||
Chip,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
|
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
||||||
import {
|
import {
|
||||||
useCategoryOptionGroups,
|
useCategoryOptionGroups,
|
||||||
useCreateVariant,
|
useCreateVariant,
|
||||||
|
useMyVariants,
|
||||||
useServiceCategories,
|
useServiceCategories,
|
||||||
useUpdateVariant,
|
useUpdateVariant,
|
||||||
} from '@/services/catalog';
|
} from '@/services/catalog';
|
||||||
import { pickCatalogName } from '@/services/catalog/names';
|
import { pickCatalogName } from '@/services/catalog/names';
|
||||||
import {
|
import {
|
||||||
PRICE_UNITS,
|
PRICE_UNITS,
|
||||||
|
optionSetSignature,
|
||||||
type NurseServiceVariant,
|
type NurseServiceVariant,
|
||||||
type PriceUnit,
|
type PriceUnit,
|
||||||
type VariantOptionSelection,
|
type VariantOptionSelection,
|
||||||
@@ -36,6 +27,8 @@ interface VariantBuilderProps {
|
|||||||
initial: NurseServiceVariant | null;
|
initial: NurseServiceVariant | null;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
onCancel: () => 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';
|
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
|
* **Edit** locks the category + option-set (changing them would change identity) and edits only
|
||||||
* price/unit/duration/display via `update`.
|
* 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 t = useTranslations('services');
|
||||||
const tCatalog = useTranslations('catalog');
|
const tCatalog = useTranslations('catalog');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
@@ -92,6 +85,21 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
|
const selectedCategory = categories.find((category) => category.id === categoryId) ?? null;
|
||||||
const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == 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.
|
// Auto-generated display name preview (create): category + chosen value labels, in the active locale.
|
||||||
const autoName = useMemo(() => {
|
const autoName = useMemo(() => {
|
||||||
if (isEdit) return initial.displayName;
|
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 = (
|
const priceStep = (
|
||||||
<Stack sx={{ gap: 2.5 }}>
|
<Stack sx={{ gap: 2.5 }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -230,14 +253,17 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{irr ? (
|
{irr ? (
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<PriceDisplay price={irr} priceUnit={priceUnit} sessionCount={sessionCount} showEstimate />
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||||
|
{t('preview_heading')}
|
||||||
|
</Typography>
|
||||||
|
<VariantCard variant={previewVariant} interactive={false} />
|
||||||
{!sessionCount ? (
|
{!sessionCount ? (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('rate_note')}
|
{t('rate_note')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Paper>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
@@ -249,21 +275,28 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{duplicate ? (
|
{duplicate ? (
|
||||||
<Paper
|
<AccentCard tone="warning" padding="sm">
|
||||||
elevation={0}
|
<Stack sx={{ gap: 1 }}>
|
||||||
sx={{
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||||
p: 1.5,
|
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
|
||||||
borderRadius: 2,
|
{/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */}
|
||||||
border: '1px solid',
|
<Typography variant="body2" sx={{ color: 'text.primary', fontWeight: 500 }}>
|
||||||
borderColor: 'divider',
|
|
||||||
borderInlineStartWidth: 4,
|
|
||||||
borderInlineStartColor: 'var(--bal-warning)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 500 }}>
|
|
||||||
{t('duplicate_warning')}
|
{t('duplicate_warning')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</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}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -423,20 +456,25 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<ToggleButtonGroup
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
exclusive
|
{group.values.map((value) => {
|
||||||
size="small"
|
const selected = selectedOptions[group.id] === value.id;
|
||||||
color="primary"
|
return (
|
||||||
value={selectedOptions[group.id] ?? null}
|
<Chip
|
||||||
onChange={(_event, valueId: number | null) => changeOption(group.id, valueId)}
|
key={value.id}
|
||||||
sx={{ flexWrap: 'wrap' }}
|
label={pickCatalogName(value, locale)}
|
||||||
>
|
onClick={() => changeOption(group.id, selected ? null : value.id)}
|
||||||
{group.values.map((value) => (
|
variant={selected ? 'filled' : 'outlined'}
|
||||||
<ToggleButton key={value.id} value={value.id}>
|
sx={{
|
||||||
{pickCatalogName(value, locale)}
|
fontWeight: 500,
|
||||||
</ToggleButton>
|
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
|
||||||
))}
|
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
||||||
</ToggleButtonGroup>
|
borderColor: 'var(--bal-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function NurseServicesPage() {
|
|||||||
initial={builder.editing}
|
initial={builder.editing}
|
||||||
onDone={() => setBuilder({ open: false })}
|
onDone={() => setBuilder({ open: false })}
|
||||||
onCancel={() => setBuilder({ open: false })}
|
onCancel={() => setBuilder({ open: false })}
|
||||||
|
onEditExisting={(variant) => setBuilder({ open: true, editing: variant })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+60
@@ -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;
|
||||||
+84
-89
@@ -1,127 +1,80 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
import { Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
import { AppButton, AppIcon, StatusChip, SurfaceCard } from '@/components';
|
||||||
import { formatNumber } from '@/utils';
|
|
||||||
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||||
import {
|
import {
|
||||||
displaySteps,
|
groupLabelKey,
|
||||||
progressCounts,
|
groupRoute,
|
||||||
routeForStep,
|
groupStatus,
|
||||||
|
groupedDisplaySteps,
|
||||||
stepDescriptionKey,
|
stepDescriptionKey,
|
||||||
stepLabelKey,
|
stepLabelKey,
|
||||||
stepStatusChip,
|
stepStatusChip,
|
||||||
|
type StepGroupKey,
|
||||||
} from './verificationSteps';
|
} from './verificationSteps';
|
||||||
|
|
||||||
interface VerificationChecklistProps {
|
interface VerificationChecklistProps {
|
||||||
status: VerificationStatus;
|
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
|
* The B3 journey body (ui-phase-8 §3.2) — ONE vertical spine of grouped step cards (هویت / مدارک
|
||||||
* shared `StatusChip`). Rows are rendered from `displaySteps(status)` — a new step type appears without a
|
* حرفهای / بانک), replacing the old flat "X از Y" + 7-row list. Each card folds the data-driven
|
||||||
* code change. Failed/expired rows surface their reason and a re-submit path; the first actionable
|
* steps `verificationSteps.ts` already catalogs (regrouped presentation only — the catalog/synthetic
|
||||||
* automated/manual step gets an inline "go" link to its screen.
|
* mobile-step architecture is unchanged) and links to the one screen that owns that group's submission.
|
||||||
*/
|
*/
|
||||||
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
|
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
|
||||||
const { passed, total } = progressCounts(status);
|
const groups = groupedDisplaySteps(status);
|
||||||
const steps = displaySteps(status);
|
|
||||||
const firstActionableId = steps.find(
|
|
||||||
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
|
|
||||||
)?.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
<ProgressMeter passed={passed} total={total} />
|
{groups.map(({ group, steps }) => (
|
||||||
<Stack sx={{ gap: 1 }}>
|
<GroupCard key={group} group={group} steps={steps} />
|
||||||
{steps.map((step) => (
|
|
||||||
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
|
const GroupCard: FunctionComponent<{ group: StepGroupKey; steps: VerificationStep[] }> = ({ group, steps }) => {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const percent = total === 0 ? 0 : (passed / total) * 100;
|
const aggregate = groupStatus(steps);
|
||||||
const format = (value: number) => formatNumber(value, locale);
|
const chip = stepStatusChip(aggregate);
|
||||||
return (
|
const route = groupRoute(group);
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
const actionable = aggregate !== 'passed' && aggregate !== 'in_review';
|
||||||
<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>
|
|
||||||
</Stack>
|
|
||||||
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
|
|
||||||
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 reason = step.failureReason
|
|
||||||
? t.has(`reason_${step.failureReason}`)
|
|
||||||
? t(`reason_${step.failureReason}`)
|
|
||||||
: step.failureReason
|
|
||||||
: null;
|
|
||||||
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
|
|
||||||
const showAutoNote = step.isAutomated && step.status === 'not_started';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<SurfaceCard
|
||||||
elevation={0}
|
padding="md"
|
||||||
|
data-journey-group={group}
|
||||||
|
data-journey-group-status={aggregate}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
borderInlineStart: '4px solid',
|
||||||
borderRadius: 2,
|
borderInlineStartColor:
|
||||||
border: '1px solid',
|
aggregate === 'failed' ? 'var(--bal-error)' : aggregate === 'passed' ? 'var(--bal-success)' : 'divider',
|
||||||
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
|
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<AppIcon icon={GROUP_ICON[group]} size={22} color="var(--bal-text-secondary)" />
|
||||||
{label}
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{t(groupLabelKey(group))}
|
||||||
</Typography>
|
</Typography>
|
||||||
{description ? (
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{showReason && reason ? (
|
<Stack sx={{ gap: 1 }}>
|
||||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
{steps.map((step) => (
|
||||||
{reason}
|
<StepLine key={step.code} step={step} />
|
||||||
</Typography>
|
))}
|
||||||
) : null}
|
</Stack>
|
||||||
|
|
||||||
{showAutoNote ? (
|
{actionable ? (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('auto_query_note')}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
|
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -129,10 +82,52 @@ const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean
|
|||||||
to={`/${locale}${route}`}
|
to={`/${locale}${route}`}
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
|
{aggregate === 'failed' ? t('row_fix') : t('row_go')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
</Paper>
|
</Stack>
|
||||||
|
</SurfaceCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StepLine: FunctionComponent<{ step: VerificationStep }> = ({ step }) => {
|
||||||
|
const t = useTranslations('verification');
|
||||||
|
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 = 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 (
|
||||||
|
<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>
|
||||||
|
{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}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+33
@@ -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 { useRouter } from 'next/navigation';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material';
|
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 type { UploadedDocInfo } from '@/components';
|
||||||
|
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
import {
|
import {
|
||||||
useSubmitCredentials,
|
useSubmitCredentials,
|
||||||
useUploadVerificationDocument,
|
useUploadVerificationDocument,
|
||||||
@@ -15,14 +17,22 @@ import {
|
|||||||
import { SPECIALTY_PRESETS } from '@/services/verification/types';
|
import { SPECIALTY_PRESETS } from '@/services/verification/types';
|
||||||
import type { VerificationStep } from '@/services/verification/types';
|
import type { VerificationStep } from '@/services/verification/types';
|
||||||
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
|
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
|
||||||
|
import VerificationJourneyHeader from '../VerificationJourneyHeader';
|
||||||
|
|
||||||
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
|
const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the
|
* 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
|
* status** (data-driven — a new manual step renders without a code change); each upload moves its step
|
||||||
* `in_review` (manual admin review — copy never claims an automated authority check). Collects the INO
|
* to `in_review` (manual admin review — copy never claims an automated authority check).
|
||||||
* number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review).
|
*
|
||||||
|
* 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() {
|
export default function CredentialsSubmitPage() {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
@@ -34,14 +44,29 @@ export default function CredentialsSubmitPage() {
|
|||||||
const uploadDocument = useUploadVerificationDocument();
|
const uploadDocument = useUploadVerificationDocument();
|
||||||
const submitCredentials = useSubmitCredentials();
|
const submitCredentials = useSubmitCredentials();
|
||||||
|
|
||||||
|
const submission = status?.credentialSubmission;
|
||||||
|
|
||||||
const [inoNumber, setInoNumber] = useState('');
|
const [inoNumber, setInoNumber] = useState('');
|
||||||
const [inoError, setInoError] = useState(false);
|
const [inoError, setInoError] = useState(false);
|
||||||
|
const [editingIno, setEditingIno] = useState(true);
|
||||||
const [specialties, setSpecialties] = useState<string[]>([]);
|
const [specialties, setSpecialties] = useState<string[]>([]);
|
||||||
const [customSpecialty, setCustomSpecialty] = useState('');
|
const [customSpecialty, setCustomSpecialty] = useState('');
|
||||||
const [issuingAuthority, setIssuingAuthority] = useState('');
|
const [issuingAuthority, setIssuingAuthority] = useState('');
|
||||||
const [issuedAt, setIssuedAt] = useState('');
|
const [issuedAt, setIssuedAt] = useState<string | null>(null);
|
||||||
const [expiresAt, setExpiresAt] = useState('');
|
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
||||||
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
|
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(
|
const manualSteps = useMemo(
|
||||||
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
|
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
|
||||||
@@ -72,8 +97,8 @@ export default function CredentialsSubmitPage() {
|
|||||||
inoNumber: inoNumber.trim(),
|
inoNumber: inoNumber.trim(),
|
||||||
specialties,
|
specialties,
|
||||||
issuingAuthority: issuingAuthority.trim() || undefined,
|
issuingAuthority: issuingAuthority.trim() || undefined,
|
||||||
issuedAt: issuedAt || undefined,
|
issuedAt: issuedAt ?? undefined,
|
||||||
expiresAt: expiresAt || undefined,
|
expiresAt: expiresAt ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -89,10 +114,8 @@ export default function CredentialsSubmitPage() {
|
|||||||
|
|
||||||
if (!status || manualSteps.length === 0) {
|
if (!status || manualSteps.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||||
<Typography variant="h5" component="h1">
|
<VerificationJourneyHeader group="credentials" />
|
||||||
{t('credentials_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('credentials_needs_start')}
|
{t('credentials_needs_start')}
|
||||||
</Typography>
|
</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 (
|
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>
|
<Box>
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||||
{t('credentials_title')}
|
{t('credentials_title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
@@ -116,10 +147,7 @@ export default function CredentialsSubmitPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
{editingIno ? (
|
||||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label={t('ino_number_label')}
|
label={t('ino_number_label')}
|
||||||
value={inoNumber}
|
value={inoNumber}
|
||||||
@@ -132,8 +160,23 @@ export default function CredentialsSubmitPage() {
|
|||||||
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
|
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
|
||||||
fullWidth
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 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 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
{manualSteps.map((step) => (
|
{manualSteps.map((step) => (
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
@@ -144,18 +187,21 @@ export default function CredentialsSubmitPage() {
|
|||||||
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
|
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
|
||||||
rejected={step.status === 'failed'}
|
rejected={step.status === 'failed'}
|
||||||
rejectionReason={step.failureReason ?? undefined}
|
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>
|
</Stack>
|
||||||
|
|
||||||
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
|
{editingIno ? (
|
||||||
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
|
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
{t('specialties_label')}
|
{t('specialties_label')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{editingIno ? (
|
||||||
|
<>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('specialties_hint')}
|
{t('specialties_hint')}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -167,10 +213,13 @@ export default function CredentialsSubmitPage() {
|
|||||||
key={preset}
|
key={preset}
|
||||||
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
||||||
onClick={() => toggleSpecialty(preset)}
|
onClick={() => toggleSpecialty(preset)}
|
||||||
|
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : undefined}
|
||||||
|
variant={selected ? 'filled' : 'outlined'}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
|
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
|
||||||
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
||||||
|
borderColor: 'var(--bal-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -203,9 +252,27 @@ export default function CredentialsSubmitPage() {
|
|||||||
{t('specialty_add')}
|
{t('specialty_add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : specialties.length > 0 ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{specialties.map((value) => (
|
||||||
|
<Chip
|
||||||
|
key={value}
|
||||||
|
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
|
||||||
|
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('summary_none')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
|
{/* 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 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
{t('registry_details_label')}
|
{t('registry_details_label')}
|
||||||
@@ -217,24 +284,37 @@ export default function CredentialsSubmitPage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<TextField
|
<JalaliDateField
|
||||||
label={t('issued_at_label')}
|
label={t('issued_at_label')}
|
||||||
type="date"
|
|
||||||
value={issuedAt}
|
value={issuedAt}
|
||||||
onChange={(event) => setIssuedAt(event.target.value)}
|
onChange={setIssuedAt}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
max={expiresAt ?? undefined}
|
||||||
sx={{ flex: 1, minWidth: 160 }}
|
sx={{ flex: 1, minWidth: 160 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<JalaliDateField
|
||||||
label={t('expires_at_label')}
|
label={t('expires_at_label')}
|
||||||
type="date"
|
|
||||||
value={expiresAt}
|
value={expiresAt}
|
||||||
onChange={(event) => setExpiresAt(event.target.value)}
|
onChange={setExpiresAt}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
min={issuedAt ?? undefined}
|
||||||
sx={{ flex: 1, minWidth: 160 }}
|
sx={{ flex: 1, minWidth: 160 }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</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
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@@ -246,20 +326,27 @@ export default function CredentialsSubmitPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{editingIno ? (
|
||||||
|
<>
|
||||||
|
{!hasAnyDocument ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('credentials_needs_document')}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
<Stack direction="row" sx={{ gap: 1 }}>
|
<Stack direction="row" sx={{ gap: 1 }}>
|
||||||
<AppButton
|
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
startIcon="license"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitCredentials.isPending || !anyUploaded}
|
|
||||||
>
|
|
||||||
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
|
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
|
||||||
{t('back_to_checklist')}
|
{t('back_to_checklist')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
{t('back_to_checklist')}
|
||||||
|
</AppButton>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useLocale, useTranslations } from 'next-intl';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
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 { ROUTES } from '@/constants';
|
||||||
import { toEnglishDigits } from '@/utils';
|
import { toEnglishDigits } from '@/utils';
|
||||||
import { useSubmitIdentity } from '@/services/verification';
|
import { useSubmitIdentity } from '@/services/verification';
|
||||||
import { isValidNationalId } from '@/services/verification/validation';
|
import { isValidNationalId } from '@/services/verification/validation';
|
||||||
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
|
import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants';
|
||||||
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
|
import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity';
|
||||||
|
import VerificationJourneyHeader from '../VerificationJourneyHeader';
|
||||||
|
|
||||||
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
|
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
|
||||||
|
|
||||||
@@ -66,9 +68,11 @@ export default function IdentitySubmitPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<Box>
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||||
{t('identity_title')}
|
{t('identity_title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
@@ -76,13 +80,6 @@ export default function IdentitySubmitPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
|
||||||
<StepperHeader
|
|
||||||
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
|
|
||||||
activeStep={0}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label={t('national_id_label')}
|
label={t('national_id_label')}
|
||||||
value={nationalId}
|
value={nationalId}
|
||||||
@@ -96,6 +93,8 @@ export default function IdentitySubmitPage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<CaptureGuideFrame variant="card" />
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
label={t('card_label')}
|
label={t('card_label')}
|
||||||
hint={t('card_hint')}
|
hint={t('card_hint')}
|
||||||
@@ -104,7 +103,10 @@ export default function IdentitySubmitPage() {
|
|||||||
onUpload={captureLocally}
|
onUpload={captureLocally}
|
||||||
onUploaded={() => setCardCaptured(true)}
|
onUploaded={() => setCardCaptured(true)}
|
||||||
/>
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<CaptureGuideFrame variant="selfie" />
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
label={t('selfie_label')}
|
label={t('selfie_label')}
|
||||||
hint={t('selfie_hint')}
|
hint={t('selfie_hint')}
|
||||||
@@ -113,6 +115,7 @@ export default function IdentitySubmitPage() {
|
|||||||
onUpload={captureLocally}
|
onUpload={captureLocally}
|
||||||
onUploaded={() => setSelfieCaptured(true)}
|
onUploaded={() => setSelfieCaptured(true)}
|
||||||
/>
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@@ -153,3 +156,48 @@ export default function IdentitySubmitPage() {
|
|||||||
</Box>
|
</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 { useRouter } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
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 { ROUTES } from '@/constants';
|
||||||
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
||||||
import { verificationKeys } from '@/services/verification/keys';
|
import { verificationKeys } from '@/services/verification/keys';
|
||||||
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
|
import { USE_VERIFICATION_MOCK } from '@/services/verification/constants';
|
||||||
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
|
import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi';
|
||||||
|
import TrustBadgePreviewPanel from './TrustBadgePreviewPanel';
|
||||||
import VerificationChecklist from './VerificationChecklist';
|
import VerificationChecklist from './VerificationChecklist';
|
||||||
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
|
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
|
||||||
|
|
||||||
@@ -44,15 +46,8 @@ export default function NurseVerificationPage() {
|
|||||||
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||||
<Box>
|
<PageHeader title={t('title')} subtitle={t('subtitle')} />
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
{t('title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
@@ -78,6 +73,7 @@ export default function NurseVerificationPage() {
|
|||||||
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
|
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<TrustBadgePreviewPanel status={status} />
|
||||||
<VerificationChecklist status={status} />
|
<VerificationChecklist status={status} />
|
||||||
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
|
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
|
||||||
<ContinueCta status={status} onContinue={handleContinue} />
|
<ContinueCta status={status} onContinue={handleContinue} />
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
import { Box, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components';
|
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 { ROUTES } from '@/constants';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
import { useVerificationStatus } from '@/services/verification';
|
import { useVerificationStatus } from '@/services/verification';
|
||||||
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
|
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
|
* B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query,
|
||||||
* views — never a second fetch). Shows the waiting message + the 24–48h expectation + a condensed
|
* two views — never a second fetch). ui-phase-8: the same journey header as B4/B5, a Shamsi submitted
|
||||||
* mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده
|
* timestamp when the server serves one (REQ-055, mock-tolerant — omitted rather than faked), a
|
||||||
* وضعیت" returns to the canonical B3 hub.
|
* what-happens-next `StatusTimeline` (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعالسازی نشان), and
|
||||||
|
* the condensed mini-checklist of what is passed vs in-review vs pending.
|
||||||
*/
|
*/
|
||||||
export default function UnderReviewPage() {
|
export default function UnderReviewPage() {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
@@ -22,41 +27,53 @@ export default function UnderReviewPage() {
|
|||||||
const steps = displaySteps(status);
|
const steps = displaySteps(status);
|
||||||
const isApproved = status?.status === 'approved';
|
const isApproved = status?.status === 'approved';
|
||||||
|
|
||||||
return (
|
const timelineNodes: TimelineNode[] = [
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
{
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
key: 'submitted',
|
||||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
|
label: t('review_timeline_submitted'),
|
||||||
</Box>
|
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
|
return (
|
||||||
elevation={0}
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||||
|
<VerificationJourneyHeader group="review" />
|
||||||
|
|
||||||
|
<SurfaceCard
|
||||||
|
padding="md"
|
||||||
sx={{
|
sx={{
|
||||||
p: 3,
|
borderInlineStart: '4px solid',
|
||||||
borderRadius: 2,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderInlineStartWidth: 4,
|
|
||||||
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||||
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||||
<Typography variant="h6" component="h1">
|
<Typography variant="h6" component="h2">
|
||||||
{isApproved ? t('review_approved_title') : t('review_title')}
|
{isApproved ? t('review_approved_title') : t('review_title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{isApproved ? t('review_approved_body') : t('review_body')}
|
{isApproved ? t('review_approved_body') : t('review_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isApproved ? (
|
</Stack>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--bal-warning)' }}>
|
</SurfaceCard>
|
||||||
{t('review_eta')}
|
|
||||||
</Typography>
|
<SurfaceCard padding="md">
|
||||||
) : null}
|
<StatusTimeline nodes={timelineNodes} />
|
||||||
</Paper>
|
</SurfaceCard>
|
||||||
|
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
|
|||||||
@@ -115,3 +115,62 @@ export function nextActionableIndex(status: VerificationStatus | undefined): num
|
|||||||
const index = steps.findIndex(isActionable);
|
const index = steps.findIndex(isActionable);
|
||||||
return index === -1 ? null : index + 1;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,6 +71,30 @@ describe('<DocumentUpload/> component', () => {
|
|||||||
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
|
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', () => {
|
it('renders an already-uploaded document from server metadata', () => {
|
||||||
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
|
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
|
||||||
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
|
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -153,32 +153,15 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
|||||||
onChange={onFileSelected}
|
onChange={onFileSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rejected ? (
|
{state === 'uploading' ? (
|
||||||
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
|
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
|
||||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
{/* A re-upload of a rejected doc must show LIVE progress, not the frozen rejected card — the
|
||||||
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
|
reason stays visible above the bar so context isn't lost while the new attempt runs. */}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
{rejected && rejectionReason ? (
|
||||||
{t('upload_rejected')}
|
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
{rejectionReason ? (
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{rejectionReason}
|
{rejectionReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : 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' }}>
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||||
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||||
@@ -222,6 +205,30 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</SurfaceCard>
|
</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' ? (
|
) : state === 'error' ? (
|
||||||
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
|
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
|
||||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -65,4 +65,15 @@ describe('<VariantCard/> component', () => {
|
|||||||
fireEvent.click(screen.getByText('deactivate'));
|
fireEvent.click(screen.getByText('deactivate'));
|
||||||
expect(onToggleActive).toHaveBeenCalledTimes(1);
|
expect(onToggleActive).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops the action row in a read-only (interactive=false) preview', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<VariantCard variant={baseVariant} interactive={false} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Elderly Care · Live-in')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('edit')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('deactivate')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,20 +11,27 @@ import PriceDisplay from '../PriceDisplay';
|
|||||||
|
|
||||||
export interface VariantCardProps {
|
export interface VariantCardProps {
|
||||||
variant: NurseServiceVariant;
|
variant: NurseServiceVariant;
|
||||||
/** Open the builder in edit mode for this variant. */
|
/** Open the builder in edit mode for this variant. Required unless `interactive` is `false`. */
|
||||||
onEdit: () => void;
|
onEdit?: () => void;
|
||||||
/** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. */
|
/** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. Required
|
||||||
onToggleActive: () => void;
|
* unless `interactive` is `false`. */
|
||||||
|
onToggleActive?: () => void;
|
||||||
|
/**
|
||||||
|
* When `false`, renders a read-only preview (no Edit/Deactivate row) — the variant builder's step-3
|
||||||
|
* "اینگونه در جستجو دیده میشوید" live listing preview. Defaults to `true` (the offerings-list card).
|
||||||
|
*/
|
||||||
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One nurse offering (variant) in the services list: its `display_name`, the category, the price via
|
* One nurse offering (variant): its `display_name`, the category, the price via the money util
|
||||||
* the money util (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip +
|
* (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip + a "can't be
|
||||||
* an "can't be booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there
|
* booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there is **no
|
||||||
* is **no delete affordance**. RTL-safe.
|
* delete affordance**. `interactive={false}` drops the action row for a read-only preview use (the
|
||||||
|
* builder's live listing preview). RTL-safe.
|
||||||
* @component VariantCard
|
* @component VariantCard
|
||||||
*/
|
*/
|
||||||
const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onToggleActive }) => {
|
const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onToggleActive, interactive = true }) => {
|
||||||
const t = useTranslations('services');
|
const t = useTranslations('services');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const active = variant.isActive;
|
const active = variant.isActive;
|
||||||
@@ -72,6 +79,7 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
|||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{interactive ? (
|
||||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={onEdit}>
|
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={onEdit}>
|
||||||
{t('edit')}
|
{t('edit')}
|
||||||
@@ -85,6 +93,7 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
|||||||
{active ? t('deactivate') : t('activate')}
|
{active ? t('deactivate') : t('activate')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import VerificationPanel from './VerificationPanel';
|
|||||||
import PaymentStateCard from './PaymentStateCard';
|
import PaymentStateCard from './PaymentStateCard';
|
||||||
import BnplProviderLogo from './BnplProviderLogo';
|
import BnplProviderLogo from './BnplProviderLogo';
|
||||||
import EscrowExplainer from './EscrowExplainer';
|
import EscrowExplainer from './EscrowExplainer';
|
||||||
|
import ActivationChecklist from './ActivationChecklist';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ProfileSummary,
|
ProfileSummary,
|
||||||
@@ -76,6 +77,7 @@ export {
|
|||||||
PaymentStateCard,
|
PaymentStateCard,
|
||||||
BnplProviderLogo,
|
BnplProviderLogo,
|
||||||
EscrowExplainer,
|
EscrowExplainer,
|
||||||
|
ActivationChecklist,
|
||||||
};
|
};
|
||||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||||
export type { OtpInputProps } from './OtpInput';
|
export type { OtpInputProps } from './OtpInput';
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const ROUTES = {
|
|||||||
// Nurse app
|
// Nurse app
|
||||||
NURSE: '/nurse',
|
NURSE: '/nurse',
|
||||||
NURSE_PROFILE: '/nurse/profile',
|
NURSE_PROFILE: '/nurse/profile',
|
||||||
|
// Public-profile preview ("نمایهٔ عمومی من") — the C3 trust-dossier pieces composed from the nurse's
|
||||||
|
// own data (own profile + variants + coverage + badge), reachable pre-publish.
|
||||||
|
NURSE_PROFILE_PREVIEW: '/nurse/profile/preview',
|
||||||
// Services & prices — the nurse variant builder + offerings list (B7 services half).
|
// Services & prices — the nurse variant builder + offerings list (B7 services half).
|
||||||
NURSE_SERVICES: '/nurse/services',
|
NURSE_SERVICES: '/nurse/services',
|
||||||
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
|
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
|
||||||
|
|||||||
@@ -107,4 +107,13 @@ export const profilesClientApi: ProfilesApi = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ui-phase-8: the real, previously-unwired go-live switch — flips `is_accepting_bookings`
|
||||||
|
// independently of `is_verified`; the server reindexes the nurse's search rows in-transaction.
|
||||||
|
setAcceptingBookings: async (accepting: boolean): Promise<void> => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/nurse_profiles/set_accepting_bookings`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ accepting }),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,4 +70,11 @@ export const profilesMockApi: ProfilesApi = {
|
|||||||
// object-storage URL (REQ-006).
|
// object-storage URL (REQ-006).
|
||||||
return { url: URL.createObjectURL(file) };
|
return { url: URL.createObjectURL(file) };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mirrors the real endpoint's flip: `isVerified` is never touched, matching the guarded server field.
|
||||||
|
setAcceptingBookings: async (accepting: boolean): Promise<void> => {
|
||||||
|
await sleep(MOCK_LATENCY_MS);
|
||||||
|
if (!nurseProfile) return;
|
||||||
|
nurseProfile = { ...nurseProfile, isAcceptingBookings: accepting };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { profilesApi } from '../apis';
|
||||||
|
import { profileKeys } from '../keys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real go-live switch (`POST nurse_profiles/set_accepting_bookings`) — flips
|
||||||
|
* `isAcceptingBookings` independently of verification. Invalidates the nurse profile so the
|
||||||
|
* activation checklist / publish gate re-render the live state from the server, never an optimistic
|
||||||
|
* "published" toast before the mutation actually succeeds.
|
||||||
|
*/
|
||||||
|
export function useSetAcceptingBookings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (accepting: boolean) => profilesApi.setAcceptingBookings(accepting),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: profileKeys.nurse() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile';
|
|||||||
export { useNurseProfile } from './hooks/useNurseProfile';
|
export { useNurseProfile } from './hooks/useNurseProfile';
|
||||||
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
|
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
|
||||||
export { useUploadAvatar } from './hooks/useUploadAvatar';
|
export { useUploadAvatar } from './hooks/useUploadAvatar';
|
||||||
|
export { useSetAcceptingBookings } from './hooks/useSetAcceptingBookings';
|
||||||
|
|||||||
@@ -73,4 +73,10 @@ export interface ProfilesApi {
|
|||||||
getNurseProfile(): Promise<NurseProfile | null>;
|
getNurseProfile(): Promise<NurseProfile | null>;
|
||||||
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
|
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
|
||||||
uploadAvatar(file: File): Promise<AvatarUploadResult>;
|
uploadAvatar(file: File): Promise<AvatarUploadResult>;
|
||||||
|
/**
|
||||||
|
* The real, previously-unwired `POST nurse_profiles/set_accepting_bookings` — flips the
|
||||||
|
* nurse's go-live switch independently of `isVerified` (the server reindexes every one of the
|
||||||
|
* nurse's search rows in the same transaction). Never a client-side simulation of "publish".
|
||||||
|
*/
|
||||||
|
setAcceptingBookings(accepting: boolean): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ let nextStepId = 1;
|
|||||||
let nextDocId = 1;
|
let nextDocId = 1;
|
||||||
let boundNationalId = '';
|
let boundNationalId = '';
|
||||||
let approvedAt: string | null = null;
|
let approvedAt: string | null = null;
|
||||||
|
// REQ-055: stamped once when the nurse first leaves `not_started` — stands in for the not-yet-served
|
||||||
|
// nurse-facing `submittedAt` (B6's timestamp line).
|
||||||
|
let submittedAt: string | null = null;
|
||||||
|
// REQ-056: the structured B5 fields, kept so a returning nurse's form hydrates instead of re-prompting
|
||||||
|
// blank — never the raw INO number, which the real server would never re-serve either.
|
||||||
|
let credentialSubmission: NonNullable<VerificationStatus['credentialSubmission']> | null = null;
|
||||||
|
|
||||||
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
|
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
|
||||||
|
|
||||||
@@ -56,13 +62,20 @@ function setStepStatus(code: string, status: VerificationStepStatus, failureReas
|
|||||||
/** Re-aggregate exactly as the server would: approved only when every step passes; blockers are the rest. */
|
/** Re-aggregate exactly as the server would: approved only when every step passes; blockers are the rest. */
|
||||||
function aggregate(): VerificationStatus {
|
function aggregate(): VerificationStatus {
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [] };
|
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [], submittedAt: null };
|
||||||
}
|
}
|
||||||
const blockingSteps = steps.filter((step) => step.status !== 'passed').map((step) => step.code);
|
const blockingSteps = steps.filter((step) => step.status !== 'passed').map((step) => step.code);
|
||||||
const allPassed = blockingSteps.length === 0;
|
const allPassed = blockingSteps.length === 0;
|
||||||
const anyInReview = steps.some((step) => step.status === 'in_review');
|
const anyInReview = steps.some((step) => step.status === 'in_review');
|
||||||
const status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
|
const status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
|
||||||
return { status, isBookable: allPassed, blockingSteps, steps: steps.map((step) => ({ ...step })) };
|
return {
|
||||||
|
status,
|
||||||
|
isBookable: allPassed,
|
||||||
|
blockingSteps,
|
||||||
|
steps: steps.map((step) => ({ ...step })),
|
||||||
|
submittedAt,
|
||||||
|
credentialSubmission,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedSteps(): void {
|
function seedSteps(): void {
|
||||||
@@ -76,6 +89,7 @@ function seedSteps(): void {
|
|||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
}));
|
}));
|
||||||
|
submittedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Admin review queue fixtures + state ---------------------------------------------------------
|
/* --- Admin review queue fixtures + state ---------------------------------------------------------
|
||||||
@@ -351,6 +365,14 @@ export const verificationMockApi: VerificationApi = {
|
|||||||
if (input.inoNumber.trim().length === 0) {
|
if (input.inoNumber.trim().length === 0) {
|
||||||
throw new ApiError(400, 'INO number is required', 'ino_number_required');
|
throw new ApiError(400, 'INO number is required', 'ino_number_required');
|
||||||
}
|
}
|
||||||
|
// Stands in for REQ-056 — the mock persists what a real read-back would serve (never the raw number).
|
||||||
|
credentialSubmission = {
|
||||||
|
inoNumberSubmitted: true,
|
||||||
|
specialties: input.specialties,
|
||||||
|
issuingAuthority: input.issuingAuthority ?? null,
|
||||||
|
issuedAt: input.issuedAt ?? null,
|
||||||
|
expiresAt: input.expiresAt ?? null,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getTrustBadge: async (nurseId) => {
|
getTrustBadge: async (nurseId) => {
|
||||||
|
|||||||
@@ -77,6 +77,27 @@ export interface VerificationStatus {
|
|||||||
/** Step codes still blocking go-live. */
|
/** Step codes still blocking go-live. */
|
||||||
blockingSteps: string[];
|
blockingSteps: string[];
|
||||||
steps: VerificationStep[];
|
steps: VerificationStep[];
|
||||||
|
/**
|
||||||
|
* When the nurse first submitted (left `not_started`). **Not on the nurse-facing wire yet** — the
|
||||||
|
* admin queue DTO already serves it, but `VerificationStatusDto` doesn't (REQ-055, filed by
|
||||||
|
* ui-phase-8). Mock-tolerant: `undefined` on the real path omits the B6 timestamp line rather than
|
||||||
|
* fake one.
|
||||||
|
*/
|
||||||
|
submittedAt?: string | null;
|
||||||
|
/**
|
||||||
|
* A nurse-facing read-back of the structured credential details B5 collects — so a returning nurse
|
||||||
|
* sees a submitted summary instead of blank fields. **Not on the wire yet** (REQ-011 delivered only
|
||||||
|
* the write; there is no nurse-facing read — REQ-056, filed by ui-phase-8). `inoNumberSubmitted` is a
|
||||||
|
* boolean, never the number itself — the raw value is encrypted server-side by design and never
|
||||||
|
* re-served. Mock-tolerant: `undefined` on the real path renders every B5 field blank, as today.
|
||||||
|
*/
|
||||||
|
credentialSubmission?: {
|
||||||
|
inoNumberSubmitted: boolean;
|
||||||
|
specialties: string[];
|
||||||
|
issuingAuthority: string | null;
|
||||||
|
issuedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */
|
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */
|
||||||
|
|||||||
@@ -860,3 +860,39 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
|||||||
- **Proposed shape:** n/a — filed to put the need on record, not to propose an endpoint shape yet.
|
- **Proposed shape:** n/a — filed to put the need on record, not to propose an endpoint shape yet.
|
||||||
- **Status:** deferred, non-blocking — build nothing for it this phase; the 15s poll remains the only
|
- **Status:** deferred, non-blocking — build nothing for it this phase; the 15s poll remains the only
|
||||||
freshness mechanism.
|
freshness mechanism.
|
||||||
|
|
||||||
|
## REQ-055 — `submittedAt` on the nurse-facing `VerificationStatusDto` — filed by ui-phase-8 — 2026-07-19
|
||||||
|
- **Need:** Add `submittedAt` (UTC ISO, nullable) to the nurse-facing `VerificationStatusDto`
|
||||||
|
(`GET nurse_verification/status`-equivalent) — the timestamp the nurse first left `not_started`.
|
||||||
|
- **Why:** The B6 unified-journey rebuild (phase §3.2) shows a Shamsi submitted timestamp above the
|
||||||
|
"what happens next" timeline. The data already exists — the **admin** queue DTO
|
||||||
|
(`AdminVerificationQueueItem.submittedAt`) serves it today — but the nurse's own status read doesn't.
|
||||||
|
The client's `VerificationStatus` type now carries `submittedAt` as an optional, mock-tolerant field
|
||||||
|
(`services/verification/types.ts`); the mock stamps it when the checklist is first seeded
|
||||||
|
(`services/verification/apis/mockApi.ts`); the real `verificationClientApi` leaves it `undefined` and
|
||||||
|
B6 simply omits the timestamp line rather than fake one.
|
||||||
|
- **Proposed shape:** `VerificationStatusDto { …, submittedAt: string | null }` (same semantics as the
|
||||||
|
admin queue's field — set once, on first leaving `not_started`, never updated after).
|
||||||
|
- **Status:** open — mock-only until served; the real path omits the B6 timestamp line.
|
||||||
|
|
||||||
|
## REQ-056 — Nurse-facing read-back of submitted credential details — filed by ui-phase-8 — 2026-07-19
|
||||||
|
- **Need:** A nurse-facing read of the structured B5 fields already accepted by REQ-011's
|
||||||
|
`submit_credential_details` write: whether an INO number is on file (boolean — **never** the number
|
||||||
|
itself, which stays encrypted server-side and is never re-served by design), `specialties: string[]`,
|
||||||
|
and the optional registry fields (`issuingAuthority`, `issuedAt`, `expiresAt`). Either add these to
|
||||||
|
`VerificationStatusDto` directly or a sibling nurse-facing read.
|
||||||
|
- **Why:** B5's rebuild (phase §3.4) fixes the "returning nurse sees blank fields and a dead disabled
|
||||||
|
submit button" defect by hydrating the form from the server — but there is **no nurse-facing read** of
|
||||||
|
what was actually submitted (REQ-011 delivered only the write; the admin `decide` endpoint is the only
|
||||||
|
place these fields are currently readable, and only post-decision). The client's `VerificationStatus`
|
||||||
|
type now carries an optional, mock-tolerant `credentialSubmission` field
|
||||||
|
(`{ inoNumberSubmitted: boolean, specialties: string[], issuingAuthority: string | null, issuedAt:
|
||||||
|
string | null, expiresAt: string | null }`); the mock persists what `submitCredentialDetails` receives
|
||||||
|
and serves it back; the real path leaves it `undefined` and B5 falls back to its pre-phase-8 blank-form
|
||||||
|
behavior (never a lie about what's on file, just less helpful).
|
||||||
|
- **Proposed shape:** `VerificationStatusDto { …, credentialSubmission?: { inoNumberSubmitted: boolean,
|
||||||
|
specialties: string[], issuingAuthority: string | null, issuedAt: string | null, expiresAt: string |
|
||||||
|
null } | null }`. Verify against Swagger first — file only if it truly doesn't exist (per the phase
|
||||||
|
doc's instruction); at authoring time no such read was found in the b6 contract or swagger snapshot.
|
||||||
|
- **Status:** open — mock-only until served; the real path degrades to the pre-phase-8 blank-form
|
||||||
|
behavior (documented, not a regression — just not yet as helpful as the mock demonstrates).
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
|||||||
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
|
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false` — `patientsClientApi` is already wired to the b3 `patients/*` routes | 🟢 (real, refinement-phase-4) |
|
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false` — `patientsClientApi` is already wired to the b3 `patients/*` routes | 🟢 (real, refinement-phase-4) |
|
||||||
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false` — `profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟢 (real, refinement-phase-4) |
|
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack. **ui-phase-8:** `setAcceptingBookings(accepting)` now mirrors the flip in-memory (`isAcceptingBookings`, never touching `isVerified`) — this is the **real** endpoint (`POST nurse_profiles/set_accepting_bookings`, previously unwired), not a mock-only gap; the mock exists only so `USE_PROFILES_MOCK=true` local dev still demos the go-live toggle | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` + `nurse_profiles/set_accepting_bookings` are all live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false` — `profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006; `setAcceptingBookings` is fully wired already) | 🟢 (real, refinement-phase-4) |
|
||||||
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false` — `nurseBankClientApi` is wired | 🟢 (real, refinement-phase-4) |
|
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false` — `nurseBankClientApi` is wired | 🟢 (real, refinement-phase-4) |
|
||||||
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp`→`{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
|
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp`→`{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
|
||||||
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false` — `geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟢 (real, refinement-phase-4) |
|
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false` — `geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||||
@@ -118,7 +118,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
|||||||
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false` — `serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
|
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false` — `serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||||
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 |
|
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 |
|
||||||
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 1–5, `sortOrder` 0–4). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false` — `catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
|
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 1–5, `sortOrder` 0–4). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false` — `catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||||
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false` — `verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
|
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true. **ui-phase-8:** `VerificationStatus` gained two mock-tolerant fields the nurse-facing wire doesn't serve yet — `submittedAt` (stamped once when `start()` first seeds the steps, REQ-055) and `credentialSubmission` (stamped by `submitCredentialDetails` — `inoNumberSubmitted` boolean + specialties/registry fields, **never** the raw INO number, REQ-056) — so B6's timestamp and B5's hydrate-on-return both demo pre-REQ | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false` — `verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveats:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet — the document uploads it accompanies are contract-backed); `submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) are both undefined until served, degrading gracefully (B6 omits the timestamp line; B5 falls back to blank fields). No hook/component change | 🟡 |
|
||||||
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine** — `checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false` — `bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) |
|
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine** — `checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false` — `bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) |
|
||||||
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
|
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
|
||||||
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداختها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
|
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداختها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# UI Phase 8 — Nurse Business & Verification — Report (2026-07-19)
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
**Real go-live switch + one activation checklist** (`src/components/ActivationChecklist/`, new shared
|
||||||
|
component + `useActivationChecklist` hook):
|
||||||
|
- The profiles seam gained `setAcceptingBookings` (types + `clientApi` + `mockApi` + a new
|
||||||
|
`useSetAcceptingBookings` hook) — wiring the real, previously-unwired
|
||||||
|
`POST nurse_profiles/set_accepting_bookings` endpoint the audit found. Invalidates the nurse-profile
|
||||||
|
query on success; the mock mirrors the flip (never touching `isVerified`).
|
||||||
|
- `ActivationChecklist` folds five already-cached queries (verification, nurse profile, `useMyVariants`,
|
||||||
|
`useServiceAreas`, `useNurseBankAccounts`) into rows with **two-tier honesty**: identity/profile
|
||||||
|
complete/≥1 active service/≥1 coverage area drive **search visibility** (`is_searchable`); the verified
|
||||||
|
bank row is labelled separately as **"برای دریافت درآمد"** and never gates search. Collapses to a
|
||||||
|
compact «فعال در جستجو» confirmation once every row passes *and* the nurse is actively accepting
|
||||||
|
bookings. Mounted on `/nurse/services` (above the offerings list, inside `MyServicesList`) and in
|
||||||
|
`DashboardActivationSlot` (replacing ui-phase-7's single-row placeholder) — one shared component.
|
||||||
|
- `PublishGate` rewritten from scratch: consumes the same `useActivationChecklist` state (no duplicated
|
||||||
|
condition logic) and renders one of three real states — unmet conditions → guidance naming exactly
|
||||||
|
what's missing (`publish_unmet_intro`); met but paused → «شروع پذیرش رزرو» calling the real mutation;
|
||||||
|
live → the on state + «توقف موقت پذیرش». Success/error toasts fire only after the mutation resolves —
|
||||||
|
the old `enqueueSnackbar('published')` no-op is gone (grep confirms no reachable `publish_done`/
|
||||||
|
`publish_cta` string remains).
|
||||||
|
|
||||||
|
**Unified vertical verification journey** (`nurse/verification/`):
|
||||||
|
- `verificationSteps.ts` gained the presentation-grouping layer (`StepGroupKey`, `GROUP_ORDER`,
|
||||||
|
`stepGroup`/`groupLabelKey`/`groupRoute`/`groupedDisplaySteps`/`groupStatus`) — folds the existing
|
||||||
|
data-driven step catalog into three journey groups (هویت / مدارک حرفهای / بانک) without touching the
|
||||||
|
catalog itself.
|
||||||
|
- `VerificationChecklist.tsx` rebuilt as ONE vertical spine of grouped step cards (replacing the flat
|
||||||
|
"X از Y" meter + 7-row list) — each card shows its steps, per-step reason/auto-note, and a single
|
||||||
|
"برو"/"رفع مشکل" CTA to the screen that owns that group.
|
||||||
|
- `TrustBadgePreviewPanel.tsx` (new) — the hub's payoff: a live `TrustBadge` (`ownBadgeState`, never
|
||||||
|
invents a state) + a per-group fill indicator, framed as «این نشان را خانوادهها میبینند».
|
||||||
|
- `VerificationJourneyHeader.tsx` (new, shared across B4/B5/B6) — replaces the competing bare 3-step
|
||||||
|
`StepperHeader` those three screens rendered alongside the hub's own meter. One progress answer now:
|
||||||
|
the hub's grouped spine is the only place progress is shown; B4/B5/B6 get a group name + «بازگشت به
|
||||||
|
مسیر تأیید» back link.
|
||||||
|
- B4 (`identity/page.tsx`): added `CaptureGuideFrame` — a cheap, dependency-free CSS viewfinder
|
||||||
|
(corner brackets for the ID card, an oval for the selfie) + a static hint line above each
|
||||||
|
`DocumentUpload`, per §3.3's capture-guidance ask. No client-side blur/darkness heuristic — judged not
|
||||||
|
"trivially cheap" without a canvas-pixel pass, so left out (documented, not silently skipped).
|
||||||
|
- B5 (`credentials/page.tsx`): hydrates INO/specialties/registry fields from a new mock-tolerant
|
||||||
|
`status.credentialSubmission` field (REQ-056, filed). Once the INO number is on file it locks into a
|
||||||
|
"شمارهٔ نظام ثبت شد" confirmation row (a «تغییر» link re-opens it) — never re-prompted as if lost, never
|
||||||
|
silently re-sent blank (the raw number is never read back by design, and the server's
|
||||||
|
`CredentialDetailsInput.inoNumber` is required, so re-submitting without re-entering it isn't possible).
|
||||||
|
The two native `type="date"` fields are now `JalaliDateField`s. The submit gate now considers
|
||||||
|
server-side document state (`in_review`/`passed`), not just this session's uploads, so a returning
|
||||||
|
nurse is never dead-ended on a disabled button with no explanation; when genuinely nothing is on file
|
||||||
|
yet, an explanatory caption replaces the silent disable.
|
||||||
|
- B6 (`review/page.tsx`): a Shamsi submitted timestamp from a new mock-tolerant `status.submittedAt`
|
||||||
|
field (REQ-055, filed) + a `StatusTimeline` what-happens-next (مدارک ثبت شد → بررسی توسط کارشناس →
|
||||||
|
فعالسازی نشان) + the same `VerificationJourneyHeader` as its siblings.
|
||||||
|
- Verification + B4/B5/B6 pages adopted the ui-phase-7 `CONTENT_MAX_WIDTH` + `mx:'auto'` + `PageHeader`
|
||||||
|
convention (closing the trust-ops audit's "verification (620), no `mx:auto`" width-chaos finding that
|
||||||
|
ui-phase-7 didn't reach).
|
||||||
|
|
||||||
|
**`DocumentUpload` precedence fix + capture guidance** (`src/components/DocumentUpload/`):
|
||||||
|
- Fixed the render-branch ordering: `state === 'uploading'` **and** the success flash now win over the
|
||||||
|
still-true `rejected` prop, so a re-upload shows live progress (with the original rejection reason kept
|
||||||
|
visible above the bar) instead of staying frozen on the red rejected card; the re-upload button is
|
||||||
|
never rendered mid-flight (no double-submit). New final order: uploading → success → rejected (resting)
|
||||||
|
→ local error → idle.
|
||||||
|
- Test suite gained a scripted rejected→re-upload→progress→success case asserting the reason stays
|
||||||
|
visible and the re-upload button disappears during the upload.
|
||||||
|
|
||||||
|
**Services & variant builder** (`nurse/services/`):
|
||||||
|
- `VariantCard` gained an `interactive` prop (default `true`; `false` drops the Edit/Deactivate row) —
|
||||||
|
used for read-only preview contexts without forking the component.
|
||||||
|
- `VariantBuilder` step 3 now renders the real `VariantCard` (`interactive={false}`) as «اینگونه در
|
||||||
|
جستجو دیده میشوید», composing the entered name/category/price, replacing the old price-only estimate
|
||||||
|
panel (`PriceDisplay`'s estimate line is still shown — it's inside `VariantCard` — nothing was lost).
|
||||||
|
- Step 2's wrapped `ToggleButtonGroup` (broken borders on wrap) replaced with a chip group, matching the
|
||||||
|
house pattern used elsewhere (B5 specialties, the profile's specializations).
|
||||||
|
- The duplicate-listing warning restyled as an `AccentCard` (`tone="warning"`) with `text.primary` body
|
||||||
|
and warning reserved for the edge/icon (was amber-on-paper, a light-mode contrast fail) — plus a new
|
||||||
|
"ویرایش خدمت موجود" affordance that resolves the colliding existing variant via `optionSetSignature`
|
||||||
|
against the already-cached `useMyVariants()` list and jumps straight into editing it
|
||||||
|
(`onEditExisting`, wired through `NurseServicesPage`).
|
||||||
|
|
||||||
|
**Coverage — one control owns whole-city** (`nurse/coverage/page.tsx`):
|
||||||
|
- Dropped the separate whole-city/districts scope `ToggleButtonGroup` entirely. `CascadingRegionSelect`'s
|
||||||
|
own district level (its «کل شهر» empty option) is now the **only** control — `districtId = null` is a
|
||||||
|
complete, valid whole-city submission, never an error state. The "district required" error class the
|
||||||
|
audit flagged can no longer be triggered by picking a UI-offered option, because there is no longer a
|
||||||
|
second control that could disagree with the select. `CascadingRegionSelect` itself is untouched — no
|
||||||
|
prop-gating was needed, so addresses/search are provably unaffected.
|
||||||
|
- `removeArea.mutate` gained an `onError` toast (was silent).
|
||||||
|
- The optional map visualization was **not built** — deliberately deferred; the phase doc marks it
|
||||||
|
optional ("(if built)" in the testing script) and real tile rendering is explicitly deferred to Phase 9.
|
||||||
|
|
||||||
|
**Bank — an accounts section** (`nurse/bank/page.tsx`):
|
||||||
|
- Restructured around a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (replacing the
|
||||||
|
form-only-when-`accounts.length===0` gate) with a cancel affordance on the now-optional form. A failed
|
||||||
|
`useNurseBankAccounts` query now renders `ErrorState` + retry — never the empty-state form (which
|
||||||
|
invited a duplicate-IBAN submission blind). The pending-inquiry copy is now explicit about the wait
|
||||||
|
(«در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول میکشد…»). `setPrimary` gained an `onError` toast.
|
||||||
|
`BankStatusPanel`'s three-state design is untouched.
|
||||||
|
|
||||||
|
**Profile — qualifications editable + public preview** (`nurse/profile/`):
|
||||||
|
- Education level/field are now real `select` fields (a curated preset list + a «سایر» free-text
|
||||||
|
fallback — there is no server-side enum, so the client stores stable internal codes, never a
|
||||||
|
locale-baked label, matching how every other coded field in this app works) submitted through the
|
||||||
|
existing upsert (no REQ, no server change — confirmed the server already accepts+persists them).
|
||||||
|
Specializations are chips off the shared `SPECIALTY_PRESETS` vocabulary (same codes B5 uses).
|
||||||
|
- Avatar upload + profile save already had `onError` toasts (the audit's citation was stale — confirmed
|
||||||
|
by reading the current code before touching it); added a `beforeunload` guard so a staged-but-unsaved
|
||||||
|
avatar is never silently discarded on a reload/tab-close. (In-app route-away interception was judged
|
||||||
|
out of scope for a page-local fix — no cross-app unsaved-changes framework exists yet; noted below.)
|
||||||
|
- New route `/nurse/profile/preview` («نمایهٔ عمومی من») — composes the C3 trust-dossier pieces
|
||||||
|
(`TrustBadge`, `VerificationPanel`, `ServicePriceRow`) **entirely from the nurse's own cached data**
|
||||||
|
(own profile + `useMyVariants` + `useServiceAreas` + own badge via `useNurseTrustBadge(me.id)`) — no
|
||||||
|
dependency on the search index, so it renders truthfully pre-publish. Linked from both the profile page
|
||||||
|
and the services list (`MyServicesList`).
|
||||||
|
|
||||||
|
## What is now testable (and exactly how)
|
||||||
|
|
||||||
|
1. As the seeded **unverified** nurse → `/nurse/services`: `ActivationChecklist` shows the unmet rows
|
||||||
|
(identity/profile/services/coverage) each with a «تکمیل» deep link, plus the separately-labelled bank
|
||||||
|
row; `PublishGate` below it shows the blocked guidance naming the same unmet items — no button that
|
||||||
|
fakes success.
|
||||||
|
2. Complete verification via the dev admin sim (`__mockApproveAll`, B3's mock controls) → the identity
|
||||||
|
row flips; once profile/services/coverage are also done, `PublishGate` shows «شروع پذیرش رزرو» → click
|
||||||
|
→ Network tab shows `POST nurse_profiles/set_accepting_bookings` → the panel flips to the live state +
|
||||||
|
«توقف موقت پذیرش»; add a verified bank account too → `ActivationChecklist` collapses to «فعال در
|
||||||
|
جستجو».
|
||||||
|
3. `/nurse/verification`: one vertical spine (هویت / مدارک حرفهای / بانک cards) + the `TrustBadgePreviewPanel`
|
||||||
|
above it. Open B4/B5 → no 3-step `StepperHeader` anywhere, just the group header + back link. B4 shows
|
||||||
|
a dashed viewfinder + hint above each capture. B6 shows the Shamsi submitted date + the 3-node
|
||||||
|
what-happens-next timeline.
|
||||||
|
4. In B5, upload a manual-step doc, reject it via the B3 mock admin control
|
||||||
|
(`__mockRejectStep('moh_competency_license', 'blurry_scan')`), then re-upload from B5 → the progress
|
||||||
|
bar animates while the rejection reason stays visible above it; the re-upload button is not rendered
|
||||||
|
mid-flight. Leave (`/nurse/verification`) and return to B5 → the INO number shows the "on file"
|
||||||
|
confirmation row (never blank), specialties/dates are pre-filled, dates open the Jalali picker.
|
||||||
|
5. `/nurse/services` → add a variant: step 2 renders chip groups (no broken borders on wrap); step 3
|
||||||
|
shows the live `VariantCard` preview; submitting the exact same category+options twice shows the
|
||||||
|
restyled (readable, non-amber-body) duplicate warning with a "ویرایش خدمت موجود" link that opens the
|
||||||
|
builder already editing the colliding listing.
|
||||||
|
6. `/nurse/coverage`: only one control (the district select) offers whole-city; picking «کل شهر» there
|
||||||
|
and submitting never errors. Kill the `removeArea` mutation (e.g. force a network error) → a toast
|
||||||
|
appears instead of silent failure.
|
||||||
|
7. `/nurse/bank` with a verified account → «افزودن حساب دیگر» opens the form (with a cancel action);
|
||||||
|
submitting shows the explicit multi-minute inquiry copy while pending; kill the API and reload →
|
||||||
|
`ErrorState` + retry, never the empty-state form.
|
||||||
|
8. `/nurse/profile`: pick an education level/field (or «سایر» + free text), toggle specialization chips,
|
||||||
|
save, reload → values persist. Stage an avatar (don't save), then try to reload the tab → the browser's
|
||||||
|
native "leave site?" prompt appears. Open «پیشنمایش نمایهٔ عمومی من» (from both `/nurse/profile` and
|
||||||
|
`/nurse/services`) → the own-data listing renders with TrustBadge + `VerificationPanel` + priced
|
||||||
|
services + coverage chips, reachable even before the nurse is search-visible.
|
||||||
|
9. Repeat the key screens on `/en`, dark mode, and a ~390px viewport.
|
||||||
|
|
||||||
|
**Verification performed:** `npm run check` (tsc + eslint) is green. `npm run test:ci` is green — 107
|
||||||
|
suites / 462 tests, including the new `ActivationChecklist` suite, the extended `DocumentUpload` and
|
||||||
|
`VariantCard` suites, and every other pre-existing suite (no regressions). i18n key parity between
|
||||||
|
`en.json`/`fa.json` was verified programmatically (0 keys only-in-one-file). **Not performed:** a live
|
||||||
|
authenticated click-through in a browser (no browser-automation tool available in this session, same
|
||||||
|
constraint ui-phase-7 noted) — the "How to test" steps above are written for a human to run that pass
|
||||||
|
manually.
|
||||||
|
|
||||||
|
## What is mocked / waiting on a real service
|
||||||
|
|
||||||
|
- `services/profiles` — **real** by default (`USE_PROFILES_MOCK=false`); `setAcceptingBookings` targets
|
||||||
|
the real, already-live `POST nurse_profiles/set_accepting_bookings` route — this is not a new mock, it
|
||||||
|
is finishing the wiring of an endpoint that already existed server-side. The mock implementation was
|
||||||
|
still added (mirrors the flip) so local dev with `USE_PROFILES_MOCK=true` keeps working. See the
|
||||||
|
updated `mocks-registry.md` row.
|
||||||
|
- `services/verification` — **mock-primary** (`USE_VERIFICATION_MOCK=true`, unchanged, per the phase
|
||||||
|
doc's explicit instruction not to flip it). Two new mock-tolerant fields added to `VerificationStatus`:
|
||||||
|
`submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) — both `undefined` on the real path,
|
||||||
|
degrading gracefully (B6 omits the timestamp; B5 falls back to blank fields, same as before this
|
||||||
|
phase). See the updated `mocks-registry.md` row.
|
||||||
|
- No other domain's mock flag changed. No new seams introduced.
|
||||||
|
|
||||||
|
## Contracts
|
||||||
|
|
||||||
|
- Consumed: `dev/contracts/domains/{identity-profiles,verification}.md` (unchanged this phase — no new
|
||||||
|
contract landed to consume; `set_accepting_bookings` was already documented server-side, just unwired
|
||||||
|
client-side).
|
||||||
|
- Filed to `for-backend.md`: **REQ-055** (`submittedAt` on the nurse-facing `VerificationStatusDto`),
|
||||||
|
**REQ-056** (nurse-facing read-back of submitted credential details — INO-on-file boolean +
|
||||||
|
specialties/registry fields, never the raw number).
|
||||||
|
|
||||||
|
## Docs updated
|
||||||
|
|
||||||
|
- `client/CLAUDE.md` "Project Structure": the nurse route tree (`profile/` → `preview/page.tsx`,
|
||||||
|
`services/` PublishGate/VariantBuilder changes, `coverage/page.tsx`, `bank/page.tsx`, the `verification/`
|
||||||
|
subtree's new `TrustBadgePreviewPanel.tsx`/`VerificationJourneyHeader.tsx` + regrouped
|
||||||
|
`VerificationChecklist.tsx`/`verificationSteps.ts`), the shared component tree (`ActivationChecklist/`,
|
||||||
|
`VariantCard`'s `interactive` prop), the `services/profiles` entry (`setAcceptingBookings`), and the
|
||||||
|
i18n namespace list (`nurseProfile`/`bank`/`coverage`/`services`/`verification` deltas + the new
|
||||||
|
`activation` namespace).
|
||||||
|
- `dev/shared-working-context/reports/mocks-registry.md`: `ProfilesApi` and `VerificationApi` rows
|
||||||
|
updated in place with this phase's additions.
|
||||||
|
- `dev/constants/routes.ts` (client): added `ROUTES.NURSE_PROFILE_PREVIEW`.
|
||||||
|
|
||||||
|
## Follow-ups for later phases
|
||||||
|
|
||||||
|
- **REQ-055/REQ-056** are genuinely useful, low-effort backend additions (both are pure reads over data
|
||||||
|
the server likely already has, or can trivially derive) — good candidates for an early slice of a
|
||||||
|
future backend refinement pass.
|
||||||
|
- The optional coverage map visualization (§3.6) was not built — deferred to Phase 9, which owns the real
|
||||||
|
map-picker work this would reuse.
|
||||||
|
- In-app (client-side-routed) unsaved-avatar interception was scoped down to the `beforeunload` guard
|
||||||
|
(covers reload/tab-close, the highest-value real risk). Intercepting an in-app `Link` navigation away
|
||||||
|
from the profile page would need a small cross-app "confirm navigation" primitive that doesn't exist
|
||||||
|
yet — worth building once a second page needs the same guard, not invented single-purpose here.
|
||||||
|
- The education-level/field select-with-"سایر"-fallback pattern stores a stable internal code (not a
|
||||||
|
wire enum — `NurseProfileDto.educationLevel`/`educationField` are plain strings with no server-side
|
||||||
|
vocabulary). If a future phase wants these queryable/filterable server-side, that would need a real
|
||||||
|
enum contract — flagged here as a design note, not filed as a REQ (no current feature needs it).
|
||||||
|
- `VariantCard`'s `visibilityon`/`visibilityoff` deactivate/reactivate icons are not registered in
|
||||||
|
`AppIcon/config.ts` (pre-existing gap predating this phase, confirmed via `git blame`-equivalent code
|
||||||
|
read — not introduced here; surfaces as a harmless dev-only console warning in tests). Out of this
|
||||||
|
phase's scope; a one-line `AppIcon/config.ts` registration whenever someone's next in that file.
|
||||||
Reference in New Issue
Block a user