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
@@ -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,
};
/**