164 lines
4.8 KiB
TypeScript
164 lines
4.8 KiB
TypeScript
import { Loader2 } from "lucide-react"
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
import { Navigate, useNavigate } from "react-router-dom"
|
|
import { toast } from "sonner"
|
|
|
|
import { sendOtp } from "../../api/users"
|
|
import { Button } from "../../components/ui/button"
|
|
import { useAuthFlow } from "../../context/AuthFlowContext"
|
|
import { useTranslation } from "../../hooks/useTranslation"
|
|
import { AuthOtpInput } from "./AuthOtpInput"
|
|
import { AuthPanel } from "./AuthPanel"
|
|
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils"
|
|
|
|
export function SignupOtpPage() {
|
|
const navigate = useNavigate()
|
|
const { t, lang } = useTranslation()
|
|
const {
|
|
state,
|
|
setCode,
|
|
setCooldown,
|
|
clearCooldown,
|
|
setOtpDelivery,
|
|
clearOtpDelivery,
|
|
} = useAuthFlow()
|
|
const isRtl = lang === "fa"
|
|
const autoSendStartedRef = useRef(false)
|
|
const [isSendingOtp, setIsSendingOtp] = useState(false)
|
|
const [isContinuing, setIsContinuing] = useState(false)
|
|
const [now, setNow] = useState(Date.now())
|
|
|
|
if (!state.signup.mobile) {
|
|
return <Navigate to="/auth/signup" replace />
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!state.signup.otpExpiresAt || getOtpRemainingSeconds(state.signup.otpExpiresAt) <= 0) {
|
|
return
|
|
}
|
|
|
|
const timer = window.setInterval(() => {
|
|
setNow(Date.now())
|
|
}, 1000)
|
|
|
|
return () => window.clearInterval(timer)
|
|
}, [state.signup.otpExpiresAt])
|
|
|
|
const sendSignupOtp = async () => {
|
|
setIsSendingOtp(true)
|
|
try {
|
|
const response = await sendOtp(state.signup.mobile, "register")
|
|
clearCooldown("signupOtpSend")
|
|
setCode("signup", "")
|
|
setOtpDelivery("signup", response.expires_in_seconds)
|
|
setNow(Date.now())
|
|
toast.success(t.login.toasts.verifySent)
|
|
} catch (error) {
|
|
clearOtpDelivery("signup")
|
|
if (
|
|
!handleThrottleError({
|
|
error,
|
|
cooldownKey: "signupOtpSend",
|
|
setCooldown,
|
|
formatTime: (seconds) => formatCooldown(seconds, isRtl),
|
|
throttleCopy: t.login.throttle,
|
|
})
|
|
) {
|
|
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
|
|
}
|
|
} finally {
|
|
setIsSendingOtp(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!state.signup.pendingOtpSend || autoSendStartedRef.current) {
|
|
return
|
|
}
|
|
|
|
autoSendStartedRef.current = true
|
|
void sendSignupOtp()
|
|
}, [state.signup.pendingOtpSend])
|
|
|
|
const otpRemainingSeconds = state.signup.otpExpiresAt
|
|
? Math.max(0, Math.ceil((state.signup.otpExpiresAt - now) / 1000))
|
|
: 0
|
|
|
|
const alert = useMemo(() => {
|
|
if (state.cooldowns.signupOtpSend <= 0) {
|
|
return null
|
|
}
|
|
|
|
const formatted = formatCooldown(state.cooldowns.signupOtpSend, isRtl)
|
|
return {
|
|
title: t.login.throttle.title,
|
|
description: t.login.throttle.otpSendMessage(formatted),
|
|
}
|
|
}, [isRtl, state.cooldowns.signupOtpSend, t.login.throttle])
|
|
|
|
const expiryMessage =
|
|
otpRemainingSeconds > 0
|
|
? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl))
|
|
: state.signup.otpExpiresAt
|
|
? t.login.otpExpired
|
|
: null
|
|
|
|
const continueToPassword = async (code: string) => {
|
|
if (code.length !== 5) {
|
|
toast.error(t.login.toasts.enterOtp)
|
|
return
|
|
}
|
|
|
|
setIsContinuing(true)
|
|
setCode("signup", code)
|
|
navigate("/auth/signup/password")
|
|
}
|
|
|
|
const isBusy = isSendingOtp || isContinuing
|
|
|
|
return (
|
|
<AuthPanel
|
|
title={t.login.signupVerifyTitle}
|
|
description={t.login.sentCodeDesc(state.signup.mobile)}
|
|
alert={alert}
|
|
>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
void continueToPassword(state.signup.code)
|
|
}}
|
|
className="grid gap-4"
|
|
>
|
|
<AuthOtpInput
|
|
id="signup-otp"
|
|
value={state.signup.code}
|
|
disabled={isBusy}
|
|
onChange={(value) => setCode("signup", value)}
|
|
onComplete={(value) => void continueToPassword(value)}
|
|
/>
|
|
|
|
{expiryMessage ? (
|
|
<p className="text-center text-sm text-slate-500 dark:text-slate-400">{expiryMessage}</p>
|
|
) : null}
|
|
|
|
<Button type="submit" className="h-11 w-full" disabled={isBusy}>
|
|
{(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
|
{isSendingOtp ? t.login.sendingOtp : t.login.continueToPassword}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full"
|
|
disabled={isBusy || otpRemainingSeconds > 0 || state.cooldowns.signupOtpSend > 0}
|
|
onClick={() => void sendSignupOtp()}
|
|
>
|
|
{state.cooldowns.signupOtpSend > 0
|
|
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.signupOtpSend, isRtl))
|
|
: t.login.resendOtp}
|
|
</Button>
|
|
</form>
|
|
</AuthPanel>
|
|
)
|
|
}
|