ui phase 3

This commit is contained in:
hamid
2026-07-18 01:56:17 +03:30
parent 222856d600
commit 53b4e1b0a4
33 changed files with 1000 additions and 81 deletions
+21 -9
View File
@@ -132,7 +132,6 @@ client/
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen
│ │ │ │ ├── BookingsScreen.tsx # 'use client' — f8 رزروها list body (useBookingList('customer')); rows → booking detail
@@ -207,12 +206,19 @@ client/
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
│ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
│ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
│ │ └── onboarding/ # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup
│ │ ├── page.tsx # Thin RSC — generateMetadata (onboarding.welcome_title) + renders OnboardingScreen
│ │ └── OnboardingScreen.tsx # 'use client' — welcome moment (not a stepper step) → relation (4 distinct icons: elderly/favorite/infant/account) → patient (StepperHeader 2 steps)
│ └── (public-routes)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ ├── loading.tsx # Auth-card-shaped skeleton (brand mark + a card-sized block)
── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
└── LoginScreen.tsx # 'use client' — the actual LoginFlow body
── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
└── LoginScreen.tsx # 'use client' — the actual LoginFlow body
│ ├── terms/page.tsx # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
│ └── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch)
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
│ ├── common/ # Foundational primitives (import from @/components or @/components/common)
│ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4)
@@ -270,7 +276,7 @@ client/
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment)
├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ ├── request.ts # getRequestConfig — loads messages/${locale}.json
@@ -282,6 +288,7 @@ client/
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
│ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot (area phases feed real names in later) + `useRouteTitle`; `isCustomerRootTab`/`CUSTOMER_ROOT_TABS` for the customer header's brand-lockup-vs-title branch
│ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — used by SideBarNavList and BottomBar so a nested route still lights up its parent tab, never a sibling
@@ -316,7 +323,7 @@ client/
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
@@ -456,7 +463,7 @@ async function MyServerComponent() {
- `'common'``DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …)
- `'shell'` — actor-shell titles + the not-yet-built placeholder body
- `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive)
- `'onboarding'` — the A3→A4 wizard + the shared enum labels (relation/condition/gender codes → labels)
- `'onboarding'` — the A3→A4 wizard (ui-phase-3 added `welcome_*` for the pre-wizard welcome moment) + the shared enum labels (relation/condition/gender codes → labels)
- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges)
- `'profile'` — the customer profile + emergency contact
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
@@ -477,7 +484,8 @@ async function MyServerComponent() {
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note`
- `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
@@ -862,7 +870,11 @@ private-routes layout) hydrates `currentUser.roles` from `/me` — the single so
on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes
the session) clears tokens and redirects to `/login`. The refresh/OTP endpoints are excluded from this retry.
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render.
**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render. On
redirect it appends the attempted locale-stripped path + query as `?next=` (`RETURN_URL_PARAM`) so a deep
link survives the round trip; `LoginFlow` reads it and `RoleRouter` resolves it via
`resolvePostLoginDestination` (`services/auth/routing.ts`) — same-origin-relative + role-permitting only,
else it falls back to `resolveRoleDestination` (never an open redirect).
**Security posture — current limits and best-practice follow-ups.** The flow above is the intended
client design, but some hardening needs *server* coordination — don't silently "fix" it client-only:
+36 -2
View File
@@ -119,7 +119,10 @@
"condition_dementia": "Dementia",
"continue": "Continue",
"save_continue": "Save and continue",
"saved": "Patient saved"
"saved": "Patient saved",
"welcome_title": "Welcome — who's this care for?",
"welcome_subtitle": "A couple of quick steps and you're ready to book a nurse.",
"welcome_cta": "Let's start"
},
"patients": {
"title": "Patients",
@@ -672,11 +675,16 @@
"role_customer_desc": "Book nurses and home care",
"role_nurse": "Nurse",
"role_nurse_desc": "Offer nursing services",
"role_add_later_note": "You can add the other role from your profile anytime.",
"continue": "Continue",
"guard_denied": "You don't have access to that area.",
"account_error_title": "Couldn't load your account",
"account_error_body": "We couldn't reach Balinyaar to load your account. Check your connection and try again.",
"account_error_retry": "Try again"
"account_error_retry": "Try again",
"trust_verified_nurses": "Licensed, identity-verified nurses",
"trust_escrow_payment": "Your payment stays in escrow until the visit is confirmed",
"trust_support": "Support at every step of care",
"consent_line": "By continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>."
},
"verification": {
"title": "Verification",
@@ -1610,5 +1618,31 @@
"not_found_title": "Page not found",
"not_found_body": "The page you are looking for does not exist or has moved.",
"go_home": "Go home"
},
"legal": {
"terms_title": "Terms of Service",
"privacy_title": "Privacy Policy",
"draft_banner": "This is placeholder legal copy — it has not yet been reviewed by counsel and must not be relied on before launch.",
"terms_intro": "These draft terms describe how Balinyaar connects families with independent, verified home-nursing professionals. By creating an account you agree to the terms below.",
"terms_sections": [
{ "title": "The service", "body": "Balinyaar is a marketplace: it does not employ nurses. Independent nurses and nursing-company staff list their own services; families search, book, and pay through the platform." },
{ "title": "Bookings and payment", "body": "You pay the full booking price through Balinyaar by card. The amount is held in an internal escrow ledger and is only released to the nurse, weekly, after your visit is confirmed and the dispute window closes." },
{ "title": "Cancellations and refunds", "body": "Cancelling a confirmed booking may incur a fee depending on how close to the visit you cancel, shown to you before you confirm. Approved refunds are returned to your original payment method or provider." },
{ "title": "Nurse verification", "body": "Every nurse on Balinyaar passes an identity check, a professional-competency license check, and other required verification steps before they can be booked. We show what has been verified on their profile." },
{ "title": "Your responsibilities", "body": "Provide accurate information about the person receiving care, communicate through the app's ticket system for anything related to a booking, and treat nurses respectfully." },
{ "title": "Liability", "body": "Balinyaar facilitates bookings between families and independent professionals; it is not itself a healthcare provider. Disputes are handled through our support ticket system." },
{ "title": "Changes to these terms", "body": "We may update these terms as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about these terms can be sent through the support ticket system in the app." }
],
"privacy_intro": "This draft policy explains what personal data Balinyaar collects to run the service, and how it is used.",
"privacy_sections": [
{ "title": "Information we collect", "body": "Your mobile number for login; for nurses, national ID and license details for verification; patient care information you or your nurse enter; approximate visit location for check-in/check-out; and payment metadata from our payment provider." },
{ "title": "How we use it", "body": "To create and manage bookings, verify nurse identity and credentials, process payments and weekly nurse payouts, and provide support." },
{ "title": "Who we share it with", "body": "Licensed payment providers, identity-verification vendors, and our licensed home-nursing partner center receive only the information each needs to do their part — never more." },
{ "title": "Data security", "body": "Sensitive fields such as national ID numbers and clinical notes are encrypted. Access to patient care records is limited to the family and the assigned nurse." },
{ "title": "Your rights", "body": "You can review and update most of your information from your profile, and can reach support to ask about, correct, or request deletion of your data." },
{ "title": "Changes to this policy", "body": "We may update this policy as the service evolves. Material changes will be announced in the app before they take effect." },
{ "title": "Contact", "body": "Questions about this policy can be sent through the support ticket system in the app." }
]
}
}
+36 -2
View File
@@ -119,7 +119,10 @@
"condition_dementia": "آلزایمر/دمانس",
"continue": "ادامه",
"save_continue": "ذخیره و ادامه",
"saved": "بیمار ثبت شد"
"saved": "بیمار ثبت شد",
"welcome_title": "خوش آمدید — مراقبت برای چه کسی است؟",
"welcome_subtitle": "با چند مرحله کوتاه، آماده رزرو پرستار می‌شوید.",
"welcome_cta": "شروع می‌کنیم"
},
"patients": {
"title": "بیماران",
@@ -672,11 +675,16 @@
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
"role_nurse": "پرستار",
"role_nurse_desc": "برای ارائه خدمات پرستاری",
"role_add_later_note": "هر زمان می‌توانید نقش دیگر را هم از پروفایل خود اضافه کنید.",
"continue": "ادامه",
"guard_denied": "شما به این بخش دسترسی ندارید.",
"account_error_title": "حساب شما بارگذاری نشد",
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_retry": "تلاش مجدد"
"account_error_retry": "تلاش مجدد",
"trust_verified_nurses": "پرستاران دارای پروانه صلاحیت حرفه‌ای و احراز هویت‌شده",
"trust_escrow_payment": "پرداخت شما تا تایید انجام خدمت، امانی نزد بالین‌یار می‌ماند",
"trust_support": "پشتیبانی در تمام مراحل مراقبت",
"consent_line": "با ورود، <terms>شرایط استفاده</terms> و <privacy>حریم خصوصی</privacy> را می‌پذیرید."
},
"verification": {
"title": "احراز هویت",
@@ -1610,5 +1618,31 @@
"not_found_title": "صفحه پیدا نشد",
"not_found_body": "صفحه‌ای که به دنبال آن هستید وجود ندارد یا جابه‌جا شده است.",
"go_home": "بازگشت به خانه"
},
"legal": {
"terms_title": "شرایط استفاده",
"privacy_title": "حریم خصوصی",
"draft_banner": "این متن پیش‌نویس است و هنوز توسط تیم حقوقی بازبینی نشده؛ پیش از انتشار نهایی قابل استناد نیست.",
"terms_intro": "این شرایط پیش‌نویس، نحوه ارتباط بالین‌یار بین خانواده‌ها و پرستاران مستقل و تاییدشده مراقبت در منزل را توضیح می‌دهد. با ساخت حساب کاربری، شرایط زیر را می‌پذیرید.",
"terms_sections": [
{ "title": "ماهیت خدمت", "body": "بالین‌یار یک بازارگاه است و پرستاران را استخدام نمی‌کند. پرستاران مستقل یا شاغل در مراکز پرستاری، خدمات خود را ثبت می‌کنند و خانواده‌ها از طریق پلتفرم جست‌وجو، رزرو و پرداخت انجام می‌دهند." },
{ "title": "رزرو و پرداخت", "body": "مبلغ کامل رزرو را از طریق بالین‌یار و با کارت پرداخت می‌کنید. این مبلغ به‌صورت امانی نزد بالین‌یار نگه‌داری می‌شود و تنها پس از تایید انجام خدمت و پایان مهلت اعتراض، به‌صورت هفتگی به پرستار پرداخت می‌شود." },
{ "title": "لغو و بازگشت وجه", "body": "لغو یک رزرو تاییدشده، بسته به فاصله زمانی تا زمان مراجعه، ممکن است مشمول کارمزد شود که پیش از تایید نهایی به شما نمایش داده می‌شود. مبالغ بازگشتی تاییدشده به همان روش پرداخت اصلی یا ارائه‌دهنده مربوطه بازمی‌گردد." },
{ "title": "احراز هویت پرستاران", "body": "هر پرستار پیش از قابل‌رزرو شدن، مراحل احراز هویت، بررسی پروانه صلاحیت حرفه‌ای و سایر مراحل الزامی را می‌گذراند. آنچه تایید شده در پروفایل او نمایش داده می‌شود." },
{ "title": "مسئولیت‌های شما", "body": "اطلاعات دقیق درباره فرد دریافت‌کننده مراقبت ارائه دهید، برای هر موضوع مرتبط با رزرو از طریق سامانه تیکت پشتیبانی اپلیکیشن ارتباط بگیرید و با پرستاران محترمانه رفتار کنید." },
{ "title": "مسئولیت‌پذیری", "body": "بالین‌یار واسط رزرو بین خانواده‌ها و پرستاران مستقل است و خود ارائه‌دهنده خدمات درمانی نیست. اختلافات از طریق سامانه تیکت پشتیبانی رسیدگی می‌شود." },
{ "title": "تغییر این شرایط", "body": "ممکن است این شرایط با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این شرایط را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
],
"privacy_intro": "این پیش‌نویس سیاست حریم خصوصی، اطلاعات شخصی که بالین‌یار برای ارائه خدمت جمع‌آوری می‌کند و نحوه استفاده از آن را توضیح می‌دهد.",
"privacy_sections": [
{ "title": "اطلاعاتی که جمع‌آوری می‌کنیم", "body": "شماره موبایل برای ورود؛ برای پرستاران، کد ملی و اطلاعات پروانه برای احراز هویت؛ اطلاعات مراقبتی بیمار که شما یا پرستار وارد می‌کنید؛ موقعیت تقریبی محل مراجعه برای ورود/خروج پرستار؛ و اطلاعات فراداده پرداخت از ارائه‌دهنده درگاه پرداخت." },
{ "title": "نحوه استفاده", "body": "برای ایجاد و مدیریت رزروها، احراز هویت و اعتبارسنجی پرستاران، پردازش پرداخت‌ها و تسویه هفتگی پرستاران، و ارائه پشتیبانی." },
{ "title": "اشتراک‌گذاری اطلاعات", "body": "ارائه‌دهندگان مجاز پرداخت، سرویس‌های احراز هویت، و مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل طرف قرارداد ما، تنها به میزان لازم برای انجام وظیفه خود به اطلاعات دسترسی دارند." },
{ "title": "امنیت اطلاعات", "body": "فیلدهای حساس مانند کد ملی و یادداشت‌های بالینی رمزنگاری می‌شوند. دسترسی به پرونده مراقبتی بیمار تنها برای خانواده و پرستار مسئول امکان‌پذیر است." },
{ "title": "حقوق شما", "body": "می‌توانید بیشتر اطلاعات خود را از پروفایل خود مشاهده و ویرایش کنید و برای پرسش، اصلاح یا درخواست حذف اطلاعات با پشتیبانی در تماس باشید." },
{ "title": "تغییر این سیاست", "body": "ممکن است این سیاست با تحول خدمت به‌روزرسانی شود. تغییرات مهم پیش از اعمال، در اپلیکیشن اطلاع‌رسانی می‌شود." },
{ "title": "تماس با ما", "body": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." }
]
}
}
+11 -2
View File
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server';
import { routing } from './src/i18n/routing';
import { COOKIE_NAMES } from './src/lib/cookies';
import { isTokenAlive } from './src/lib/auth/token';
import { HEADER_NAMES, PUBLIC_PATHS, ROUTES } from './src/constants';
import { HEADER_NAMES, PUBLIC_PATHS, RETURN_URL_PARAM, ROUTES } from './src/constants';
const intlMiddleware = createMiddleware(routing);
@@ -26,7 +26,16 @@ export default function middleware(request: NextRequest) {
const token = request.cookies.get(COOKIE_NAMES.ACCESS_TOKEN)?.value;
if (!isTokenAlive(token)) {
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? routing.defaultLocale;
return NextResponse.redirect(new URL(`/${locale}${ROUTES.LOGIN}`, request.url));
const loginUrl = new URL(`/${locale}${ROUTES.LOGIN}`, request.url);
// Carry the attempted (locale-stripped) destination so a deep link — an SMS booking link, a
// shared nurse profile — survives the round trip through login instead of dumping the user
// on their role home. Validated same-origin + role-permitting on the way back out
// (resolvePostLoginDestination in services/auth/routing.ts); '/' is the default anyway.
const next = pathWithoutLocale + request.nextUrl.search;
if (next && next !== '/') {
loginUrl.searchParams.set(RETURN_URL_PARAM, next);
}
return NextResponse.redirect(loginUrl);
}
}
@@ -0,0 +1,19 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Customer-focused route group a chrome-free counterpart to `(customer)` for flows the user
* should not tab away from mid-task (today only first-run onboarding, ui-phase-3 §3.5). A route
* group adds chrome without adding a URL segment, so `/onboarding` is unchanged. RoleGuard still
* gates it on a resolved customer role, identically to the full `(customer)` shell.
*/
export default function CustomerFocusedRouteLayout({ children }: { children: ReactNode }) {
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<FocusedLayout>{children}</FocusedLayout>
</RoleGuard>
);
}
@@ -4,7 +4,8 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components';
import { AppButton, AppIcon, PatientForm, RelationSelect, StepperHeader } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
import { ROUTES } from '@/constants';
import { useCreatePatient } from '@/services/patients';
import { RELATION_CODES } from '@/services/patients/constants';
@@ -12,12 +13,26 @@ import type { CreatePatientInput, Relation } from '@/services/patients/types';
const ONBOARDING_MAX_WIDTH = 520;
// Distinct per-option glyph (the prior defect: all four relations shared the generic 'account'
// icon) — 'elderly' fits a parent, 'favorite' a spouse, 'infant' a child, 'account' one's self.
const RELATION_ICONS: Record<string, string> = {
parent: 'elderly',
spouse: 'favorite',
child: 'infant',
self: 'account',
};
type Phase = 'welcome' | 'relation' | 'patient';
/**
* A3 A4 onboarding wizard: pick who care is for, then register the first patient. The
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already
* chosen here). On save it creates the patient and lands on Home (A5).
* The chrome-free A3 A4 first-run journey: a one-screen welcome moment, then pick who care is
* for, then register the first patient. `FocusedLayout` (the route group above this) strips the
* bottom nav/bell so there's nothing to tab away to mid-setup. The welcome screen doesn't count
* as a stepper step; relation patient does. The chosen relation pre-shapes the patient (hidden
* on the A4 form since it's already chosen here). On save it creates the patient and lands on
* Home (A5).
*/
export default function OnboardingPage() {
export default function OnboardingScreen() {
const t = useTranslations('onboarding');
const tc = useTranslations('common');
const router = useRouter();
@@ -25,10 +40,14 @@ export default function OnboardingPage() {
const { enqueueSnackbar } = useSnackbar();
const createPatient = useCreatePatient();
const [step, setStep] = useState(0);
const [phase, setPhase] = useState<Phase>('welcome');
const [relation, setRelation] = useState<Relation | null>(null);
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }));
const relationOptions = RELATION_CODES.map((code) => ({
code,
label: t(`relation_${code}`),
icon: RELATION_ICONS[code] ?? 'account',
}));
const handleCreate = (input: CreatePatientInput) => {
createPatient.mutate(
@@ -42,11 +61,32 @@ export default function OnboardingPage() {
);
};
if (phase === 'welcome') {
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 3, textAlign: 'center' }}>
<BrandMark />
<Stack sx={{ gap: 1, maxWidth: ONBOARDING_MAX_WIDTH }}>
<Typography variant="h5" component="h1">
{t('welcome_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('welcome_subtitle')}
</Typography>
</Stack>
<AppButton color="primary" variant="contained" onClick={() => setPhase('relation')}>
{t('welcome_cta')}
</AppButton>
</Stack>
);
}
const activeStep = phase === 'relation' ? 0 : 1;
return (
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} />
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={activeStep} />
{step === 0 ? (
{phase === 'relation' ? (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
@@ -66,7 +106,8 @@ export default function OnboardingPage() {
variant="contained"
fullWidth
disabled={!relation}
onClick={() => setStep(1)}
onClick={() => setPhase('patient')}
endIcon={<AppIcon icon="forward" size={18} aria-hidden="true" />}
>
{t('continue')}
</AppButton>
@@ -86,7 +127,7 @@ export default function OnboardingPage() {
submitLabel={t('save_continue')}
submitting={createPatient.isPending}
onSubmit={handleCreate}
onCancel={() => setStep(0)}
onCancel={() => setPhase('relation')}
cancelLabel={tc('back')}
/>
</Stack>
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import OnboardingScreen from './OnboardingScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'onboarding' });
return { title: t('welcome_title') };
}
export default function Page() {
return <OnboardingScreen />;
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('privacy_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Privacy Policy placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function PrivacyPage() {
const t = await getTranslations('legal');
const sections = t.raw('privacy_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('privacy_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('privacy_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('terms_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Terms of Service placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function TermsPage() {
const t = await getTranslations('legal');
const sections = t.raw('terms_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('terms_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('terms_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -46,4 +46,22 @@ describe('<OtpInput/> component', () => {
await user.type(boxes[3], '4');
expect(onComplete).toHaveBeenCalledWith('1234');
});
it('carries autoComplete="one-time-code" so the OS can offer the SMS code', () => {
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
boxes.forEach((box) => expect(box).toHaveAttribute('autocomplete', 'one-time-code'));
});
it('backspace on an empty box clears the previous digit and moves focus there in one keypress', async () => {
const user = userEvent.setup();
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
await user.type(boxes[0], '1');
await user.type(boxes[1], '2');
boxes[2].focus();
await user.keyboard('{Backspace}');
expect(boxes[1].value).toBe('');
expect(boxes[1]).toHaveFocus();
});
});
+8 -1
View File
@@ -87,8 +87,13 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
};
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace' && !chars[index]) {
if (event.key === 'Backspace' && !chars[index] && index > 0) {
// Empty box + backspace clears the previous digit too, so one keypress erases one digit
// instead of the first press only moving focus and the second doing the clearing.
const next = [...chars];
next[index - 1] = '';
focusBox(index - 1);
emit(next);
}
};
@@ -122,6 +127,8 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
htmlInput: {
inputMode: 'numeric',
maxLength: 1,
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
autoComplete: 'one-time-code',
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
},
@@ -43,4 +43,9 @@ describe('<PhoneNumberField/> component', () => {
expect(isIranianMobile('0912345678')).toBe(false);
expect(isIranianMobile('19123456789')).toBe(false);
});
it('carries autoComplete="tel" so the browser/keyboard can offer the saved number', () => {
render(<Harness />);
expect(screen.getByRole('textbox')).toHaveAttribute('autocomplete', 'tel');
});
});
@@ -47,6 +47,8 @@ const PhoneNumberField: FunctionComponent<PhoneNumberFieldProps> = ({ value, onC
dir: 'ltr',
inputMode: 'numeric',
maxLength: IRAN_MOBILE_LENGTH,
// Offers the user's own number from the browser/keyboard's saved contact info.
autoComplete: 'tel',
style: { textAlign: 'start' },
},
...slotProps,
+32 -14
View File
@@ -1,32 +1,50 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH } from './constants';
import { AUTH_CARD_MAX_WIDTH, AUTH_HERO_MAX_WIDTH } from './constants';
import BrandMark from './BrandMark';
import AuthIllustration from './AuthIllustration';
import TrustBullets from './TrustBullets';
/**
* Centered branded card that hosts each auth step (phone, OTP, role selection). Presentational
* shell only the step content is passed as children.
* Centered branded hero that hosts each auth step (phone, OTP). The card stays readable at
* 320px; a calm illustration joins beside it once the viewport has room to breathe (desktop),
* with the platform's trust facts underneath — login is the product's only front door, so it
* carries trust evidence rather than a bare form.
* @component AuthCard
*/
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
<Paper
elevation={0}
<Stack
direction={{ xs: 'column', md: 'row' }}
sx={{
width: '100%',
maxWidth: AUTH_CARD_MAX_WIDTH,
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
maxWidth: AUTH_HERO_MAX_WIDTH,
alignItems: 'center',
justifyContent: 'center',
gap: { xs: 0, md: 6 },
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
<AuthIllustration sx={{ display: { xs: 'none', md: 'flex' } }} />
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3, flexShrink: 0 }}>
<Paper
elevation={0}
sx={{
width: '100%',
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
</Stack>
</Paper>
<TrustBullets />
</Stack>
</Paper>
</Stack>
</Stack>
);
@@ -0,0 +1,85 @@
'use client';
import { FunctionComponent } from 'react';
import { Box, SxProps, Theme } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
const ILLUSTRATION_SIZE = 220;
interface AuthIllustrationProps {
sx?: SxProps<Theme>;
}
/**
* A calm, abstract hero graphic for the login front door layered soft-tint circles (brand
* tokens only, no stock photos or raster art) with a centered family glyph and a floating
* trust badge, echoing the trust bullets underneath. Purely decorative.
* @component AuthIllustration
*/
const AuthIllustration: FunctionComponent<AuthIllustrationProps> = ({ sx }) => (
<Box
aria-hidden="true"
sx={{
position: 'relative',
width: ILLUSTRATION_SIZE,
height: ILLUSTRATION_SIZE,
flexShrink: 0,
...sx,
}}
>
<Box sx={{ position: 'absolute', inset: 0, borderRadius: '50%', bgcolor: 'var(--bal-primary-soft)' }} />
<Box
sx={{
position: 'absolute',
insetInlineStart: '16%',
insetBlockEnd: '4%',
width: '46%',
height: '46%',
borderRadius: '50%',
bgcolor: 'var(--bal-secondary-soft)',
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '58%',
height: '58%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="family" size={64} color="var(--bal-primary)" />
</Box>
</Box>
<Box
sx={{
position: 'absolute',
insetInlineEnd: '8%',
insetBlockStart: '10%',
width: '30%',
height: '30%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="verified" size={30} color="var(--bal-trust)" />
</Box>
</Box>
);
export default AuthIllustration;
+4 -2
View File
@@ -2,7 +2,7 @@
import { FunctionComponent, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { APP_ROLES, type AppRole } from '@/constants';
import { APP_ROLES, RETURN_URL_PARAM, type AppRole } from '@/constants';
import { OTP_RESEND_FALLBACK_SECONDS } from '@/services/auth/constants';
import AuthCard from './AuthCard';
import PhoneStep from './PhoneStep';
@@ -25,9 +25,11 @@ const LoginFlow: FunctionComponent = () => {
const [step, setStep] = useState<Step>('phone');
const [phone, setPhone] = useState('');
const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS);
// Carried from middleware.ts's redirect-to-login so a deep link survives the auth round trip.
const next = searchParams.get(RETURN_URL_PARAM);
if (step === 'routing') {
return <RoleRouter intendedRole={intendedRole} />;
return <RoleRouter intendedRole={intendedRole} next={next} />;
}
return (
+8 -1
View File
@@ -6,7 +6,14 @@ const mockVerifyMutate = jest.fn();
const mockVerifyReset = jest.fn();
const mockRequestMutate = jest.fn();
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
jest.mock('next-intl', () => ({
useLocale: () => 'fa',
useTranslations: () => {
const t = (key: string) => key;
t.rich = (key: string) => key;
return t;
},
}));
jest.mock('@/services/auth', () => ({
useVerifyOtp: () => ({ isPending: false, isError: false, mutate: mockVerifyMutate, reset: mockVerifyReset }),
useRequestOtp: () => ({ isPending: false, mutate: mockRequestMutate }),
+34 -14
View File
@@ -1,7 +1,7 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { Box, CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import OtpInput from '@/components/OtpInput';
import { maskIranMobile } from '@/components/PhoneNumberField';
@@ -9,8 +9,10 @@ import { AppButton } from '@/components';
import { APP_ROLES, type AppRole } from '@/constants';
import { useRequestOtp, useVerifyOtp } from '@/services/auth';
import { OTP_CODE_LENGTH, OTP_LOCKED_CODE } from '@/services/auth/constants';
import { formatClock } from '@/utils';
import type { ApiError } from '@/lib/api/errors';
import { useCountdown } from './useCountdown';
import { useWebOtp } from './useWebOtp';
interface OtpStepProps {
phone: string;
@@ -21,17 +23,11 @@ interface OtpStepProps {
onChangeNumber: () => void;
}
function formatMmSs(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
/**
* A2 (customer) / B2 (nurse) the OTP code step. Auto-verifies once the last box is filled,
* runs a single resend countdown (cleaned up on unmount), and renders explicit wrong-code,
* expired-code and max-attempts-lockout states. Same mechanism for both actors; the CTA copy
* differs.
* A2 (customer) / B2 (nurse) the OTP code step. Auto-verifies once the last box is filled
* (manually or via WebOTP autofill), runs a single resend countdown (cleaned up on unmount), and
* renders explicit wrong-code, expired-code and max-attempts-lockout states. Same mechanism for
* both actors; the CTA copy differs.
* @component OtpStep
*/
const OtpStep: FunctionComponent<OtpStepProps> = ({
@@ -42,6 +38,7 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
onChangeNumber,
}) => {
const t = useTranslations('auth');
const locale = useLocale();
const [code, setCode] = useState('');
const [locked, setLocked] = useState(false);
const countdown = useCountdown();
@@ -68,6 +65,13 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
);
};
// Reads the SMS code on a supporting browser (REQ-039) and feeds it through the same path
// manual entry uses. Stops (aborts the pending read) once a verification is underway.
useWebOtp((otpCode) => {
setCode(otpCode);
verify(otpCode);
}, !locked && !verifyOtp.isPending);
const submit = (event: FormEvent) => {
event.preventDefault();
verify(code);
@@ -97,7 +101,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
{t('otp_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('otp_sent_to', { phone: maskIranMobile(phone) })}
{t.rich('otp_sent_to', {
// <bdi dir="ltr"> isolates the masked number so the RTL sentence's bidi algorithm
// never reorders the digit/bullet runs around it (the classic digits-around-neutrals bug).
phone: () => (
<Box component="bdi" dir="ltr">
{maskIranMobile(phone)}
</Box>
),
})}
</Typography>
</Stack>
@@ -136,7 +148,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
<Stack sx={{ alignItems: 'center', gap: 1 }}>
{resendDisabled && countdown.isActive && !locked ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('resend_in', { time: formatMmSs(countdown.seconds) })}
{t.rich('resend_in', {
// Same bidi-isolation reasoning as the phone echo above — keeps mm:ss reading
// left-to-right (in Persian digits on /fa) inside the RTL sentence.
time: () => (
<Box component="bdi" dir="ltr">
{formatClock(countdown.seconds, locale)}
</Box>
),
})}
</Typography>
) : (
<MuiLink
+12 -4
View File
@@ -1,11 +1,11 @@
'use client';
import { FormEvent, FunctionComponent, useState } from 'react';
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
import { AppButton } from '@/components';
import { APP_ROLES, type AppRole } from '@/constants';
import { AppButton, AppLink } from '@/components';
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
import { useRequestOtp } from '@/services/auth';
import type { ApiError } from '@/lib/api/errors';
import type { RequestOtpResult } from '@/services/auth/types';
@@ -26,6 +26,7 @@ const RATE_LIMIT_STATUS = 429;
*/
const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRole, onSent }) => {
const t = useTranslations('auth');
const locale = useLocale();
const [phone, setPhone] = useState('');
const [invalid, setInvalid] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
@@ -72,7 +73,7 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
}}
label={t('phone_label')}
placeholder="0912 000 0000"
error={invalid}
error={invalid || rateLimited}
helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}
fullWidth
autoFocus
@@ -89,6 +90,13 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
{t('request_code')}
</AppButton>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t.rich('consent_line', {
terms: (chunks) => <AppLink to={`/${locale}${ROUTES.TERMS}`}>{chunks}</AppLink>,
privacy: (chunks) => <AppLink to={`/${locale}${ROUTES.PRIVACY}`}>{chunks}</AppLink>,
})}
</Typography>
<MuiLink
component="button"
type="button"
+14 -2
View File
@@ -15,10 +15,10 @@ jest.mock('@/services/auth', () => ({ useMe: () => meResult }));
import RoleRouter from './RoleRouter';
function renderRouter(intendedRole?: 'customer' | 'nurse') {
function renderRouter(intendedRole?: 'customer' | 'nurse', next?: string | null) {
render(
<ThemeProvider>
<RoleRouter intendedRole={intendedRole} />
<RoleRouter intendedRole={intendedRole} next={next} />
</ThemeProvider>,
);
}
@@ -55,4 +55,16 @@ describe('<RoleRouter/>', () => {
renderRouter();
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
});
it('honors a safe next deep link over the default role destination', async () => {
meResult = { data: { roles: ['customer'] }, isError: false };
renderRouter(undefined, '/bookings/42');
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/bookings/42'));
});
it('ignores an unsafe next and falls back to the role destination', async () => {
meResult = { data: { roles: ['customer'] }, isError: false };
renderRouter(undefined, '//evil.com');
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
});
});
+9 -7
View File
@@ -5,22 +5,24 @@ import { useLocale, useTranslations } from 'next-intl';
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
import { useMe } from '@/services/auth';
import { resolveRoleDestination } from '@/services/auth/routing';
import { resolvePostLoginDestination } from '@/services/auth/routing';
import AuthSplash from './AuthSplash';
interface RoleRouterProps {
/** The role the user logged in as (A1 vs B1); disambiguates a multi-role user and pre-selects on SelectRole. */
intendedRole?: AppRole;
/** The `?next=` deep link carried from middleware.ts's redirect-to-login, if any. */
next?: string | null;
}
/**
* Runs after authentication (never re-implements the auth gate the middleware owns that) and
* sends the user to the right app: customer family, nurse nurse app, no role SelectRole,
* admin admin console. Shows the branded splash while `/me` is in flight so the wrong shell
* never flashes.
* sends the user to the right app: a validated `next` deep link when the session's roles permit
* it, else customer family, nurse nurse app, no role SelectRole, admin admin console.
* Shows the branded splash while `/me` is in flight so the wrong shell never flashes.
* @component RoleRouter
*/
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole, next }) => {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('auth');
@@ -33,13 +35,13 @@ const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
}
if (!me) return;
let destination = resolveRoleDestination(me, intendedRole);
let destination = resolvePostLoginDestination(me, intendedRole, next);
// Carry the login intent into role selection so it can pre-select the right role.
if (destination === ROUTES.SELECT_ROLE && intendedRole === APP_ROLES.NURSE) {
destination = `${destination}?role=${APP_ROLES.NURSE}`;
}
router.replace(`/${locale}${destination}`);
}, [me, isError, intendedRole, router, locale]);
}, [me, isError, intendedRole, next, router, locale]);
return <AuthSplash message={t('routing_title')} />;
};
+38 -7
View File
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { Box, CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
@@ -18,9 +18,11 @@ interface SelectRoleProps {
initialRole?: AppRole;
}
// 'family' (a household under one roof) reads as "receiving care"; 'visits' (a clinical-visit
// glyph) reads as "nurse professional" — a house icon for the nurse option was the prior defect.
const ROLE_OPTIONS: ReadonlyArray<{ role: PublicRole; icon: string }> = [
{ role: 'customer', icon: 'account' },
{ role: 'nurse', icon: 'home' },
{ role: 'customer', icon: 'family' },
{ role: 'nurse', icon: 'visits' },
];
/**
@@ -68,11 +70,14 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
tabIndex={0}
onClick={() => setSelected(role)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') setSelected(role);
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelected(role);
}
}}
elevation={0}
sx={{
p: 2,
p: 2.5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -80,10 +85,29 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 2,
// Selection is never color-only: the soft fill pairs with a check glyph below.
bgcolor: isSelected ? 'var(--bal-primary-soft)' : 'transparent',
transition:
'background-color var(--bal-motion-base) var(--bal-easing-standard), border-color var(--bal-motion-base) var(--bal-easing-standard)',
}}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 0.25 }}>
<Box
aria-hidden="true"
sx={{
width: 56,
height: 56,
flexShrink: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-1)',
}}
>
<AppIcon icon={icon} size={30} color="var(--bal-primary)" />
</Box>
<Stack sx={{ gap: 0.25, flexGrow: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t(`role_${role}`)}
</Typography>
@@ -91,11 +115,18 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
{t(`role_${role}_desc`)}
</Typography>
</Stack>
<Box sx={{ width: 24, height: 24, flexShrink: 0 }} aria-hidden="true">
{isSelected && <AppIcon icon="verified" size={24} color="var(--bal-primary)" />}
</Box>
</Paper>
);
})}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('role_add_later_note')}
</Typography>
<AppButton
color="primary"
variant="contained"
@@ -0,0 +1,41 @@
'use client';
import { FunctionComponent } from 'react';
import { Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import AppIcon from '@/components/common/AppIcon';
const BULLETS: ReadonlyArray<{ icon: string; key: string }> = [
{ icon: 'verification', key: 'trust_verified_nurses' },
{ icon: 'lock', key: 'trust_escrow_payment' },
{ icon: 'support', key: 'trust_support' },
];
/**
* The 23 trust facts under the login card what Balinyaar actually verifies and escrows
* (licensed + identity-verified nurses, escrow held until a confirmed check-out, support).
* Copy is scoped to features the platform implements today, never aspirational claims.
* @component TrustBullets
*/
const TrustBullets: FunctionComponent = () => {
const t = useTranslations('auth');
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
{BULLETS.map((bullet) => (
<Stack key={bullet.key} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={bullet.icon}
size={20}
color="var(--bal-primary)"
style={{ flexShrink: 0, marginTop: 2 }}
aria-hidden="true"
/>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(bullet.key)}
</Typography>
</Stack>
))}
</Stack>
);
};
export default TrustBullets;
+3
View File
@@ -1,2 +1,5 @@
/** Reading width for the branded auth screens (login, OTP, role selection). */
export const AUTH_CARD_MAX_WIDTH = 420;
/** Width of the login hero row (illustration + card) once the illustration joins on desktop. */
export const AUTH_HERO_MAX_WIDTH = 760;
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
interface OTPCredential extends Credential {
code: string;
}
interface OtpCredentialRequestOptions extends CredentialRequestOptions {
otp: { transport: string[] };
}
/**
* WebOTP autofill (web.dev/articles/web-otp): on a supporting Android/Chrome browser, reads the
* SMS-delivered code once the server's template ends with the origin-bound `@<domain> #<code>`
* line (REQ-039) and feeds it through `onCode` the caller wires this into the same
* onChange/onComplete path manual entry uses. Feature-detects `OTPCredential`; unsupported
* browsers (desktop, iOS Safari, Firefox) silently no-op no error, no UI difference. Aborted on
* unmount and whenever `active` turns false (manual verification starts), so a late resolution
* never overwrites a fresher code.
*/
export function useWebOtp(onCode: (code: string) => void, active: boolean): void {
const onCodeRef = useRef(onCode);
// Keep the ref current without adding `onCode` (a fresh closure every render) to the effect
// below's dependency array — that would abort and re-issue the WebOTP read on every render.
useEffect(() => {
onCodeRef.current = onCode;
});
useEffect(() => {
if (!active || typeof window === 'undefined' || !('OTPCredential' in window)) return;
const controller = new AbortController();
navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: controller.signal } as OtpCredentialRequestOptions)
.then((credential) => {
const code = (credential as OTPCredential | null)?.code;
if (code) onCodeRef.current(code);
})
.catch(() => {
// Aborted, unsupported, or dismissed by the user — manual entry remains available.
});
return () => controller.abort();
}, [active]);
}
@@ -100,6 +100,8 @@ import ShareIcon from '@mui/icons-material/ShareRounded';
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
import LanguageIcon from '@mui/icons-material/TranslateRounded';
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
/**
* List of all available Icon names
@@ -204,6 +206,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
favorite: FavoriteIcon,
};
/**
+11 -1
View File
@@ -2,6 +2,9 @@ export const ROUTES = {
LOGIN: '/login',
// Post-login role picker for a brand-new user whose /me has no public role yet.
SELECT_ROLE: '/select-role',
// Draft legal copy (ui-phase-3) — flagged for human/legal review before launch.
TERMS: '/terms',
PRIVACY: '/privacy',
// Customer (family) app — mobile-first, bottom-tab nav
HOME: '/',
@@ -169,4 +172,11 @@ export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string
};
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY];
/**
* Query param the middleware appends when it redirects an unauthenticated deep link to login
* (e.g. `/fa/bookings/42` `/fa/login?next=%2Fbookings%2F42`), so `resolvePostLoginDestination`
* (`services/auth/routing.ts`) can send the user back to where they were headed after OTP.
*/
export const RETURN_URL_PARAM = 'next';
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { AppIcon, ErrorBoundary } from '@/components';
/**
* Chrome-free shell for a focused, can't-tab-away flow today only first-run onboarding
* (ui-phase-3 §3.5). No bottom nav, no bell, no sidebar: a slim logo strip and the step content,
* in the same spirit as `AuthCard`/`PublicLayout`. `RoleGuard` still gates the route group above
* this a chrome-free shell is a UX choice, not a security boundary.
* @layout FocusedLayout
*/
const FocusedLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const tChrome = useTranslations('routeChrome');
return (
<Stack sx={{ minHeight: '100vh' }}>
<Stack component="header" direction="row" sx={{ alignItems: 'center', justifyContent: 'center', paddingBlock: 2 }}>
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
</Stack>
<Stack component="main" sx={{ flexGrow: 1, px: 2, pb: 4 }}>
<ErrorBoundary
name="Focused"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
{children}
</ErrorBoundary>
</Stack>
</Stack>
);
};
export default FocusedLayout;
+11 -1
View File
@@ -4,6 +4,16 @@ import CustomerLayout from './CustomerLayout';
import NurseLayout from './NurseLayout';
import AdminLayout from './AdminLayout';
import PartnerLayout from './PartnerLayout';
import FocusedLayout from './FocusedLayout';
import ActorSwitcher from './components/ActorSwitcher';
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout, ActorSwitcher };
export {
PublicLayout,
PrivateLayout,
CustomerLayout,
NurseLayout,
AdminLayout,
PartnerLayout,
FocusedLayout,
ActorSwitcher,
};
+32 -1
View File
@@ -1,5 +1,5 @@
import { ROUTES } from '@/constants';
import { isAdminRole, resolveRoleDestination, toAppRoles } from './routing';
import { isAdminRole, resolvePostLoginDestination, resolveRoleDestination, toAppRoles } from './routing';
import type { RoleCode } from './types';
describe('toAppRoles', () => {
@@ -56,3 +56,34 @@ describe('resolveRoleDestination (role-router branches)', () => {
expect(resolveRoleDestination({ roles: ['customer', 'nurse'] })).toBe(ROUTES.HOME);
});
});
describe('resolvePostLoginDestination (returnUrl)', () => {
it('honors a safe same-origin next path the session is allowed to reach', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/bookings/42')).toBe('/bookings/42');
});
it('honors a nurse-owned next path for a nurse session', () => {
expect(resolvePostLoginDestination({ roles: ['nurse'] }, undefined, '/nurse/requests')).toBe('/nurse/requests');
});
it('falls back to the role destination for a protocol-relative next (open-redirect guard)', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '//evil.com')).toBe(ROUTES.HOME);
});
it('falls back to the role destination for an absolute-URL next (open-redirect guard)', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, 'https://evil.com')).toBe(ROUTES.HOME);
});
it('falls back when next belongs to a role the session does not hold', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/nurse/requests')).toBe(ROUTES.HOME);
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/admin/audit')).toBe(ROUTES.HOME);
});
it('falls back to select-role for a role-less user regardless of next', () => {
expect(resolvePostLoginDestination({ roles: [] }, undefined, '/bookings')).toBe(ROUTES.SELECT_ROLE);
});
it('falls back to the role destination when next is absent', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, null)).toBe(ROUTES.HOME);
});
});
+35
View File
@@ -45,3 +45,38 @@ export function resolveRoleDestination(me: Pick<Me, 'roles'>, intendedRole?: App
return target === APP_ROLES.NURSE ? ROUTES.NURSE : ROUTES.HOME;
}
/** True for a same-origin relative path only — rejects a protocol-relative (`//host`) or absolute-URL `next`. */
function isSafeRelativePath(path: string): boolean {
return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\');
}
/** The app (nurse/admin/customer) whose route tree owns `path`; customer paths carry no prefix. */
function appRoleForPath(path: string): AppRole | null {
const pathname = path.split('?')[0].split('#')[0];
if (pathname === ROUTES.NURSE || pathname.startsWith(`${ROUTES.NURSE}/`)) return APP_ROLES.NURSE;
if (pathname === ROUTES.ADMIN || pathname.startsWith(`${ROUTES.ADMIN}/`)) return APP_ROLES.ADMIN;
// The partner portal isn't an AppRole (it self-gates via useMyPartnerCenter) — never a `next` target.
if (pathname === ROUTES.PARTNER || pathname.startsWith(`${ROUTES.PARTNER}/`)) return null;
return APP_ROLES.CUSTOMER;
}
/**
* The `?next=` deep-link carried through login (middleware.ts appends it on the redirect-to-login).
* Returns `next` only when it is a validated same-origin relative path **and** the resolved session
* actually holds the role that owns it (a customer's `next=/nurse/...` falls through) otherwise
* defers to `resolveRoleDestination`, which stays the single "which app" source of truth. Never
* returns an absolute URL or a protocol-relative path this is the one guard against `next`
* becoming an open redirect.
*/
export function resolvePostLoginDestination(
me: Pick<Me, 'roles'>,
intendedRole: AppRole | undefined,
next: string | null | undefined,
): string {
if (next && isSafeRelativePath(next)) {
const owner = appRoleForPath(next);
if (owner && toAppRoles(me.roles).includes(owner)) return next;
}
return resolveRoleDestination(me, intendedRole);
}