14 KiB
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, callsnavigator.credentials.get({ otp: { transport: ['sms'] }, signal })with anAbortController, aborted on unmount and whenever verification starts (activeflips false). Wired intoOtpStepso a WebOTP read feeds the samesetCode/verifypath manual entry uses. Unsupported browsers (desktop, Firefox, Safari) silently no-op.OtpStep: the masked-phone echo and the resend countdown are now rendered viat.richwith 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-1formatClockhelper (Persian digits on/fa, Latin on/en) instead of the old rawString/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 AppLinks. 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)
/falogged-out →/fa/login: branded hero (logotype, tagline, illustration on desktop, 3 trust bullets, consent line with working «شرایط استفاده»/«حریم خصوصی» links). Toggle dark mode and/en— tracks.- 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. - Wrong digit + backspace twice on the OTP boxes — each press erases one digit. Paste a 6-digit code — auto-verifies (unchanged).
- Spam request-OTP to trigger a 429 → the phone field renders in error styling, not grey helper text.
/termsand/privacyload logged-out, both locales — draft-copy banner + 7-8 sections each.- 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. - 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. - A seeded nurse still lands on
/nurse;?role=nursestill pre-selects nurse copy through to select-role (both unchanged branches, exercised by the existing + newRoleRoutertests).
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 aresolvePostLoginDestinationtest block torouting.test.ts, twoRoleRoutertests, anautoComplete/backspace test toOtpInput.test.tsx, and anautoCompletetest toPhoneNumberField.test.tsx;OtpStep.test.tsx'snext-intlmock was extended withuseLocaleand at.richstub so the existing suite kept passing against the rewritten component).npm run dev+ SSR smoke (viacurl --noproxy "*", working around the same loopback-proxy 502 Phase 2's report already documented):/fa/login,/en/login,/fa/terms,/fa/privacyall return 200 with no error-boundary/MISSING_MESSAGEmarkers in the HTML;/fa/termswas 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 .NETHttpClientfrom PowerShell withUseProxy=$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. Aconsole.logplaced at the very top ofmiddleware.tsnever printed for any of these requests, even after a full dev-server restart — the middleware bundle compiles (.next/dev/server/middleware/middleware-manifest.jsonlists it with a correct matcher regex) but does not appear to run for a bare HTTP GET in thisnext dev/Turbopack session. Since the exact same non-redirect happens on untouched pre-existing private routes with byte-identical, unmodified redirect logic (confirmed viagit 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/earningswithError: Missing .env variable!insidesrc/components/notifications/index.ts— a pre-existing production-build/env-config gap unrelated to this phase (not investigated further, out of scope). Confidence in thenext-handling code itself comes fromresolvePostLoginDestination's unit tests (open-redirect guards for//evil.comandhttps://evil.com, role-forbidden-path fallback, role-less-user fallback, happy path) andRoleRouter'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 newterms//privacy/pages,FocusedLayout.tsx, thecomponents/auth/bullet (addeduseWebOtp,AuthIllustration,TrustBullets); the i18n namespace list (authadditions, newlegalnamespace,onboarding'swelcome_*); the Middleware section (returnUrl behavior +resolvePostLoginDestination).
Follow-ups for later phases
- Human/legal review of
/termsand/privacybefore 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!insrc/components/notifications/index.tsduring static prerendering of/fa/addresses,/fa/partner/bookings,/fa/nurse/earnings) is pre-existing and unrelated to this phase — worth a ticket sonpm run buildis 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).