ui phase 3
This commit is contained in:
+21
-9
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
+52
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')} />;
|
||||
};
|
||||
|
||||
@@ -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 2–3 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -601,3 +601,22 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
`demo_partner_*` phone user linked as its admin) so the actor is reachable end-to-end like the others.
|
||||
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
|
||||
- **Status:** open
|
||||
|
||||
## REQ-039 — WebOTP-conformant OTP SMS template — filed by ui-phase-3 — 2026-07-17
|
||||
- **Need:** the OTP SMS body must end with the origin-bound last line `@<domain> #<code>` (the
|
||||
[WebOTP](https://web.dev/articles/web-otp) / origin-bound one-time-code convention), e.g.:
|
||||
```
|
||||
کد ورود بالینیار: 123456
|
||||
@balinyaar.com #123456
|
||||
```
|
||||
This is a server/SMS-template change on the Kavenegar adapter (refinement-phase-8) — zero API-shape impact,
|
||||
just the text body the SMS gateway sends.
|
||||
- **Why:** the client now feature-detects `'OTPCredential' in window` and calls
|
||||
`navigator.credentials.get({ otp: { transport: ['sms'] } })` (`components/auth/useWebOtp.ts`, wired into
|
||||
`OtpStep`) plus `autoComplete="one-time-code"` on every OTP box (`OtpInput.tsx`) — but Chrome's WebOTP API
|
||||
and the iOS/Android keyboard "from SMS" suggestion only auto-read a code when the SMS ends with this exact
|
||||
origin-bound line. Without it, the client work still ships (autoComplete alone helps on iOS/some Android
|
||||
keyboards) but silently degrades to manual entry everywhere.
|
||||
- **Proposed shape:** append `\n@<domain> #<code>` as the SMS's last line, where `<domain>` is the site's own
|
||||
host (no scheme, no trailing slash) and `<code>` is the exact code the user must enter.
|
||||
- **Status:** open — client-side WebOTP wiring ships regardless and degrades gracefully until this lands.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# UI Phase 3 — Auth & first-run — Report (2026-07-17)
|
||||
|
||||
## What was built
|
||||
|
||||
### 3.1 Login hero — a branded, trust-forward front door
|
||||
`AuthCard` is redesigned as a hero: on `≥md` a calm abstract illustration (`AuthIllustration`,
|
||||
new — layered soft-tint circles in `--bal-primary-soft`/`--bal-secondary-soft` with a centered
|
||||
`family` glyph and a floating `verified` trust badge, all CSS/SVG in brand tokens, no stock art)
|
||||
sits beside the card; below the card, `TrustBullets` (new) renders three facts the platform
|
||||
actually implements — licensed + identity-verified nurses, escrow held until a confirmed
|
||||
check-out, support — each with a registry icon (`verification`/`lock`/`support`, all pre-existing).
|
||||
The card stays a single `AUTH_CARD_MAX_WIDTH` (420px) column at `xs`, readable at 320px; the
|
||||
illustration is `display:none` below `md`. `LoginFlow`'s single stack (`intendedRole` parameter,
|
||||
never a forked tree) and the `' '` helperText placeholder are untouched.
|
||||
|
||||
### 3.2 OTP ergonomics
|
||||
- `OtpInput`: `autoComplete="one-time-code"` on every digit box; backspace on an empty box now
|
||||
clears the **previous** box and moves focus there in one keypress (was: focus-only, needing two
|
||||
presses to erase a digit).
|
||||
- `components/auth/useWebOtp.ts` (new): feature-detects `'OTPCredential' in window`, calls
|
||||
`navigator.credentials.get({ otp: { transport: ['sms'] }, signal })` with an `AbortController`,
|
||||
aborted on unmount and whenever verification starts (`active` flips false). Wired into `OtpStep`
|
||||
so a WebOTP read feeds the same `setCode`/`verify` path manual entry uses. Unsupported browsers
|
||||
(desktop, Firefox, Safari) silently no-op.
|
||||
- `OtpStep`: the masked-phone echo and the resend countdown are now rendered via `t.rich` with a
|
||||
`<Box component="bdi" dir="ltr">` wrapper — bidi-isolated so the RTL sentence's bidi algorithm
|
||||
can't reorder the digit/bullet runs around them. The countdown uses the Phase-1 `formatClock`
|
||||
helper (Persian digits on `/fa`, Latin on `/en`) instead of the old raw `String`/`padStart`.
|
||||
- `PhoneStep`: `error={invalid || rateLimited}` — the 429 rate-limit message now renders in error
|
||||
styling on the field instead of grey helper text.
|
||||
|
||||
### 3.3 Consent + legal pages
|
||||
`PhoneStep` renders a consent line under the CTA («با ورود، شرایط استفاده و حریم خصوصی را
|
||||
میپذیرید») via `t.rich` with `<terms>`/`<privacy>` tag functions rendering `AppLink`s. New
|
||||
`ROUTES.TERMS`/`ROUTES.PRIVACY` (`/terms`, `/privacy`), both appended to `PUBLIC_PATHS`. New pages
|
||||
`(public-routes)/terms/page.tsx` and `.../privacy/page.tsx` — Server Components (no `'use client'`
|
||||
needed; matches the existing `not-found.tsx` pattern of RSCs rendering MUI/`BrandMark` directly),
|
||||
data-driven from a new `legal` i18n namespace (`terms_sections`/`privacy_sections` are arrays of
|
||||
`{title, body}` read via `t.raw` — the one namespace using structured JSON, everywhere else stays
|
||||
flat keys). Each page opens with an `AppAlert severity="info"` **draft-copy banner**.
|
||||
|
||||
**⚠️ Draft legal copy — human/legal review required before launch.** The Terms of Service and
|
||||
Privacy Policy text (8 sections + 7 sections, both locales) was written by this agent from the
|
||||
product docs (`platform-summary.md`, `02-nurse-verification.md`, `08-payments-and-escrow.md`) to
|
||||
be factually accurate to what the platform does today, but it is **not** attorney-reviewed and
|
||||
must not be treated as binding before a legal pass.
|
||||
|
||||
### 3.4 Select-role — illustrated fork
|
||||
`ROLE_OPTIONS` icons changed from `{customer: 'account', nurse: 'home'}` (a house for "I am a
|
||||
nurse" was the defect) to `{customer: 'family', nurse: 'visits'}` (`FamilyRestroomRounded`/
|
||||
`MedicalServicesRounded`, both pre-existing registry entries). Each card gets a 56px circular icon
|
||||
badge (paper background + `--bal-shadow-1`, echoing the login hero's illustration language).
|
||||
Selected state = `--bal-primary-soft` fill **and** a `verified` check glyph at the row's end (a
|
||||
fixed-width placeholder Box holds its position when unselected, so nothing shifts) — color is no
|
||||
longer the only signal. Added a reassurance line (`role_add_later_note`) below the cards. Radio
|
||||
a11y semantics (`role="radio"`, `aria-checked`, `tabIndex`, Enter/Space) are unchanged.
|
||||
|
||||
### 3.5 Onboarding as a focused journey
|
||||
Relocated `/onboarding` from `(private-routes)/(customer)/onboarding/` to a new sibling route
|
||||
group `(private-routes)/(customer-focused)/onboarding/` (the old file is deleted outright, not
|
||||
shimmed — two `onboarding/page.tsx` at the same URL would conflict). The new route group's
|
||||
`layout.tsx` keeps `RoleGuard(expected=customer)` and wraps children in a new `FocusedLayout`
|
||||
(`src/layout/`) — a slim logo-only header, no BottomBar/bell/sidebar, in the spirit of
|
||||
`AuthCard`/`PublicLayout`. Route groups add no URL segment, so `/onboarding`, `ROUTES.ONBOARDING`,
|
||||
and the home redirect gate (`(customer)/page.tsx` `isEmpty` effect) all keep working untouched.
|
||||
|
||||
`OnboardingScreen.tsx` (new, replaces the old inline `onboarding/page.tsx` body) adds a `'welcome'`
|
||||
phase before `'relation'`/`'patient'` — brand mark, «خوش آمدید — مراقبت برای چه کسی است؟» framing,
|
||||
one CTA. `StepperHeader` only renders for the two counted steps (welcome doesn't count). The four
|
||||
relation options get distinct icons instead of all sharing `'account'`: `parent→elderly`,
|
||||
`spouse→favorite` (**new registry icon**, `FavoriteRounded` — noted per the Phase-0
|
||||
ownership rule), `child→infant`, `self→account` (the latter three already registered). The
|
||||
relation-pre-shapes-patient-form behavior and the settled-list redirect gate are untouched.
|
||||
|
||||
**Deferred, per phase scope:** a «بعداً تکمیل میکنم» skip path (→ Phase 4, changes the zero-patient
|
||||
home gate) and OTP voice-call fallback (→ Phase 12, needs a second delivery channel server-side).
|
||||
|
||||
### 3.6 returnUrl — deep links survive login
|
||||
`middleware.ts`: on the redirect-to-login it now appends `?next=<pathWithoutLocale + search>` (new
|
||||
`RETURN_URL_PARAM = 'next'` constant) — e.g. `/fa/bookings/42` → `/fa/login?next=%2Fbookings%2F42`.
|
||||
The token check, locale detection, header propagation, and next-intl handling are untouched;
|
||||
only the redirect branch gained the extra `searchParams.set`.
|
||||
|
||||
`services/auth/routing.ts` adds `resolvePostLoginDestination(me, intendedRole, next)` (pure,
|
||||
exported, unit-tested) beside the existing `resolveRoleDestination`: returns `next` only when (a)
|
||||
`isSafeRelativePath` — starts with `/`, not `//`, not `/\` (rejects protocol-relative and
|
||||
absolute-URL values; an absolute URL like `https://evil.com` already fails the `startsWith('/')`
|
||||
check) — and (b) `appRoleForPath(next)` resolves to a role the session's `toAppRoles(me.roles)`
|
||||
actually holds (a customer's `next=/nurse/...` falls through; the partner portal, not an
|
||||
`AppRole`, always falls through). Otherwise defers to `resolveRoleDestination`, which stays the
|
||||
single "which app" source of truth. `LoginFlow` reads `next` from `useSearchParams()` and passes
|
||||
it to `RoleRouter`, which now calls `resolvePostLoginDestination` instead of
|
||||
`resolveRoleDestination` directly (the select-role-with-nurse-intent carry-through logic is
|
||||
unchanged, layered on top of the resolved destination).
|
||||
|
||||
### 3.7 PhoneNumberField autofill
|
||||
`autoComplete="tel"` added to the default `slotProps.htmlInput`; LTR forcing and digit
|
||||
normalization untouched.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
|
||||
1. `/fa` logged-out → `/fa/login`: branded hero (logotype, tagline, illustration on desktop, 3
|
||||
trust bullets, consent line with working «شرایط استفاده»/«حریم خصوصی» links). Toggle dark mode
|
||||
and `/en` — tracks.
|
||||
2. Enter a phone, request the code → OTP screen: masked number renders un-scrambled inside the
|
||||
Persian sentence; the resend countdown ticks in Persian digits on `/fa`, Latin on `/en`.
|
||||
3. Wrong digit + backspace twice on the OTP boxes — each press erases one digit. Paste a 6-digit
|
||||
code — auto-verifies (unchanged).
|
||||
4. Spam request-OTP to trigger a 429 → the phone field renders in error styling, not grey helper
|
||||
text.
|
||||
5. `/terms` and `/privacy` load logged-out, both locales — draft-copy banner + 7-8 sections each.
|
||||
6. Fresh phone → `/select-role`: two illustrated cards (family/nurse icons, not house); selecting
|
||||
shows soft fill + check glyph; reassurance line present; Tab/Enter/Space still work.
|
||||
7. Zero-patient customer → `/onboarding`: welcome screen (brand mark + CTA, no stepper) → relation
|
||||
(4 distinct icons) → patient form (relation hidden) → save → Home. No BottomBar/bell visible at
|
||||
any point in the wizard.
|
||||
8. A seeded nurse still lands on `/nurse`; `?role=nurse` still pre-selects nurse copy through to
|
||||
select-role (both unchanged branches, exercised by the existing + new `RoleRouter` tests).
|
||||
|
||||
## Verification performed this session
|
||||
|
||||
- **`npm run check`** (type + lint) — clean.
|
||||
- **`npm run test:ci`** — **98 suites / 420 tests pass** (was 98/408 going in — this phase added a
|
||||
`resolvePostLoginDestination` test block to `routing.test.ts`, two `RoleRouter` tests, an
|
||||
`autoComplete`/backspace test to `OtpInput.test.tsx`, and an `autoComplete` test to
|
||||
`PhoneNumberField.test.tsx`; `OtpStep.test.tsx`'s `next-intl` mock was extended with `useLocale`
|
||||
and a `t.rich` stub so the existing suite kept passing against the rewritten component).
|
||||
- **`npm run dev` + SSR smoke** (via `curl --noproxy "*"`, working around the same loopback-proxy
|
||||
502 Phase 2's report already documented): `/fa/login`, `/en/login`, `/fa/terms`, `/fa/privacy`
|
||||
all return 200 with no error-boundary/`MISSING_MESSAGE` markers in the HTML; `/fa/terms` was
|
||||
grepped for its own distinctive section title («ماهیت خدمت») and the draft banner
|
||||
(«این متن پیشنویس است») to confirm real translated content renders, not just the hydration
|
||||
payload.
|
||||
- **Not conclusively verified this session: the `?next=` middleware redirect itself.** Requesting
|
||||
a private route with no cookie (`curl`, and independently a .NET `HttpClient` from PowerShell
|
||||
with `UseProxy=$false` — two unrelated HTTP stacks, ruling out the proxy explanation) returned
|
||||
**200 with the page's own content** instead of a 3xx to `/login`, for both routes this phase
|
||||
never touches (`/fa/nurse/profile`, `/fa/bookings/999` — the latter a dynamic segment, so not a
|
||||
static-cache artifact either) and routes it does. A `console.log` placed at the very top of
|
||||
`middleware.ts` never printed for any of these requests, even after a full dev-server restart —
|
||||
the middleware bundle compiles (`.next/dev/server/middleware/middleware-manifest.json` lists it
|
||||
with a correct matcher regex) but does not appear to run for a bare HTTP GET in this
|
||||
`next dev`/Turbopack session. Since the exact same non-redirect happens on **untouched**
|
||||
pre-existing private routes with **byte-identical, unmodified** redirect logic (confirmed via
|
||||
`git show HEAD:client/middleware.ts`), this reads as an environment/toolchain anomaly specific
|
||||
to this Next.js 16.2.9-canary + Turbopack dev session, not a regression from this phase's diff —
|
||||
but I could not get a real browser in front of it to confirm the alternate hypothesis (that
|
||||
actual browser navigation, unlike a cold HTTP client request, does trigger it correctly). A
|
||||
production build (`npm run build`) also could not confirm this independently: it fails during
|
||||
static-page prerendering on `/fa/addresses`, `/fa/partner/bookings`, `/fa/nurse/earnings` with
|
||||
`Error: Missing .env variable!` inside `src/components/notifications/index.ts` — a pre-existing
|
||||
production-build/env-config gap unrelated to this phase (not investigated further, out of
|
||||
scope). **Confidence in the `next`-handling code itself comes from `resolvePostLoginDestination`'s
|
||||
unit tests** (open-redirect guards for `//evil.com` and `https://evil.com`, role-forbidden-path
|
||||
fallback, role-less-user fallback, happy path) and `RoleRouter`'s two new tests exercising the
|
||||
same function through the component — not from an end-to-end browser confirmation. **Flagging
|
||||
this explicitly per the "don't claim UI success you can't see" rule** — recommend a human
|
||||
re-verify item 8 in "How to test" (§7 of the phase doc) in an actual browser before merging.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
|
||||
None introduced. Auth stays real (`USE_AUTH_MOCK = false`); every deliverable here is
|
||||
presentation, ergonomics, or client-side routing — no mock flag was flipped.
|
||||
|
||||
## Contracts
|
||||
|
||||
None produced or consumed as an endpoint change. One request filed:
|
||||
|
||||
- **REQ-039** — WebOTP-conformant OTP SMS template (`@<domain> #<code>` origin-bound last line) —
|
||||
a server/Kavenegar-adapter SMS-template change, zero API-shape impact. The client-side WebOTP
|
||||
wiring (`useWebOtp` + `autoComplete="one-time-code"`) ships regardless and degrades to manual
|
||||
entry until the template lands.
|
||||
|
||||
## Docs updated
|
||||
|
||||
- `client/CLAUDE.md` — Project Structure: the new `(customer-focused)/onboarding/` route group,
|
||||
the deleted `(customer)/onboarding/`, the new `terms/`/`privacy/` pages, `FocusedLayout.tsx`,
|
||||
the `components/auth/` bullet (added `useWebOtp`, `AuthIllustration`, `TrustBullets`); the i18n
|
||||
namespace list (`auth` additions, new `legal` namespace, `onboarding`'s `welcome_*`); the
|
||||
Middleware section (returnUrl behavior + `resolvePostLoginDestination`).
|
||||
|
||||
## Follow-ups for later phases
|
||||
|
||||
- **Human/legal review of `/terms` and `/privacy`** before launch — flagged in-page (draft banner)
|
||||
and here; not a code follow-up, a legal one.
|
||||
- **Re-verify the `?next=` middleware redirect in an actual browser** (see "Verification performed"
|
||||
above) — the code is unit-tested and unchanged in its core logic, but this session's tooling
|
||||
couldn't get a conclusive live HTTP confirmation.
|
||||
- The production-build failure (`Missing .env variable!` in `src/components/notifications/index.ts`
|
||||
during static prerendering of `/fa/addresses`, `/fa/partner/bookings`, `/fa/nurse/earnings`) is
|
||||
pre-existing and unrelated to this phase — worth a ticket so `npm run build` is verifiable
|
||||
end-to-end by a future phase.
|
||||
- Skip-onboarding («بعداً تکمیل میکنم») → Phase 4 (changes the zero-patient home gate, storefront
|
||||
territory). OTP voice-call fallback → Phase 12 (needs a second server-side delivery channel).
|
||||
Reference in New Issue
Block a user