From 53b4e1b0a4d22af1d735bd1061585f9b91f25fca Mon Sep 17 00:00:00 2001 From: hamid Date: Sat, 18 Jul 2026 01:56:17 +0330 Subject: [PATCH] ui phase 3 --- client/CLAUDE.md | 30 ++- client/messages/en.json | 38 +++- client/messages/fa.json | 38 +++- client/middleware.ts | 13 +- .../(customer-focused)/layout.tsx | 19 ++ .../onboarding/OnboardingScreen.tsx} | 63 +++++- .../(customer-focused)/onboarding/page.tsx | 13 ++ .../[locale]/(public-routes)/privacy/page.tsx | 56 +++++ .../[locale]/(public-routes)/terms/page.tsx | 56 +++++ .../src/components/OtpInput/OtpInput.test.tsx | 18 ++ client/src/components/OtpInput/OtpInput.tsx | 9 +- .../PhoneNumberField.test.tsx | 5 + .../PhoneNumberField/PhoneNumberField.tsx | 2 + client/src/components/auth/AuthCard.tsx | 46 +++-- .../src/components/auth/AuthIllustration.tsx | 85 ++++++++ client/src/components/auth/LoginFlow.tsx | 6 +- client/src/components/auth/OtpStep.test.tsx | 9 +- client/src/components/auth/OtpStep.tsx | 48 +++-- client/src/components/auth/PhoneStep.tsx | 16 +- .../src/components/auth/RoleRouter.test.tsx | 16 +- client/src/components/auth/RoleRouter.tsx | 16 +- client/src/components/auth/SelectRole.tsx | 45 +++- client/src/components/auth/TrustBullets.tsx | 41 ++++ client/src/components/auth/constants.ts | 3 + client/src/components/auth/useWebOtp.ts | 45 ++++ .../src/components/common/AppIcon/config.ts | 3 + client/src/constants/routes.ts | 12 +- client/src/layout/FocusedLayout.tsx | 37 ++++ client/src/layout/index.tsx | 12 +- client/src/services/auth/routing.test.ts | 33 ++- client/src/services/auth/routing.ts | 35 ++++ .../frontend/requests/for-backend.md | 19 ++ .../reports/ui-phase-3-report.md | 194 ++++++++++++++++++ 33 files changed, 1000 insertions(+), 81 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer-focused)/layout.tsx rename client/src/app/[locale]/(private-routes)/{(customer)/onboarding/page.tsx => (customer-focused)/onboarding/OnboardingScreen.tsx} (54%) create mode 100644 client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/page.tsx create mode 100644 client/src/app/[locale]/(public-routes)/privacy/page.tsx create mode 100644 client/src/app/[locale]/(public-routes)/terms/page.tsx create mode 100644 client/src/components/auth/AuthIllustration.tsx create mode 100644 client/src/components/auth/TrustBullets.tsx create mode 100644 client/src/components/auth/useWebOtp.ts create mode 100644 client/src/layout/FocusedLayout.tsx create mode 100644 dev/shared-working-context/reports/ui-phase-3-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 0b7b54e..03bf9cc 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 ``/`` 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: diff --git a/client/messages/en.json b/client/messages/en.json index 060c2f6..a0c346e 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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 of Service and Privacy Policy." }, "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." } + ] } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 7cb3771..a301c73 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "با ورود، شرایط استفاده و حریم خصوصی را می‌پذیرید." }, "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": "سوالات درباره این سیاست را می‌توانید از طریق سامانه تیکت پشتیبانی در اپلیکیشن ارسال کنید." } + ] } } diff --git a/client/middleware.ts b/client/middleware.ts index c5a617e..c93e44a 100644 --- a/client/middleware.ts +++ b/client/middleware.ts @@ -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); } } diff --git a/client/src/app/[locale]/(private-routes)/(customer-focused)/layout.tsx b/client/src/app/[locale]/(private-routes)/(customer-focused)/layout.tsx new file mode 100644 index 0000000..8f11a45 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer-focused)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx b/client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx similarity index 54% rename from client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx rename to client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx index cd8572b..8d0c662 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/onboarding/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx @@ -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 = { + 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('welcome'); const [relation, setRelation] = useState(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 ( + + + + + {t('welcome_title')} + + + {t('welcome_subtitle')} + + + setPhase('relation')}> + {t('welcome_cta')} + + + ); + } + + const activeStep = phase === 'relation' ? 0 : 1; + return ( - + - {step === 0 ? ( + {phase === 'relation' ? ( @@ -66,7 +106,8 @@ export default function OnboardingPage() { variant="contained" fullWidth disabled={!relation} - onClick={() => setStep(1)} + onClick={() => setPhase('patient')} + endIcon={ diff --git a/client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/page.tsx b/client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/page.tsx new file mode 100644 index 0000000..ccf83f8 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/page.tsx @@ -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 { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'onboarding' }); + return { title: t('welcome_title') }; +} + +export default function Page() { + return ; +} diff --git a/client/src/app/[locale]/(public-routes)/privacy/page.tsx b/client/src/app/[locale]/(public-routes)/privacy/page.tsx new file mode 100644 index 0000000..2f383f8 --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/privacy/page.tsx @@ -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 { + 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 ( + + + + + + + {t('privacy_title')} + + + {t('draft_banner')} + + + {t('privacy_intro')} + + {sections.map((section, index) => ( + + + + {index + 1}. {section.title} + + + {section.body} + + + ))} + + + ); +} diff --git a/client/src/app/[locale]/(public-routes)/terms/page.tsx b/client/src/app/[locale]/(public-routes)/terms/page.tsx new file mode 100644 index 0000000..5117c9a --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/terms/page.tsx @@ -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 { + 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 ( + + + + + + + {t('terms_title')} + + + {t('draft_banner')} + + + {t('terms_intro')} + + {sections.map((section, index) => ( + + + + {index + 1}. {section.title} + + + {section.body} + + + ))} + + + ); +} diff --git a/client/src/components/OtpInput/OtpInput.test.tsx b/client/src/components/OtpInput/OtpInput.test.tsx index fe26042..b71c49a 100644 --- a/client/src/components/OtpInput/OtpInput.test.tsx +++ b/client/src/components/OtpInput/OtpInput.test.tsx @@ -46,4 +46,22 @@ describe(' 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(); + 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(); + 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(); + }); }); diff --git a/client/src/components/OtpInput/OtpInput.tsx b/client/src/components/OtpInput/OtpInput.tsx index 680396b..18f9f19 100644 --- a/client/src/components/OtpInput/OtpInput.tsx +++ b/client/src/components/OtpInput/OtpInput.tsx @@ -87,8 +87,13 @@ const OtpInput: FunctionComponent = ({ }; const handleKeyDown = (index: number, event: KeyboardEvent) => { - 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 = ({ 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 }, }, diff --git a/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx b/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx index befcfdf..1b6b0d8 100644 --- a/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx +++ b/client/src/components/PhoneNumberField/PhoneNumberField.test.tsx @@ -43,4 +43,9 @@ describe(' 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(); + expect(screen.getByRole('textbox')).toHaveAttribute('autocomplete', 'tel'); + }); }); diff --git a/client/src/components/PhoneNumberField/PhoneNumberField.tsx b/client/src/components/PhoneNumberField/PhoneNumberField.tsx index 2cdb59c..dc7961e 100644 --- a/client/src/components/PhoneNumberField/PhoneNumberField.tsx +++ b/client/src/components/PhoneNumberField/PhoneNumberField.tsx @@ -47,6 +47,8 @@ const PhoneNumberField: FunctionComponent = ({ 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, diff --git a/client/src/components/auth/AuthCard.tsx b/client/src/components/auth/AuthCard.tsx index 6d50e50..20ca246 100644 --- a/client/src/components/auth/AuthCard.tsx +++ b/client/src/components/auth/AuthCard.tsx @@ -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 = ({ children }) => ( - - - - {children} + + + + + + {children} + + + - + ); diff --git a/client/src/components/auth/AuthIllustration.tsx b/client/src/components/auth/AuthIllustration.tsx new file mode 100644 index 0000000..81348cf --- /dev/null +++ b/client/src/components/auth/AuthIllustration.tsx @@ -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; +} + +/** + * 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 = ({ sx }) => ( +