From 1ef4feb91121403f995d8d47a6cbd507fe4545d9 Mon Sep 17 00:00:00 2001 From: hamid Date: Sun, 19 Jul 2026 13:57:11 +0330 Subject: [PATCH] ui phase 8 --- client/CLAUDE.md | 42 +-- client/messages/en.json | 100 ++++-- client/messages/fa.json | 100 ++++-- .../nurse/DashboardActivationSlot.tsx | 57 +--- .../nurse/NurseDashboardScreen.tsx | 2 +- .../(private-routes)/nurse/bank/page.tsx | 141 +++++--- .../(private-routes)/nurse/coverage/page.tsx | 81 +---- .../(private-routes)/nurse/profile/page.tsx | 135 +++++++- .../nurse/profile/preview/page.tsx | 182 ++++++++++ .../nurse/services/MyServicesList.tsx | 18 +- .../nurse/services/PublishGate.tsx | 146 ++++---- .../nurse/services/VariantBuilder.tsx | 132 +++++--- .../(private-routes)/nurse/services/page.tsx | 1 + .../verification/TrustBadgePreviewPanel.tsx | 60 ++++ .../verification/VerificationChecklist.tsx | 159 +++++---- .../VerificationJourneyHeader.tsx | 33 ++ .../nurse/verification/credentials/page.tsx | 317 +++++++++++------- .../nurse/verification/identity/page.tsx | 100 ++++-- .../nurse/verification/page.tsx | 16 +- .../nurse/verification/review/page.tsx | 85 +++-- .../nurse/verification/verificationSteps.ts | 59 ++++ .../ActivationChecklist.test.tsx | 115 +++++++ .../ActivationChecklist.tsx | 104 ++++++ .../components/ActivationChecklist/index.tsx | 5 + .../useActivationChecklist.ts | 101 ++++++ .../DocumentUpload/DocumentUpload.test.tsx | 24 ++ .../DocumentUpload/DocumentUpload.tsx | 53 +-- .../VariantCard/VariantCard.test.tsx | 11 + .../components/VariantCard/VariantCard.tsx | 53 +-- client/src/components/index.tsx | 2 + client/src/constants/routes.ts | 3 + .../src/services/profiles/apis/clientApi.ts | 9 + client/src/services/profiles/apis/mockApi.ts | 7 + .../profiles/hooks/useSetAcceptingBookings.ts | 17 + client/src/services/profiles/index.ts | 1 + client/src/services/profiles/types.ts | 6 + .../src/services/verification/apis/mockApi.ts | 26 +- client/src/services/verification/types.ts | 21 ++ .../frontend/requests/for-backend.md | 36 ++ .../reports/mocks-registry.md | 4 +- .../reports/ui-phase-8-report.md | 216 ++++++++++++ 41 files changed, 2113 insertions(+), 667 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/nurse/profile/preview/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/verification/TrustBadgePreviewPanel.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/verification/VerificationJourneyHeader.tsx create mode 100644 client/src/components/ActivationChecklist/ActivationChecklist.test.tsx create mode 100644 client/src/components/ActivationChecklist/ActivationChecklist.tsx create mode 100644 client/src/components/ActivationChecklist/index.tsx create mode 100644 client/src/components/ActivationChecklist/useActivationChecklist.ts create mode 100644 client/src/services/profiles/hooks/useSetAcceptingBookings.ts create mode 100644 dev/shared-working-context/reports/ui-phase-8-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 4a69f3c..c042207 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -166,19 +166,21 @@ client/ │ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton │ │ │ ├── page.tsx # /nurse (dashboard) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx │ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout - │ │ │ ├── DashboardActivationSlot.tsx # ui-phase-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) - │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) - │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live) - │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked) - │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch); the f5 bank_account_verification step deep-links here - │ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views - │ │ │ │ ├── page.tsx # B3 hub — "X از Y" meter + data-driven checklist (StatusChip rows) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim - │ │ │ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar - │ │ │ │ ├── credentials/page.tsx # B5 — INO number + specialty chips + a DocumentUpload per manual step (data-driven) → in_review - │ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist) - │ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only) - │ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven) + │ │ │ ├── profile/ # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish + │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list) + │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor. ui-phase-8: the separate whole-city/districts scope toggle is gone — `CascadingRegionSelect`'s own district level (its «کل شهر» empty option) is the ONLY control for the whole-city choice; `removeArea` now has an onError toast + │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch; `BankStatusPanel` unchanged). ui-phase-8: restructured as an accounts section — a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (never dead-ends a nurse switching banks), an explicit pending-inquiry copy, and a real error state (never the empty-state form) on a failed query + │ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone) + │ │ │ │ ├── page.tsx # B3 hub — grouped step cards (هویت/مدارک حرفه‌ای/بانک, `VerificationChecklist`) + `TrustBadgePreviewPanel` («این نشان را خانواده‌ها می‌بینند», fills per group) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim + │ │ │ │ ├── identity/page.tsx # 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 — hydrates INO/specialties/registry fields from `status.credentialSubmission` (REQ-056, mock-tolerant) so a returning nurse sees a submitted summary, never blank fields; the INO number locks into a "شمارهٔ نظام ثبت شد" row (never re-prompted, never re-sent blank — the raw value is never read back by design); Jalali `JalaliDateField`s replace the native `type="date"` issue/expiry inputs; the submit gate considers server-side document state too, so a returning nurse is never dead-ended on a disabled button with no explanation + │ │ │ │ ├── review/page.tsx # B6 — under-review; a Shamsi submitted timestamp (REQ-055, mock-tolerant) + a `StatusTimeline` what-happens-next (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعال‌سازی نشان) + `VerificationJourneyHeader` + │ │ │ │ ├── VerificationChecklist.tsx # B3 body: grouped step cards over `groupedDisplaySteps` (co-located, page-only) + │ │ │ │ ├── TrustBadgePreviewPanel.tsx # B3's payoff — live TrustBadge + a per-group fill indicator, never fakes a state beyond `ownBadgeState` + │ │ │ │ ├── VerificationJourneyHeader.tsx # the ONE progress header B4/B5/B6 share — group name + «بازگشت به مسیر تأیید» (reused across 3 sibling pages) + │ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step + ui-phase-8's group→steps folding (`groupedDisplaySteps`/`groupStatus`/`GROUP_ORDER`) — keeps rendering data-driven │ │ │ ├── visits/ # /nurse/visits — f8 EVV, ui-phase-7 day-surface + detail pass: page.tsx = Shamsi «امروز، …» date anchor + ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner; 60s refetchInterval; SessionCard's EVV CTA is now the full-width hero action + a confirm step on check-out) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": address card + geo: map link, an in-visit «در حال ویزیت» banner promoting check-out, EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords) │ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption) │ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx @@ -254,7 +256,8 @@ client/ │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested) │ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested) - │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested) + │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested) + │ ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (tested) │ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile; ui-phase-4 added an opt-in `nurseId` prop that makes the badge tappable, opening a bottom-sheet/dialog explainer (the shared VerificationPanel, fed by a LAZY useNurseTrustBadge(nurseId) fetch) — split into an inner `InteractiveTrustBadge` so the default (no `nurseId`) mode calls no query hook at all and needs no QueryClientProvider in its callers' tests (tested) │ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested) │ ├── 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 │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard) │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper - │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) + │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam). ui-phase-8 added `setAcceptingBookings` (`useSetAcceptingBookings`) — the real, previously-unwired `POST nurse_profiles/set_accepting_bookings` go-live switch (mock mirrors the flip; real+mock both invalidate `profileKeys.nurse()`) │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states │ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search) │ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) @@ -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) - `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges) - `'profile'` — the customer profile + emergency contact -- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder) -- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states) +- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder); ui-phase-8 added editable education level/field + specialties (`education_*`, `specializations_label`), the public-profile preview (`preview_*`), and the avatar/save error copy +- `'activation'` — ui-phase-8 — the shared `ActivationChecklist` row labels (`row_identity`/`row_profile`/`row_services`/`row_coverage`/`row_bank` + the "getting paid, not search-visibility" hint) and the collapsed «فعال در جستجو» state; consumed by `ActivationChecklist` wherever it's mounted (services page, dashboard slot) +- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states); ui-phase-8 added the accounts-section copy (`add_another`, `load_error`) and an explicit pending-inquiry duration hint - `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints) - `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link -- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings) +- `'coverage'` — the nurse coverage-area editor (chips, duplicate + "won't appear in search" warnings); ui-phase-8 dropped the now-unused `scope_*`/`district_required` keys once the separate scope toggle was removed, added `remove_error` - `'catalog'` — **shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side) -- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm) +- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm); ui-phase-8 added the live-preview (`preview_heading`/`preview_untitled`) and 409-recovery (`duplicate_edit_existing`) copy, replaced `publish_*` with the state-driven go-live copy (`publish_start_accepting`/`publish_pause_accepting`/`publish_live_*`/`publish_unmet_intro` — the old no-op `publish_done`/`publish_cta` are gone) - `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA - `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites -- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages +- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, shared-SIM/mismatch messages; ui-phase-8 replaced `journey_identity`/`journey_credentials`/`journey_review` with the journey-group labels (`group_identity`/`group_credentials`/`group_bank`/`group_review`, shared by `VerificationJourneyHeader`), added the payoff panel copy (`payoff_*`), the B4 capture hints (`capture_hint_card`/`capture_hint_selfie`), the B5 hydration copy (`ino_number_submitted`/`ino_number_change`/`credentials_needs_document`), and the B6 timeline labels (`review_timeline_*`) - `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge` - `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*` — `bnpl_revert` 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` diff --git a/client/messages/en.json b/client/messages/en.json index 255ba11..53d3e8a 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -190,7 +190,35 @@ "unverified_cta": "Complete verification", "deferred_services": "Manage your services & prices in the Services section; available days come in a later step.", "save_error": "Couldn't save your profile. Please try again.", - "avatar_upload_error": "Couldn't upload your photo. Please try again." + "avatar_upload_error": "Couldn't upload your photo. Please try again.", + "education_level_label": "Education level", + "education_level_diploma": "Diploma", + "education_level_associate": "Associate degree", + "education_level_bachelor": "Bachelor's degree", + "education_level_master": "Master's degree", + "education_level_doctorate": "Doctorate", + "education_field_label": "Field of study", + "education_field_nursing": "Nursing", + "education_field_midwifery": "Midwifery", + "education_field_anesthesia": "Anesthesia", + "education_field_operating_room": "Operating room technology", + "education_field_public_health": "Public health", + "education_other": "Other", + "education_level_other_label": "Enter your education level", + "education_field_other_label": "Enter your field of study", + "specializations_label": "Specialties", + "preview_cta": "Preview my public profile", + "preview_title": "My public profile", + "preview_subtitle": "This is what families see when they find you.", + "back_to_profile": "Back to profile", + "preview_reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})", + "preview_completed_visits": "{count, number} successful visits", + "preview_years_experience": "{years} years' experience", + "preview_verified_title": "What Balinyaar verified", + "preview_services_title": "Services", + "preview_services_empty": "No active services yet — add one in the Services section.", + "preview_coverage_title": "Coverage areas", + "preview_coverage_empty": "No coverage areas yet." }, "bank": { "title": "Bank account", @@ -205,9 +233,11 @@ "submitting": "Submitting…", "added": "Account submitted — ownership inquiry started", "add_error": "This account couldn't be added. Check the IBAN and try again.", + "add_another": "Add another account", + "load_error": "Couldn't load your bank accounts.", "status_pending_chip": "Checking", "status_pending_title": "Verifying account ownership", - "status_pending_body": "The Sheba ownership inquiry is running; this can take a moment.", + "status_pending_body": "Checking the IBAN's ownership — this usually takes a few minutes.", "status_verified_chip": "Verified", "status_verified_title": "Account verified", "status_verified_body": "This account is ready to receive your payouts.", @@ -217,6 +247,7 @@ "reenter": "Enter another account", "make_primary": "Make primary", "primary_set": "Primary account updated", + "primary_set_error": "Couldn't update the primary account. Please try again.", "iban_masked_label": "IBAN", "primary": "Primary", "empty_title": "No bank account yet", @@ -273,9 +304,6 @@ "subtitle": "The cities and districts you'll travel to.", "areas_heading": "Your areas", "add_title": "Add a coverage area", - "scope_label": "Coverage", - "scope_whole_city": "Whole city", - "scope_districts": "Specific districts", "add": "Add area", "adding": "Adding…", "whole_city_chip": "Whole city", @@ -283,13 +311,13 @@ "empty_warning": "You won't appear in search until you add at least one coverage area.", "duplicate": "You already cover this area.", "city_required": "Select a city", - "district_required": "Select a district, or switch to whole city.", "added": "Coverage area added", "add_error": "This area couldn't be added. Please try again.", "remove_title": "Remove coverage area?", "remove_body": "You'll no longer be matched for visits in this area.", "remove_confirm": "Remove", - "removed": "Coverage area removed" + "removed": "Coverage area removed", + "remove_error": "This area couldn't be removed. Please try again." }, "catalog": { "unit_per_hour": "hourly", @@ -349,6 +377,9 @@ "display_name_label": "Display name", "display_name_hint": "Auto-generated from your options — you can edit it.", "duplicate_warning": "You already have a service with these exact details. Change an option or the category.", + "duplicate_edit_existing": "Edit that existing listing instead", + "preview_heading": "This is what appears in search", + "preview_untitled": "New service", "summary_options": "Options", "summary_none": "No options", "next": "Next", @@ -360,6 +391,21 @@ "list_error": "Couldn't load your services.", "options_error": "Couldn't load this category's options." }, + "activation": { + "title": "Get set up", + "subtitle_visibility": "These control whether you appear in search.", + "row_identity": "Identity & document verification", + "row_profile": "Complete your profile", + "row_services": "At least one active service", + "row_coverage": "A coverage area", + "row_bank": "A verified bank account", + "row_bank_hint": "For getting paid — not required to appear in search.", + "row_fix": "Complete", + "live_title": "Active in search", + "live_body": "Setup is complete and you're accepting bookings — families can find and book you.", + "load_error": "Couldn't load your setup status.", + "retry": "Retry" + }, "search": { "title": "Find a nurse", "subtitle": "Only verified, background-checked nurses appear here.", @@ -680,9 +726,6 @@ "earnings_snapshot_title": "Earnings", "earnings_snapshot_cta": "View details", "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_unread": "{count} unread", "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_national_id_mismatch": "The national ID didn't match.", "reason_blurry_scan": "The document image wasn't clear. Please upload a sharper copy.", - "journey_identity": "Identity", - "journey_credentials": "Credentials", - "journey_review": "Review", + "journey_back": "Back to the verification journey", + "group_identity": "Identity", + "group_credentials": "Professional credentials", + "group_bank": "Bank", + "group_review": "Review", + "payoff_title": "This is the badge families will see", + "payoff_subtitle": "It fills in as you complete each group below.", "identity_title": "Verify identity", "identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.", "national_id_label": "National ID", @@ -861,8 +908,10 @@ "card_label": "National-ID card image", "card_hint": "Photograph your ID card in good light, clearly readable.", "card_recommended": "Uploading the ID card image is recommended.", + "capture_hint_card": "Good lighting, no blur, all four corners of the card inside the frame.", "selfie_label": "Liveness selfie", "selfie_hint": "Take a selfie for face verification.", + "capture_hint_selfie": "Good lighting, face centered and unobstructed.", "auto_registry_note": "An automatic civil-registry check is performed.", "error_national_id_mismatch": "The national ID didn't match the civil registry. Please check and try again.", "error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.", @@ -877,6 +926,9 @@ "ino_number_label": "Nursing-council number", "ino_number_hint": "Enter your nursing-council membership number.", "ino_number_required": "Enter your nursing-council number.", + "ino_number_submitted": "Nursing-council number on file", + "ino_number_change": "Change", + "credentials_needs_document": "Upload at least one document to submit.", "doc_uploaded": "Document uploaded", "education_label": "Education certificate", "education_hint": "Optional — an image of your latest qualification.", @@ -901,6 +953,9 @@ "review_title": "Under review", "review_body": "Your documents were submitted and are being reviewed by our team.", "review_eta": "This usually takes 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_body": "Every step is verified. You can now publish your profile.", "review_summary_title": "Status summary", @@ -915,13 +970,18 @@ "explainer_approved_at": "Verified on {date}", "explainer_not_verified": "This nurse is not verified yet.", "explainer_error": "Couldn't load the verification details.", - "publish_ready_title": "Ready to publish", - "publish_ready_body": "Your verification is complete. Your services are visible and bookable.", - "publish_blocked_title": "Complete verification to publish", - "publish_blocked_body": "Until verification is complete, your services won't appear in search and can't be booked.", - "publish_cta": "Publish profile", - "publish_complete_verification": "Complete verification", - "publish_done": "Your profile has been published", + "publish_ready_title": "Ready to go live", + "publish_ready_body": "Setup is complete. Turn on bookings to appear in search and start receiving requests.", + "publish_blocked_title": "Finish setup to go live", + "publish_unmet_intro": "To appear in search: {items}.", + "publish_start_accepting": "Start accepting bookings", + "publish_pause_accepting": "Pause bookings", + "publish_toggling": "Updating…", + "publish_live_title": "Live in search", + "publish_live_body": "Families can find and book you right now.", + "publish_accepting_success": "You're now accepting bookings", + "publish_paused_success": "Bookings paused — you won't appear in search until you resume", + "publish_toggle_error": "Couldn't update your booking status. Please try again.", "mock_admin_title": "Simulate admin review (demo)", "mock_admin_approve": "Approve all steps", "mock_admin_reject": "Reject a document", diff --git a/client/messages/fa.json b/client/messages/fa.json index f6fb05c..60139cc 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -190,7 +190,35 @@ "unverified_cta": "تکمیل احراز هویت", "deferred_services": "خدمات و قیمت‌های خود را در بخش «خدمات و قیمت‌ها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه می‌شود.", "save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.", - "avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید." + "avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید.", + "education_level_label": "مقطع تحصیلی", + "education_level_diploma": "دیپلم", + "education_level_associate": "کاردانی", + "education_level_bachelor": "کارشناسی", + "education_level_master": "کارشناسی ارشد", + "education_level_doctorate": "دکترا", + "education_field_label": "رشتهٔ تحصیلی", + "education_field_nursing": "پرستاری", + "education_field_midwifery": "مامایی", + "education_field_anesthesia": "هوشبری", + "education_field_operating_room": "اتاق عمل", + "education_field_public_health": "بهداشت عمومی", + "education_other": "سایر", + "education_level_other_label": "مقطع تحصیلی خود را وارد کنید", + "education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید", + "specializations_label": "تخصص‌ها", + "preview_cta": "پیش‌نمایش نمایهٔ عمومی من", + "preview_title": "نمایهٔ عمومی من", + "preview_subtitle": "این چیزی است که خانواده‌ها هنگام یافتن شما می‌بینند.", + "back_to_profile": "بازگشت به پروفایل", + "preview_reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})", + "preview_completed_visits": "{count, number} ویزیت موفق", + "preview_years_experience": "{years} سال سابقه", + "preview_verified_title": "بالین‌یار چه چیزی را تایید کرده است", + "preview_services_title": "خدمات", + "preview_services_empty": "هنوز خدمت فعالی ندارید — یک خدمت در بخش «خدمات و قیمت‌ها» اضافه کنید.", + "preview_coverage_title": "مناطق تحت پوشش", + "preview_coverage_empty": "هنوز منطقه‌ای تحت پوشش ثبت نشده." }, "bank": { "title": "حساب بانکی", @@ -205,9 +233,11 @@ "submitting": "در حال ثبت…", "added": "حساب ثبت شد — استعلام مالکیت آغاز شد", "add_error": "ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.", + "add_another": "افزودن حساب دیگر", + "load_error": "بارگذاری حساب‌های بانکی شما ممکن نشد.", "status_pending_chip": "در حال استعلام", "status_pending_title": "در حال استعلام مالکیت حساب", - "status_pending_body": "استعلام شبا در حال انجام است؛ ممکن است چند لحظه طول بکشد.", + "status_pending_body": "در حال استعلام صحت شبا؛ معمولاً چند دقیقه طول می‌کشد…", "status_verified_chip": "تاییدشد", "status_verified_title": "حساب تایید شد", "status_verified_body": "این حساب برای واریز درآمد شما آماده است.", @@ -217,6 +247,7 @@ "reenter": "ثبت حساب دیگر", "make_primary": "انتخاب به‌عنوان حساب اصلی", "primary_set": "حساب اصلی به‌روزرسانی شد", + "primary_set_error": "به‌روزرسانی حساب اصلی ممکن نشد. دوباره تلاش کنید.", "iban_masked_label": "شبا", "primary": "حساب اصلی", "empty_title": "هنوز حسابی ثبت نشده", @@ -273,9 +304,6 @@ "subtitle": "شهرها و مناطقی که به آن‌ها می‌روید.", "areas_heading": "مناطق شما", "add_title": "افزودن منطقهٔ تحت پوشش", - "scope_label": "گستره", - "scope_whole_city": "کل شهر", - "scope_districts": "مناطق خاص", "add": "افزودن منطقه", "adding": "در حال افزودن…", "whole_city_chip": "کل شهر", @@ -283,13 +311,13 @@ "empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جست‌وجو نمایش داده نمی‌شوید.", "duplicate": "این منطقه از قبل تحت پوشش شماست.", "city_required": "شهر را انتخاب کنید", - "district_required": "یک منطقه انتخاب کنید یا به کل شهر تغییر دهید.", "added": "منطقهٔ تحت پوشش اضافه شد", "add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.", "remove_title": "حذف منطقهٔ تحت پوشش؟", "remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید.", "remove_confirm": "حذف", - "removed": "منطقهٔ تحت پوشش حذف شد" + "removed": "منطقهٔ تحت پوشش حذف شد", + "remove_error": "حذف این منطقه ممکن نشد. دوباره تلاش کنید." }, "catalog": { "unit_per_hour": "ساعتی", @@ -349,6 +377,9 @@ "display_name_label": "نام نمایشی", "display_name_hint": "به‌صورت خودکار از گزینه‌ها ساخته می‌شود؛ می‌توانید تغییرش دهید.", "duplicate_warning": "شما قبلاً خدمتی با همین مشخصات دارید. یک گزینه یا دسته را تغییر دهید.", + "duplicate_edit_existing": "به‌جای آن، همان خدمت موجود را ویرایش کنید", + "preview_heading": "این‌گونه در جستجو دیده می‌شوید", + "preview_untitled": "خدمت جدید", "summary_options": "گزینه‌ها", "summary_none": "بدون گزینه", "next": "بعدی", @@ -360,6 +391,21 @@ "list_error": "خدمات شما بارگذاری نشد.", "options_error": "گزینه‌های این دسته بارگذاری نشد." }, + "activation": { + "title": "راه‌اندازی حساب", + "subtitle_visibility": "این موارد نمایش شما در جستجو را کنترل می‌کنند.", + "row_identity": "احراز هویت و مدارک", + "row_profile": "تکمیل نمایه", + "row_services": "حداقل یک خدمت فعال", + "row_coverage": "محدودهٔ پوشش", + "row_bank": "شبای تاییدشده", + "row_bank_hint": "برای دریافت درآمد — برای نمایش در جستجو لازم نیست.", + "row_fix": "تکمیل", + "live_title": "فعال در جستجو", + "live_body": "راه‌اندازی کامل است و پذیرش رزرو فعال است — خانواده‌ها می‌توانند شما را پیدا و رزرو کنند.", + "load_error": "بارگذاری وضعیت راه‌اندازی ممکن نشد.", + "retry": "تلاش مجدد" + }, "search": { "title": "یافتن پرستار", "subtitle": "فقط پرستاران تاییدشده و دارای صلاحیت اینجا نمایش داده می‌شوند.", @@ -680,9 +726,6 @@ "earnings_snapshot_title": "درآمد", "earnings_snapshot_cta": "مشاهده جزئیات", "earnings_snapshot_error": "بارگذاری موجودی شما ممکن نشد.", - "activation_title": "تکمیل فعال‌سازی پروفایل", - "activation_body": "برای دیده‌شدن در جستجو و دریافت درخواست رزرو، احراز هویت خود را تکمیل کنید.", - "activation_cta": "ادامهٔ احراز هویت", "notifications_entry_title": "اعلان‌ها", "notifications_entry_unread": "{count} خوانده‌نشده", "notifications_entry_empty": "اعلان جدیدی ندارید" @@ -850,9 +893,13 @@ "reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.", "reason_national_id_mismatch": "کد ملی مطابقت نداشت.", "reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.", - "journey_identity": "هویت", - "journey_credentials": "مدارک", - "journey_review": "بررسی", + "journey_back": "بازگشت به مسیر تأیید", + "group_identity": "هویت", + "group_credentials": "مدارک حرفه‌ای", + "group_bank": "بانک", + "group_review": "بررسی", + "payoff_title": "این نشان را خانواده‌ها می‌بینند", + "payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.", "identity_title": "تأیید هویت", "identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.", "national_id_label": "کد ملی", @@ -861,8 +908,10 @@ "card_label": "تصویر کارت ملی", "card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.", "card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.", + "capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.", "selfie_label": "سلفی زنده", "selfie_hint": "برای تشخیص چهره، سلفی بگیرید.", + "capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.", "auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.", "error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.", "error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.", @@ -877,6 +926,9 @@ "ino_number_label": "شماره نظام پرستاری", "ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.", "ino_number_required": "شماره نظام پرستاری را وارد کنید.", + "ino_number_submitted": "شمارهٔ نظام پرستاری ثبت شد", + "ino_number_change": "تغییر", + "credentials_needs_document": "برای ثبت، حداقل یک مدرک بارگذاری کنید.", "doc_uploaded": "مدرک بارگذاری شد", "education_label": "مدرک تحصیلی", "education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.", @@ -901,6 +953,9 @@ "review_title": "در حال بررسی", "review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.", "review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان می‌برد.", + "review_timeline_submitted": "مدارک ثبت شد", + "review_timeline_review": "بررسی توسط کارشناس", + "review_timeline_activation": "فعال‌سازی نشان", "review_approved_title": "احراز هویت تکمیل شد", "review_approved_body": "همهٔ مراحل تأیید شد. اکنون می‌توانید پروفایل خود را منتشر کنید.", "review_summary_title": "خلاصهٔ وضعیت", @@ -915,13 +970,18 @@ "explainer_approved_at": "تاریخ تایید: {date}", "explainer_not_verified": "این پرستار هنوز احراز هویت نشده است.", "explainer_error": "بارگذاری جزئیات تاییدیه ممکن نشد.", - "publish_ready_title": "آمادهٔ انتشار", - "publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.", - "publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید", - "publish_blocked_body": "تا تکمیل احراز هویت، خدمات شما در جستجو نمایش داده نمی‌شود و قابل رزرو نیست.", - "publish_cta": "انتشار پروفایل", - "publish_complete_verification": "تکمیل احراز هویت", - "publish_done": "پروفایل شما منتشر شد", + "publish_ready_title": "آمادهٔ فعال‌سازی", + "publish_ready_body": "راه‌اندازی کامل است. پذیرش رزرو را روشن کنید تا در جستجو نمایش داده شوید و درخواست دریافت کنید.", + "publish_blocked_title": "برای فعال‌سازی، راه‌اندازی را کامل کنید", + "publish_unmet_intro": "برای نمایش در جستجو: {items}.", + "publish_start_accepting": "شروع پذیرش رزرو", + "publish_pause_accepting": "توقف موقت پذیرش", + "publish_toggling": "در حال به‌روزرسانی…", + "publish_live_title": "فعال در جستجو", + "publish_live_body": "هم‌اکنون خانواده‌ها می‌توانند شما را پیدا کرده و رزرو کنند.", + "publish_accepting_success": "اکنون رزرو دریافت می‌کنید", + "publish_paused_success": "پذیرش رزرو متوقف شد — تا از سرگیری، در جستجو نمایش داده نمی‌شوید", + "publish_toggle_error": "به‌روزرسانی وضعیت پذیرش رزرو ممکن نشد. دوباره تلاش کنید.", "mock_admin_title": "شبیه‌سازی بررسی مدیر (نمایشی)", "mock_admin_approve": "تأیید همهٔ مراحل", "mock_admin_reject": "رد یک مدرک", diff --git a/client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx b/client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx index b76d06c..c4f1815 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx @@ -1,56 +1,13 @@ 'use client'; -import { useLocale, useTranslations } from 'next-intl'; -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; -} +import { ActivationChecklist } from '@/components'; /** - * The dashboard's activation/go-live composition point — **named and exported so a later phase can find - * it**. This phase fills it only with the existing verification-status banner (rendered while the nurse - * isn't yet approved; nothing once approved). The fuller "go live" checklist (profile/services/coverage/ - * bank all done) is **DEFERRED to ui-phase-8**, which owns this slot's content from here — extend this - * component in place rather than adding a second slot. + * The dashboard's activation/go-live composition point — **named and exported so a later phase can + * find it** (ui-phase-7's hand-off note). ui-phase-8 fills it with the real `ActivationChecklist` + * (the same shared component mounted on `/nurse/services`) — replacing the placeholder single-row + * verification banner phase 7 left here. Extend this component in place, don't add a second slot. * @component DashboardActivationSlot */ -export default function DashboardActivationSlot({ status, isLoading }: DashboardActivationSlotProps) { - const t = useTranslations('dashboard'); - const locale = useLocale(); - const router = useRouter(); - - if (isLoading) return ; - if (isApproved(status)) return null; - - return ( - - - - - - - {t('activation_title')} - - - {t('activation_body')} - - - - router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`)} - > - {t('activation_cta')} - - - - ); +export default function DashboardActivationSlot() { + return ; } diff --git a/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx b/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx index 5115acb..4c72d58 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx @@ -68,7 +68,7 @@ export default function NurseDashboardScreen() { - + ); diff --git a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx index fb1a7a2..497d164 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx @@ -3,21 +3,26 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; import { Box, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components'; +import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components'; import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse'; import { isValidSheba } from '@/services/nurse/iban'; import { deriveBankStatus } from '@/services/nurse/types'; /** - * Nurse payout bank settings — submit an IBAN (شبا) + account-holder name, then watch the - * ownership inquiry resolve through its three states (pending → verified / mismatch). The list - * polls only while pending; a verified account shows the masked IBAN; mismatch offers re-enter. + * Nurse payout bank settings — an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit + * an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three + * states (pending → verified / mismatch, `BankStatusPanel` unchanged). Once at least one account + * exists, a persistent «افزودن حساب دیگر» CTA replaces the old form-only-when-empty gate, so a nurse + * switching banks is never dead-ended — the old account stays listed until the nurse makes the new + * one primary. A failed accounts query renders the error state with retry, **never** the empty-state + * form (which would invite a duplicate-IBAN submission blind). */ export default function NurseBankPage() { const t = useTranslations('bank'); + const tc = useTranslations('common'); const { enqueueSnackbar } = useSnackbar(); - const { data, isLoading } = useNurseBankAccounts(); + const { data, isLoading, isError, refetch } = useNurseBankAccounts(); const addAccount = useAddNurseBankAccount(); const setPrimary = useSetPrimaryBankAccount(); @@ -28,7 +33,7 @@ export default function NurseBankPage() { const [showForm, setShowForm] = useState(false); const accounts = data ?? []; - const showFormNow = !isLoading && (accounts.length === 0 || showForm); + const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm); const submit = () => { const ibanInvalid = !isValidSheba(iban); @@ -64,47 +69,67 @@ export default function NurseBankPage() { {isLoading ? : null} - {accounts.map((account) => { - const status = deriveBankStatus(account); - return ( - - setShowForm(true) : undefined} - reenterLabel={t('reenter')} - /> - {/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */} - {status === 'verified' && !account.isPrimary ? ( - - setPrimary.mutate(account.id, { - onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }), - }) - } - sx={{ alignSelf: 'flex-start' }} - > - {t('make_primary')} - - ) : null} - - ); - })} + {isError ? refetch()} /> : null} - {!isLoading && accounts.length === 0 ? ( + {!isError + ? accounts.map((account) => { + const status = deriveBankStatus(account); + return ( + + setShowForm(true) : undefined} + reenterLabel={t('reenter')} + /> + {/* Promote a verified non-primary account so payouts (gated on matchedNationalId) target it. */} + {status === 'verified' && !account.isPrimary ? ( + + setPrimary.mutate(account.id, { + onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }), + onError: () => enqueueSnackbar(t('primary_set_error'), { variant: 'error' }), + }) + } + sx={{ alignSelf: 'flex-start' }} + > + {t('make_primary')} + + ) : null} + + ); + }) + : null} + + {!isLoading && !isError && accounts.length === 0 ? ( ) : 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 ? ( + setShowForm(true)} + sx={{ alignSelf: 'flex-start' }} + > + {t('add_another')} + + ) : null} + {showFormNow ? ( - - {addAccount.isPending ? t('submitting') : t('submit')} - + + + {addAccount.isPending ? t('submitting') : t('submit')} + + {accounts.length > 0 ? ( + { + setShowForm(false); + setIban(''); + setHolder(''); + setIbanError(false); + setHolderError(false); + }} + disabled={addAccount.isPending} + > + {tc('cancel')} + + ) : null} + ) : null} diff --git a/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx index 867323e..d0e4f8e 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/coverage/page.tsx @@ -2,36 +2,25 @@ import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { - Box, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Paper, - Skeleton, - Stack, - ToggleButton, - ToggleButtonGroup, - Typography, -} from '@mui/material'; +import { Box, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { AppButton, AppIcon } from '@/components'; import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography'; import { ApiError } from '@/lib/api/errors'; -import { useDistricts } from '@/services/geography'; import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas'; import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types'; -type Scope = 'whole_city' | 'districts'; const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null }; /** * The nurse coverage-area editor — the cities/districts a nurse will travel to, so search (f6) - * can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add - * control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A - * duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to - * the same message). Empty → a warning that the nurse won't appear in search. + * can fan them out geographically. Areas render as chips (whole-city shown explicitly). + * + * ui-phase-8: **one control owns the whole-city choice** — `CascadingRegionSelect`'s own district + * level, whose "کل شهر" empty option *is* the choice (`districtId = null`, matching the serviceAreas + * contract both ways). The separate scope toggle this page used to render is gone — it let a nurse + * pick "specific districts" and then still land on the district select's own whole-city option, + * tripping a "district required" error the UI itself had offered. City is the only required field; + * leaving the district unset is a complete, valid whole-city submission, never an error state. */ export default function NurseCoveragePage() { const t = useTranslations('coverage'); @@ -44,22 +33,12 @@ export default function NurseCoveragePage() { const removeArea = useRemoveServiceArea(); const [region, setRegion] = useState(EMPTY_REGION); - const [scope, setScope] = useState('whole_city'); const [cityError, setCityError] = useState(false); - const [districtError, setDistrictError] = useState(false); const [duplicate, setDuplicate] = useState(false); const [removeTarget, setRemoveTarget] = useState(null); const areas = data?.items ?? []; - // A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the - // same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a - // district that cannot exist. - const districtsQuery = useDistricts(region.cityId); - const cityHasNoDistricts = - region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0; - const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope; - const chipLabel = (area: NurseServiceArea) => { const city = locale === 'en' ? area.cityNameEn : area.cityNameFa; if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`; @@ -67,33 +46,21 @@ export default function NurseCoveragePage() { return `${city} · ${district}`; }; - const changeScope = (next: Scope | null) => { - if (!next) return; - setScope(next); - setDistrictError(false); - setDuplicate(false); - // Whole-city ignores any picked district — clear it so the submitted pair is unambiguous. - if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null })); - }; - const resetForm = () => { setRegion(EMPTY_REGION); - setScope('whole_city'); setCityError(false); - setDistrictError(false); setDuplicate(false); }; const handleAdd = () => { const cityInvalid = region.cityId == null; - const districtInvalid = effectiveScope === 'districts' && region.districtId == null; setCityError(cityInvalid); - setDistrictError(districtInvalid); setDuplicate(false); - if (cityInvalid || districtInvalid) return; + if (cityInvalid) return; const cityId = region.cityId as number; - const districtId = effectiveScope === 'whole_city' ? null : region.districtId; + // Whatever the district select currently holds is the complete choice — `null` = whole city. + const districtId = region.districtId; // Fast path: block a duplicate before firing (null district treated as a real value). if (areaExists(areas, cityId, districtId)) { @@ -123,6 +90,7 @@ export default function NurseCoveragePage() { setRemoveTarget(null); removeArea.mutate(id, { onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }), + onError: () => enqueueSnackbar(t('remove_error'), { variant: 'error' }), }); }; @@ -191,38 +159,15 @@ export default function NurseCoveragePage() { {t('add_title')} - - - {t('scope_label')} - - changeScope(next)} - > - {t('scope_whole_city')} - {/* A district-less city forces whole-city — disable the option rather than dead-end on it. */} - - {t('scope_districts')} - - - - { setRegion(next); if (cityError && next.cityId != null) setCityError(false); - if (districtError && next.districtId != null) setDistrictError(false); setDuplicate(false); }} - includeDistrict={effectiveScope === 'districts'} cityError={cityError} cityErrorText={t('city_required')} - districtError={districtError} - districtErrorText={t('district_required')} /> {duplicate ? ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx index 2f887f4..fcf252c 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx @@ -1,18 +1,30 @@ 'use client'; -import { ChangeEvent, FunctionComponent, useRef, useState } from 'react'; +import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { Avatar, Box, Paper, Stack, TextField, Typography } from '@mui/material'; +import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components'; import { ROUTES } from '@/constants'; import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles'; import type { NurseProfile } from '@/services/profiles/types'; import { useVerificationStatus } from '@/services/verification'; -import { ownBadgeState } from '@/services/verification/types'; +import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types'; const MAX_YEARS = 80; +const OTHER_CODE = '__other'; +const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const; +const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const; -/** Nurse profile bootstrap (B7 header): avatar + bio + years. Services/availability are deferred (f4). */ +function parseSpecializations(json: string): string[] { + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +} + +/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */ export default function NurseProfilePage() { const { data: profile, isLoading } = useNurseProfile(); if (isLoading) return ; @@ -21,6 +33,7 @@ export default function NurseProfilePage() { const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => { const t = useTranslations('nurseProfile'); + const tv = useTranslations('verification'); const tc = useTranslations('common'); const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); @@ -35,6 +48,37 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : ''); const [yearsError, setYearsError] = useState(false); + const initialLevel = initial?.educationLevel ?? ''; + const initialField = initial?.educationField ?? ''; + const [educationLevel, setEducationLevel] = useState( + (EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '', + ); + const [educationLevelOther, setEducationLevelOther] = useState( + (EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel, + ); + const [educationField, setEducationField] = useState( + (EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '', + ); + const [educationFieldOther, setEducationFieldOther] = useState( + (EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField, + ); + const [specializations, setSpecializations] = useState( + parseSpecializations(initial?.specializationsJson ?? '[]'), + ); + + // A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close. + const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null); + useEffect(() => { + if (!avatarDirty) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // Chrome (and most engines) only show the native confirm dialog when returnValue is set. + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [avatarDirty]); + const pickFile = () => fileInputRef.current?.click(); const onFileSelected = (event: ChangeEvent) => { @@ -47,6 +91,9 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ }); }; + const toggleSpecialty = (value: string) => + setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value])); + const handleSave = () => { const trimmed = years.trim(); const yearsNum = trimmed === '' ? 0 : Number(trimmed); @@ -54,13 +101,16 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ setYearsError(yearsInvalid); if (yearsInvalid) return; + const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel; + const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField; + upsert.mutate( { bio: bio.trim(), yearsOfExperience: yearsNum, - educationLevel: initial?.educationLevel ?? '', - educationField: initial?.educationField ?? '', - specializationsJson: initial?.specializationsJson ?? '[]', + educationLevel: resolvedLevel, + educationField: resolvedField, + specializationsJson: JSON.stringify(specializations), avatarUrl, }, { @@ -163,10 +213,81 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ sx={{ maxWidth: 200 }} /> + + setEducationLevel(e.target.value)} fullWidth> + {EDUCATION_LEVELS.map((code) => ( + + {t(`education_level_${code}`)} + + ))} + {t('education_other')} + + setEducationField(e.target.value)} fullWidth> + {EDUCATION_FIELDS.map((code) => ( + + {t(`education_field_${code}`)} + + ))} + {t('education_other')} + + + + {educationLevel === OTHER_CODE ? ( + setEducationLevelOther(e.target.value)} + fullWidth + /> + ) : null} + {educationField === OTHER_CODE ? ( + setEducationFieldOther(e.target.value)} + fullWidth + /> + ) : null} + + + + {t('specializations_label')} + + + {SPECIALTY_PRESETS.map((code) => { + const selected = specializations.includes(code); + return ( + 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)', + }} + /> + ); + })} + + + {t('deferred_services')} + + {t('preview_cta')} + + 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 ( + + + + + + + + + + ); + } + + 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 ( + + + + + + + {profile?.avatarUrl ? null : (displayName || '؟').charAt(0)} + + + + {displayName} + + {profile && profile.totalReviews > 0 ? ( + + + + {formatNumber(profile.averageRating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} + + + {t('preview_reviews_count', { count: profile.totalReviews })} + + + ) : null} + {profile ? ( + + {t('preview_completed_visits', { count: formatNumber(profile.totalCompletedBookings, locale) })} + + ) : null} + + + + + + {profile?.bio ? ( + + {profile.bio} + + ) : null} + + + {profile?.yearsOfExperience || specializations.length > 0 ? ( + + {profile?.yearsOfExperience ? ( + + ) : null} + {specializations.map((code) => ( + + ))} + + ) : null} + + + + {t('preview_verified_title')} + + + + + + + + + {t('preview_services_title')} + + {activeVariants.length === 0 ? ( + + {t('preview_services_empty')} + + ) : ( + + {activeVariants.map((variant) => ( + + ))} + + )} + + + + + {t('preview_coverage_title')} + + {areas.length === 0 ? ( + + ) : ( + + {areas.map((area) => { + const city = locale === 'en' ? area.cityNameEn : area.cityNameFa; + const district = locale === 'en' ? area.districtNameEn : area.districtNameFa; + return ( + + ); + })} + + )} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx index b82ba1e..61fecd7 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx @@ -1,6 +1,6 @@ 'use client'; import { FunctionComponent, useState } from 'react'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; import { Box, @@ -12,7 +12,8 @@ import { Stack, Typography, } from '@mui/material'; -import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components'; +import { ActivationChecklist, AppButton, EmptyState, ErrorState, VariantCard } from '@/components'; +import { ROUTES } from '@/constants'; import { useMyVariants, useSetVariantActive } from '@/services/catalog'; import type { NurseServiceVariant } from '@/services/catalog/types'; import PublishGate from './PublishGate'; @@ -31,6 +32,8 @@ interface MyServicesListProps { const MyServicesList: FunctionComponent = ({ onAdd, onEdit }) => { const t = useTranslations('services'); const tc = useTranslations('common'); + const tNurseProfile = useTranslations('nurseProfile'); + const locale = useLocale(); const { enqueueSnackbar } = useSnackbar(); const { data, isLoading, isError, refetch } = useMyVariants(); @@ -86,8 +89,19 @@ const MyServicesList: FunctionComponent = ({ onAdd, onEdit ) : null} + + + {tNurseProfile('preview_cta')} + + {isLoading ? ( {[0, 1].map((key) => ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx index a920cc8..f8f013e 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/PublishGate.tsx @@ -1,81 +1,99 @@ 'use client'; import { FunctionComponent } from 'react'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { Paper, Stack, Typography } from '@mui/material'; -import { AppButton, AppIcon } from '@/components'; -import { ROUTES } from '@/constants'; -import { useVerificationStatus } from '@/services/verification'; -import { isApproved } from '@/services/verification/types'; +import { Skeleton, Stack, Typography } from '@mui/material'; +import { AccentCard, AppButton, AppIcon } from '@/components'; +import { useActivationChecklist } from '@/components/ActivationChecklist'; +import { useSetAcceptingBookings } from '@/services/profiles'; /** - * The go-live gate for the nurse's services (the f4 publish stub, wired to verification here). A nurse - * is **not bookable and cannot publish until verified**: when the aggregate isn't `approved` the publish - * CTA is **disabled** with a blocked-until-verified explanation that links to the B3 checklist; once - * approved it enables. Mirrors the server's guarded `is_verified` flip — the UI never implies a nurse is - * live before verification completes. Reads the shared `VerificationStatus` query (cached across the app). + * The go-live gate for the nurse's services — **the real switch**, not a snackbar. Reads the same + * `useActivationChecklist` state `ActivationChecklist` renders (one source of truth, no duplicated + * search-visibility logic): unmet conditions render guidance naming exactly what's missing; met but + * paused renders the real `set_accepting_bookings` CTA; live renders the on state + a pause action. + * Success copy fires only after the mutation actually succeeds — this replaces the old + * `enqueueSnackbar('published')` no-op. */ const PublishGate: FunctionComponent = () => { const t = useTranslations('verification'); - const locale = useLocale(); + const tActivation = useTranslations('activation'); const { enqueueSnackbar } = useSnackbar(); - const { data: status, isLoading } = useVerificationStatus(); + const state = useActivationChecklist(); + const setAccepting = useSetAcceptingBookings(); - if (isLoading) return null; - const approved = isApproved(status); + if (state.isLoading) return ; + 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 ( - - - - - - {approved ? t('publish_ready_title') : t('publish_blocked_title')} - - - {approved ? t('publish_ready_body') : t('publish_blocked_body')} - + + + + + + + {live + ? t('publish_live_title') + : state.isSearchVisible + ? t('publish_ready_title') + : t('publish_blocked_title')} + + + {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('، '), + })} + + + + + + {live ? ( + toggle(false)} + disabled={setAccepting.isPending} + > + {setAccepting.isPending ? t('publish_toggling') : t('publish_pause_accepting')} + + ) : state.isSearchVisible ? ( + toggle(true)} + disabled={setAccepting.isPending} + > + {setAccepting.isPending ? t('publish_toggling') : t('publish_start_accepting')} + + ) : null} - - - enqueueSnackbar(t('publish_done'), { variant: 'success' })} - > - {t('publish_cta')} - - {!approved ? ( - - {t('publish_complete_verification')} - - ) : null} - - + ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx index 944e455..42fc70f 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx @@ -2,30 +2,21 @@ import { FunctionComponent, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useSnackbar } from 'notistack'; -import { - Box, - Chip, - MenuItem, - Paper, - Skeleton, - Stack, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography, -} from '@mui/material'; -import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components'; +import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; +import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components'; import { ApiError } from '@/lib/api/errors'; import { digitsOnly, rialToToman, tomanToRial } from '@/utils'; import { useCategoryOptionGroups, useCreateVariant, + useMyVariants, useServiceCategories, useUpdateVariant, } from '@/services/catalog'; import { pickCatalogName } from '@/services/catalog/names'; import { PRICE_UNITS, + optionSetSignature, type NurseServiceVariant, type PriceUnit, type VariantOptionSelection, @@ -36,6 +27,8 @@ interface VariantBuilderProps { initial: NurseServiceVariant | null; onDone: () => void; onCancel: () => void; + /** Jump straight into editing an already-existing listing — the create step's 409-duplicate recovery. */ + onEditExisting: (variant: NurseServiceVariant) => void; } const DEFAULT_UNIT: PriceUnit = 'per_hour'; @@ -55,7 +48,7 @@ const MAX_DURATION_DIGITS = 4; * **Edit** locks the category + option-set (changing them would change identity) and edits only * price/unit/duration/display via `update`. */ -const VariantBuilder: FunctionComponent = ({ initial, onDone, onCancel }) => { +const VariantBuilder: FunctionComponent = ({ initial, onDone, onCancel, onEditExisting }) => { const t = useTranslations('services'); const tCatalog = useTranslations('catalog'); const tc = useTranslations('common'); @@ -92,6 +85,21 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon const selectedCategory = categories.find((category) => category.id === categoryId) ?? null; const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null); + // Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which + // existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly. + const myVariantsQuery = useMyVariants(); + const existingMatch = useMemo(() => { + if (isEdit || categoryId == null) return null; + const signature = optionSetSignature(categoryId, Object.values(selectedOptions)); + return ( + (myVariantsQuery.data?.items ?? []).find( + (variant) => + variant.serviceCategoryId === categoryId && + optionSetSignature(categoryId, variant.options.map((option) => option.optionValueId)) === signature, + ) ?? null + ); + }, [isEdit, categoryId, selectedOptions, myVariantsQuery.data]); + // Auto-generated display name preview (create): category + chosen value labels, in the active locale. const autoName = useMemo(() => { if (isEdit) return initial.displayName; @@ -188,6 +196,21 @@ const VariantBuilder: FunctionComponent = ({ 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 = ( = ({ initial, onDon {irr ? ( - - + + + {t('preview_heading')} + + {!sessionCount ? ( - + {t('rate_note')} ) : null} - + ) : null} = ({ initial, onDon /> {duplicate ? ( - - - {t('duplicate_warning')} - - + + + + + {/* Warning is reserved for the edge + icon; the body reads as normal text, not amber-on-paper. */} + + {t('duplicate_warning')} + + + {existingMatch ? ( + onEditExisting(existingMatch)} + sx={{ alignSelf: 'flex-start' }} + > + {t('duplicate_edit_existing')} + + ) : null} + + ) : null} ); @@ -423,20 +456,25 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon }} /> - changeOption(group.id, valueId)} - sx={{ flexWrap: 'wrap' }} - > - {group.values.map((value) => ( - - {pickCatalogName(value, locale)} - - ))} - + + {group.values.map((value) => { + const selected = selectedOptions[group.id] === value.id; + return ( + changeOption(group.id, selected ? null : value.id)} + variant={selected ? 'filled' : 'outlined'} + sx={{ + fontWeight: 500, + backgroundColor: selected ? 'var(--bal-primary)' : 'transparent', + color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', + borderColor: 'var(--bal-primary)', + }} + /> + ); + })} + ); }) diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx index f76a48b..2084a10 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/page.tsx @@ -22,6 +22,7 @@ export default function NurseServicesPage() { initial={builder.editing} onDone={() => setBuilder({ open: false })} onCancel={() => setBuilder({ open: false })} + onEditExisting={(variant) => setBuilder({ open: true, editing: variant })} /> ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/TrustBadgePreviewPanel.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/TrustBadgePreviewPanel.tsx new file mode 100644 index 0000000..20c375f --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/TrustBadgePreviewPanel.tsx @@ -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 = ({ status }) => { + const t = useTranslations('verification'); + const groups = groupedDisplaySteps(status); + + return ( + + + + + {t('payoff_title')} + + + {t('payoff_subtitle')} + + + + + + + {groups.map(({ group, steps }) => { + const passed = groupStatus(steps) === 'passed'; + return ( + + + + {t(groupLabelKey(group))} + + + ); + })} + + + + ); +}; + +export default TrustBadgePreviewPanel; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx index 62e8a2e..a2ef04c 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx @@ -1,138 +1,133 @@ 'use client'; import { FunctionComponent } from 'react'; import { useLocale, useTranslations } from 'next-intl'; -import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material'; -import { AppButton, AppIcon, StatusChip } from '@/components'; -import { formatNumber } from '@/utils'; +import { Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, StatusChip, SurfaceCard } from '@/components'; import type { VerificationStatus, VerificationStep } from '@/services/verification/types'; import { - displaySteps, - progressCounts, - routeForStep, + groupLabelKey, + groupRoute, + groupStatus, + groupedDisplaySteps, stepDescriptionKey, stepLabelKey, stepStatusChip, + type StepGroupKey, } from './verificationSteps'; interface VerificationChecklistProps { status: VerificationStatus; } +const GROUP_ICON: Record = { identity: 'identity', credentials: 'license', bank: 'bank' }; + /** - * The B3 checklist body: the "X از Y" progress meter + the data-driven, ordered step rows (reusing the - * shared `StatusChip`). Rows are rendered from `displaySteps(status)` — a new step type appears without a - * code change. Failed/expired rows surface their reason and a re-submit path; the first actionable - * automated/manual step gets an inline "go" link to its screen. + * The B3 journey body (ui-phase-8 §3.2) — ONE vertical spine of grouped step cards (هویت / مدارک + * حرفه‌ای / بانک), replacing the old flat "X از Y" + 7-row list. Each card folds the data-driven + * steps `verificationSteps.ts` already catalogs (regrouped presentation only — the catalog/synthetic + * mobile-step architecture is unchanged) and links to the one screen that owns that group's submission. */ const VerificationChecklist: FunctionComponent = ({ status }) => { - const { passed, total } = progressCounts(status); - const steps = displaySteps(status); - const firstActionableId = steps.find( - (step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null, - )?.id; - + const groups = groupedDisplaySteps(status); return ( - - - {steps.map((step) => ( - - ))} - + {groups.map(({ group, steps }) => ( + + ))} ); }; -const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => { +const GroupCard: FunctionComponent<{ group: StepGroupKey; steps: VerificationStep[] }> = ({ group, steps }) => { const t = useTranslations('verification'); const locale = useLocale(); - const percent = total === 0 ? 0 : (passed / total) * 100; - const format = (value: number) => formatNumber(value, locale); + const aggregate = groupStatus(steps); + const chip = stepStatusChip(aggregate); + const route = groupRoute(group); + const actionable = aggregate !== 'passed' && aggregate !== 'in_review'; + return ( - - - - {t('progress_title')} - - - {t('progress_count', { passed: format(passed), total: format(total) })} - + + + + + + + {t(groupLabelKey(group))} + + + + + + + {steps.map((step) => ( + + ))} + + + {actionable ? ( + + {aggregate === 'failed' ? t('row_fix') : t('row_go')} + + ) : null} - - + ); }; -const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => { +const StepLine: FunctionComponent<{ step: VerificationStep }> = ({ step }) => { const t = useTranslations('verification'); - const locale = useLocale(); const chip = stepStatusChip(step.status); const labelKey = stepLabelKey(step.code); const descKey = stepDescriptionKey(step.code); const label = t.has(labelKey) ? t(labelKey) : step.displayName; - const description = t.has(descKey) ? t(descKey) : ''; - const route = routeForStep(step.code); - const showReason = step.status === 'failed' || step.status === 'expired'; + const description = step.status !== 'passed' && t.has(descKey) ? t(descKey) : null; const reason = step.failureReason ? t.has(`reason_${step.failureReason}`) ? t(`reason_${step.failureReason}`) : step.failureReason : null; + const showReason = (step.status === 'failed' || step.status === 'expired') && reason; // Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint. const showAutoNote = step.isAutomated && step.status === 'not_started'; return ( - - - - - - {label} - - {description ? ( - - {description} - - ) : null} - - + + + {label} + - - {showReason && reason ? ( - + {description ? ( + + {description} + + ) : null} + {showReason ? ( + {reason} ) : null} - {showAutoNote ? ( {t('auto_query_note')} ) : null} - - {route && (highlighted || step.status === 'failed' || step.status === 'expired') ? ( - - {step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')} - - ) : null} - + ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationJourneyHeader.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationJourneyHeader.tsx new file mode 100644 index 0000000..1ecb81b --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationJourneyHeader.tsx @@ -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 = ({ group }) => { + const t = useTranslations('verification'); + const locale = useLocale(); + return ( + + ); +}; + +export default VerificationJourneyHeader; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx index 26d178f..2f56a4f 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx @@ -4,9 +4,11 @@ import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useSnackbar } from 'notistack'; import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, AppLoading, DocumentUpload, StepperHeader } from '@/components'; +import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components'; import type { UploadedDocInfo } from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; +import { formatShamsiDate } from '@/utils'; import { useSubmitCredentials, useUploadVerificationDocument, @@ -15,14 +17,22 @@ import { import { SPECIALTY_PRESETS } from '@/services/verification/types'; import type { VerificationStep } from '@/services/verification/types'; import { stepDescriptionKey, stepLabelKey } from '../verificationSteps'; +import VerificationJourneyHeader from '../VerificationJourneyHeader'; const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record']; /** * B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the - * status** (data-driven — a new manual step renders without a code change); each upload moves its step to - * `in_review` (manual admin review — copy never claims an automated authority check). Collects the INO - * number + specialty chips + optional registry fields, persisted on submit. Lands on B6 (under review). + * status** (data-driven — a new manual step renders without a code change); each upload moves its step + * to `in_review` (manual admin review — copy never claims an automated authority check). + * + * Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been + * recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary — never re-prompted as if lost, and + * never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is + * never read back by design, so re-submitting it isn't possible without the nurse re-entering it via + * "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each + * upload takes effect immediately, no re-submit needed) and simply returns to the journey — no dead + * disabled button, because there is no button to be dead. */ export default function CredentialsSubmitPage() { const t = useTranslations('verification'); @@ -34,14 +44,29 @@ export default function CredentialsSubmitPage() { const uploadDocument = useUploadVerificationDocument(); const submitCredentials = useSubmitCredentials(); + const submission = status?.credentialSubmission; + const [inoNumber, setInoNumber] = useState(''); const [inoError, setInoError] = useState(false); + const [editingIno, setEditingIno] = useState(true); const [specialties, setSpecialties] = useState([]); const [customSpecialty, setCustomSpecialty] = useState(''); const [issuingAuthority, setIssuingAuthority] = useState(''); - const [issuedAt, setIssuedAt] = useState(''); - const [expiresAt, setExpiresAt] = useState(''); + const [issuedAt, setIssuedAt] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); const [uploadedSteps, setUploadedSteps] = useState>({}); + const [hydrated, setHydrated] = useState(false); + + // Hydrate once from the server's read-back, adjusted directly during render (never in an effect — + // that would cascade an extra render) — and never overwrite what the nurse is actively editing. + if (!hydrated && submission) { + setHydrated(true); + setEditingIno(!submission.inoNumberSubmitted); + setSpecialties(submission.specialties); + setIssuingAuthority(submission.issuingAuthority ?? ''); + setIssuedAt(submission.issuedAt ?? null); + setExpiresAt(submission.expiresAt ?? null); + } const manualSteps = useMemo( () => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)), @@ -72,8 +97,8 @@ export default function CredentialsSubmitPage() { inoNumber: inoNumber.trim(), specialties, issuingAuthority: issuingAuthority.trim() || undefined, - issuedAt: issuedAt || undefined, - expiresAt: expiresAt || undefined, + issuedAt: issuedAt ?? undefined, + expiresAt: expiresAt ?? undefined, }, { onSuccess: () => { @@ -89,10 +114,8 @@ export default function CredentialsSubmitPage() { if (!status || manualSteps.length === 0) { return ( - - - {t('credentials_title')} - + + {t('credentials_needs_start')} @@ -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 ( - + + + - + {t('credentials_title')} @@ -116,24 +147,36 @@ export default function CredentialsSubmitPage() { - - - + {editingIno ? ( + { + setInoNumber(event.target.value); + if (inoError) setInoError(false); + }} + error={inoError} + helperText={inoError ? t('ino_number_required') : t('ino_number_hint')} + slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }} + fullWidth + /> + ) : ( + + + + {t('ino_number_submitted')} + + setEditingIno(true)}> + {t('ino_number_change')} + + + )} - { - setInoNumber(event.target.value); - if (inoError) setInoError(false); - }} - error={inoError} - helperText={inoError ? t('ino_number_required') : t('ino_number_hint')} - slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }} - fullWidth - /> - - {/* One uploader per manual credential step — data-driven from the status. */} + {/* One uploader per manual credential step — data-driven from the status. Re-uploading a + rejected document always takes effect immediately, whether or not the INO number is locked. */} {manualSteps.map((step) => ( setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))} rejected={step.status === 'failed'} rejectionReason={step.failureReason ?? undefined} - existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null} + existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null} /> ))} - {/* Supplementary education certificate — a local attachment (no dedicated step). */} - ({ name: file.name })} /> + {editingIno ? ( + ({ name: file.name })} /> + ) : null} {t('specialties_label')} - - {t('specialties_hint')} - - - {SPECIALTY_PRESETS.map((preset) => { - const selected = specialties.includes(preset); - return ( - toggleSpecialty(preset)} - sx={{ - fontWeight: 500, - backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)', - color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', + {editingIno ? ( + <> + + {t('specialties_hint')} + + + {SPECIALTY_PRESETS.map((preset) => { + const selected = specialties.includes(preset); + return ( + toggleSpecialty(preset)} + icon={selected ? : undefined} + variant={selected ? 'filled' : 'outlined'} + sx={{ + fontWeight: 500, + backgroundColor: selected ? 'var(--bal-primary)' : 'transparent', + color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', + borderColor: 'var(--bal-primary)', + }} + /> + ); + })} + {specialties + .filter((value) => !SPECIALTY_PRESETS.includes(value)) + .map((value) => ( + toggleSpecialty(value)} + sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }} + /> + ))} + + + setCustomSpecialty(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCustomSpecialty(); + } }} /> - ); - })} - {specialties - .filter((value) => !SPECIALTY_PRESETS.includes(value)) - .map((value) => ( + + {t('specialty_add')} + + + + ) : specialties.length > 0 ? ( + + {specialties.map((value) => ( toggleSpecialty(value)} - sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }} + label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value} + sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }} /> ))} - - - setCustomSpecialty(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - addCustomSpecialty(); - } - }} - /> - - {t('specialty_add')} - - + + ) : ( + + {t('summary_none')} + + )} - {/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */} - - - {t('registry_details_label')} - - setIssuingAuthority(event.target.value)} - fullWidth - /> - + {/* 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 ? ( + + + {t('registry_details_label')} + setIssuedAt(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - sx={{ flex: 1, minWidth: 160 }} - /> - setExpiresAt(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - sx={{ flex: 1, minWidth: 160 }} + label={t('issuing_authority_label')} + value={issuingAuthority} + onChange={(event) => setIssuingAuthority(event.target.value)} + fullWidth /> + + + + - + ) : issuingAuthority || issuedAt || expiresAt ? ( + + + {t('registry_details_label')} + + {issuingAuthority ? {issuingAuthority} : null} + {issuedAt || expiresAt ? ( + + {issuedAt ? formatShamsiDate(issuedAt, locale) : ''} + {issuedAt && expiresAt ? ' – ' : ''} + {expiresAt ? formatShamsiDate(expiresAt, locale) : ''} + + ) : null} + + ) : null} - - - {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')} - - + {editingIno ? ( + <> + {!hasAnyDocument ? ( + + {t('credentials_needs_document')} + + ) : null} + + + {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')} + + + {t('back_to_checklist')} + + + + ) : ( + {t('back_to_checklist')} - + )} ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx index aab47fc..4c355cf 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx @@ -4,13 +4,15 @@ import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useSnackbar } from 'notistack'; import { Box, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppAlert, AppButton, AppIcon, DocumentUpload, StepperHeader } from '@/components'; +import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; import { toEnglishDigits } from '@/utils'; import { useSubmitIdentity } from '@/services/verification'; import { isValidNationalId } from '@/services/verification/validation'; import { ACCEPTED_IMAGE_TYPES, NATIONAL_ID_LENGTH } from '@/services/verification/constants'; import type { SubmitIdentityResult } from '@/services/verification/hooks/useSubmitIdentity'; +import VerificationJourneyHeader from '../VerificationJourneyHeader'; type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null; @@ -66,9 +68,11 @@ export default function IdentitySubmitPage() { }; return ( - + + + - + {t('identity_title')} @@ -76,13 +80,6 @@ export default function IdentitySubmitPage() { - - - - - setCardCaptured(true)} - /> + + + setCardCaptured(true)} + /> + - setSelfieCaptured(true)} - /> + + + setSelfieCaptured(true)} + /> + ); } + +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 ( + + + {isCard + ? CORNER_POSITIONS.map((pos, index) => ( + + )) + : null} + + + {t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx index c947c2c..dae5f5b 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx @@ -3,12 +3,14 @@ import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material'; -import { AppAlert, AppButton, AppIcon, EmptyState } from '@/components'; +import { AppAlert, AppButton, AppIcon, EmptyState, PageHeader } from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; import { useStartVerification, useVerificationStatus } from '@/services/verification'; import { verificationKeys } from '@/services/verification/keys'; import { USE_VERIFICATION_MOCK } from '@/services/verification/constants'; import { __mockApproveAll, __mockRejectStep } from '@/services/verification/apis/mockApi'; +import TrustBadgePreviewPanel from './TrustBadgePreviewPanel'; import VerificationChecklist from './VerificationChecklist'; import { nextActionRoute, nextActionableIndex } from './verificationSteps'; @@ -44,15 +46,8 @@ export default function NurseVerificationPage() { const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() }); return ( - - - - {t('title')} - - - {t('subtitle')} - - + + {isLoading ? ( @@ -78,6 +73,7 @@ export default function NurseVerificationPage() { go(ROUTES.NURSE_SERVICES)} /> ) : ( <> + 0} /> diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx index 59dc652..5902661 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/review/page.tsx @@ -1,16 +1,21 @@ 'use client'; import { useLocale, useTranslations } from 'next-intl'; -import { Box, Paper, Stack, Typography } from '@mui/material'; -import { AppButton, AppIcon, AppLoading, StatusChip, StepperHeader } from '@/components'; +import { Box, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, AppLoading, StatusChip, StatusTimeline, SurfaceCard } from '@/components'; +import type { TimelineNode } from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; +import { formatShamsiDate } from '@/utils'; import { useVerificationStatus } from '@/services/verification'; import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps'; +import VerificationJourneyHeader from '../VerificationJourneyHeader'; /** - * B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, two - * views — never a second fetch). Shows the waiting message + the 24–48h expectation + a condensed - * mini-checklist (reusing the shared `StatusChip`) of what is passed vs in-review vs pending. "مشاهده - * وضعیت" returns to the canonical B3 hub. + * B6 — under review. A focused view of the **same cached `VerificationStatus`** B3 reads (one query, + * two views — never a second fetch). ui-phase-8: the same journey header as B4/B5, a Shamsi submitted + * timestamp when the server serves one (REQ-055, mock-tolerant — omitted rather than faked), a + * what-happens-next `StatusTimeline` (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعال‌سازی نشان), and + * the condensed mini-checklist of what is passed vs in-review vs pending. */ export default function UnderReviewPage() { const t = useTranslations('verification'); @@ -22,41 +27,53 @@ export default function UnderReviewPage() { const steps = displaySteps(status); const isApproved = status?.status === 'approved'; - return ( - - - - + const timelineNodes: TimelineNode[] = [ + { + key: 'submitted', + label: t('review_timeline_submitted'), + timestamp: status?.submittedAt ? formatShamsiDate(status.submittedAt, locale) : undefined, + state: 'completed', + }, + { + key: 'review', + label: t('review_timeline_review'), + note: isApproved ? undefined : t('review_eta'), + state: isApproved ? 'completed' : 'current', + }, + { + key: 'activation', + label: t('review_timeline_activation'), + state: isApproved ? 'completed' : 'pending', + }, + ]; - + + + - - - - {isApproved ? t('review_approved_title') : t('review_title')} + + + + + {isApproved ? t('review_approved_title') : t('review_title')} + + + + {isApproved ? t('review_approved_body') : t('review_body')} - - {isApproved ? t('review_approved_body') : t('review_body')} - - {!isApproved ? ( - - {t('review_eta')} - - ) : null} - + + + + + diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts b/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts index 5622bbf..5b82f86 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts @@ -115,3 +115,62 @@ export function nextActionableIndex(status: VerificationStatus | undefined): num const index = steps.findIndex(isActionable); return index === -1 ? null : index + 1; } + +/** + * The unified vertical journey (ui-phase-8 §3.2) groups the flat step list into three spines — + * هویت (mobile + identity KYC + Shahkar), مدارک حرفه‌ای (the three manual credential steps), بانک + * (IBAN-owner match) — matching the three submission screens (B4/B5/bank). This is presentation + * grouping only; the underlying data-driven `steps[]` catalog is unchanged. + */ +export type StepGroupKey = 'identity' | 'credentials' | 'bank'; + +export const GROUP_ORDER: readonly StepGroupKey[] = ['identity', 'credentials', 'bank'] as const; + +const GROUP_BY_CODE: Record = { + 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'; +} diff --git a/client/src/components/ActivationChecklist/ActivationChecklist.test.tsx b/client/src/components/ActivationChecklist/ActivationChecklist.test.tsx new file mode 100644 index 0000000..25ac00b --- /dev/null +++ b/client/src/components/ActivationChecklist/ActivationChecklist.test.tsx @@ -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(' 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + expect(screen.queryByText('live_title')).not.toBeInTheDocument(); + expect(container.querySelectorAll('[data-passed="false"]')).toHaveLength(0); + }); +}); diff --git a/client/src/components/ActivationChecklist/ActivationChecklist.tsx b/client/src/components/ActivationChecklist/ActivationChecklist.tsx new file mode 100644 index 0000000..c7c4039 --- /dev/null +++ b/client/src/components/ActivationChecklist/ActivationChecklist.tsx @@ -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 ; + if (state.isError) { + return ; + } + + if (state.isFullyLive) { + return ( + + + + + + {t('live_title')} + + + {t('live_body')} + + + + + ); + } + + return ( + + + + + {t('title')} + + + {t('subtitle_visibility')} + + + + + {state.searchRows.map((row) => ( + + ))} + + + + + + + + {t('row_bank_hint')} + + + + + ); +}; + +const ActivationRowItem: FunctionComponent<{ row: ActivationRow }> = ({ row }) => { + const t = useTranslations('activation'); + const locale = useLocale(); + return ( + + + {!row.passed ? ( + + {t('row_fix')} + + ) : null} + + ); +}; + +export default ActivationChecklist; diff --git a/client/src/components/ActivationChecklist/index.tsx b/client/src/components/ActivationChecklist/index.tsx new file mode 100644 index 0000000..796a251 --- /dev/null +++ b/client/src/components/ActivationChecklist/index.tsx @@ -0,0 +1,5 @@ +import ActivationChecklist from './ActivationChecklist'; + +export default ActivationChecklist; +export { useActivationChecklist } from './useActivationChecklist'; +export type { ActivationRow, ActivationChecklistState } from './useActivationChecklist'; diff --git a/client/src/components/ActivationChecklist/useActivationChecklist.ts b/client/src/components/ActivationChecklist/useActivationChecklist.ts new file mode 100644 index 0000000..ec13005 --- /dev/null +++ b/client/src/components/ActivationChecklist/useActivationChecklist.ts @@ -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, + }; +} diff --git a/client/src/components/DocumentUpload/DocumentUpload.test.tsx b/client/src/components/DocumentUpload/DocumentUpload.test.tsx index 70cbda9..a28c983 100644 --- a/client/src/components/DocumentUpload/DocumentUpload.test.tsx +++ b/client/src/components/DocumentUpload/DocumentUpload.test.tsx @@ -71,6 +71,30 @@ describe(' component', () => { expect(screen.getByText('upload_reupload')).toBeInTheDocument(); }); + it('shows live progress (reason still visible, re-upload button gone) when re-uploading a rejected document, then succeeds', async () => { + let resolveUpload: ((doc: { name: string }) => void) | undefined; + const onUpload = jest.fn( + () => + new Promise<{ name: string }>((resolve) => { + resolveUpload = resolve; + }), + ); + const { input, container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan', onUpload }); + expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument(); + + selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' })); + + // The precedence fix: `state === 'uploading'` wins over the still-true `rejected` prop. + await waitFor(() => expect(container.querySelector('[data-upload-state="uploading"]')).toBeInTheDocument()); + expect(container.querySelector('[data-upload-state="rejected"]')).not.toBeInTheDocument(); + expect(screen.getByText('Blurry scan')).toBeInTheDocument(); + expect(screen.queryByText('upload_reupload')).not.toBeInTheDocument(); + + resolveUpload?.({ name: 'license-v2.pdf' }); + await waitFor(() => expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument()); + expect(screen.getByText('license-v2.pdf')).toBeInTheDocument(); + }); + it('renders an already-uploaded document from server metadata', () => { const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } }); expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument(); diff --git a/client/src/components/DocumentUpload/DocumentUpload.tsx b/client/src/components/DocumentUpload/DocumentUpload.tsx index b36deab..4f69e39 100644 --- a/client/src/components/DocumentUpload/DocumentUpload.tsx +++ b/client/src/components/DocumentUpload/DocumentUpload.tsx @@ -153,32 +153,15 @@ const DocumentUpload: FunctionComponent = ({ onChange={onFileSelected} /> - {rejected ? ( - - - - - {t('upload_rejected')} - - - {rejectionReason ? ( - + {state === 'uploading' ? ( + + {/* A re-upload of a rejected doc must show LIVE progress, not the frozen rejected card — the + reason stays visible above the bar so context isn't lost while the new attempt runs. */} + {rejected && rejectionReason ? ( + {rejectionReason} ) : null} - - {t('upload_reupload')} - - - ) : state === 'uploading' ? ( - @@ -222,6 +205,30 @@ const DocumentUpload: FunctionComponent = ({ + ) : rejected ? ( + + + + + {t('upload_rejected')} + + + {rejectionReason ? ( + + {rejectionReason} + + ) : null} + + {t('upload_reupload')} + + ) : state === 'error' ? ( diff --git a/client/src/components/VariantCard/VariantCard.test.tsx b/client/src/components/VariantCard/VariantCard.test.tsx index fbc9992..146edc0 100644 --- a/client/src/components/VariantCard/VariantCard.test.tsx +++ b/client/src/components/VariantCard/VariantCard.test.tsx @@ -65,4 +65,15 @@ describe(' component', () => { fireEvent.click(screen.getByText('deactivate')); expect(onToggleActive).toHaveBeenCalledTimes(1); }); + + it('drops the action row in a read-only (interactive=false) preview', () => { + render( + + + , + ); + expect(screen.getByText('Elderly Care · Live-in')).toBeInTheDocument(); + expect(screen.queryByText('edit')).not.toBeInTheDocument(); + expect(screen.queryByText('deactivate')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/VariantCard/VariantCard.tsx b/client/src/components/VariantCard/VariantCard.tsx index e1e2ddc..ec955e5 100644 --- a/client/src/components/VariantCard/VariantCard.tsx +++ b/client/src/components/VariantCard/VariantCard.tsx @@ -11,20 +11,27 @@ import PriceDisplay from '../PriceDisplay'; export interface VariantCardProps { variant: NurseServiceVariant; - /** Open the builder in edit mode for this variant. */ - onEdit: () => void; - /** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. */ - onToggleActive: () => void; + /** Open the builder in edit mode for this variant. Required unless `interactive` is `false`. */ + onEdit?: () => void; + /** Deactivate (when active) or reactivate (when inactive) — soft only, never a delete. Required + * 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 - * the money util (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip + - * an "can't be booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there - * is **no delete affordance**. RTL-safe. + * One nurse offering (variant): its `display_name`, the category, the price via the money util + * (`PriceDisplay`), and an active/deactivated distinction (dimmed + a neutral chip + a "can't be + * booked" hint on inactive rows). Row actions are Edit and Deactivate/Reactivate — there is **no + * delete affordance**. `interactive={false}` drops the action row for a read-only preview use (the + * builder's live listing preview). RTL-safe. * @component VariantCard */ -const VariantCard: FunctionComponent = ({ variant, onEdit, onToggleActive }) => { +const VariantCard: FunctionComponent = ({ variant, onEdit, onToggleActive, interactive = true }) => { const t = useTranslations('services'); const locale = useLocale(); const active = variant.isActive; @@ -72,19 +79,21 @@ const VariantCard: FunctionComponent = ({ variant, onEdit, onT ) : null} - - - {t('edit')} - - - {active ? t('deactivate') : t('activate')} - - + {interactive ? ( + + + {t('edit')} + + + {active ? t('deactivate') : t('activate')} + + + ) : null} ); diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 16fe68f..647f181 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -37,6 +37,7 @@ import VerificationPanel from './VerificationPanel'; import PaymentStateCard from './PaymentStateCard'; import BnplProviderLogo from './BnplProviderLogo'; import EscrowExplainer from './EscrowExplainer'; +import ActivationChecklist from './ActivationChecklist'; export { ProfileSummary, @@ -76,6 +77,7 @@ export { PaymentStateCard, BnplProviderLogo, EscrowExplainer, + ActivationChecklist, }; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 1d8b284..e878b68 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -48,6 +48,9 @@ export const ROUTES = { // Nurse app NURSE: '/nurse', 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). NURSE_SERVICES: '/nurse/services', // Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search). diff --git a/client/src/services/profiles/apis/clientApi.ts b/client/src/services/profiles/apis/clientApi.ts index 2390cbb..95ee95c 100644 --- a/client/src/services/profiles/apis/clientApi.ts +++ b/client/src/services/profiles/apis/clientApi.ts @@ -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 => { + await clientFetch>(`${BASE}/nurse_profiles/set_accepting_bookings`, { + method: 'POST', + body: JSON.stringify({ accepting }), + }); + }, }; diff --git a/client/src/services/profiles/apis/mockApi.ts b/client/src/services/profiles/apis/mockApi.ts index d609827..b74040d 100644 --- a/client/src/services/profiles/apis/mockApi.ts +++ b/client/src/services/profiles/apis/mockApi.ts @@ -70,4 +70,11 @@ export const profilesMockApi: ProfilesApi = { // object-storage URL (REQ-006). 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 => { + await sleep(MOCK_LATENCY_MS); + if (!nurseProfile) return; + nurseProfile = { ...nurseProfile, isAcceptingBookings: accepting }; + }, }; diff --git a/client/src/services/profiles/hooks/useSetAcceptingBookings.ts b/client/src/services/profiles/hooks/useSetAcceptingBookings.ts new file mode 100644 index 0000000..73e4be0 --- /dev/null +++ b/client/src/services/profiles/hooks/useSetAcceptingBookings.ts @@ -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() }), + }); +} diff --git a/client/src/services/profiles/index.ts b/client/src/services/profiles/index.ts index e791eaa..5610e7c 100644 --- a/client/src/services/profiles/index.ts +++ b/client/src/services/profiles/index.ts @@ -3,3 +3,4 @@ export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile'; export { useNurseProfile } from './hooks/useNurseProfile'; export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile'; export { useUploadAvatar } from './hooks/useUploadAvatar'; +export { useSetAcceptingBookings } from './hooks/useSetAcceptingBookings'; diff --git a/client/src/services/profiles/types.ts b/client/src/services/profiles/types.ts index 85d5177..da44d00 100644 --- a/client/src/services/profiles/types.ts +++ b/client/src/services/profiles/types.ts @@ -73,4 +73,10 @@ export interface ProfilesApi { getNurseProfile(): Promise; upsertNurseProfile(input: UpsertNurseProfileInput): Promise; uploadAvatar(file: File): Promise; + /** + * 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; } diff --git a/client/src/services/verification/apis/mockApi.ts b/client/src/services/verification/apis/mockApi.ts index 10e27d8..3350095 100644 --- a/client/src/services/verification/apis/mockApi.ts +++ b/client/src/services/verification/apis/mockApi.ts @@ -42,6 +42,12 @@ let nextStepId = 1; let nextDocId = 1; let boundNationalId = ''; 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 | null = null; 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. */ function aggregate(): VerificationStatus { 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 allPassed = blockingSteps.length === 0; const anyInReview = steps.some((step) => step.status === 'in_review'); 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 { @@ -76,6 +89,7 @@ function seedSteps(): void { expiresAt: null, failureReason: null, })); + submittedAt = new Date().toISOString(); } /* --- Admin review queue fixtures + state --------------------------------------------------------- @@ -351,6 +365,14 @@ export const verificationMockApi: VerificationApi = { if (input.inoNumber.trim().length === 0) { 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) => { diff --git a/client/src/services/verification/types.ts b/client/src/services/verification/types.ts index 0bc02ef..2bbc4c5 100644 --- a/client/src/services/verification/types.ts +++ b/client/src/services/verification/types.ts @@ -77,6 +77,27 @@ export interface VerificationStatus { /** Step codes still blocking go-live. */ blockingSteps: string[]; 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. */ diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index d5bbd61..8b78e20 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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. - **Status:** deferred, non-blocking — build nothing for it this phase; the 15s poll remains the only 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). diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index ad5a3d0..330177b 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | | --- | --- | --- | --- | --- | --- | | `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) | | `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) | @@ -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) | | `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) | -| `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) | | `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) | diff --git a/dev/shared-working-context/reports/ui-phase-8-report.md b/dev/shared-working-context/reports/ui-phase-8-report.md new file mode 100644 index 0000000..dd6e437 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-8-report.md @@ -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.