frontend phase 5 & backend phase 12
This commit is contained in:
+13
-3
@@ -130,10 +130,16 @@ client/
|
||||
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
|
||||
│ │ │ ├── page.tsx # /nurse (dashboard)
|
||||
│ │ │ ├── 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 co-located)
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── verification/page.tsx # /nurse/verification
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
|
||||
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
|
||||
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
|
||||
@@ -158,6 +164,8 @@ client/
|
||||
│ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1 (tested)
|
||||
│ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested)
|
||||
│ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete) (tested)
|
||||
│ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile (tested)
|
||||
│ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested)
|
||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
|
||||
├── i18n/
|
||||
@@ -207,6 +215,7 @@ client/
|
||||
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
|
||||
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
|
||||
│ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper
|
||||
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
|
||||
│ └── {domain}/
|
||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||
@@ -300,6 +309,7 @@ async function MyServerComponent() {
|
||||
- `'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)
|
||||
- `'search'` — the f4 deferred `/search` placeholder (title + "arrives next phase" + query/category echo); f6 fills it out
|
||||
- `'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
|
||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||
|
||||
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
|
||||
|
||||
@@ -343,5 +343,130 @@
|
||||
"role_nurse": "Nurse",
|
||||
"role_nurse_desc": "Offer nursing services",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verification",
|
||||
"subtitle": "Complete these steps to activate your profile and start receiving bookings.",
|
||||
"retry": "Try again",
|
||||
"load_error": "Couldn't load your verification status.",
|
||||
"start_title": "Start verification",
|
||||
"start_body": "Trust is the whole of Balinyaar. Completing these steps lets families choose you with confidence.",
|
||||
"start_cta": "Start verification",
|
||||
"starting": "Preparing…",
|
||||
"progress_title": "Verification progress",
|
||||
"progress_count": "{passed} of {total}",
|
||||
"blocking_summary": "Your profile isn't visible or bookable until every step is complete.",
|
||||
"continue": "Continue",
|
||||
"continue_step": "Continue step {n}",
|
||||
"approved_title": "Verification complete",
|
||||
"approved_body": "Everything checks out. You can now publish your profile and services.",
|
||||
"approved_cta": "Publish services",
|
||||
"step_mobile_verified": "Mobile number",
|
||||
"step_mobile_verified_desc": "Verified when you signed in.",
|
||||
"step_identity_kyc": "Identity (civil registry)",
|
||||
"step_identity_kyc_desc": "Automatic civil-registry check from your national ID + a liveness selfie.",
|
||||
"step_shahkar_match": "Shahkar match",
|
||||
"step_shahkar_match_desc": "Automatic check that your mobile number matches your national ID.",
|
||||
"step_moh_competency_license": "Competency license (MoH)",
|
||||
"step_moh_competency_license_desc": "Upload the document; reviewed manually by our team.",
|
||||
"step_ino_membership": "Nursing-council membership",
|
||||
"step_ino_membership_desc": "Upload your nursing-council card; reviewed manually.",
|
||||
"step_criminal_record": "Criminal-record clearance",
|
||||
"step_criminal_record_desc": "Upload the certificate; reviewed manually. Has an expiry date.",
|
||||
"step_bank_account_verification": "Bank-account verification",
|
||||
"step_bank_account_verification_desc": "Automatic IBAN-ownership check against your national ID.",
|
||||
"status_passed": "Verified",
|
||||
"status_in_review": "Under review",
|
||||
"status_pending": "Pending",
|
||||
"status_failed": "Rejected",
|
||||
"status_expired": "Expired",
|
||||
"status_next": "Next",
|
||||
"auto_query_note": "Automatic check — no document upload needed.",
|
||||
"row_go": "Complete this step",
|
||||
"row_fix": "Fix and resubmit",
|
||||
"reason_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.",
|
||||
"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",
|
||||
"identity_title": "Verify identity",
|
||||
"identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.",
|
||||
"national_id_label": "National ID",
|
||||
"national_id_hint": "Enter your 10-digit national ID.",
|
||||
"national_id_invalid": "Enter a valid national ID.",
|
||||
"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.",
|
||||
"selfie_label": "Liveness selfie",
|
||||
"selfie_hint": "Take a selfie for face verification.",
|
||||
"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.",
|
||||
"error_shahkar_mismatch": "The mobile-to-national-ID match failed. Please check and try again.",
|
||||
"identity_submit": "Submit & verify",
|
||||
"identity_submitting": "Verifying…",
|
||||
"identity_submitted": "Identity submitted — verification started",
|
||||
"back_to_checklist": "Back to checklist",
|
||||
"credentials_title": "Professional credentials",
|
||||
"credentials_subtitle": "Your documents are reviewed by our team after upload.",
|
||||
"credentials_needs_start": "Start verification from the status checklist first.",
|
||||
"ino_number_label": "Nursing-council number",
|
||||
"ino_number_hint": "Enter your nursing-council membership number.",
|
||||
"ino_number_required": "Enter your nursing-council number.",
|
||||
"doc_uploaded": "Document uploaded",
|
||||
"education_label": "Education certificate",
|
||||
"education_hint": "Optional — an image of your latest qualification.",
|
||||
"specialties_label": "Specialties",
|
||||
"specialties_hint": "Choose or add your areas of specialty.",
|
||||
"specialty_elderly": "Elderly care",
|
||||
"specialty_icu": "ICU",
|
||||
"specialty_pediatric": "Pediatric",
|
||||
"specialty_post_surgery": "Post-surgery",
|
||||
"specialty_wound_care": "Wound care",
|
||||
"specialty_add_placeholder": "Another specialty",
|
||||
"specialty_add": "Add",
|
||||
"registry_details_label": "Credential details (optional)",
|
||||
"issuing_authority_label": "Issuing authority",
|
||||
"issued_at_label": "Issue date",
|
||||
"expires_at_label": "Expiry date",
|
||||
"manual_review_note": "These documents are reviewed manually by our team — not an instant approval.",
|
||||
"credentials_submit": "Submit credentials",
|
||||
"credentials_submitting": "Submitting…",
|
||||
"credentials_submitted": "Credentials submitted — under review",
|
||||
"credentials_error": "Couldn't submit your credentials. Check the inputs and try again.",
|
||||
"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_approved_title": "Verification complete",
|
||||
"review_approved_body": "Every step is verified. You can now publish your profile.",
|
||||
"review_summary_title": "Status summary",
|
||||
"review_view_status": "View status",
|
||||
"badge_verified": "Verified",
|
||||
"badge_unverified": "Not verified",
|
||||
"badge_expired": "Expired",
|
||||
"publish_ready_title": "Ready to publish",
|
||||
"publish_ready_body": "Your verification is complete. Your services are visible and bookable.",
|
||||
"publish_blocked_title": "Complete verification to publish",
|
||||
"publish_blocked_body": "Until verification is complete, your services won't appear in search and can't be booked.",
|
||||
"publish_cta": "Publish profile",
|
||||
"publish_complete_verification": "Complete verification",
|
||||
"publish_done": "Your profile has been published",
|
||||
"mock_admin_title": "Simulate admin review (demo)",
|
||||
"mock_admin_approve": "Approve all steps",
|
||||
"mock_admin_reject": "Reject a document",
|
||||
"upload_choose": "Choose file",
|
||||
"upload_capture": "Take photo",
|
||||
"upload_size_hint": "JPG, PNG or PDF — up to {size} MB.",
|
||||
"upload_uploading": "Uploading…",
|
||||
"upload_success": "Uploaded",
|
||||
"upload_change": "Change",
|
||||
"upload_error": "Upload failed.",
|
||||
"upload_bad_type": "That file type isn't allowed. Use JPG, PNG or PDF.",
|
||||
"upload_too_large": "The file must be smaller than {size} MB.",
|
||||
"upload_retry": "Try again",
|
||||
"upload_rejected": "This document was rejected",
|
||||
"upload_reupload": "Upload again"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,5 +343,130 @@
|
||||
"role_nurse": "پرستار",
|
||||
"role_nurse_desc": "برای ارائه خدمات پرستاری",
|
||||
"continue": "ادامه"
|
||||
},
|
||||
"verification": {
|
||||
"title": "احراز هویت",
|
||||
"subtitle": "برای فعالشدن پروفایل و دریافت رزرو، این مراحل را کامل کنید.",
|
||||
"retry": "تلاش مجدد",
|
||||
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
|
||||
"start_title": "احراز هویت را آغاز کنید",
|
||||
"start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانوادهها با اطمینان شما را انتخاب میکنند.",
|
||||
"start_cta": "شروع احراز هویت",
|
||||
"starting": "در حال آمادهسازی…",
|
||||
"progress_title": "پیشرفت احراز هویت",
|
||||
"progress_count": "{passed} از {total}",
|
||||
"blocking_summary": "تا تکمیل همهٔ مراحل، پروفایل شما قابل نمایش و رزرو نیست.",
|
||||
"continue": "ادامه",
|
||||
"continue_step": "ادامه مرحلهٔ {n}",
|
||||
"approved_title": "احراز هویت تکمیل شد",
|
||||
"approved_body": "همهچیز تأیید شد. اکنون میتوانید پروفایل و خدمات خود را منتشر کنید.",
|
||||
"approved_cta": "انتشار خدمات",
|
||||
"step_mobile_verified": "شماره موبایل",
|
||||
"step_mobile_verified_desc": "با ورود شما تأیید شد.",
|
||||
"step_identity_kyc": "احراز هویت (ثبت احوال)",
|
||||
"step_identity_kyc_desc": "استعلام خودکار از ثبت احوال با کد ملی و تصویر زنده.",
|
||||
"step_shahkar_match": "تطبیق شاهکار",
|
||||
"step_shahkar_match_desc": "استعلام خودکار تطبیق شماره موبایل با کد ملی.",
|
||||
"step_moh_competency_license": "پروانه صلاحیت (وزارت بهداشت)",
|
||||
"step_moh_competency_license_desc": "آپلود مدرک؛ بررسی دستی توسط کارشناس.",
|
||||
"step_ino_membership": "عضویت نظام پرستاری",
|
||||
"step_ino_membership_desc": "آپلود کارت نظام پرستاری؛ بررسی دستی.",
|
||||
"step_criminal_record": "گواهی عدم سوءپیشینه",
|
||||
"step_criminal_record_desc": "آپلود گواهی؛ بررسی دستی. دارای تاریخ انقضا.",
|
||||
"step_bank_account_verification": "تأیید حساب بانکی",
|
||||
"step_bank_account_verification_desc": "استعلام خودکار مالکیت شبا با کد ملی.",
|
||||
"status_passed": "تاییدشده",
|
||||
"status_in_review": "در حال بررسی",
|
||||
"status_pending": "در انتظار",
|
||||
"status_failed": "رد شد",
|
||||
"status_expired": "منقضی شده",
|
||||
"status_next": "بعدی",
|
||||
"auto_query_note": "استعلام خودکار — بدون نیاز به بارگذاری مدرک.",
|
||||
"row_go": "تکمیل این مرحله",
|
||||
"row_fix": "رفع مشکل و ارسال دوباره",
|
||||
"reason_shared_sim": "به نظر میرسد این سیمکارت به نام شما نیست. لطفاً با سیمکارتی که به نام خودتان است دوباره تلاش کنید.",
|
||||
"reason_kyc_no_match": "اطلاعات هویتی با ثبت احوال مطابقت نداشت.",
|
||||
"reason_national_id_mismatch": "کد ملی مطابقت نداشت.",
|
||||
"reason_blurry_scan": "تصویر مدرک واضح نبود. لطفاً نسخهٔ خواناتری بارگذاری کنید.",
|
||||
"journey_identity": "هویت",
|
||||
"journey_credentials": "مدارک",
|
||||
"journey_review": "بررسی",
|
||||
"identity_title": "تأیید هویت",
|
||||
"identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.",
|
||||
"national_id_label": "کد ملی",
|
||||
"national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.",
|
||||
"national_id_invalid": "کد ملی معتبر وارد کنید.",
|
||||
"card_label": "تصویر کارت ملی",
|
||||
"card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.",
|
||||
"card_recommended": "بارگذاری تصویر کارت ملی توصیه میشود.",
|
||||
"selfie_label": "سلفی زنده",
|
||||
"selfie_hint": "برای تشخیص چهره، سلفی بگیرید.",
|
||||
"auto_registry_note": "استعلام خودکار از ثبت احوال انجام میشود.",
|
||||
"error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.",
|
||||
"error_shared_sim": "به نظر میرسد این سیمکارت به نام شما نیست. لطفاً با سیمکارتی که به نام خودتان است دوباره تلاش کنید.",
|
||||
"error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.",
|
||||
"identity_submit": "ثبت و استعلام",
|
||||
"identity_submitting": "در حال استعلام…",
|
||||
"identity_submitted": "هویت ثبت شد — استعلام آغاز شد",
|
||||
"back_to_checklist": "بازگشت به فهرست",
|
||||
"credentials_title": "مدارک حرفهای",
|
||||
"credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی میشود.",
|
||||
"credentials_needs_start": "ابتدا احراز هویت را از فهرست وضعیت آغاز کنید.",
|
||||
"ino_number_label": "شماره نظام پرستاری",
|
||||
"ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.",
|
||||
"ino_number_required": "شماره نظام پرستاری را وارد کنید.",
|
||||
"doc_uploaded": "مدرک بارگذاری شد",
|
||||
"education_label": "مدرک تحصیلی",
|
||||
"education_hint": "اختیاری — تصویر آخرین مدرک تحصیلی.",
|
||||
"specialties_label": "تخصصها",
|
||||
"specialties_hint": "حوزههای تخصص خود را انتخاب یا اضافه کنید.",
|
||||
"specialty_elderly": "سالمندان",
|
||||
"specialty_icu": "آیسییو",
|
||||
"specialty_pediatric": "کودکان",
|
||||
"specialty_post_surgery": "پس از جراحی",
|
||||
"specialty_wound_care": "زخم و پانسمان",
|
||||
"specialty_add_placeholder": "تخصص دیگر",
|
||||
"specialty_add": "افزودن",
|
||||
"registry_details_label": "جزئیات مدرک (اختیاری)",
|
||||
"issuing_authority_label": "مرجع صادرکننده",
|
||||
"issued_at_label": "تاریخ صدور",
|
||||
"expires_at_label": "تاریخ انقضا",
|
||||
"manual_review_note": "این مدارک بهصورت دستی توسط کارشناس بررسی میشوند؛ تأیید فوری نیست.",
|
||||
"credentials_submit": "ثبت مدارک",
|
||||
"credentials_submitting": "در حال ثبت…",
|
||||
"credentials_submitted": "مدارک ثبت شد — در حال بررسی",
|
||||
"credentials_error": "ثبت مدارک ممکن نشد. ورودیها را بررسی کرده و دوباره تلاش کنید.",
|
||||
"review_title": "در حال بررسی",
|
||||
"review_body": "مدارک شما ثبت شد و در حال بررسی توسط کارشناس است.",
|
||||
"review_eta": "معمولاً ۲۴ تا ۴۸ ساعت زمان میبرد.",
|
||||
"review_approved_title": "احراز هویت تکمیل شد",
|
||||
"review_approved_body": "همهٔ مراحل تأیید شد. اکنون میتوانید پروفایل خود را منتشر کنید.",
|
||||
"review_summary_title": "خلاصهٔ وضعیت",
|
||||
"review_view_status": "مشاهده وضعیت",
|
||||
"badge_verified": "تاییدشده",
|
||||
"badge_unverified": "احراز نشده",
|
||||
"badge_expired": "منقضی شده",
|
||||
"publish_ready_title": "آمادهٔ انتشار",
|
||||
"publish_ready_body": "احراز هویت شما کامل است. خدمات شما قابل نمایش و رزرو هستند.",
|
||||
"publish_blocked_title": "برای انتشار، احراز هویت را کامل کنید",
|
||||
"publish_blocked_body": "تا تکمیل احراز هویت، خدمات شما در جستجو نمایش داده نمیشود و قابل رزرو نیست.",
|
||||
"publish_cta": "انتشار پروفایل",
|
||||
"publish_complete_verification": "تکمیل احراز هویت",
|
||||
"publish_done": "پروفایل شما منتشر شد",
|
||||
"mock_admin_title": "شبیهسازی بررسی مدیر (نمایشی)",
|
||||
"mock_admin_approve": "تأیید همهٔ مراحل",
|
||||
"mock_admin_reject": "رد یک مدرک",
|
||||
"upload_choose": "انتخاب فایل",
|
||||
"upload_capture": "گرفتن عکس",
|
||||
"upload_size_hint": "JPG، PNG یا PDF — حداکثر {size} مگابایت.",
|
||||
"upload_uploading": "در حال بارگذاری…",
|
||||
"upload_success": "بارگذاری شد",
|
||||
"upload_change": "تغییر",
|
||||
"upload_error": "بارگذاری ناموفق بود.",
|
||||
"upload_bad_type": "نوع فایل مجاز نیست. از JPG، PNG یا PDF استفاده کنید.",
|
||||
"upload_too_large": "حجم فایل باید کمتر از {size} مگابایت باشد.",
|
||||
"upload_retry": "تلاش مجدد",
|
||||
"upload_rejected": "این مدرک رد شد",
|
||||
"upload_reupload": "بارگذاری دوباره"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ChangeEvent, FunctionComponent, 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 { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
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';
|
||||
|
||||
const MAX_YEARS = 80;
|
||||
|
||||
@@ -24,6 +26,8 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertNurseProfile();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const { data: verificationStatus } = useVerificationStatus();
|
||||
const badgeState = ownBadgeState(verificationStatus);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
|
||||
@@ -63,41 +67,47 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
|
||||
<TrustBadge state={badgeState} />
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Not bookable until verification (f5) — a neutral placeholder, not the real banner. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
|
||||
{badgeState !== 'verified' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('unverified_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('unverified_body')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('unverified_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
||||
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import PublishGate from './PublishGate';
|
||||
|
||||
interface MyServicesListProps {
|
||||
onAdd: () => void;
|
||||
@@ -86,6 +87,8 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<PublishGate />
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1].map((key) => (
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, 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';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
const PublishGate: FunctionComponent = () => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
|
||||
if (isLoading) return null;
|
||||
const approved = isApproved(status);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: approved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon
|
||||
icon={approved ? 'verified' : 'warning'}
|
||||
size={24}
|
||||
color={approved ? 'var(--bal-success)' : 'var(--bal-warning)'}
|
||||
/>
|
||||
<Stack sx={{ gap: 0.5, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{approved ? t('publish_ready_title') : t('publish_blocked_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{approved ? t('publish_ready_body') : t('publish_blocked_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="publish"
|
||||
disabled={!approved}
|
||||
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('publish_cta')}
|
||||
</AppButton>
|
||||
{!approved ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('publish_complete_verification')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishGate;
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
'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 type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||
import {
|
||||
displaySteps,
|
||||
progressCounts,
|
||||
routeForStep,
|
||||
stepDescriptionKey,
|
||||
stepLabelKey,
|
||||
stepStatusChip,
|
||||
} from './verificationSteps';
|
||||
|
||||
interface VerificationChecklistProps {
|
||||
status: VerificationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const VerificationChecklist: FunctionComponent<VerificationChecklistProps> = ({ status }) => {
|
||||
const { passed, total } = progressCounts(status);
|
||||
const steps = displaySteps(status);
|
||||
const firstActionableId = steps.find(
|
||||
(step) => step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null,
|
||||
)?.id;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<ProgressMeter passed={passed} total={total} />
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{steps.map((step) => (
|
||||
<StepRow key={step.code} step={step} highlighted={step.id === firstActionableId} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ passed, total }) => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const percent = total === 0 ? 0 : (passed / total) * 100;
|
||||
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('progress_title')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'var(--bal-primary)' }}>
|
||||
{t('progress_count', { passed: format(passed), total: format(total) })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={percent} sx={{ height: 8, borderRadius: 1 }} />
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean }> = ({ step, highlighted }) => {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const chip = stepStatusChip(step.status);
|
||||
const labelKey = stepLabelKey(step.code);
|
||||
const descKey = stepDescriptionKey(step.code);
|
||||
const label = t.has(labelKey) ? t(labelKey) : step.displayName;
|
||||
const description = t.has(descKey) ? t(descKey) : '';
|
||||
const route = routeForStep(step.code);
|
||||
const showReason = step.status === 'failed' || step.status === 'expired';
|
||||
const reason = step.failureReason
|
||||
? t.has(`reason_${step.failureReason}`)
|
||||
? t(`reason_${step.failureReason}`)
|
||||
: step.failureReason
|
||||
: null;
|
||||
// Only the genuinely automated checks may advertise "استعلام خودکار" — the honesty constraint.
|
||||
const showAutoNote = step.isAutomated && step.status === 'not_started';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: highlighted ? 'var(--bal-primary)' : 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={step.isAutomated ? 'verified' : 'document'} size={22} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{description ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
||||
</Stack>
|
||||
|
||||
{showReason && reason ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
||||
{reason}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{showAutoNote ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('auto_query_note')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{route && (highlighted || step.status === 'failed' || step.status === 'expired') ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="edit"
|
||||
to={`/${locale}${route}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationChecklist;
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
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 type { UploadedDocInfo } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
useSubmitCredentials,
|
||||
useUploadVerificationDocument,
|
||||
useVerificationStatus,
|
||||
} from '@/services/verification';
|
||||
import { SPECIALTY_PRESETS } from '@/services/verification/types';
|
||||
import type { VerificationStep } from '@/services/verification/types';
|
||||
import { stepDescriptionKey, stepLabelKey } from '../verificationSteps';
|
||||
|
||||
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).
|
||||
*/
|
||||
export default function CredentialsSubmitPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
const uploadDocument = useUploadVerificationDocument();
|
||||
const submitCredentials = useSubmitCredentials();
|
||||
|
||||
const [inoNumber, setInoNumber] = useState('');
|
||||
const [inoError, setInoError] = useState(false);
|
||||
const [specialties, setSpecialties] = useState<string[]>([]);
|
||||
const [customSpecialty, setCustomSpecialty] = useState('');
|
||||
const [issuingAuthority, setIssuingAuthority] = useState('');
|
||||
const [issuedAt, setIssuedAt] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
|
||||
|
||||
const manualSteps = useMemo(
|
||||
() => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)),
|
||||
[status],
|
||||
);
|
||||
|
||||
const toggleSpecialty = (value: string) =>
|
||||
setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
|
||||
|
||||
const addCustomSpecialty = () => {
|
||||
const value = customSpecialty.trim();
|
||||
if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]);
|
||||
setCustomSpecialty('');
|
||||
};
|
||||
|
||||
const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => {
|
||||
const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress });
|
||||
return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const inoValid = inoNumber.trim().length > 0;
|
||||
setInoError(!inoValid);
|
||||
if (!inoValid) return;
|
||||
|
||||
submitCredentials.mutate(
|
||||
{
|
||||
inoNumber: inoNumber.trim(),
|
||||
specialties,
|
||||
issuingAuthority: issuingAuthority.trim() || undefined,
|
||||
issuedAt: issuedAt || undefined,
|
||||
expiresAt: expiresAt || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('credentials_submitted'), { variant: 'success' });
|
||||
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`);
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
if (!status || manualSteps.length === 0) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('credentials_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('credentials_needs_start')}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const anyUploaded = Object.values(uploadedSteps).some(Boolean);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('credentials_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('credentials_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={1} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('ino_number_label')}
|
||||
value={inoNumber}
|
||||
onChange={(event) => {
|
||||
setInoNumber(event.target.value);
|
||||
if (inoError) setInoError(false);
|
||||
}}
|
||||
error={inoError}
|
||||
helperText={inoError ? t('ino_number_required') : t('ino_number_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* One uploader per manual credential step — data-driven from the status. */}
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{manualSteps.map((step) => (
|
||||
<DocumentUpload
|
||||
key={step.code}
|
||||
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
|
||||
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
|
||||
onUpload={uploadToStep(step)}
|
||||
onUploaded={() => setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))}
|
||||
rejected={step.status === 'failed'}
|
||||
rejectionReason={step.failureReason ?? undefined}
|
||||
existingDoc={step.status === 'in_review' ? { name: t('doc_uploaded') } : null}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Supplementary education certificate — a local attachment (no dedicated step). */}
|
||||
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('specialties_label')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('specialties_hint')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
{SPECIALTY_PRESETS.map((preset) => {
|
||||
const selected = specialties.includes(preset);
|
||||
return (
|
||||
<Chip
|
||||
key={preset}
|
||||
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
||||
onClick={() => toggleSpecialty(preset)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
|
||||
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{specialties
|
||||
.filter((value) => !SPECIALTY_PRESETS.includes(value))
|
||||
.map((value) => (
|
||||
<Chip
|
||||
key={value}
|
||||
label={value}
|
||||
onDelete={() => toggleSpecialty(value)}
|
||||
sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder={t('specialty_add_placeholder')}
|
||||
value={customSpecialty}
|
||||
onChange={(event) => setCustomSpecialty(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addCustomSpecialty();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty} sx={{ m: 0 }}>
|
||||
{t('specialty_add')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Optional registry details the admin cross-checks — issue/expiry are stored UTC (Shamsi shown elsewhere). */}
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('registry_details_label')}
|
||||
</Typography>
|
||||
<TextField
|
||||
label={t('issuing_authority_label')}
|
||||
value={issuingAuthority}
|
||||
onChange={(event) => setIssuingAuthority(event.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label={t('issued_at_label')}
|
||||
type="date"
|
||||
value={issuedAt}
|
||||
onChange={(event) => setIssuedAt(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('expires_at_label')}
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(event) => setExpiresAt(event.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('manual_review_note')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="license"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitCredentials.isPending || !anyUploaded}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
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 { 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';
|
||||
|
||||
type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null;
|
||||
|
||||
/**
|
||||
* B4 — identity submission. Collects the national id (10-digit + checksum), a national-ID card image,
|
||||
* and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The
|
||||
* card/selfie are **local captures** feeding the automated check (identity stores no document server-side),
|
||||
* so `DocumentUpload` runs in local mode here. The auto-query note is honest — this check is performed.
|
||||
* The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on
|
||||
* its own step.
|
||||
*/
|
||||
export default function IdentitySubmitPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const submitIdentity = useSubmitIdentity();
|
||||
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [idError, setIdError] = useState(false);
|
||||
const [cardCaptured, setCardCaptured] = useState(false);
|
||||
const [selfieCaptured, setSelfieCaptured] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<SubmitError>(null);
|
||||
|
||||
// Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately.
|
||||
const captureLocally = async (file: File) => ({ name: file.name });
|
||||
|
||||
const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const idValid = isValidNationalId(nationalId);
|
||||
setIdError(!idValid);
|
||||
setSubmitError(null);
|
||||
if (!idValid || !selfieCaptured) return;
|
||||
|
||||
submitIdentity.mutate(
|
||||
{ nationalId, livenessCaptured: selfieCaptured },
|
||||
{
|
||||
onSuccess: (result: SubmitIdentityResult) => {
|
||||
if (result.identity.stepStatus === 'failed') {
|
||||
setSubmitError({ key: 'national_id_mismatch' });
|
||||
return;
|
||||
}
|
||||
if (result.shahkar?.stepStatus === 'failed') {
|
||||
setSubmitError({ key: result.shahkar.failureReason === 'shared_sim' ? 'shared_sim' : 'shahkar_mismatch' });
|
||||
return;
|
||||
}
|
||||
enqueueSnackbar(t('identity_submitted'), { variant: 'success' });
|
||||
router.push(`/${locale}${ROUTES.NURSE_VERIFICATION}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('identity_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('identity_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader
|
||||
steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]}
|
||||
activeStep={0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label={t('national_id_label')}
|
||||
value={nationalId}
|
||||
onChange={(event) => {
|
||||
setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH));
|
||||
if (idError) setIdError(false);
|
||||
}}
|
||||
error={idError}
|
||||
helperText={idError ? t('national_id_invalid') : t('national_id_hint')}
|
||||
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<DocumentUpload
|
||||
label={t('card_label')}
|
||||
hint={t('card_hint')}
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
capture="environment"
|
||||
onUpload={captureLocally}
|
||||
onUploaded={() => setCardCaptured(true)}
|
||||
/>
|
||||
|
||||
<DocumentUpload
|
||||
label={t('selfie_label')}
|
||||
hint={t('selfie_hint')}
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
capture="user"
|
||||
onUpload={captureLocally}
|
||||
onUploaded={() => setSelfieCaptured(true)}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('auto_registry_note')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{submitError ? (
|
||||
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
|
||||
{t(`error_${submitError.key}`)}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
{!cardCaptured ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('card_recommended')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="identity"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
||||
{t('back_to_checklist')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,237 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
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 } from '@/components';
|
||||
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 VerificationChecklist from './VerificationChecklist';
|
||||
import { nextActionRoute, nextActionableIndex } from './verificationSteps';
|
||||
|
||||
export default async function NurseVerificationPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="verification" title={t('verification')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* B3 — the verification status hub. The canonical view of the single cached `VerificationStatus` query
|
||||
* (B6 is a focused second view of the same data, never a separate fetch). Renders the loading skeleton,
|
||||
* error, the `not_started` start-CTA, the in-progress checklist + a single "continue" CTA that routes to
|
||||
* the next actionable step, and the terminal `approved` state (link to publish). The mock-only admin
|
||||
* simulation lets a human observe the verified flip while the b6 admin queue is deferred (f15).
|
||||
*/
|
||||
export default function NurseVerificationPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: status, isLoading, isError, refetch } = useVerificationStatus();
|
||||
const startVerification = useStartVerification();
|
||||
|
||||
const go = (route: string) => router.push(`/${locale}${route}`);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!status || status.status === 'not_started') {
|
||||
startVerification.mutate(undefined, {
|
||||
onSuccess: () => go(ROUTES.NURSE_VERIFICATION_IDENTITY),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const route = nextActionRoute(status);
|
||||
if (route) go(route);
|
||||
};
|
||||
|
||||
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 620 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<AppAlert
|
||||
severity="error"
|
||||
action={
|
||||
<AppButton variant="text" color="inherit" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
}
|
||||
>
|
||||
{t('load_error')}
|
||||
</AppAlert>
|
||||
) : !status || status.status === 'not_started' ? (
|
||||
<NotStarted onStart={handleContinue} pending={startVerification.isPending} />
|
||||
) : status.status === 'approved' ? (
|
||||
<Approved onPublish={() => go(ROUTES.NURSE_SERVICES)} />
|
||||
) : (
|
||||
<>
|
||||
<VerificationChecklist status={status} />
|
||||
<BlockingSummary hasBlocking={status.blockingSteps.length > 0} />
|
||||
<ContinueCta status={status} onContinue={handleContinue} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dev-only: stand in for the deferred (f15) admin review queue so a human can watch the flip. */}
|
||||
{USE_VERIFICATION_MOCK && status && status.status !== 'not_started' ? (
|
||||
<MockAdminControls
|
||||
onApprove={() => {
|
||||
__mockApproveAll();
|
||||
refreshStatus();
|
||||
}}
|
||||
onReject={() => {
|
||||
__mockRejectStep('moh_competency_license', 'blurry_scan');
|
||||
refreshStatus();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('start_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
|
||||
{t('start_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
onClick={onStart}
|
||||
disabled={pending}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pending ? t('starting') : t('start_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function Approved({ onPublish }: { onPublish: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-success)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={28} color="var(--bal-success)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('approved_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('approved_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="publish"
|
||||
onClick={onPublish}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('approved_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockingSummary({ hasBlocking }: { hasBlocking: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
if (!hasBlocking) return null;
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('blocking_summary')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueCta({
|
||||
status,
|
||||
onContinue,
|
||||
}: {
|
||||
status: Parameters<typeof nextActionRoute>[0];
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const t = useTranslations('verification');
|
||||
const index = nextActionableIndex(status);
|
||||
const route = nextActionRoute(status);
|
||||
if (route == null) return null;
|
||||
return (
|
||||
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||
{index != null ? t('continue_step', { n: index }) : t('continue')}
|
||||
</AppButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('mock_admin_title')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="outlined" color="primary" onClick={onApprove} sx={{ m: 0 }}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject} sx={{ m: 0 }}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'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 { ROUTES } from '@/constants';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { displaySteps, stepLabelKey, stepStatusChip } from '../verificationSteps';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function UnderReviewPage() {
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
const { data: status, isLoading } = useVerificationStatus();
|
||||
|
||||
if (isLoading) return <AppLoading />;
|
||||
|
||||
const steps = displaySteps(status);
|
||||
const isApproved = status?.status === 'approved';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<StepperHeader steps={[t('journey_identity'), t('journey_credentials'), t('journey_review')]} activeStep={2} />
|
||||
</Box>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: isApproved ? 'var(--bal-success)' : 'var(--bal-warning)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={isApproved ? 'verified' : 'pending'} size={28} color={isApproved ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||
<Typography variant="h6" component="h1">
|
||||
{isApproved ? t('review_approved_title') : t('review_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isApproved ? t('review_approved_body') : t('review_body')}
|
||||
</Typography>
|
||||
{!isApproved ? (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'var(--bal-warning)' }}>
|
||||
{t('review_eta')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('review_summary_title')}
|
||||
</Typography>
|
||||
{steps.map((step) => {
|
||||
const chip = stepStatusChip(step.status);
|
||||
const label = t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName;
|
||||
return (
|
||||
<Stack
|
||||
key={step.code}
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', py: 0.5 }}
|
||||
>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
<StatusChip status={chip.kind} label={t(chip.labelKey)} sx={{ flexShrink: 0 }} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('review_view_status')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import type { VerificationStatus, VerificationStep, VerificationStepStatus } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* Screen helpers shared across the verification subtree (B3/B4/B5/B6). Keeps the rendering **data-driven**:
|
||||
* the screens iterate `steps[]` and map each `code`/`status` to a label + chip via these helpers — a new
|
||||
* step type in the response renders without a code change. Labels are i18n keys off the `code`/`status`,
|
||||
* never derived from the enum string (honesty + localisation rule).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A display-only "mobile verified" step prepended to the checklist. It is **not** a server step — it is
|
||||
* satisfied at login (f1 phone-OTP), so it always renders `passed` and never blocks. Id 0 keeps it out
|
||||
* of the server-id space.
|
||||
*/
|
||||
export const MOBILE_STEP: VerificationStep = {
|
||||
id: 0,
|
||||
code: 'mobile_verified',
|
||||
displayName: 'mobile_verified',
|
||||
status: 'passed',
|
||||
isAutomated: true,
|
||||
expiresAt: null,
|
||||
failureReason: null,
|
||||
};
|
||||
|
||||
/** The full ordered checklist as shown: the synthetic mobile step, then the server's seeded steps. */
|
||||
export function displaySteps(status: VerificationStatus | undefined): VerificationStep[] {
|
||||
return status ? [MOBILE_STEP, ...status.steps] : [MOBILE_STEP];
|
||||
}
|
||||
|
||||
/** "X از Y" meter counts — X = passed steps, Y = total (all seeded steps are required). */
|
||||
export function progressCounts(status: VerificationStatus | undefined): { passed: number; total: number } {
|
||||
const steps = displaySteps(status);
|
||||
return { passed: steps.filter((step) => step.status === 'passed').length, total: steps.length };
|
||||
}
|
||||
|
||||
/** The i18n label key for a step, keyed off its stable `code`. */
|
||||
export function stepLabelKey(code: string): string {
|
||||
return `step_${code}`;
|
||||
}
|
||||
|
||||
/** The i18n one-line description key for a step (what it verifies / why it is manual vs automatic). */
|
||||
export function stepDescriptionKey(code: string): string {
|
||||
return `step_${code}_desc`;
|
||||
}
|
||||
|
||||
interface StepChip {
|
||||
kind: StatusKind;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip kind + label key for a per-step status. Encodes the wireframe legend: green = passed,
|
||||
* amber = pending/in-review/expired, grey = not-started/next, red = failed. Expired is amber but keeps
|
||||
* its own label so copy stays honest ("expired", not "pending").
|
||||
*/
|
||||
export function stepStatusChip(status: VerificationStepStatus): StepChip {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return { kind: 'verified', labelKey: 'status_passed' };
|
||||
case 'in_review':
|
||||
return { kind: 'pending', labelKey: 'status_in_review' };
|
||||
case 'pending':
|
||||
return { kind: 'pending', labelKey: 'status_pending' };
|
||||
case 'failed':
|
||||
return { kind: 'rejected', labelKey: 'status_failed' };
|
||||
case 'expired':
|
||||
return { kind: 'pending', labelKey: 'status_expired' };
|
||||
case 'not_started':
|
||||
default:
|
||||
return { kind: 'neutral', labelKey: 'status_next' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Which submission screen owns a given step code (drives the row CTA + the "continue" router). */
|
||||
export function routeForStep(code: string): string | null {
|
||||
switch (code) {
|
||||
case 'identity_kyc':
|
||||
case 'shahkar_match':
|
||||
return ROUTES.NURSE_VERIFICATION_IDENTITY;
|
||||
case 'moh_competency_license':
|
||||
case 'ino_membership':
|
||||
case 'criminal_record':
|
||||
return ROUTES.NURSE_VERIFICATION_CREDENTIALS;
|
||||
case 'bank_account_verification':
|
||||
return ROUTES.NURSE_BANK;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A step the nurse can act on now — not passed, not waiting on an admin (`in_review`). */
|
||||
function isActionable(step: VerificationStep): boolean {
|
||||
return step.status !== 'passed' && step.status !== 'in_review' && routeForStep(step.code) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route the B3 "continue" CTA targets: the first actionable step's screen (in checklist order); if
|
||||
* everything left is waiting on admin (`in_review`), the under-review screen; `null` when approved (the
|
||||
* publish CTA takes over).
|
||||
*/
|
||||
export function nextActionRoute(status: VerificationStatus | undefined): string | null {
|
||||
if (!status || status.status === 'approved') return null;
|
||||
const next = status.steps.find(isActionable);
|
||||
if (next) return routeForStep(next.code);
|
||||
if (status.steps.some((step) => step.status === 'in_review')) return ROUTES.NURSE_VERIFICATION_REVIEW;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The 1-based index of the next actionable step (for the "continue to step N" CTA label). */
|
||||
export function nextActionableIndex(status: VerificationStatus | undefined): number | null {
|
||||
if (!status) return null;
|
||||
const steps = displaySteps(status);
|
||||
const index = steps.findIndex(isActionable);
|
||||
return index === -1 ? null : index + 1;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl echoes keys (and ignores interpolation params) so we assert on the state keys.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import DocumentUpload from './DocumentUpload';
|
||||
|
||||
function renderUpload(props: Partial<React.ComponentProps<typeof DocumentUpload>> = {}) {
|
||||
const onUpload = props.onUpload ?? jest.fn().mockResolvedValue({ name: 'license.pdf' });
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<DocumentUpload label="License" onUpload={onUpload} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const input = utils.container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
return { ...utils, input, onUpload };
|
||||
}
|
||||
|
||||
function selectFile(input: HTMLInputElement, file: File) {
|
||||
Object.defineProperty(input, 'files', { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
describe('<DocumentUpload/> component', () => {
|
||||
it('renders the field label and the idle choose zone', () => {
|
||||
const { container } = renderUpload();
|
||||
expect(screen.getByText('License')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-upload-state="idle"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a disallowed file type before uploading', () => {
|
||||
const { input, onUpload, container } = renderUpload();
|
||||
selectFile(input, new File(['x'], 'note.txt', { type: 'text/plain' }));
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('upload_bad_type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a file over the size cap before uploading', () => {
|
||||
const { input, onUpload } = renderUpload({ maxSizeBytes: 10 });
|
||||
const big = new File([new Uint8Array(50)], 'card.png', { type: 'image/png' });
|
||||
selectFile(input, big);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('upload_too_large')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uploads a valid file and shows the success state', async () => {
|
||||
const onUploaded = jest.fn();
|
||||
const { input, onUpload } = renderUpload({ onUploaded });
|
||||
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
|
||||
await waitFor(() => expect(screen.getByText('upload_success')).toBeInTheDocument());
|
||||
expect(onUpload).toHaveBeenCalledTimes(1);
|
||||
expect(onUploaded).toHaveBeenCalledWith({ name: 'license.pdf' });
|
||||
});
|
||||
|
||||
it('shows a retryable error when the upload fails', async () => {
|
||||
const onUpload = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
const { input, container } = renderUpload({ onUpload });
|
||||
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
|
||||
await waitFor(() => expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument());
|
||||
expect(screen.getByText('upload_retry')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the rejected state with its reason and a re-upload affordance', () => {
|
||||
const { container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan' });
|
||||
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
|
||||
expect(screen.getByText('upload_reupload')).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();
|
||||
expect(screen.getByText('prior-license.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppButton from '../common/AppButton';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants';
|
||||
|
||||
/** The stored document as the uploader displays it — a name + optional size, never bytes. */
|
||||
export interface UploadedDocInfo {
|
||||
name: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
type UploadState = 'idle' | 'uploading' | 'success' | 'error';
|
||||
|
||||
export interface DocumentUploadProps {
|
||||
/** Field label (already translated). */
|
||||
label: string;
|
||||
/** Optional helper text under the label. */
|
||||
hint?: string;
|
||||
/** Accepted MIME types. Defaults to jpg/png/pdf (the b6 object-storage limits). */
|
||||
accept?: readonly string[];
|
||||
/** Max file size in bytes. Defaults to 5 MB. */
|
||||
maxSizeBytes?: number;
|
||||
/** Mobile camera hint: `environment` (rear — ID card) or `user` (front — selfie). */
|
||||
capture?: 'user' | 'environment';
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The async upload action — receives the file + a progress reporter (0–100) and resolves with the
|
||||
* stored doc. For a server step this calls the verification seam; for a local capture (B4 identity) it
|
||||
* validates + resolves locally without a round-trip.
|
||||
*/
|
||||
onUpload: (file: File, onProgress: (percent: number) => void) => Promise<UploadedDocInfo>;
|
||||
/** Called after a successful upload with the resolved metadata. */
|
||||
onUploaded?: (doc: UploadedDocInfo) => void;
|
||||
/** A previously-uploaded document (drives the "already uploaded ✓" state from server metadata). */
|
||||
existingDoc?: UploadedDocInfo | null;
|
||||
/** When the step was rejected — renders the reason and a re-upload affordance (never a dead end). */
|
||||
rejected?: boolean;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable document uploader for every verification document step (national-ID card, license, education
|
||||
* cert, criminal record). Owns the full state machine — idle → validating → uploading (progress %) →
|
||||
* success (✓ + file name / local image preview) → error (retry) — with client-side type/size validation
|
||||
* before any upload and a re-upload affordance on reject. Returns the server's stored **metadata** only;
|
||||
* a local image preview never becomes the source of the "uploaded" truth. Its own chrome strings come
|
||||
* from the `verification` namespace; the caller passes the field `label`/`hint`.
|
||||
* @component DocumentUpload
|
||||
*/
|
||||
const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
label,
|
||||
hint,
|
||||
accept = ACCEPTED_DOCUMENT_TYPES,
|
||||
maxSizeBytes = MAX_DOCUMENT_SIZE_BYTES,
|
||||
capture,
|
||||
disabled = false,
|
||||
onUpload,
|
||||
onUploaded,
|
||||
existingDoc = null,
|
||||
rejected = false,
|
||||
rejectionReason,
|
||||
}) => {
|
||||
const t = useTranslations('verification');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [state, setState] = useState<UploadState>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// A local image preview is an object URL — revoke it when it changes or the component unmounts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
|
||||
const onFileSelected = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = ''; // allow re-picking the same file after an error
|
||||
if (!file) return;
|
||||
|
||||
if (!accept.includes(file.type)) {
|
||||
setState('error');
|
||||
setErrorKey('upload_bad_type');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxSizeBytes) {
|
||||
setState('error');
|
||||
setErrorKey('upload_too_large');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorKey(null);
|
||||
setFileName(file.name);
|
||||
if (file.type.startsWith('image/')) {
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
setState('uploading');
|
||||
|
||||
try {
|
||||
const doc = await onUpload(file, setProgress);
|
||||
setFileName(doc.name);
|
||||
setState('success');
|
||||
onUploaded?.(doc);
|
||||
} catch {
|
||||
// 401/403/5xx are already toasted by the fetch layer; show a retryable inline error here.
|
||||
setState('error');
|
||||
setErrorKey('upload_error');
|
||||
}
|
||||
};
|
||||
|
||||
const megabytes = Math.round(maxSizeBytes / (1024 * 1024));
|
||||
const acceptAttr = accept.join(',');
|
||||
// The "already uploaded" resting state is driven by server metadata (existingDoc) or a just-completed
|
||||
// upload — never by retained bytes.
|
||||
const showUploaded = state === 'success' || (state === 'idle' && existingDoc != null);
|
||||
const uploadedName = fileName ?? existingDoc?.name ?? '';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
capture={capture}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={onFileSelected}
|
||||
/>
|
||||
|
||||
{rejected ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="rejected"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('upload_rejected')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{rejectionReason ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{rejectionReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="upload"
|
||||
onClick={openPicker}
|
||||
disabled={disabled}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('upload_reupload')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : state === 'uploading' ? (
|
||||
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||
{fileName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{progress}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_uploading')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : showUploaded ? (
|
||||
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{previewUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
|
||||
)}
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, wordBreak: 'break-all' }}>
|
||||
{uploadedName}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
|
||||
{t('upload_success')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AppButton variant="text" color="primary" onClick={openPicker} disabled={disabled} sx={{ m: 0 }}>
|
||||
{t('upload_change')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : state === 'error' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="error"
|
||||
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
|
||||
{t(errorKey ?? 'upload_error', { size: megabytes })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refresh"
|
||||
onClick={openPicker}
|
||||
disabled={disabled}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('upload_retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
data-upload-state="idle"
|
||||
onClick={disabled ? undefined : openPicker}
|
||||
onKeyDown={(event) => {
|
||||
if (!disabled && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
openPicker();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
textAlign: 'center',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
transition: 'border-color 120ms',
|
||||
'&:hover': disabled ? undefined : { borderColor: 'var(--bal-primary)' },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={capture ? 'camera' : 'upload'} size={28} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{capture ? t('upload_capture') : t('upload_choose')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_size_hint', { size: megabytes })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Shared card styling for the uploading / success / error states.
|
||||
const uploadedSx = {
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
} as const;
|
||||
|
||||
export default DocumentUpload;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './DocumentUpload';
|
||||
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
// next-intl echoes keys so we assert on the label key each state maps to.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import TrustBadge from './TrustBadge';
|
||||
import type { BadgeState } from '@/services/verification/types';
|
||||
|
||||
function renderBadge(state: BadgeState) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<TrustBadge state={state} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<TrustBadge/> component', () => {
|
||||
it('renders the verified label + a data attribute for the verified state', () => {
|
||||
const { container } = renderBadge('verified');
|
||||
expect(screen.getByText('badge_verified')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-badge-state="verified"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unverified state distinctly (neutral, not alarming)', () => {
|
||||
const { container } = renderBadge('unverified');
|
||||
expect(screen.getByText('badge_unverified')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-badge-state="unverified"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders expired as its own state, distinct from unverified', () => {
|
||||
const { container } = renderBadge('expired');
|
||||
expect(screen.getByText('badge_expired')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Chip, { ChipProps } from '@mui/material/Chip';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import type { BadgeState } from '@/services/verification/types';
|
||||
|
||||
interface BadgeStyle {
|
||||
bg: string;
|
||||
fg: string;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
// Colors resolve from the semantic --bal-* tokens so the badge switches with the color scheme.
|
||||
// verified = green trust mark; unverified = neutral (never alarming); expired = amber "needs renewal"
|
||||
// (distinct from unverified — a required credential lapsed). Never a hard-coded hex.
|
||||
const BADGE_STYLE: Record<BadgeState, BadgeStyle> = {
|
||||
verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified', labelKey: 'badge_verified' },
|
||||
unverified: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info', labelKey: 'badge_unverified' },
|
||||
expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' },
|
||||
};
|
||||
|
||||
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
/** The trust state — verified / unverified / expired. */
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public trust signal (the "✓ تاییدشده" mark) rendered on a nurse's profile and — reused unchanged
|
||||
* in f6 — on search results and the public nurse profile. Fed by `GetVerifiedBadgeQuery`; the state is
|
||||
* derived by the caller (`ownBadgeState`/`publicBadgeState`). Honest by construction: `verified` only
|
||||
* renders when the aggregate is approved; `expired` is visually distinct from never-verified.
|
||||
* @component TrustBadge
|
||||
*/
|
||||
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, size = 'small', sx, ...rest }) => {
|
||||
const t = useTranslations('verification');
|
||||
const style = BADGE_STYLE[state];
|
||||
return (
|
||||
<Chip
|
||||
data-badge-state={state}
|
||||
size={size}
|
||||
label={t(style.labelKey)}
|
||||
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
|
||||
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrustBadge;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './TrustBadge';
|
||||
export type { TrustBadgeProps } from './TrustBadge';
|
||||
@@ -48,6 +48,13 @@ import PostSurgeryIcon from '@mui/icons-material/HealingOutlined';
|
||||
import InfantIcon from '@mui/icons-material/ChildCareOutlined';
|
||||
import ChronicIcon from '@mui/icons-material/MonitorHeartOutlined';
|
||||
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismOutlined';
|
||||
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
|
||||
import UploadIcon from '@mui/icons-material/CloudUploadOutlined';
|
||||
import DocumentIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||
import RefreshIcon from '@mui/icons-material/RefreshOutlined';
|
||||
import IdentityIcon from '@mui/icons-material/BadgeOutlined';
|
||||
import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined';
|
||||
import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -110,4 +117,10 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
infant: InfantIcon,
|
||||
chronic: ChronicIcon,
|
||||
companionship: CompanionshipIcon,
|
||||
upload: UploadIcon,
|
||||
document: DocumentIcon,
|
||||
refresh: RefreshIcon,
|
||||
identity: IdentityIcon,
|
||||
license: LicenseIcon,
|
||||
publish: PublishIcon,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import BankStatusPanel from './BankStatusPanel';
|
||||
import CategoryTile from './CategoryTile';
|
||||
import PriceDisplay from './PriceDisplay';
|
||||
import VariantCard from './VariantCard';
|
||||
import TrustBadge from './TrustBadge';
|
||||
import DocumentUpload from './DocumentUpload';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -32,6 +34,8 @@ export {
|
||||
CategoryTile,
|
||||
PriceDisplay,
|
||||
VariantCard,
|
||||
TrustBadge,
|
||||
DocumentUpload,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -47,3 +51,5 @@ export type { BankStatusPanelProps } from './BankStatusPanel';
|
||||
export type { CategoryTileProps } from './CategoryTile';
|
||||
export type { PriceDisplayProps } from './PriceDisplay';
|
||||
export type { VariantCardProps } from './VariantCard';
|
||||
export type { TrustBadgeProps } from './TrustBadge';
|
||||
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
|
||||
|
||||
@@ -24,7 +24,11 @@ export const ROUTES = {
|
||||
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
|
||||
NURSE_COVERAGE: '/nurse/coverage',
|
||||
NURSE_BANK: '/nurse/bank',
|
||||
// Verification (trust engine) subtree — B3 hub + the staged submission screens (B4/B5/B6).
|
||||
NURSE_VERIFICATION: '/nurse/verification',
|
||||
NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity',
|
||||
NURSE_VERIFICATION_CREDENTIALS: '/nurse/verification/credentials',
|
||||
NURSE_VERIFICATION_REVIEW: '/nurse/verification/review',
|
||||
NURSE_VISITS: '/nurse/visits',
|
||||
|
||||
// Admin / backoffice console
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type {
|
||||
CredentialDetailsInput,
|
||||
DocumentConfirmedResult,
|
||||
IdentityKycInput,
|
||||
RunStepResult,
|
||||
TrustBadge,
|
||||
UploadUrlResult,
|
||||
VerificationApi,
|
||||
VerificationDocument,
|
||||
VerificationStatus,
|
||||
} from '../types';
|
||||
|
||||
const BASE = '/api/v1/nurse_verification';
|
||||
const NURSES_BASE = '/api/v1/nurses';
|
||||
|
||||
/**
|
||||
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
|
||||
* (SHA-256 hex). Runs in the browser only (Web Crypto); the mock skips it.
|
||||
*/
|
||||
async function sha256Hex(file: File): Promise<string> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs the file bytes to the signed object-storage URL with upload **progress**. This is a direct PUT
|
||||
* to `IObjectStorage` (not our API), so it uses XHR — `fetch` can't report upload progress and the
|
||||
* signed URL needs no bearer. The bearer-carrying JSON calls still go through `clientFetch`.
|
||||
*/
|
||||
function putSignedUrl(uploadUrl: string, file: File, onProgress?: (percent: number) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new ApiError(xhr.status, 'Object storage upload failed', 'upload_failed'));
|
||||
xhr.onerror = () => reject(new ApiError(0, 'Network error during upload', 'network_error'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the VerificationApi seam (b6 contract). Routes are action-style +
|
||||
* snake_case; JSON bodies/fields are camelCase; step ids come from the route. Automated-step failures
|
||||
* come back as `200` with `stepStatus:"failed"` — surfaced, not thrown. Selected once
|
||||
* USE_VERIFICATION_MOCK is false.
|
||||
*
|
||||
* Gap: `submitCredentialDetails` has no nurse-facing b6 endpoint (admin enters the structured fields on
|
||||
* review) — it is filed in `for-backend.md` and no-ops here; the document uploads it accompanies ARE
|
||||
* contract-backed (upload_url → PUT → documents). The mock persists the details for the standalone demo.
|
||||
*/
|
||||
export const verificationClientApi: VerificationApi = {
|
||||
getStatus: async () => unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(BASE)),
|
||||
|
||||
start: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(`${BASE}/submit`, { method: 'POST' })),
|
||||
|
||||
runIdentityKyc: async ({ nationalId, livenessCaptured }: IdentityKycInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/identity_kyc/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nationalId, livenessPayload: livenessCaptured ? 'captured' : null }),
|
||||
}),
|
||||
),
|
||||
|
||||
runShahkarMatch: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/shahkar_match/run`, { method: 'POST' })),
|
||||
|
||||
runBankVerification: async () =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/bank_account_verification/run`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
),
|
||||
|
||||
uploadStepDocument: async (stepId, file, onProgress): Promise<VerificationDocument> => {
|
||||
const { objectStorageKey, uploadUrl } = unwrap(
|
||||
await clientFetch<ApiEnvelope<UploadUrlResult>>(`${BASE}/steps/${stepId}/upload_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: file.type, fileName: file.name }),
|
||||
}),
|
||||
);
|
||||
await putSignedUrl(uploadUrl, file, onProgress);
|
||||
const integrityHash = await sha256Hex(file);
|
||||
const confirmed = unwrap(
|
||||
await clientFetch<ApiEnvelope<DocumentConfirmedResult>>(`${BASE}/steps/${stepId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objectStorageKey,
|
||||
integrityHash,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: confirmed.documentId,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
url: '',
|
||||
};
|
||||
},
|
||||
|
||||
// No nurse-facing endpoint yet (see gap note above / for-backend.md); the accompanying document
|
||||
// uploads carry the real signal. Kept as a seam method so the mock can persist details unchanged.
|
||||
submitCredentialDetails: async (_input: CredentialDetailsInput) => {},
|
||||
|
||||
getTrustBadge: async (nurseId) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_VERIFICATION_MOCK } from '../constants';
|
||||
import type { VerificationApi } from '../types';
|
||||
import { verificationClientApi } from './clientApi';
|
||||
import { verificationMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected VerificationApi implementation — the single seam the hooks import. Selection is by
|
||||
* config (USE_VERIFICATION_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const verificationApi: VerificationApi = USE_VERIFICATION_MOCK ? verificationMockApi : verificationClientApi;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type {
|
||||
IdentityKycInput,
|
||||
StepTypeCode,
|
||||
TrustBadge,
|
||||
VerificationApi,
|
||||
VerificationDocument,
|
||||
VerificationStatus,
|
||||
VerificationStep,
|
||||
VerificationStepStatus,
|
||||
} from '../types';
|
||||
import { NATIONAL_ID_LENGTH } from '../constants';
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
/**
|
||||
* The seeded step-types, in checklist order (mirrors the b6 required step-type seed). Every step is
|
||||
* required, so `steps.length` is the "Y" of the "X از Y" meter. `automated` drives the honest copy and
|
||||
* whether the step runs (`/run`) or waits on a manual document + admin decision.
|
||||
*/
|
||||
const SEED: ReadonlyArray<{ code: StepTypeCode; automated: boolean }> = [
|
||||
{ code: 'identity_kyc', automated: true },
|
||||
{ code: 'shahkar_match', automated: true },
|
||||
{ code: 'moh_competency_license', automated: false },
|
||||
{ code: 'ino_membership', automated: false },
|
||||
{ code: 'criminal_record', automated: false },
|
||||
{ code: 'bank_account_verification', automated: true },
|
||||
];
|
||||
|
||||
// Deterministic test triggers matching the backend b6 mock seams (documented in the mock registry).
|
||||
const KYC_FAIL_NATIONAL_ID = '0000000000'; // MockIdentityKycProvider fail id
|
||||
const SHAHKAR_SHARED_SIM_NATIONAL_ID = '1111111111'; // stands in for the shared-SIM handled failure
|
||||
|
||||
let steps: VerificationStep[] = [];
|
||||
let nextStepId = 1;
|
||||
let nextDocId = 1;
|
||||
let boundNationalId = '';
|
||||
let approvedAt: string | null = null;
|
||||
|
||||
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
|
||||
|
||||
function findStep(code: string): VerificationStep | undefined {
|
||||
return steps.find((step) => step.code === code);
|
||||
}
|
||||
|
||||
function setStepStatus(code: string, status: VerificationStepStatus, failureReason: string | null = null): void {
|
||||
steps = steps.map((step) => (step.code === code ? { ...step, status, failureReason } : step));
|
||||
}
|
||||
|
||||
/** 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: [] };
|
||||
}
|
||||
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 })) };
|
||||
}
|
||||
|
||||
function seedSteps(): void {
|
||||
if (steps.length > 0) return; // idempotent — never duplicates a step (contract submit semantics)
|
||||
steps = SEED.map(({ code, automated }) => ({
|
||||
id: nextStepId++,
|
||||
code,
|
||||
displayName: code,
|
||||
status: 'not_started',
|
||||
isAutomated: automated,
|
||||
expiresAt: null,
|
||||
failureReason: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end — the
|
||||
* automated runs (identity/shahkar/bank), the manual document uploads (→ in_review), the structured
|
||||
* credential details, and the admin-approval simulation (`__mockApproveAll`) that flips the aggregate
|
||||
* to `approved` so a human can watch the trust badge + publish gate unlock. Mirrors the real shapes for
|
||||
* a one-line swap.
|
||||
*/
|
||||
export const verificationMockApi: VerificationApi = {
|
||||
getStatus: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return aggregate();
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
seedSteps();
|
||||
return aggregate();
|
||||
},
|
||||
|
||||
runIdentityKyc: async ({ nationalId }: IdentityKycInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!nationalIdShape.test(nationalId)) {
|
||||
throw new ApiError(400, 'Malformed national id', 'invalid_national_id');
|
||||
}
|
||||
boundNationalId = nationalId;
|
||||
const step = findStep('identity_kyc');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (nationalId === KYC_FAIL_NATIONAL_ID) {
|
||||
setStepStatus('identity_kyc', 'failed', 'kyc_no_match');
|
||||
return { stepId: step.id, stepStatus: 'failed', failureReason: 'kyc_no_match' };
|
||||
}
|
||||
setStepStatus('identity_kyc', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
runShahkarMatch: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const step = findStep('shahkar_match');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (findStep('identity_kyc')?.status !== 'passed') {
|
||||
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
|
||||
}
|
||||
if (boundNationalId === SHAHKAR_SHARED_SIM_NATIONAL_ID) {
|
||||
setStepStatus('shahkar_match', 'failed', 'shared_sim');
|
||||
return { stepId: step.id, stepStatus: 'failed', failureReason: 'shared_sim' };
|
||||
}
|
||||
setStepStatus('shahkar_match', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
runBankVerification: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const step = findStep('bank_account_verification');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (findStep('identity_kyc')?.status !== 'passed') {
|
||||
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
|
||||
}
|
||||
setStepStatus('bank_account_verification', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
uploadStepDocument: async (stepId, file, onProgress) => {
|
||||
// Simulate the signed-URL PUT progress, then confirm (→ in_review).
|
||||
for (let percent = 0; percent <= 100; percent += 25) {
|
||||
onProgress?.(percent);
|
||||
await sleep(MOCK_LATENCY_MS / 5);
|
||||
}
|
||||
const step = steps.find((candidate) => candidate.id === stepId);
|
||||
if (!step) throw new ApiError(404, 'Step not found', 'not_found');
|
||||
setStepStatus(step.code, 'in_review');
|
||||
return {
|
||||
id: nextDocId++,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
url: '',
|
||||
} satisfies VerificationDocument;
|
||||
},
|
||||
|
||||
submitCredentialDetails: async (input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// The server validates the structured registry fields; the mock enforces the one the UI collects.
|
||||
if (input.inoNumber.trim().length === 0) {
|
||||
throw new ApiError(400, 'INO number is required', 'ino_number_required');
|
||||
}
|
||||
},
|
||||
|
||||
getTrustBadge: async (nurseId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const agg = aggregate();
|
||||
return {
|
||||
nurseId,
|
||||
isVerified: agg.status === 'approved' && agg.isBookable,
|
||||
approvedAt,
|
||||
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
|
||||
} satisfies TrustBadge;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dev-only admin-decision simulation (reachable from B3/B6 while `USE_VERIFICATION_MOCK` is true) — the
|
||||
* b6 admin review queue is deferred to f15, so this stands in to let a human **observe** the state
|
||||
* change: passes every step and flips the aggregate to `approved`. Never shipped against the real
|
||||
* backend (the caller gates it on the mock flag).
|
||||
*/
|
||||
export function __mockApproveAll(): void {
|
||||
approvedAt = '2026-07-09T00:00:00.000Z';
|
||||
steps = steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
|
||||
}
|
||||
|
||||
/** Dev-only: reject a manual step with a reason, to exercise the rejected-with-reason re-submit path. */
|
||||
export function __mockRejectStep(code: string, reason: string): void {
|
||||
setStepStatus(code, 'failed', reason);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* When true, the verification domain is served by the in-memory mock (apis/mockApi.ts) behind the
|
||||
* VerificationApi seam. The b6 routes exist server-side, but — like `catalog` — the mock lets the full
|
||||
* nurse flow (checklist → identity run → credential upload → under-review → admin-approval → verified
|
||||
* badge + publish gate) demo standalone before the backend is reachable in this environment. Flip to
|
||||
* false to hit the live endpoints — no hook/component changes (see
|
||||
* dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_VERIFICATION_MOCK = true;
|
||||
|
||||
/**
|
||||
* The checklist is **moderately fresh** — submitting a step changes it, and every mutation invalidates
|
||||
* it, so a short staleTime avoids a refetch when B3 and B6 mount from the same cached query.
|
||||
*/
|
||||
export const VERIFICATION_STATUS_STALE_TIME = 30_000;
|
||||
|
||||
/** The public trust badge changes only on a step decision / suspension / expiry — keep it warm longer. */
|
||||
export const TRUST_BADGE_STALE_TIME = 5 * 60_000; // 5 min
|
||||
export const TRUST_BADGE_GC_TIME = 30 * 60_000; // 30 min
|
||||
|
||||
/** Client-side document guardrails (mirrors the b6 `IObjectStorage` limits). jpg/png/pdf, 5 MB cap. */
|
||||
export const ACCEPTED_DOCUMENT_TYPES: readonly string[] = ['image/jpeg', 'image/png', 'application/pdf'] as const;
|
||||
export const ACCEPTED_IMAGE_TYPES: readonly string[] = ['image/jpeg', 'image/png'] as const;
|
||||
export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */
|
||||
export const NATIONAL_ID_LENGTH = 10;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { TRUST_BADGE_GC_TIME, TRUST_BADGE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The public trust badge for a nurse (verified state + credential **types**, never numbers). Public
|
||||
* (`[AllowAnonymous]`) and long-lived — it changes only on a step decision / suspension / expiry — so a
|
||||
* generous `staleTime` keeps it warm across the nurse's own profile and (in f6) search + public profile,
|
||||
* which reuse this same query key. Pass `nurseId = undefined` to disable until the id is known.
|
||||
*/
|
||||
export function useNurseTrustBadge(nurseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.badge(nurseId ?? -1),
|
||||
queryFn: () => verificationApi.getTrustBadge(nurseId as number),
|
||||
enabled: nurseId != null,
|
||||
staleTime: TRUST_BADGE_STALE_TIME,
|
||||
gcTime: TRUST_BADGE_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Runs the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard) for the
|
||||
* `bank_account_verification` step. Requires a verified identity + a primary bank account (added on the
|
||||
* f2 bank screen this checklist deep-links to) — a `400` otherwise, surfaced inline. Invalidates the
|
||||
* status so the checklist reflects the step's new state.
|
||||
*/
|
||||
export function useRunBankVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => verificationApi.runBankVerification(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Opens (or re-opens) verification and seeds the checklist — called from the B3 "start / continue" CTA
|
||||
* when the aggregate is `not_started`. Idempotent server-side. Writes the fresh status straight into
|
||||
* the cache so the checklist renders the seeded steps without a second fetch.
|
||||
*/
|
||||
export function useStartVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => verificationApi.start(),
|
||||
onSuccess: (status) => {
|
||||
queryClient.setQueryData(verificationKeys.status(), status);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { CredentialDetailsInput } from '../types';
|
||||
|
||||
/**
|
||||
* Persists the structured professional-credential details (INO number, specialties, license fields) the
|
||||
* registry needs. The credential **documents** themselves move their steps to `in_review` as they
|
||||
* upload (via `useUploadVerificationDocument`); this finalises B5 by saving the structured metadata,
|
||||
* then invalidates the status so the checklist / B6 reflect the in-review credential steps. A missing
|
||||
* INO number surfaces as `400` inline. (No nurse-facing b6 endpoint accepts these yet — gap filed;
|
||||
* mock-persisted meanwhile.)
|
||||
*/
|
||||
export function useSubmitCredentials() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CredentialDetailsInput) => verificationApi.submitCredentialDetails(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { IdentityKycInput, RunStepResult } from '../types';
|
||||
|
||||
export interface SubmitIdentityResult {
|
||||
identity: RunStepResult;
|
||||
/** Null when identity KYC itself failed — Shahkar requires a verified national id first. */
|
||||
shahkar: RunStepResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the automated identity flow: national-ID + liveness KYC, then — only if that **passes** — the
|
||||
* phone↔national-id Shahkar match the server chains off the bound national id. Both are surfaced so B4
|
||||
* can show each step's outcome (incl. the handled shared-SIM Shahkar failure). Invalidates the status
|
||||
* so B3 reflects the new step states from cache. A malformed national id throws `400`; a vendor
|
||||
* mismatch is a `failed` step in the result (not a throw).
|
||||
*/
|
||||
export function useSubmitIdentity() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: IdentityKycInput): Promise<SubmitIdentityResult> => {
|
||||
const identity = await verificationApi.runIdentityKyc(input);
|
||||
const shahkar = identity.stepStatus === 'passed' ? await verificationApi.runShahkarMatch() : null;
|
||||
return { identity, shahkar };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { VerificationDocument } from '../types';
|
||||
|
||||
export interface UploadDocumentVars {
|
||||
stepId: number;
|
||||
file: File;
|
||||
/** Progress callback (0–100) the uploader wires to its progress bar. */
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a manual step's document (url → PUT bytes → confirm) and moves it to `in_review`. Progress
|
||||
* is reported through `vars.onProgress` (React Query can't stream it), so the `<DocumentUpload>`
|
||||
* component owns the bar while the mutation owns the request + cache invalidation — the checklist then
|
||||
* shows the step `in_review` from cache with no manual refetch.
|
||||
*/
|
||||
export function useUploadVerificationDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ stepId, file, onProgress }: UploadDocumentVars): Promise<VerificationDocument> =>
|
||||
verificationApi.uploadStepDocument(stepId, file, onProgress),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { VERIFICATION_STATUS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's own verification checklist + aggregate status. **The single cached source B3 and B6 both
|
||||
* read** — one query, two views (checklist hub / under-review). Every submit/upload/run mutation
|
||||
* invalidates `verificationKeys.status()`, so the checklist re-renders from cache with no manual
|
||||
* refetch. A moderate `staleTime` avoids a refetch when the two screens mount in sequence.
|
||||
*/
|
||||
export function useVerificationStatus() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.status(),
|
||||
queryFn: () => verificationApi.getStatus(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: VERIFICATION_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useVerificationStatus } from './hooks/useVerificationStatus';
|
||||
export { useStartVerification } from './hooks/useStartVerification';
|
||||
export { useSubmitIdentity } from './hooks/useSubmitIdentity';
|
||||
export { useRunBankVerification } from './hooks/useRunBankVerification';
|
||||
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
|
||||
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
|
||||
export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* React Query key factory for the verification domain. The nurse's own `status()` is the **single
|
||||
* cached source** that both B3 (checklist) and B6 (under-review) read — one query, two views. Every
|
||||
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
|
||||
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
|
||||
*/
|
||||
export const verificationKeys = {
|
||||
all: ['verification'] as const,
|
||||
|
||||
// The signed-in nurse's checklist — moderately fresh; every mutation invalidates it.
|
||||
status: () => [...verificationKeys.all, 'status'] as const,
|
||||
|
||||
// A manual step's uploaded documents (metadata only), if a screen ever lists them separately.
|
||||
documents: (stepCode: string) => [...verificationKeys.all, 'documents', stepCode] as const,
|
||||
|
||||
// The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile.
|
||||
badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const,
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Verification domain — the trust engine's front-end data layer. Shapes mirror the b6 contract
|
||||
* (`dev/contracts/domains/verification.md`) exactly; the wire is **camelCase** and `clientFetch`
|
||||
* unwraps the `ApiResult<T>` envelope, so these are the post-`unwrap()` payloads.
|
||||
*
|
||||
* Load-bearing semantics (see the contract "Key semantics"):
|
||||
* - `VerificationStatus.status` is the **single source of verification truth**; `isBookable` is the
|
||||
* only flag the UI gates on. The client never infers `is_verified`.
|
||||
* - Steps are **data-driven**: render the ordered `steps[]`, mapping each `code`/`status` to a label
|
||||
* + chip. A new step type appearing in the response must render without a code change.
|
||||
* - Automated steps (`isAutomated:true`) run via a `/run` endpoint; manual steps take a document upload
|
||||
* and wait for an admin decision. **Honest copy** keys off `isAutomated` — a manual step is never
|
||||
* presented as an automated authority check.
|
||||
* - Credential **numbers never cross the wire** (encrypted, never serialized); the badge exposes
|
||||
* credential **types** only.
|
||||
*/
|
||||
|
||||
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
|
||||
export type VerificationAggregateStatus =
|
||||
| 'not_started'
|
||||
| 'pending'
|
||||
| 'in_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'suspended';
|
||||
|
||||
/** Per-step `verification_steps.status`. `failed` renders as the rejected (red) chip; `expired` re-gates. */
|
||||
export type VerificationStepStatus =
|
||||
| 'not_started'
|
||||
| 'pending'
|
||||
| 'in_review'
|
||||
| 'passed'
|
||||
| 'failed'
|
||||
| 'expired';
|
||||
|
||||
/** The six seeded, **stable** step-type codes. Labels are i18n keys off the code — never derived from it. */
|
||||
export type StepTypeCode =
|
||||
| 'identity_kyc'
|
||||
| 'shahkar_match'
|
||||
| 'moh_competency_license'
|
||||
| 'ino_membership'
|
||||
| 'criminal_record'
|
||||
| 'bank_account_verification';
|
||||
|
||||
/** The three credential-bearing step types recorded on admin approval. */
|
||||
export type CredentialType = 'moh_competency_license' | 'ino_membership' | 'criminal_record';
|
||||
|
||||
/** How a credential was verified. Today every real credential resolves `manual` (admin review). */
|
||||
export type VerificationMethod = 'manual' | 'portal' | 'api';
|
||||
|
||||
/**
|
||||
* Trust-badge display state — derived client-side, not a wire enum. `verified` when the badge/aggregate
|
||||
* is approved; `expired` when a required credential lapsed (distinct from never-verified); else
|
||||
* `unverified`. The public badge endpoint only carries `isVerified`, so `expired` is computed by the
|
||||
* nurse's own-profile view from its `VerificationStatus`; public consumers (search/f6) see verified/unverified.
|
||||
*/
|
||||
export type BadgeState = 'verified' | 'unverified' | 'expired';
|
||||
|
||||
/** `VerificationStepDto` — one row of the checklist. Every seeded step is required (Y of the "X از Y" meter). */
|
||||
export interface VerificationStep {
|
||||
id: number;
|
||||
code: string;
|
||||
/** Server-provided fallback label; the UI prefers the i18n label keyed off `code`. */
|
||||
displayName: string;
|
||||
status: VerificationStepStatus;
|
||||
isAutomated: boolean;
|
||||
expiresAt: string | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** `VerificationStatusDto` — the aggregate + ordered per-step list driving B3/B6. */
|
||||
export interface VerificationStatus {
|
||||
status: VerificationAggregateStatus;
|
||||
isBookable: boolean;
|
||||
/** Step codes still blocking go-live. */
|
||||
blockingSteps: string[];
|
||||
steps: VerificationStep[];
|
||||
}
|
||||
|
||||
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */
|
||||
export interface UploadUrlResult {
|
||||
objectStorageKey: string;
|
||||
uploadUrl: string;
|
||||
}
|
||||
|
||||
/** `DocumentConfirmedResult` — the step's new status after a document is confirmed. */
|
||||
export interface DocumentConfirmedResult {
|
||||
documentId: number;
|
||||
stepStatus: VerificationStepStatus;
|
||||
}
|
||||
|
||||
/** `VerificationDocumentDto` — **metadata only**, never bytes. `url` is a short-lived signed GET URL. */
|
||||
export interface VerificationDocument {
|
||||
id: number;
|
||||
contentType: string;
|
||||
fileSizeBytes: number;
|
||||
originalFileName: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** `RunStepResult` — the outcome of an automated step run; a vendor fail is `stepStatus:"failed"` + reason. */
|
||||
export interface RunStepResult {
|
||||
stepId: number;
|
||||
stepStatus: VerificationStepStatus;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** `NurseCredentialDto` — a recorded credential. `credentialNumber` is **never** present. */
|
||||
export interface NurseCredential {
|
||||
id: number;
|
||||
credentialType: CredentialType;
|
||||
holderNameSnapshot: string;
|
||||
issuingAuthority: string;
|
||||
issuedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
verificationMethod: VerificationMethod;
|
||||
}
|
||||
|
||||
/** `TrustBadgeDto` — the public trust signal. Credential **types** only, never numbers. */
|
||||
export interface TrustBadge {
|
||||
nurseId: number;
|
||||
isVerified: boolean;
|
||||
approvedAt: string | null;
|
||||
credentialTypes: string[];
|
||||
}
|
||||
|
||||
/** Body for the automated identity-KYC run. `livenessCaptured` stands in for the vendor liveness payload. */
|
||||
export interface IdentityKycInput {
|
||||
nationalId: string;
|
||||
livenessCaptured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured professional-credential details the registry needs (INO number, specialties, license
|
||||
* number, issuing authority, holder name, issue/expiry). **No nurse-facing b6 endpoint accepts these
|
||||
* yet** (admin enters them on review) — the mock persists them and the gap is filed for the backend
|
||||
* (`for-backend.md`). The document uploads themselves are contract-backed (upload_url → documents).
|
||||
*/
|
||||
export interface CredentialDetailsInput {
|
||||
inoNumber: string;
|
||||
specialties: string[];
|
||||
licenseNumber?: string;
|
||||
issuingAuthority?: string;
|
||||
holderName?: string;
|
||||
issuedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification domain's API seam — the real HTTP client and the in-memory mock both implement
|
||||
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface VerificationApi {
|
||||
/** The nurse's own checklist + aggregate + blocking summary (a `not_started` empty list, never a 404). */
|
||||
getStatus(): Promise<VerificationStatus>;
|
||||
/** Open (or re-open) verification and seed the checklist. Idempotent. */
|
||||
start(): Promise<VerificationStatus>;
|
||||
/** Automated national-ID + liveness check. A vendor fail is a `failed` status, not a thrown error. */
|
||||
runIdentityKyc(input: IdentityKycInput): Promise<RunStepResult>;
|
||||
/** Automated phone↔national-id Shahkar match (requires identity KYC passed). Shared-SIM is a handled fail. */
|
||||
runShahkarMatch(): Promise<RunStepResult>;
|
||||
/** Automated استعلام شبا IBAN-owner ↔ national-id money-mule guard (requires a primary bank account). */
|
||||
runBankVerification(): Promise<RunStepResult>;
|
||||
/**
|
||||
* Upload a document for a manual step (url → PUT bytes → confirm), moving it to `in_review`. Reports
|
||||
* upload progress (0–100) via `onProgress`. Returns the server's stored **metadata** (never bytes).
|
||||
*/
|
||||
uploadStepDocument(stepId: number, file: File, onProgress?: (percent: number) => void): Promise<VerificationDocument>;
|
||||
/** Persist the structured professional-credential details (gap-filed; mock-persisted for now). */
|
||||
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
|
||||
/** The public trust badge for a nurse (types only). */
|
||||
getTrustBadge(nurseId: number): Promise<TrustBadge>;
|
||||
}
|
||||
|
||||
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */
|
||||
export const SPECIALTY_PRESETS: readonly string[] = ['elderly', 'icu', 'pediatric', 'post_surgery', 'wound_care'] as const;
|
||||
|
||||
/** Aggregate statuses at which the nurse's services may go live and the trust badge shows verified. */
|
||||
export function isApproved(status: VerificationStatus | undefined): boolean {
|
||||
return status?.status === 'approved' && status.isBookable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trust-badge state for the nurse's **own** profile, computed from the full status: `expired` when a
|
||||
* required step has lapsed (distinct from never-verified), `verified` when approved, else `unverified`.
|
||||
*/
|
||||
export function ownBadgeState(status: VerificationStatus | undefined): BadgeState {
|
||||
if (!status) return 'unverified';
|
||||
if (status.steps.some((step) => step.status === 'expired')) return 'expired';
|
||||
return isApproved(status) ? 'verified' : 'unverified';
|
||||
}
|
||||
|
||||
/** The public-badge state (search/f6 + public profile) — no `expired` signal on the public payload. */
|
||||
export function publicBadgeState(badge: TrustBadge | undefined): BadgeState {
|
||||
return badge?.isVerified ? 'verified' : 'unverified';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NATIONAL_ID_LENGTH } from './constants';
|
||||
|
||||
/**
|
||||
* Validates an Iranian national id (کد ملی): 10 digits with the official mod-11 checksum. The server
|
||||
* re-validates (contract `400` on a malformed id), but a client check gives an instant field error and
|
||||
* spares a round-trip. Rejects the trivial all-same-digit ids the algorithm otherwise accepts.
|
||||
*/
|
||||
export function isValidNationalId(value: string): boolean {
|
||||
if (!new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`).test(value)) return false;
|
||||
if (/^(\d)\1{9}$/.test(value)) return false;
|
||||
|
||||
const digits = value.split('').map(Number);
|
||||
const check = digits[9];
|
||||
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (NATIONAL_ID_LENGTH - index), 0);
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? check === remainder : check === 11 - remainder;
|
||||
}
|
||||
Reference in New Issue
Block a user