191 lines
6.3 KiB
TypeScript
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;
|