ui phase 3
This commit is contained in:
@@ -46,4 +46,22 @@ describe('<OtpInput/> component', () => {
|
||||
await user.type(boxes[3], '4');
|
||||
expect(onComplete).toHaveBeenCalledWith('1234');
|
||||
});
|
||||
|
||||
it('carries autoComplete="one-time-code" so the OS can offer the SMS code', () => {
|
||||
render(<Harness length={4} />);
|
||||
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
boxes.forEach((box) => expect(box).toHaveAttribute('autocomplete', 'one-time-code'));
|
||||
});
|
||||
|
||||
it('backspace on an empty box clears the previous digit and moves focus there in one keypress', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness length={4} />);
|
||||
const boxes = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
await user.type(boxes[0], '1');
|
||||
await user.type(boxes[1], '2');
|
||||
boxes[2].focus();
|
||||
await user.keyboard('{Backspace}');
|
||||
expect(boxes[1].value).toBe('');
|
||||
expect(boxes[1]).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,8 +87,13 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Backspace' && !chars[index]) {
|
||||
if (event.key === 'Backspace' && !chars[index] && index > 0) {
|
||||
// Empty box + backspace clears the previous digit too, so one keypress erases one digit
|
||||
// instead of the first press only moving focus and the second doing the clearing.
|
||||
const next = [...chars];
|
||||
next[index - 1] = '';
|
||||
focusBox(index - 1);
|
||||
emit(next);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,6 +127,8 @@ const OtpInput: FunctionComponent<OtpInputProps> = ({
|
||||
htmlInput: {
|
||||
inputMode: 'numeric',
|
||||
maxLength: 1,
|
||||
// Lets iOS/Android offer the SMS code as a keyboard suggestion even without WebOTP.
|
||||
autoComplete: 'one-time-code',
|
||||
'aria-label': `${ariaLabel ?? 'digit'} ${index + 1}`,
|
||||
style: { textAlign: 'center', fontSize: '1.25rem', width: BOX_SIZE, padding: 8 },
|
||||
},
|
||||
|
||||
@@ -43,4 +43,9 @@ describe('<PhoneNumberField/> component', () => {
|
||||
expect(isIranianMobile('0912345678')).toBe(false);
|
||||
expect(isIranianMobile('19123456789')).toBe(false);
|
||||
});
|
||||
|
||||
it('carries autoComplete="tel" so the browser/keyboard can offer the saved number', () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('autocomplete', 'tel');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,8 @@ const PhoneNumberField: FunctionComponent<PhoneNumberFieldProps> = ({ value, onC
|
||||
dir: 'ltr',
|
||||
inputMode: 'numeric',
|
||||
maxLength: IRAN_MOBILE_LENGTH,
|
||||
// Offers the user's own number from the browser/keyboard's saved contact info.
|
||||
autoComplete: 'tel',
|
||||
style: { textAlign: 'start' },
|
||||
},
|
||||
...slotProps,
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren } from 'react';
|
||||
import { Paper, Stack } from '@mui/material';
|
||||
import { AUTH_CARD_MAX_WIDTH } from './constants';
|
||||
import { AUTH_CARD_MAX_WIDTH, AUTH_HERO_MAX_WIDTH } from './constants';
|
||||
import BrandMark from './BrandMark';
|
||||
import AuthIllustration from './AuthIllustration';
|
||||
import TrustBullets from './TrustBullets';
|
||||
|
||||
/**
|
||||
* Centered branded card that hosts each auth step (phone, OTP, role selection). Presentational
|
||||
* shell only — the step content is passed as children.
|
||||
* Centered branded hero that hosts each auth step (phone, OTP). The card stays readable at
|
||||
* 320px; a calm illustration joins beside it once the viewport has room to breathe (desktop),
|
||||
* with the platform's trust facts underneath — login is the product's only front door, so it
|
||||
* carries trust evidence rather than a bare form.
|
||||
* @component AuthCard
|
||||
*/
|
||||
const AuthCard: FunctionComponent<PropsWithChildren> = ({ children }) => (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', px: 2, py: 4 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: AUTH_CARD_MAX_WIDTH,
|
||||
p: { xs: 3, sm: 4 },
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
maxWidth: AUTH_HERO_MAX_WIDTH,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: { xs: 0, md: 6 },
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BrandMark withTagline />
|
||||
{children}
|
||||
<AuthIllustration sx={{ display: { xs: 'none', md: 'flex' } }} />
|
||||
<Stack sx={{ width: '100%', maxWidth: AUTH_CARD_MAX_WIDTH, gap: 3, flexShrink: 0 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: { xs: 3, sm: 4 },
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<BrandMark withTagline />
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
<TrustBullets />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Box, SxProps, Theme } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
const ILLUSTRATION_SIZE = 220;
|
||||
|
||||
interface AuthIllustrationProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A calm, abstract hero graphic for the login front door — layered soft-tint circles (brand
|
||||
* tokens only, no stock photos or raster art) with a centered family glyph and a floating
|
||||
* trust badge, echoing the trust bullets underneath. Purely decorative.
|
||||
* @component AuthIllustration
|
||||
*/
|
||||
const AuthIllustration: FunctionComponent<AuthIllustrationProps> = ({ sx }) => (
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: ILLUSTRATION_SIZE,
|
||||
height: ILLUSTRATION_SIZE,
|
||||
flexShrink: 0,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', inset: 0, borderRadius: '50%', bgcolor: 'var(--bal-primary-soft)' }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
insetInlineStart: '16%',
|
||||
insetBlockEnd: '4%',
|
||||
width: '46%',
|
||||
height: '46%',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--bal-secondary-soft)',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '58%',
|
||||
height: '58%',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
boxShadow: 'var(--bal-shadow-3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="family" size={64} color="var(--bal-primary)" />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
insetInlineEnd: '8%',
|
||||
insetBlockStart: '10%',
|
||||
width: '30%',
|
||||
height: '30%',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
boxShadow: 'var(--bal-shadow-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="verified" size={30} color="var(--bal-trust)" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default AuthIllustration;
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { APP_ROLES, RETURN_URL_PARAM, type AppRole } from '@/constants';
|
||||
import { OTP_RESEND_FALLBACK_SECONDS } from '@/services/auth/constants';
|
||||
import AuthCard from './AuthCard';
|
||||
import PhoneStep from './PhoneStep';
|
||||
@@ -25,9 +25,11 @@ const LoginFlow: FunctionComponent = () => {
|
||||
const [step, setStep] = useState<Step>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [resendSeconds, setResendSeconds] = useState(OTP_RESEND_FALLBACK_SECONDS);
|
||||
// Carried from middleware.ts's redirect-to-login so a deep link survives the auth round trip.
|
||||
const next = searchParams.get(RETURN_URL_PARAM);
|
||||
|
||||
if (step === 'routing') {
|
||||
return <RoleRouter intendedRole={intendedRole} />;
|
||||
return <RoleRouter intendedRole={intendedRole} next={next} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,14 @@ const mockVerifyMutate = jest.fn();
|
||||
const mockVerifyReset = jest.fn();
|
||||
const mockRequestMutate = jest.fn();
|
||||
|
||||
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'fa',
|
||||
useTranslations: () => {
|
||||
const t = (key: string) => key;
|
||||
t.rich = (key: string) => key;
|
||||
return t;
|
||||
},
|
||||
}));
|
||||
jest.mock('@/services/auth', () => ({
|
||||
useVerifyOtp: () => ({ isPending: false, isError: false, mutate: mockVerifyMutate, reset: mockVerifyReset }),
|
||||
useRequestOtp: () => ({ isPending: false, mutate: mockRequestMutate }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import OtpInput from '@/components/OtpInput';
|
||||
import { maskIranMobile } from '@/components/PhoneNumberField';
|
||||
@@ -9,8 +9,10 @@ import { AppButton } from '@/components';
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { useRequestOtp, useVerifyOtp } from '@/services/auth';
|
||||
import { OTP_CODE_LENGTH, OTP_LOCKED_CODE } from '@/services/auth/constants';
|
||||
import { formatClock } from '@/utils';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import { useCountdown } from './useCountdown';
|
||||
import { useWebOtp } from './useWebOtp';
|
||||
|
||||
interface OtpStepProps {
|
||||
phone: string;
|
||||
@@ -21,17 +23,11 @@ interface OtpStepProps {
|
||||
onChangeNumber: () => void;
|
||||
}
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled,
|
||||
* runs a single resend countdown (cleaned up on unmount), and renders explicit wrong-code,
|
||||
* expired-code and max-attempts-lockout states. Same mechanism for both actors; the CTA copy
|
||||
* differs.
|
||||
* A2 (customer) / B2 (nurse) — the OTP code step. Auto-verifies once the last box is filled
|
||||
* (manually or via WebOTP autofill), runs a single resend countdown (cleaned up on unmount), and
|
||||
* renders explicit wrong-code, expired-code and max-attempts-lockout states. Same mechanism for
|
||||
* both actors; the CTA copy differs.
|
||||
* @component OtpStep
|
||||
*/
|
||||
const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
@@ -42,6 +38,7 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
onChangeNumber,
|
||||
}) => {
|
||||
const t = useTranslations('auth');
|
||||
const locale = useLocale();
|
||||
const [code, setCode] = useState('');
|
||||
const [locked, setLocked] = useState(false);
|
||||
const countdown = useCountdown();
|
||||
@@ -68,6 +65,13 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Reads the SMS code on a supporting browser (REQ-039) and feeds it through the same path
|
||||
// manual entry uses. Stops (aborts the pending read) once a verification is underway.
|
||||
useWebOtp((otpCode) => {
|
||||
setCode(otpCode);
|
||||
verify(otpCode);
|
||||
}, !locked && !verifyOtp.isPending);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
verify(code);
|
||||
@@ -97,7 +101,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
{t('otp_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('otp_sent_to', { phone: maskIranMobile(phone) })}
|
||||
{t.rich('otp_sent_to', {
|
||||
// <bdi dir="ltr"> isolates the masked number so the RTL sentence's bidi algorithm
|
||||
// never reorders the digit/bullet runs around it (the classic digits-around-neutrals bug).
|
||||
phone: () => (
|
||||
<Box component="bdi" dir="ltr">
|
||||
{maskIranMobile(phone)}
|
||||
</Box>
|
||||
),
|
||||
})}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -136,7 +148,15 @@ const OtpStep: FunctionComponent<OtpStepProps> = ({
|
||||
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
||||
{resendDisabled && countdown.isActive && !locked ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('resend_in', { time: formatMmSs(countdown.seconds) })}
|
||||
{t.rich('resend_in', {
|
||||
// Same bidi-isolation reasoning as the phone echo above — keeps mm:ss reading
|
||||
// left-to-right (in Persian digits on /fa) inside the RTL sentence.
|
||||
time: () => (
|
||||
<Box component="bdi" dir="ltr">
|
||||
{formatClock(countdown.seconds, locale)}
|
||||
</Box>
|
||||
),
|
||||
})}
|
||||
</Typography>
|
||||
) : (
|
||||
<MuiLink
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useState } from 'react';
|
||||
import { CircularProgress, Link as MuiLink, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { AppButton } from '@/components';
|
||||
import { APP_ROLES, type AppRole } from '@/constants';
|
||||
import { AppButton, AppLink } from '@/components';
|
||||
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
|
||||
import { useRequestOtp } from '@/services/auth';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import type { RequestOtpResult } from '@/services/auth/types';
|
||||
@@ -26,6 +26,7 @@ const RATE_LIMIT_STATUS = 429;
|
||||
*/
|
||||
const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRole, onSent }) => {
|
||||
const t = useTranslations('auth');
|
||||
const locale = useLocale();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
@@ -72,7 +73,7 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
|
||||
}}
|
||||
label={t('phone_label')}
|
||||
placeholder="0912 000 0000"
|
||||
error={invalid}
|
||||
error={invalid || rateLimited}
|
||||
helperText={invalid ? t('phone_invalid') : rateLimited ? t('rate_limited') : ' '}
|
||||
fullWidth
|
||||
autoFocus
|
||||
@@ -89,6 +90,13 @@ const PhoneStep: FunctionComponent<PhoneStepProps> = ({ intendedRole, onSwitchRo
|
||||
{t('request_code')}
|
||||
</AppButton>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{t.rich('consent_line', {
|
||||
terms: (chunks) => <AppLink to={`/${locale}${ROUTES.TERMS}`}>{chunks}</AppLink>,
|
||||
privacy: (chunks) => <AppLink to={`/${locale}${ROUTES.PRIVACY}`}>{chunks}</AppLink>,
|
||||
})}
|
||||
</Typography>
|
||||
|
||||
<MuiLink
|
||||
component="button"
|
||||
type="button"
|
||||
|
||||
@@ -15,10 +15,10 @@ jest.mock('@/services/auth', () => ({ useMe: () => meResult }));
|
||||
|
||||
import RoleRouter from './RoleRouter';
|
||||
|
||||
function renderRouter(intendedRole?: 'customer' | 'nurse') {
|
||||
function renderRouter(intendedRole?: 'customer' | 'nurse', next?: string | null) {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<RoleRouter intendedRole={intendedRole} />
|
||||
<RoleRouter intendedRole={intendedRole} next={next} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
@@ -55,4 +55,16 @@ describe('<RoleRouter/>', () => {
|
||||
renderRouter();
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/login'));
|
||||
});
|
||||
|
||||
it('honors a safe next deep link over the default role destination', async () => {
|
||||
meResult = { data: { roles: ['customer'] }, isError: false };
|
||||
renderRouter(undefined, '/bookings/42');
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/bookings/42'));
|
||||
});
|
||||
|
||||
it('ignores an unsafe next and falls back to the role destination', async () => {
|
||||
meResult = { data: { roles: ['customer'] }, isError: false };
|
||||
renderRouter(undefined, '//evil.com');
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,22 +5,24 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
import { APP_ROLES, ROUTES, type AppRole } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { resolveRoleDestination } from '@/services/auth/routing';
|
||||
import { resolvePostLoginDestination } from '@/services/auth/routing';
|
||||
import AuthSplash from './AuthSplash';
|
||||
|
||||
interface RoleRouterProps {
|
||||
/** The role the user logged in as (A1 vs B1); disambiguates a multi-role user and pre-selects on SelectRole. */
|
||||
intendedRole?: AppRole;
|
||||
/** The `?next=` deep link carried from middleware.ts's redirect-to-login, if any. */
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs after authentication (never re-implements the auth gate — the middleware owns that) and
|
||||
* sends the user to the right app: customer → family, nurse → nurse app, no role → SelectRole,
|
||||
* admin → admin console. Shows the branded splash while `/me` is in flight so the wrong shell
|
||||
* never flashes.
|
||||
* sends the user to the right app: a validated `next` deep link when the session's roles permit
|
||||
* it, else customer → family, nurse → nurse app, no role → SelectRole, admin → admin console.
|
||||
* Shows the branded splash while `/me` is in flight so the wrong shell never flashes.
|
||||
* @component RoleRouter
|
||||
*/
|
||||
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
|
||||
const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole, next }) => {
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth');
|
||||
@@ -33,13 +35,13 @@ const RoleRouter: FunctionComponent<RoleRouterProps> = ({ intendedRole }) => {
|
||||
}
|
||||
if (!me) return;
|
||||
|
||||
let destination = resolveRoleDestination(me, intendedRole);
|
||||
let destination = resolvePostLoginDestination(me, intendedRole, next);
|
||||
// Carry the login intent into role selection so it can pre-select the right role.
|
||||
if (destination === ROUTES.SELECT_ROLE && intendedRole === APP_ROLES.NURSE) {
|
||||
destination = `${destination}?role=${APP_ROLES.NURSE}`;
|
||||
}
|
||||
router.replace(`/${locale}${destination}`);
|
||||
}, [me, isError, intendedRole, router, locale]);
|
||||
}, [me, isError, intendedRole, next, router, locale]);
|
||||
|
||||
return <AuthSplash message={t('routing_title')} />;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Box, CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
@@ -18,9 +18,11 @@ interface SelectRoleProps {
|
||||
initialRole?: AppRole;
|
||||
}
|
||||
|
||||
// 'family' (a household under one roof) reads as "receiving care"; 'visits' (a clinical-visit
|
||||
// glyph) reads as "nurse professional" — a house icon for the nurse option was the prior defect.
|
||||
const ROLE_OPTIONS: ReadonlyArray<{ role: PublicRole; icon: string }> = [
|
||||
{ role: 'customer', icon: 'account' },
|
||||
{ role: 'nurse', icon: 'home' },
|
||||
{ role: 'customer', icon: 'family' },
|
||||
{ role: 'nurse', icon: 'visits' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -68,11 +70,14 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
|
||||
tabIndex={0}
|
||||
onClick={() => setSelected(role)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setSelected(role);
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setSelected(role);
|
||||
}
|
||||
}}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
p: 2.5,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -80,10 +85,29 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
// Selection is never color-only: the soft fill pairs with a check glyph below.
|
||||
bgcolor: isSelected ? 'var(--bal-primary-soft)' : 'transparent',
|
||||
transition:
|
||||
'background-color var(--bal-motion-base) var(--bal-easing-standard), border-color var(--bal-motion-base) var(--bal-easing-standard)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
boxShadow: 'var(--bal-shadow-1)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={30} color="var(--bal-primary)" />
|
||||
</Box>
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t(`role_${role}`)}
|
||||
</Typography>
|
||||
@@ -91,11 +115,18 @@ const SelectRole: FunctionComponent<SelectRoleProps> = ({ initialRole }) => {
|
||||
{t(`role_${role}_desc`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ width: 24, height: 24, flexShrink: 0 }} aria-hidden="true">
|
||||
{isSelected && <AppIcon icon="verified" size={24} color="var(--bal-primary)" />}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{t('role_add_later_note')}
|
||||
</Typography>
|
||||
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
const BULLETS: ReadonlyArray<{ icon: string; key: string }> = [
|
||||
{ icon: 'verification', key: 'trust_verified_nurses' },
|
||||
{ icon: 'lock', key: 'trust_escrow_payment' },
|
||||
{ icon: 'support', key: 'trust_support' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The 2–3 trust facts under the login card — what Balinyaar actually verifies and escrows
|
||||
* (licensed + identity-verified nurses, escrow held until a confirmed check-out, support).
|
||||
* Copy is scoped to features the platform implements today, never aspirational claims.
|
||||
* @component TrustBullets
|
||||
*/
|
||||
const TrustBullets: FunctionComponent = () => {
|
||||
const t = useTranslations('auth');
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5, width: '100%' }}>
|
||||
{BULLETS.map((bullet) => (
|
||||
<Stack key={bullet.key} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon
|
||||
icon={bullet.icon}
|
||||
size={20}
|
||||
color="var(--bal-primary)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(bullet.key)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrustBullets;
|
||||
@@ -1,2 +1,5 @@
|
||||
/** Reading width for the branded auth screens (login, OTP, role selection). */
|
||||
export const AUTH_CARD_MAX_WIDTH = 420;
|
||||
|
||||
/** Width of the login hero row (illustration + card) once the illustration joins on desktop. */
|
||||
export const AUTH_HERO_MAX_WIDTH = 760;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface OTPCredential extends Credential {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface OtpCredentialRequestOptions extends CredentialRequestOptions {
|
||||
otp: { transport: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* WebOTP autofill (web.dev/articles/web-otp): on a supporting Android/Chrome browser, reads the
|
||||
* SMS-delivered code once the server's template ends with the origin-bound `@<domain> #<code>`
|
||||
* line (REQ-039) and feeds it through `onCode` — the caller wires this into the same
|
||||
* onChange/onComplete path manual entry uses. Feature-detects `OTPCredential`; unsupported
|
||||
* browsers (desktop, iOS Safari, Firefox) silently no-op — no error, no UI difference. Aborted on
|
||||
* unmount and whenever `active` turns false (manual verification starts), so a late resolution
|
||||
* never overwrites a fresher code.
|
||||
*/
|
||||
export function useWebOtp(onCode: (code: string) => void, active: boolean): void {
|
||||
const onCodeRef = useRef(onCode);
|
||||
// Keep the ref current without adding `onCode` (a fresh closure every render) to the effect
|
||||
// below's dependency array — that would abort and re-issue the WebOTP read on every render.
|
||||
useEffect(() => {
|
||||
onCodeRef.current = onCode;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || typeof window === 'undefined' || !('OTPCredential' in window)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
navigator.credentials
|
||||
.get({ otp: { transport: ['sms'] }, signal: controller.signal } as OtpCredentialRequestOptions)
|
||||
.then((credential) => {
|
||||
const code = (credential as OTPCredential | null)?.code;
|
||||
if (code) onCodeRef.current(code);
|
||||
})
|
||||
.catch(() => {
|
||||
// Aborted, unsupported, or dismissed by the user — manual entry remains available.
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [active]);
|
||||
}
|
||||
@@ -100,6 +100,8 @@ import ShareIcon from '@mui/icons-material/ShareRounded';
|
||||
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
|
||||
import LanguageIcon from '@mui/icons-material/TranslateRounded';
|
||||
// Auth & first-run (ui-phase-3): the onboarding relation fork needs a distinct glyph per option
|
||||
import FavoriteIcon from '@mui/icons-material/FavoriteRounded';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -204,6 +206,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
copy: CopyIcon,
|
||||
attachment: AttachmentIcon,
|
||||
language: LanguageIcon,
|
||||
favorite: FavoriteIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user