import { useEffect, useRef, useState } from 'react'; import { requestVerificationCode } from '@/lib/api/auth'; import { ImApiError } from '@/lib/api/client'; const PHONE_RE = /^\d{11}$/; interface VerificationCodeButtonProps { phone: string; scene: 'reset_password'; onChallenge: (challengeId: string) => void; onError: (message: string) => void; } /** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) { const [seconds, setSeconds] = useState(0); const [busy, setBusy] = useState(false); const timerRef = useRef(undefined); // Without this, navigating away mid-countdown (e.g. switching from register back to login) // leaves the interval running against an unmounted component indefinitely until it naturally // reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it. useEffect(() => () => window.clearInterval(timerRef.current), []); // A countdown started for one phone number must not block sending a code to a DIFFERENT one -- // otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still // counting down for the old number) until the old timer happens to run out on its own. useEffect(() => { window.clearInterval(timerRef.current); setSeconds(0); }, [phone]); const tick = (initial: number) => { window.clearInterval(timerRef.current); setSeconds(initial); timerRef.current = window.setInterval(() => { setSeconds((s) => { if (s <= 1) { window.clearInterval(timerRef.current); return 0; } return s - 1; }); }, 1000); }; const click = async () => { if (busy || seconds > 0 || !PHONE_RE.test(phone)) return; // The phone number this request is actually FOR -- captured now, not read again after the // `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in // flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever // number is now showing, with nothing on screen indicating the mismatch -- the eventual // verification would silently operate on an account the user never intended. const requestedPhone = phone; setBusy(true); try { const data = await requestVerificationCode(scene, requestedPhone); if (requestedPhone !== phone) return; onChallenge(data.challenge_id); tick(data.resend_after_seconds || 60); } catch (err) { if (requestedPhone !== phone) return; onError(err instanceof ImApiError ? err.message : '获取验证码失败'); // Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it. const details = err instanceof ImApiError ? (err.details as Record | undefined) : undefined; if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds); if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id); } finally { setBusy(false); } }; return ( ); }