# 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).
