ui phase 3

This commit is contained in:
hamid
2026-07-18 01:56:17 +03:30
parent 222856d600
commit 53b4e1b0a4
33 changed files with 1000 additions and 81 deletions
@@ -0,0 +1,19 @@
'use client';
import type { ReactNode } from 'react';
import { FocusedLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/*
* Customer-focused route group — a chrome-free counterpart to `(customer)` for flows the user
* should not tab away from mid-task (today only first-run onboarding, ui-phase-3 §3.5). A route
* group adds chrome without adding a URL segment, so `/onboarding` is unchanged. RoleGuard still
* gates it on a resolved customer role, identically to the full `(customer)` shell.
*/
export default function CustomerFocusedRouteLayout({ children }: { children: ReactNode }) {
return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<FocusedLayout>{children}</FocusedLayout>
</RoleGuard>
);
}
@@ -4,7 +4,8 @@ import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, PatientForm, RelationSelect, StepperHeader } from '@/components';
import { AppButton, AppIcon, PatientForm, RelationSelect, StepperHeader } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
import { ROUTES } from '@/constants';
import { useCreatePatient } from '@/services/patients';
import { RELATION_CODES } from '@/services/patients/constants';
@@ -12,12 +13,26 @@ import type { CreatePatientInput, Relation } from '@/services/patients/types';
const ONBOARDING_MAX_WIDTH = 520;
// Distinct per-option glyph (the prior defect: all four relations shared the generic 'account'
// icon) — 'elderly' fits a parent, 'favorite' a spouse, 'infant' a child, 'account' one's self.
const RELATION_ICONS: Record<string, string> = {
parent: 'elderly',
spouse: 'favorite',
child: 'infant',
self: 'account',
};
type Phase = 'welcome' | 'relation' | 'patient';
/**
* A3 A4 onboarding wizard: pick who care is for, then register the first patient. The
* chosen relation pre-shapes the patient (it is hidden on the A4 form since it's already
* chosen here). On save it creates the patient and lands on Home (A5).
* The chrome-free A3 A4 first-run journey: a one-screen welcome moment, then pick who care is
* for, then register the first patient. `FocusedLayout` (the route group above this) strips the
* bottom nav/bell so there's nothing to tab away to mid-setup. The welcome screen doesn't count
* as a stepper step; relation patient does. The chosen relation pre-shapes the patient (hidden
* on the A4 form since it's already chosen here). On save it creates the patient and lands on
* Home (A5).
*/
export default function OnboardingPage() {
export default function OnboardingScreen() {
const t = useTranslations('onboarding');
const tc = useTranslations('common');
const router = useRouter();
@@ -25,10 +40,14 @@ export default function OnboardingPage() {
const { enqueueSnackbar } = useSnackbar();
const createPatient = useCreatePatient();
const [step, setStep] = useState(0);
const [phase, setPhase] = useState<Phase>('welcome');
const [relation, setRelation] = useState<Relation | null>(null);
const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`), icon: 'account' }));
const relationOptions = RELATION_CODES.map((code) => ({
code,
label: t(`relation_${code}`),
icon: RELATION_ICONS[code] ?? 'account',
}));
const handleCreate = (input: CreatePatientInput) => {
createPatient.mutate(
@@ -42,11 +61,32 @@ export default function OnboardingPage() {
);
};
if (phase === 'welcome') {
return (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 3, textAlign: 'center' }}>
<BrandMark />
<Stack sx={{ gap: 1, maxWidth: ONBOARDING_MAX_WIDTH }}>
<Typography variant="h5" component="h1">
{t('welcome_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('welcome_subtitle')}
</Typography>
</Stack>
<AppButton color="primary" variant="contained" onClick={() => setPhase('relation')}>
{t('welcome_cta')}
</AppButton>
</Stack>
);
}
const activeStep = phase === 'relation' ? 0 : 1;
return (
<Box sx={{ maxWidth: ONBOARDING_MAX_WIDTH, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={step} />
<StepperHeader steps={[t('step_relation'), t('step_patient')]} activeStep={activeStep} />
{step === 0 ? (
{phase === 'relation' ? (
<Stack sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
@@ -66,7 +106,8 @@ export default function OnboardingPage() {
variant="contained"
fullWidth
disabled={!relation}
onClick={() => setStep(1)}
onClick={() => setPhase('patient')}
endIcon={<AppIcon icon="forward" size={18} aria-hidden="true" />}
>
{t('continue')}
</AppButton>
@@ -86,7 +127,7 @@ export default function OnboardingPage() {
submitLabel={t('save_continue')}
submitting={createPatient.isPending}
onSubmit={handleCreate}
onCancel={() => setStep(0)}
onCancel={() => setPhase('relation')}
cancelLabel={tc('back')}
/>
</Stack>
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import OnboardingScreen from './OnboardingScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'onboarding' });
return { title: t('welcome_title') };
}
export default function Page() {
return <OnboardingScreen />;
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('privacy_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Privacy Policy — placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function PrivacyPage() {
const t = await getTranslations('legal');
const sections = t.raw('privacy_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('privacy_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('privacy_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Container, Divider, Stack, Typography } from '@mui/material';
import { AppAlert } from '@/components';
import BrandMark from '@/components/auth/BrandMark';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'legal' });
return { title: t('terms_title') };
}
interface LegalSection {
title: string;
body: string;
}
/**
* Draft Terms of Service — placeholder legal copy flagged for human/legal review before launch
* (ui-phase-3 §3.3). Publicly reachable logged-out (in `PUBLIC_PATHS`) so the login consent line
* can link to it. A Server Component: static translated copy only, no interactivity.
*/
export default async function TermsPage() {
const t = await getTranslations('legal');
const sections = t.raw('terms_sections') as LegalSection[];
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Stack sx={{ alignItems: 'center', mb: 4 }}>
<BrandMark />
</Stack>
<Stack sx={{ gap: 3 }}>
<Typography variant="h5" component="h1">
{t('terms_title')}
</Typography>
<AppAlert severity="info" variant="outlined">
{t('draft_banner')}
</AppAlert>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('terms_intro')}
</Typography>
{sections.map((section, index) => (
<Stack key={section.title} sx={{ gap: 1 }}>
<Divider />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{index + 1}. {section.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{section.body}
</Typography>
</Stack>
))}
</Stack>
</Container>
);
}
@@ -46,4 +46,22 @@ describe('<OtpInput/> component', () => {
await user.type(boxes[3], '4');
expect(onComplete).toHaveBeenCalledWith('1234');
});
it('carries autoComplete="one-time-code" so the OS can offer the SMS code', () => {
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
boxes.forEach((box) => expect(box).toHaveAttribute('autocomplete', 'one-time-code'));
});
it('backspace on an empty box clears the previous digit and moves focus there in one keypress', async () => {
const user = userEvent.setup();
render(<Harness length={4} />);
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
await user.type(boxes[0], '1');
await user.type(boxes[1], '2');
boxes[2].focus();
await user.keyboard('{Backspace}');
expect(boxes[1].value).toBe('');
expect(boxes[1]).toHaveFocus();
});
});
+8 -1
View File
@@ -87,8 +87,13 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
};
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace' && !chars[index]) {
if (event.key === 'Backspace' && !chars[index] && index > 0) {
// Empty box + backspace clears the previous digit too, so one keypress erases one digit
// instead of the first press only moving focus and the second doing the clearing.
const next = [...chars];
next[index - 1] = '';
focusBox(index - 1);
emit(next);
}
};
@@ -122,6 +127,8 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
htmlInput: {
inputMode: 'numeric',
maxLength: 1,
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
autoComplete: 'one-time-code',
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
},
@@ -43,4 +43,9 @@ describe('<PhoneNumberField/> component', () => {
expect(isIranianMobile('0912345678')).toBe(false);
expect(isIranianMobile('19123456789')).toBe(false);
});
it('carries autoComplete="tel" so the browser/keyboard can offer the saved number', () => {
render(<Harness />);
expect(screen.getByRole('textbox')).toHaveAttribute('autocomplete', 'tel');
});
});
@@ -47,6 +47,8 @@ const PhoneNumberField: FunctionComponent<PhoneNumberFieldProps> = ({ value, onC
dir: 'ltr',
inputMode: 'numeric',
maxLength: IRAN_MOBILE_LENGTH,
// Offers the user's own number from the browser/keyboard's saved contact info.
autoComplete: 'tel',
style: { textAlign: 'start' },
},
...slotProps,
+32 -14
View File
@@ -1,32 +1,50 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { Paper, Stack } from '@mui/material';
import { AUTH_CARD_MAX_WIDTH } from './constants';
import { AUTH_CARD_MAX_WIDTH, AUTH_HERO_MAX_WIDTH } from './constants';
import BrandMark from './BrandMark';
import AuthIllustration from './AuthIllustration';
import TrustBullets from './TrustBullets';
/**
* Centered branded card that hosts each auth step (phone, OTP, role selection). Presentational
* shell only — the step content is passed as children.
* Centered branded hero that hosts each auth step (phone, OTP). The card stays readable at
* 320px; a calm illustration joins beside it once the viewport has room to breathe (desktop),
* with the platform's trust facts underneath — login is the product's only front door, so it
* carries trust evidence rather than a bare form.
* @component AuthCard
*/
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
<Paper
elevation={0}
<Stack
direction={{ xs: 'column', md: 'row' }}
sx={{
width: '100%',
maxWidth: AUTH_CARD_MAX_WIDTH,
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
maxWidth: AUTH_HERO_MAX_WIDTH,
alignItems: 'center',
justifyContent: 'center',
gap: { xs: 0, md: 6 },
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
<AuthIllustration sx={{ display: { xs: 'none', md: 'flex' } }} />
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3, flexShrink: 0 }}>
<Paper
elevation={0}
sx={{
width: '100%',
p: { xs: 3, sm: 4 },
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
}}
>
<Stack sx={{ gap: 3 }}>
<BrandMark withTagline />
{children}
</Stack>
</Paper>
<TrustBullets />
</Stack>
</Paper>
</Stack>
</Stack>
);
@@ -0,0 +1,85 @@
'use client';
import { FunctionComponent } from 'react';
import { Box, SxProps, Theme } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
const ILLUSTRATION_SIZE = 220;
interface AuthIllustrationProps {
sx?: SxProps<Theme>;
}
/**
* A calm, abstract hero graphic for the login front door — layered soft-tint circles (brand
* tokens only, no stock photos or raster art) with a centered family glyph and a floating
* trust badge, echoing the trust bullets underneath. Purely decorative.
* @component AuthIllustration
*/
const AuthIllustration: FunctionComponent<AuthIllustrationProps> = ({ sx }) => (
<Box
aria-hidden="true"
sx={{
position: 'relative',
width: ILLUSTRATION_SIZE,
height: ILLUSTRATION_SIZE,
flexShrink: 0,
...sx,
}}
>
<Box sx={{ position: 'absolute', inset: 0, borderRadius: '50%', bgcolor: 'var(--bal-primary-soft)' }} />
<Box
sx={{
position: 'absolute',
insetInlineStart: '16%',
insetBlockEnd: '4%',
width: '46%',
height: '46%',
borderRadius: '50%',
bgcolor: 'var(--bal-secondary-soft)',
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
width: '58%',
height: '58%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="family" size={64} color="var(--bal-primary)" />
</Box>
</Box>
<Box
sx={{
position: 'absolute',
insetInlineEnd: '8%',
insetBlockStart: '10%',
width: '30%',
height: '30%',
borderRadius: '50%',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon="verified" size={30} color="var(--bal-trust)" />
</Box>
</Box>
);
export default AuthIllustration;
+4 -2
View File
@@ -2,7 +2,7 @@
import { FunctionComponent, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { APP_ROLES, type AppRole } from '@/constants';
import { APP_ROLES, RETURN_URL_PARAM, type AppRole } from '@/constants';
import { OTP_RESEND_FALLBACK_SECONDS } from '@/services/auth/constants';
import AuthCard from './AuthCard';
import PhoneStep from './PhoneStep';
@@ -25,9 +25,11 @@ const LoginFlow: FunctionComponent = () => {
const [step, setStep] = useState<Step>('phone');
const [phone, setPhone] = useState('');
const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS);
// Carried from middleware.ts's redirect-to-login so a deep link survives the auth round trip.
const next = searchParams.get(RETURN_URL_PARAM);
if (step === 'routing') {
return <RoleRouter intendedRole={intendedRole} />;
return <RoleRouter intendedRole={intendedRole} next={next} />;
}
return (
+8 -1
View File
@@ -6,7 +6,14 @@ const mockVerifyMutate = jest.fn();
const mockVerifyReset = jest.fn();
const mockRequestMutate = jest.fn();
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
jest.mock('next-intl', () => ({
useLocale: () => 'fa',
useTranslations: () => {
const t = (key: string) => key;
t.rich = (key: string) => key;
return t;
},
}));
jest.mock('@/services/auth', () => ({
useVerifyOtp: () => ({ isPending: false, isError: false, mutate: mockVerifyMutate, reset: mockVerifyReset }),
useRequestOtp: () => ({ isPending: false, mutate: mockRequestMutate }),
+34 -14
View File
@@ -1,7 +1,7 @@
'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { Box, CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useLocale, useTranslations } from 'next-intl';
import OtpInput from '@/components/OtpInput';
import { maskIranMobile } from '@/components/PhoneNumberField';
@@ -9,8 +9,10 @@ import { AppButton } from '@/components';
import { APP_ROLES, type AppRole } from '@/constants';
import { useRequestOtp, useVerifyOtp } from '@/services/auth';
import { OTP_CODE_LENGTH, OTP_LOCKED_CODE } from '@/services/auth/constants';
import { formatClock } from '@/utils';
import type { ApiError } from '@/lib/api/errors';
import { useCountdown } from './useCountdown';
import { useWebOtp } from './useWebOtp';
interface OtpStepProps {
phone: string;
@@ -21,17 +23,11 @@ interface OtpStepProps {
onChangeNumber: () => void;
}
function formatMmSs(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
/**
* A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled,
* runs a single resend countdown (cleaned up on unmount), and renders explicit wrong-code,
* expired-code and max-attempts-lockout states. Same mechanism for both actors; the CTA copy
* differs.
* A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled
* (manually or via WebOTP autofill), runs a single resend countdown (cleaned up on unmount), and
* renders explicit wrong-code, expired-code and max-attempts-lockout states. Same mechanism for
* both actors; the CTA copy differs.
* @component OtpStep
*/
const OtpStep: FunctionComponent<OtpStepProps> = ({
@@ -42,6 +38,7 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
onChangeNumber,
}) => {
const t = useTranslations('auth');
const locale = useLocale();
const [code, setCode] = useState('');
const [locked, setLocked] = useState(false);
const countdown = useCountdown();
@@ -68,6 +65,13 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
);
};
// Reads the SMS code on a supporting browser (REQ-039) and feeds it through the same path
// manual entry uses. Stops (aborts the pending read) once a verification is underway.
useWebOtp((otpCode) => {
setCode(otpCode);
verify(otpCode);
}, !locked && !verifyOtp.isPending);
const submit = (event: FormEvent) => {
event.preventDefault();
verify(code);
@@ -97,7 +101,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
{t('otp_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('otp_sent_to', { phone: maskIranMobile(phone) })}
{t.rich('otp_sent_to', {
// <bdi dir="ltr"> isolates the masked number so the RTL sentence's bidi algorithm
// never reorders the digit/bullet runs around it (the classic digits-around-neutrals bug).
phone: () => (
<Box component="bdi" dir="ltr">
{maskIranMobile(phone)}
</Box>
),
})}
</Typography>
</Stack>
@@ -136,7 +148,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
<Stack sx={{ alignItems: 'center', gap: 1 }}>
{resendDisabled && countdown.isActive && !locked ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('resend_in', { time: formatMmSs(countdown.seconds) })}
{t.rich('resend_in', {
// Same bidi-isolation reasoning as the phone echo above — keeps mm:ss reading
// left-to-right (in Persian digits on /fa) inside the RTL sentence.
time: () => (
<Box component="bdi" dir="ltr">
{formatClock(countdown.seconds, locale)}
</Box>
),
})}
</Typography>
) : (
<MuiLink
+12 -4
View File
@@ -1,11 +1,11 @@
'use client';
import { FormEvent, FunctionComponent, useState } from 'react';
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
import { AppButton } from '@/components';
import { APP_ROLES, type AppRole } from '@/constants';
import { AppButton, AppLink } from '@/components';
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
import { useRequestOtp } from '@/services/auth';
import type { ApiError } from '@/lib/api/errors';
import type { RequestOtpResult } from '@/services/auth/types';
@@ -26,6 +26,7 @@ const RATE_LIMIT_STATUS = 429;
*/
const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRole, onSent }) => {
const t = useTranslations('auth');
const locale = useLocale();
const [phone, setPhone] = useState('');
const [invalid, setInvalid] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
@@ -72,7 +73,7 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
}}
label={t('phone_label')}
placeholder="0912 000 0000"
error={invalid}
error={invalid || rateLimited}
helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}
fullWidth
autoFocus
@@ -89,6 +90,13 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
{t('request_code')}
</AppButton>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t.rich('consent_line', {
terms: (chunks) => <AppLink to={`/${locale}${ROUTES.TERMS}`}>{chunks}</AppLink>,
privacy: (chunks) => <AppLink to={`/${locale}${ROUTES.PRIVACY}`}>{chunks}</AppLink>,
})}
</Typography>
<MuiLink
component="button"
type="button"
+14 -2
View File
@@ -15,10 +15,10 @@ jest.mock('@/services/auth', () => ({ useMe: () => meResult }));
import RoleRouter from './RoleRouter';
function renderRouter(intendedRole?: 'customer' | 'nurse') {
function renderRouter(intendedRole?: 'customer' | 'nurse', next?: string | null) {
render(
<ThemeProvider>
<RoleRouter intendedRole={intendedRole} />
<RoleRouter intendedRole={intendedRole} next={next} />
</ThemeProvider>,
);
}
@@ -55,4 +55,16 @@ describe('<RoleRouter/>', () => {
renderRouter();
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
});
it('honors a safe next deep link over the default role destination', async () => {
meResult = { data: { roles: ['customer'] }, isError: false };
renderRouter(undefined, '/bookings/42');
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/bookings/42'));
});
it('ignores an unsafe next and falls back to the role destination', async () => {
meResult = { data: { roles: ['customer'] }, isError: false };
renderRouter(undefined, '//evil.com');
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
});
});
+9 -7
View File
@@ -5,22 +5,24 @@ import { useLocale, useTranslations } from 'next-intl';
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
import { useMe } from '@/services/auth';
import { resolveRoleDestination } from '@/services/auth/routing';
import { resolvePostLoginDestination } from '@/services/auth/routing';
import AuthSplash from './AuthSplash';
interface RoleRouterProps {
/** The role the user logged in as (A1 vs B1); disambiguates a multi-role user and pre-selects on SelectRole. */
intendedRole?: AppRole;
/** The `?next=` deep link carried from middleware.ts's redirect-to-login, if any. */
next?: string | null;
}
/**
* Runs after authentication (never re-implements the auth gate the middleware owns that) and
* sends the user to the right app: customer family, nurse nurse app, no role SelectRole,
* admin admin console. Shows the branded splash while `/me` is in flight so the wrong shell
* never flashes.
* sends the user to the right app: a validated `next` deep link when the session's roles permit
* it, else customer family, nurse nurse app, no role SelectRole, admin admin console.
* Shows the branded splash while `/me` is in flight so the wrong shell never flashes.
* @component RoleRouter
*/
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole, next }) => {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('auth');
@@ -33,13 +35,13 @@ const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
}
if (!me) return;
let destination = resolveRoleDestination(me, intendedRole);
let destination = resolvePostLoginDestination(me, intendedRole, next);
// Carry the login intent into role selection so it can pre-select the right role.
if (destination === ROUTES.SELECT_ROLE && intendedRole === APP_ROLES.NURSE) {
destination = `${destination}?role=${APP_ROLES.NURSE}`;
}
router.replace(`/${locale}${destination}`);
}, [me, isError, intendedRole, router, locale]);
}, [me, isError, intendedRole, next, router, locale]);
return <AuthSplash message={t('routing_title')} />;
};
+38 -7
View File
@@ -1,6 +1,6 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { Box, CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
@@ -18,9 +18,11 @@ interface SelectRoleProps {
initialRole?: AppRole;
}
// 'family' (a household under one roof) reads as "receiving care"; 'visits' (a clinical-visit
// glyph) reads as "nurse professional" — a house icon for the nurse option was the prior defect.
const ROLE_OPTIONS: ReadonlyArray<{ role: PublicRole; icon: string }> = [
{ role: 'customer', icon: 'account' },
{ role: 'nurse', icon: 'home' },
{ role: 'customer', icon: 'family' },
{ role: 'nurse', icon: 'visits' },
];
/**
@@ -68,11 +70,14 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
tabIndex={0}
onClick={() => setSelected(role)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') setSelected(role);
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelected(role);
}
}}
elevation={0}
sx={{
p: 2,
p: 2.5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -80,10 +85,29 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
borderRadius: 2,
// Selection is never color-only: the soft fill pairs with a check glyph below.
bgcolor: isSelected ? 'var(--bal-primary-soft)' : 'transparent',
transition:
'background-color var(--bal-motion-base) var(--bal-easing-standard), border-color var(--bal-motion-base) var(--bal-easing-standard)',
}}
>
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 0.25 }}>
<Box
aria-hidden="true"
sx={{
width: 56,
height: 56,
flexShrink: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-bg-paper)',
boxShadow: 'var(--bal-shadow-1)',
}}
>
<AppIcon icon={icon} size={30} color="var(--bal-primary)" />
</Box>
<Stack sx={{ gap: 0.25, flexGrow: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t(`role_${role}`)}
</Typography>
@@ -91,11 +115,18 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
{t(`role_${role}_desc`)}
</Typography>
</Stack>
<Box sx={{ width: 24, height: 24, flexShrink: 0 }} aria-hidden="true">
{isSelected && <AppIcon icon="verified" size={24} color="var(--bal-primary)" />}
</Box>
</Paper>
);
})}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('role_add_later_note')}
</Typography>
<AppButton
color="primary"
variant="contained"
@@ -0,0 +1,41 @@
'use client';
import { FunctionComponent } from 'react';
import { Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import AppIcon from '@/components/common/AppIcon';
const BULLETS: ReadonlyArray<{ icon: string; key: string }> = [
{ icon: 'verification', key: 'trust_verified_nurses' },
{ icon: 'lock', key: 'trust_escrow_payment' },
{ icon: 'support', key: 'trust_support' },
];
/**
* The 23 trust facts under the login card what Balinyaar actually verifies and escrows
* (licensed + identity-verified nurses, escrow held until a confirmed check-out, support).
* Copy is scoped to features the platform implements today, never aspirational claims.
* @component TrustBullets
*/
const TrustBullets: FunctionComponent = () => {
const t = useTranslations('auth');
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
{BULLETS.map((bullet) => (
<Stack key={bullet.key} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon
icon={bullet.icon}
size={20}
color="var(--bal-primary)"
style={{ flexShrink: 0, marginTop: 2 }}
aria-hidden="true"
/>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(bullet.key)}
</Typography>
</Stack>
))}
</Stack>
);
};
export default TrustBullets;
+3
View File
@@ -1,2 +1,5 @@
/** Reading width for the branded auth screens (login, OTP, role selection). */
export const AUTH_CARD_MAX_WIDTH = 420;
/** Width of the login hero row (illustration + card) once the illustration joins on desktop. */
export const AUTH_HERO_MAX_WIDTH = 760;
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
interface OTPCredential extends Credential {
code: string;
}
interface OtpCredentialRequestOptions extends CredentialRequestOptions {
otp: { transport: string[] };
}
/**
* WebOTP autofill (web.dev/articles/web-otp): on a supporting Android/Chrome browser, reads the
* SMS-delivered code once the server's template ends with the origin-bound `@<domain> #<code>`
* line (REQ-039) and feeds it through `onCode` the caller wires this into the same
* onChange/onComplete path manual entry uses. Feature-detects `OTPCredential`; unsupported
* browsers (desktop, iOS Safari, Firefox) silently no-op no error, no UI difference. Aborted on
* unmount and whenever `active` turns false (manual verification starts), so a late resolution
* never overwrites a fresher code.
*/
export function useWebOtp(onCode: (code: string) => void, active: boolean): void {
const onCodeRef = useRef(onCode);
// Keep the ref current without adding `onCode` (a fresh closure every render) to the effect
// below's dependency array — that would abort and re-issue the WebOTP read on every render.
useEffect(() => {
onCodeRef.current = onCode;
});
useEffect(() => {
if (!active || typeof window === 'undefined' || !('OTPCredential' in window)) return;
const controller = new AbortController();
navigator.credentials
.get({ otp: { transport: ['sms'] }, signal: controller.signal } as OtpCredentialRequestOptions)
.then((credential) => {
const code = (credential as OTPCredential | null)?.code;
if (code) onCodeRef.current(code);
})
.catch(() => {
// Aborted, unsupported, or dismissed by the user — manual entry remains available.
});
return () => controller.abort();
}, [active]);
}
@@ -100,6 +100,8 @@ import ShareIcon from '@mui/icons-material/ShareRounded';
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
import LanguageIcon from '@mui/icons-material/TranslateRounded';
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
/**
* List of all available Icon names
@@ -204,6 +206,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
copy: CopyIcon,
attachment: AttachmentIcon,
language: LanguageIcon,
favorite: FavoriteIcon,
};
/**
+11 -1
View File
@@ -2,6 +2,9 @@ export const ROUTES = {
LOGIN: '/login',
// Post-login role picker for a brand-new user whose /me has no public role yet.
SELECT_ROLE: '/select-role',
// Draft legal copy (ui-phase-3) — flagged for human/legal review before launch.
TERMS: '/terms',
PRIVACY: '/privacy',
// Customer (family) app — mobile-first, bottom-tab nav
HOME: '/',
@@ -169,4 +172,11 @@ export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string
};
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY];
/**
* Query param the middleware appends when it redirects an unauthenticated deep link to login
* (e.g. `/fa/bookings/42` `/fa/login?next=%2Fbookings%2F42`), so `resolvePostLoginDestination`
* (`services/auth/routing.ts`) can send the user back to where they were headed after OTP.
*/
export const RETURN_URL_PARAM = 'next';
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { FunctionComponent, PropsWithChildren } from 'react';
import { useTranslations } from 'next-intl';
import { Stack } from '@mui/material';
import { AppIcon, ErrorBoundary } from '@/components';
/**
* Chrome-free shell for a focused, can't-tab-away flow today only first-run onboarding
* (ui-phase-3 §3.5). No bottom nav, no bell, no sidebar: a slim logo strip and the step content,
* in the same spirit as `AuthCard`/`PublicLayout`. `RoleGuard` still gates the route group above
* this a chrome-free shell is a UX choice, not a security boundary.
* @layout FocusedLayout
*/
const FocusedLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const tChrome = useTranslations('routeChrome');
return (
<Stack sx={{ minHeight: '100vh' }}>
<Stack component="header" direction="row" sx={{ alignItems: 'center', justifyContent: 'center', paddingBlock: 2 }}>
<AppIcon icon="logo" size={28} color="var(--bal-primary)" aria-hidden="true" />
</Stack>
<Stack component="main" sx={{ flexGrow: 1, px: 2, pb: 4 }}>
<ErrorBoundary
name="Focused"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
{children}
</ErrorBoundary>
</Stack>
</Stack>
);
};
export default FocusedLayout;
+11 -1
View File
@@ -4,6 +4,16 @@ import CustomerLayout from './CustomerLayout';
import NurseLayout from './NurseLayout';
import AdminLayout from './AdminLayout';
import PartnerLayout from './PartnerLayout';
import FocusedLayout from './FocusedLayout';
import ActorSwitcher from './components/ActorSwitcher';
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout, ActorSwitcher };
export {
PublicLayout,
PrivateLayout,
CustomerLayout,
NurseLayout,
AdminLayout,
PartnerLayout,
FocusedLayout,
ActorSwitcher,
};
+32 -1
View File
@@ -1,5 +1,5 @@
import { ROUTES } from '@/constants';
import { isAdminRole, resolveRoleDestination, toAppRoles } from './routing';
import { isAdminRole, resolvePostLoginDestination, resolveRoleDestination, toAppRoles } from './routing';
import type { RoleCode } from './types';
describe('toAppRoles', () => {
@@ -56,3 +56,34 @@ describe('resolveRoleDestination (role-router branches)', () => {
expect(resolveRoleDestination({ roles: ['customer', 'nurse'] })).toBe(ROUTES.HOME);
});
});
describe('resolvePostLoginDestination (returnUrl)', () => {
it('honors a safe same-origin next path the session is allowed to reach', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/bookings/42')).toBe('/bookings/42');
});
it('honors a nurse-owned next path for a nurse session', () => {
expect(resolvePostLoginDestination({ roles: ['nurse'] }, undefined, '/nurse/requests')).toBe('/nurse/requests');
});
it('falls back to the role destination for a protocol-relative next (open-redirect guard)', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '//evil.com')).toBe(ROUTES.HOME);
});
it('falls back to the role destination for an absolute-URL next (open-redirect guard)', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, 'https://evil.com')).toBe(ROUTES.HOME);
});
it('falls back when next belongs to a role the session does not hold', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/nurse/requests')).toBe(ROUTES.HOME);
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, '/admin/audit')).toBe(ROUTES.HOME);
});
it('falls back to select-role for a role-less user regardless of next', () => {
expect(resolvePostLoginDestination({ roles: [] }, undefined, '/bookings')).toBe(ROUTES.SELECT_ROLE);
});
it('falls back to the role destination when next is absent', () => {
expect(resolvePostLoginDestination({ roles: ['customer'] }, undefined, null)).toBe(ROUTES.HOME);
});
});
+35
View File
@@ -45,3 +45,38 @@ export function resolveRoleDestination(me: Pick<Me, 'roles'>, intendedRole?: App
return target === APP_ROLES.NURSE ? ROUTES.NURSE : ROUTES.HOME;
}
/** True for a same-origin relative path only — rejects a protocol-relative (`//host`) or absolute-URL `next`. */
function isSafeRelativePath(path: string): boolean {
return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\');
}
/** The app (nurse/admin/customer) whose route tree owns `path`; customer paths carry no prefix. */
function appRoleForPath(path: string): AppRole | null {
const pathname = path.split('?')[0].split('#')[0];
if (pathname === ROUTES.NURSE || pathname.startsWith(`${ROUTES.NURSE}/`)) return APP_ROLES.NURSE;
if (pathname === ROUTES.ADMIN || pathname.startsWith(`${ROUTES.ADMIN}/`)) return APP_ROLES.ADMIN;
// The partner portal isn't an AppRole (it self-gates via useMyPartnerCenter) — never a `next` target.
if (pathname === ROUTES.PARTNER || pathname.startsWith(`${ROUTES.PARTNER}/`)) return null;
return APP_ROLES.CUSTOMER;
}
/**
* The `?next=` deep-link carried through login (middleware.ts appends it on the redirect-to-login).
* Returns `next` only when it is a validated same-origin relative path **and** the resolved session
* actually holds the role that owns it (a customer's `next=/nurse/...` falls through) otherwise
* defers to `resolveRoleDestination`, which stays the single "which app" source of truth. Never
* returns an absolute URL or a protocol-relative path this is the one guard against `next`
* becoming an open redirect.
*/
export function resolvePostLoginDestination(
me: Pick<Me, 'roles'>,
intendedRole: AppRole | undefined,
next: string | null | undefined,
): string {
if (next && isSafeRelativePath(next)) {
const owner = appRoleForPath(next);
if (owner && toAppRoles(me.roles).includes(owner)) return next;
}
return resolveRoleDestination(me, intendedRole);
}