'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 = ({ 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 ( {t('otp_title')} {t.rich('otp_sent_to', { // A rich-text TAG (`` 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. // 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: () => ( {maskIranMobile(phone)} ), })} {locked ? ( {t('otp_locked')} ) : hasError ? ( {t('otp_invalid')} ) : null} : undefined} > {isNurse ? t('otp_verify_nurse') : t('otp_verify_customer')} {resendDisabled && countdown.isActive && !locked ? ( {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: () => ( {formatClock(countdown.seconds, locale)} ), })} ) : ( {t('resend')} )} {t('change_number')} ); }; export default OtpStep;