Files
baya-monorepo/client/src/components/auth/OtpStep.tsx
T
2026-07-27 22:27:04 +03:30

191 lines
6.3 KiB
TypeScript

'use client';
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
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';
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;
intendedRole: AppRole;
/** Server's resend cooldown from the request step; drives the initial countdown. */
resendSeconds: number;
onVerified: () => void;
onChangeNumber: () => void;
}
/**
* 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> = ({
phone,
intendedRole,
resendSeconds,
onVerified,
onChangeNumber,
}) => {
const t = useTranslations('auth');
const locale = useLocale();
const [code, setCode] = useState('');
const [locked, setLocked] = useState(false);
const countdown = useCountdown();
const verifyOtp = useVerifyOtp();
const resendOtp = useRequestOtp();
const { start } = countdown;
useEffect(() => start(resendSeconds), [start, resendSeconds]);
const isNurse = intendedRole === APP_ROLES.NURSE;
const hasError = verifyOtp.isError;
const verify = (value: string) => {
if (value.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked) return;
verifyOtp.mutate(
{ phone, code: value },
{
onSuccess: onVerified,
onError: (error) => {
if ((error as ApiError).code === OTP_LOCKED_CODE) setLocked(true);
setCode(''); // clear the boxes so the user can retry a fresh code
},
},
);
};
// 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);
};
const resend = () => {
resendOtp.mutate(
{ phone },
{
onSuccess: (result) => {
setLocked(false);
setCode('');
verifyOtp.reset();
start(result.resendAvailableInSeconds);
},
},
);
};
// While locked, requesting a new code is the way out — allow resend regardless of the timer.
const resendDisabled = resendOtp.isPending || (countdown.isActive && !locked);
return (
<Stack component="form" onSubmit={submit} sx={{ gap: 2 }}>
<Stack sx={{ gap: 0.5, textAlign: 'center' }}>
<Typography variant="h6" component="h1">
{t('otp_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('otp_sent_to', {
// A rich-text TAG (`<phone></phone>` in both message files), not a `{phone}` value — a
// function passed for a value placeholder is handed straight to React as a child, which
// is the "Functions are not valid as a React child" crash this replaced.
// <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>
<OtpInput
length={OTP_CODE_LENGTH}
value={code}
onChange={setCode}
onComplete={verify}
disabled={locked || verifyOtp.isPending}
error={hasError}
autoFocus
aria-label={t('otp_title')}
/>
{locked ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
{t('otp_locked')}
</Typography>
) : hasError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', textAlign: 'center' }}>
{t('otp_invalid')}
</Typography>
) : null}
<AppButton
type="submit"
color="primary"
variant="contained"
fullWidth
disabled={code.length !== OTP_CODE_LENGTH || verifyOtp.isPending || locked}
startIcon={verifyOtp.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{isNurse ? t('otp_verify_nurse') : t('otp_verify_customer')}
</AppButton>
<Stack sx={{ alignItems: 'center', gap: 1 }}>
{resendDisabled && countdown.isActive && !locked ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.rich('resend_in', {
// Same tag-not-value rule and 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
component="button"
type="button"
onClick={resend}
disabled={resendDisabled}
underline="hover"
sx={{ color: 'primary.main' }}
>
{t('resend')}
</MuiLink>
)}
<MuiLink
component="button"
type="button"
onClick={onChangeNumber}
underline="hover"
sx={{ color: 'text.secondary' }}
>
{t('change_number')}
</MuiLink>
</Stack>
</Stack>
);
};
export default OtpStep;