diff --git a/src/api/users.ts b/src/api/users.ts index 6312076..a7a4af1 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,29 +1,44 @@ import { authFetch, buildApiError, buildApiUrl } from './client'; - -// --- Auth Endpoints --- - + +const normalizeDigits = (value: string) => + value + .replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) + .replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))) + +// --- Auth Endpoints --- + export const loginWithPassword = async (mobile: string, password: string) => { + const normalizedMobile = normalizeDigits(mobile) const response = await authFetch('/api/users/login/', { method: 'POST', - body: JSON.stringify({ mobile, password }) + body: JSON.stringify({ mobile: normalizedMobile, password }) }); if (!response.ok) throw await buildApiError(response); return response.json(); }; -export const sendOtp = async (mobile: string, mode: string) => { +export interface SendOtpResponse { + detail: string + expires_in_seconds: number + expires_at?: string | null +} + +export const sendOtp = async (mobile: string, mode: string): Promise => { + const normalizedMobile = normalizeDigits(mobile) const response = await authFetch('/api/users/otp/send/', { method: 'POST', - body: JSON.stringify({ mobile, mode }) + body: JSON.stringify({ mobile: normalizedMobile, mode }) }); if (!response.ok) throw await buildApiError(response); return response.json(); }; export const loginWithOtp = async (mobile: string, otp: string) => { + const normalizedMobile = normalizeDigits(mobile) + const normalizedOtp = normalizeDigits(otp) const response = await authFetch('/api/users/otp/login/', { method: 'POST', - body: JSON.stringify({ mobile, code: otp }) + body: JSON.stringify({ mobile: normalizedMobile, code: normalizedOtp }) }); if (!response.ok) throw await buildApiError(response); return response.json(); @@ -37,9 +52,18 @@ export const registerWithOtp = async ( first_name = "", last_name = "", ) => { + const normalizedMobile = normalizeDigits(mobile) + const normalizedCode = normalizeDigits(code) const response = await authFetch("/api/users/register/", { method: "POST", - body: JSON.stringify({ mobile, code, password, re_password, first_name, last_name }), + body: JSON.stringify({ + mobile: normalizedMobile, + code: normalizedCode, + password, + re_password, + first_name, + last_name, + }), }) if (!response.ok) throw await buildApiError(response) return response.json() @@ -51,9 +75,16 @@ export const resetPasswordWithOtp = async ( password: string, re_password: string, ) => { + const normalizedMobile = normalizeDigits(mobile) + const normalizedCode = normalizeDigits(code) const response = await authFetch("/api/users/password/reset/", { method: "POST", - body: JSON.stringify({ mobile, code, password, re_password }), + body: JSON.stringify({ + mobile: normalizedMobile, + code: normalizedCode, + password, + re_password, + }), }) if (!response.ok) throw await buildApiError(response) return response.json() @@ -107,9 +138,10 @@ export const completeGoogleOAuthSignup = async ( flow: string, mobile: string, ): Promise => { + const normalizedMobile = normalizeDigits(mobile) const response = await authFetch("/api/users/oauth/google/complete/", { method: "POST", - body: JSON.stringify({ flow, mobile }), + body: JSON.stringify({ flow, mobile: normalizedMobile }), }); if (!response.ok) throw await buildApiError(response); return response.json(); @@ -128,9 +160,10 @@ export const verifyGoogleOAuthClaim = async ( flow: string, code: string, ): Promise => { + const normalizedCode = normalizeDigits(code) const response = await authFetch("/api/users/oauth/google/claim/verify/", { method: "POST", - body: JSON.stringify({ flow, code }), + body: JSON.stringify({ flow, code: normalizedCode }), }); if (!response.ok) throw await buildApiError(response); return response.json(); @@ -193,9 +226,9 @@ export interface SearchedUser { profile_picture: string | null; } -export const searchUserByExactMobile = async (mobile: string): Promise => { - try { - const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(mobile)}`); +export const searchUserByExactMobile = async (mobile: string): Promise => { + try { + const response = await authFetch(`/api/users/search/?mobile=${encodeURIComponent(normalizeDigits(mobile))}`); if (!response.ok) return null; // Returns null on 404 or other errors return await response.json(); } catch (error) { diff --git a/src/context/AuthFlowContext.tsx b/src/context/AuthFlowContext.tsx index 33a820d..6aba529 100644 --- a/src/context/AuthFlowContext.tsx +++ b/src/context/AuthFlowContext.tsx @@ -11,6 +11,8 @@ export type CooldownKey = interface FlowBranchState { mobile: string code: string + otpExpiresAt: number | null + pendingOtpSend: boolean } interface CooldownState { @@ -32,25 +34,34 @@ interface AuthFlowContextValue { state: AuthFlowState setMobile: (flow: FlowName, mobile: string) => void setCode: (flow: FlowName, code: string) => void + markOtpSendPending: (flow: FlowName) => void + setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void + clearOtpDelivery: (flow: FlowName) => void setCooldown: (key: CooldownKey, seconds: number) => void clearCooldown: (key: CooldownKey) => void resetFlow: (flow: FlowName) => void } -const STORAGE_KEY = "auth_flow_state:v1" +const STORAGE_KEY = "auth_flow_state:v2" const defaultState: AuthFlowState = { login: { mobile: "", code: "", + otpExpiresAt: null, + pendingOtpSend: false, }, signup: { mobile: "", code: "", + otpExpiresAt: null, + pendingOtpSend: false, }, forgotPassword: { mobile: "", code: "", + otpExpiresAt: null, + pendingOtpSend: false, }, cooldowns: { loginOtpSend: 0, @@ -80,14 +91,20 @@ const parseStoredState = (): AuthFlowState => { login: { mobile: parsed.login?.mobile ?? "", code: parsed.login?.code ?? "", + otpExpiresAt: parsed.login?.otpExpiresAt ?? null, + pendingOtpSend: parsed.login?.pendingOtpSend ?? false, }, signup: { mobile: parsed.signup?.mobile ?? "", code: parsed.signup?.code ?? "", + otpExpiresAt: parsed.signup?.otpExpiresAt ?? null, + pendingOtpSend: parsed.signup?.pendingOtpSend ?? false, }, forgotPassword: { mobile: parsed.forgotPassword?.mobile ?? "", code: parsed.forgotPassword?.code ?? "", + otpExpiresAt: parsed.forgotPassword?.otpExpiresAt ?? null, + pendingOtpSend: parsed.forgotPassword?.pendingOtpSend ?? false, }, cooldowns: { loginOtpSend: parsed.cooldowns?.loginOtpSend ?? 0, @@ -151,6 +168,36 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) { }, })) }, + markOtpSendPending: (flow) => { + setState((current) => ({ + ...current, + [flow]: { + ...current[flow], + code: "", + pendingOtpSend: true, + }, + })) + }, + setOtpDelivery: (flow, expiresInSeconds) => { + setState((current) => ({ + ...current, + [flow]: { + ...current[flow], + pendingOtpSend: false, + otpExpiresAt: Date.now() + Math.max(0, expiresInSeconds) * 1000, + }, + })) + }, + clearOtpDelivery: (flow) => { + setState((current) => ({ + ...current, + [flow]: { + ...current[flow], + pendingOtpSend: false, + otpExpiresAt: null, + }, + })) + }, setCooldown: (key, seconds) => { setState((current) => ({ ...current, @@ -175,6 +222,8 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) { [flow]: { mobile: "", code: "", + otpExpiresAt: null, + pendingOtpSend: false, }, })) }, diff --git a/src/locales/en.ts b/src/locales/en.ts index c8eba9d..73f9a03 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -77,9 +77,14 @@ export const en = { passwordPlaceholder: "Password", signIn: "Sign In", back: "Back", - otpPlaceholder: "5-digit code", - verifyAndContinue: "Verify & Continue", - terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.", + otpPlaceholder: "5-digit code", + verifyAndContinue: "Verify & Continue", + sendingOtp: "Sending code...", + verifyingOtp: "Verifying code...", + resendOtp: "Resend code", + otpExpiresIn: (time: string) => `Code expires in ${time}`, + otpExpired: "This code has expired. Request a new code to continue.", + terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.", brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.", toasts: { enterMobile: "Please enter your mobile number", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index a04521b..a4364c3 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -75,11 +75,16 @@ export const fa = { otpLogin: "ورود با کد یکبار مصرف", register: "ثبت نام", passwordPlaceholder: "رمز عبور", - signIn: "ورود", - back: "بازگشت", - otpPlaceholder: "کد ۵ رقمی", - verifyAndContinue: "تایید و ادامه", - terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", + signIn: "ورود", + back: "بازگشت", + otpPlaceholder: "کد ۵ رقمی", + verifyAndContinue: "تایید و ادامه", + sendingOtp: "در حال ارسال کد...", + verifyingOtp: "در حال تأیید کد...", + resendOtp: "ارسال دوباره کد", + otpExpiresIn: (time: string) => `اعتبار کد تا ${time} دیگر است`, + otpExpired: "اعتبار این کد به پایان رسیده است. برای ادامه کد جدید بگیرید.", + terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.", brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.", toasts: { enterMobile: "لطفا شماره موبایل خود را وارد کنید", diff --git a/src/pages/auth/AuthOtpInput.tsx b/src/pages/auth/AuthOtpInput.tsx new file mode 100644 index 0000000..f577c5d --- /dev/null +++ b/src/pages/auth/AuthOtpInput.tsx @@ -0,0 +1,132 @@ +import { useEffect, useMemo, useRef } from "react" + +const OTP_LENGTH = 5 + +const normalizeDigits = (value: string) => + value + .replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) + .replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))) + +const sanitizeOtp = (value: string) => normalizeDigits(value).replace(/\D/g, "").slice(0, OTP_LENGTH) + +export function AuthOtpInput({ + id, + value, + disabled, + onChange, + onComplete, +}: { + id: string + value: string + disabled?: boolean + onChange: (value: string) => void + onComplete?: (value: string) => void +}) { + const inputRefs = useRef>([]) + const lastCompletedValueRef = useRef("") + const normalizedValue = useMemo(() => sanitizeOtp(value), [value]) + const digits = useMemo( + () => Array.from({ length: OTP_LENGTH }, (_, index) => normalizedValue[index] ?? ""), + [normalizedValue], + ) + + useEffect(() => { + if (normalizedValue.length !== OTP_LENGTH) { + lastCompletedValueRef.current = "" + return + } + + if (normalizedValue !== lastCompletedValueRef.current) { + lastCompletedValueRef.current = normalizedValue + onComplete?.(normalizedValue) + } + }, [normalizedValue, onComplete]) + + const focusIndex = (index: number) => { + inputRefs.current[index]?.focus() + inputRefs.current[index]?.select() + } + + const handleSlotChange = (index: number, nextRawValue: string) => { + const nextValue = sanitizeOtp(nextRawValue) + if (!nextValue) { + const updated = digits.slice() + updated[index] = "" + onChange(updated.join("")) + return + } + + if (nextValue.length > 1) { + onChange(nextValue) + const focusTarget = Math.min(nextValue.length, OTP_LENGTH - 1) + focusIndex(focusTarget) + return + } + + const updated = digits.slice() + updated[index] = nextValue + const combined = updated.join("") + onChange(combined) + + if (index < OTP_LENGTH - 1) { + focusIndex(index + 1) + } + } + + const handleKeyDown = (index: number, event: React.KeyboardEvent) => { + if (event.key === "Backspace" && !digits[index] && index > 0) { + const updated = digits.slice() + updated[index - 1] = "" + onChange(updated.join("")) + focusIndex(index - 1) + event.preventDefault() + return + } + + if (event.key === "ArrowLeft" && index > 0) { + focusIndex(index - 1) + event.preventDefault() + return + } + + if (event.key === "ArrowRight" && index < OTP_LENGTH - 1) { + focusIndex(index + 1) + event.preventDefault() + } + } + + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault() + const pasted = sanitizeOtp(event.clipboardData.getData("text")) + if (!pasted) { + return + } + onChange(pasted) + focusIndex(Math.min(pasted.length, OTP_LENGTH - 1)) + } + + return ( +
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = element + }} + id={index === 0 ? id : undefined} + type="text" + inputMode="numeric" + autoComplete="one-time-code" + pattern="[0-9]*" + maxLength={OTP_LENGTH} + disabled={disabled} + value={digit} + onChange={(event) => handleSlotChange(index, event.target.value)} + onKeyDown={(event) => handleKeyDown(index, event)} + onPaste={handlePaste} + className="h-12 w-12 rounded-2xl border border-slate-200 bg-white text-center text-lg font-semibold tracking-[0.18em] text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:ring-sky-500/20 sm:h-14 sm:w-14" + /> + ))} +
+ ) +} diff --git a/src/pages/auth/ForgotPasswordMobilePage.tsx b/src/pages/auth/ForgotPasswordMobilePage.tsx index 01f9bb5..88a665c 100644 --- a/src/pages/auth/ForgotPasswordMobilePage.tsx +++ b/src/pages/auth/ForgotPasswordMobilePage.tsx @@ -1,22 +1,19 @@ -import { Loader2 } from "lucide-react" -import { useMemo, useState } from "react" +import { useMemo } from "react" import { Link, useNavigate } from "react-router-dom" import { toast } from "sonner" -import { sendOtp } from "../../api/users" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { useAuthFlow } from "../../context/AuthFlowContext" import { useTranslation } from "../../hooks/useTranslation" import { AuthPanel } from "./AuthPanel" -import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" +import { formatCooldown } from "./utils" export function ForgotPasswordMobilePage() { const navigate = useNavigate() const { t, lang } = useTranslation() - const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() + const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow() const isRtl = lang === "fa" - const [loading, setLoading] = useState(false) const alert = useMemo(() => { if (state.cooldowns.forgotPasswordOtpSend <= 0) { @@ -41,28 +38,9 @@ export function ForgotPasswordMobilePage() { return } - setLoading(true) - try { - await sendOtp(state.forgotPassword.mobile, "forget_password") - clearCooldown("forgotPasswordOtpSend") - setCode("forgotPassword", "") - navigate("/auth/forgot-password/verify") - toast.success(t.login.toasts.verifySent) - } catch (error) { - if ( - !handleThrottleError({ - error, - cooldownKey: "forgotPasswordOtpSend", - setCooldown, - formatTime: (seconds) => formatCooldown(seconds, isRtl), - throttleCopy: t.login.throttle, - }) - ) { - toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) - } - } finally { - setLoading(false) - } + clearOtpDelivery("forgotPassword") + markOtpSendPending("forgotPassword") + navigate("/auth/forgot-password/verify") } return ( @@ -78,7 +56,6 @@ export function ForgotPasswordMobilePage() { type="tel" dir="ltr" maxLength={11} - disabled={loading} value={state.forgotPassword.mobile} onChange={(event) => setMobile("forgotPassword", event.target.value)} className={`h-11 ${isRtl ? "text-end" : "text-start"}`} @@ -86,10 +63,9 @@ export function ForgotPasswordMobilePage() { diff --git a/src/pages/auth/ForgotPasswordOtpPage.tsx b/src/pages/auth/ForgotPasswordOtpPage.tsx index 8005357..3b5928d 100644 --- a/src/pages/auth/ForgotPasswordOtpPage.tsx +++ b/src/pages/auth/ForgotPasswordOtpPage.tsx @@ -1,55 +1,161 @@ -import { useState } from "react" -import { Link, Navigate, useNavigate } from "react-router-dom" +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 { Input } from "../../components/ui/input" 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 ForgotPasswordOtpPage() { const navigate = useNavigate() - const { t } = useTranslation() - const { state, setCode } = useAuthFlow() - const [loading, setLoading] = useState(false) + 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.forgotPassword.mobile) { return } - const handleContinue = async (event: React.FormEvent) => { - event.preventDefault() + useEffect(() => { + if (!state.forgotPassword.otpExpiresAt || getOtpRemainingSeconds(state.forgotPassword.otpExpiresAt) <= 0) { + return + } - if (!state.forgotPassword.code) { + const timer = window.setInterval(() => { + setNow(Date.now()) + }, 1000) + + return () => window.clearInterval(timer) + }, [state.forgotPassword.otpExpiresAt]) + + const sendForgotPasswordOtp = async () => { + setIsSendingOtp(true) + try { + const response = await sendOtp(state.forgotPassword.mobile, "forget_password") + clearCooldown("forgotPasswordOtpSend") + setCode("forgotPassword", "") + setOtpDelivery("forgotPassword", response.expires_in_seconds) + setNow(Date.now()) + toast.success(t.login.toasts.verifySent) + } catch (error) { + clearOtpDelivery("forgotPassword") + if ( + !handleThrottleError({ + error, + cooldownKey: "forgotPasswordOtpSend", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) + } + } finally { + setIsSendingOtp(false) + } + } + + useEffect(() => { + if (!state.forgotPassword.pendingOtpSend || autoSendStartedRef.current) { + return + } + + autoSendStartedRef.current = true + void sendForgotPasswordOtp() + }, [state.forgotPassword.pendingOtpSend]) + + const otpRemainingSeconds = state.forgotPassword.otpExpiresAt + ? Math.max(0, Math.ceil((state.forgotPassword.otpExpiresAt - now) / 1000)) + : 0 + + const alert = useMemo(() => { + if (state.cooldowns.forgotPasswordOtpSend <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.otpSendMessage(formatted), + } + }, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle]) + + const expiryMessage = + otpRemainingSeconds > 0 + ? t.login.otpExpiresIn(formatCooldown(otpRemainingSeconds, isRtl)) + : state.forgotPassword.otpExpiresAt + ? t.login.otpExpired + : null + + const continueToReset = async (code: string) => { + if (code.length !== 5) { toast.error(t.login.toasts.enterOtp) return } - setLoading(true) + setIsContinuing(true) + setCode("forgotPassword", code) navigate("/auth/forgot-password/password") } + const isBusy = isSendingOtp || isContinuing + return ( -
- { + event.preventDefault() + void continueToReset(state.forgotPassword.code) + }} + className="grid gap-4" + > + setCode("forgotPassword", event.target.value)} - className="h-11 text-center text-lg tracking-widest" + disabled={isBusy} + onChange={(value) => setCode("forgotPassword", value)} + onComplete={(value) => void continueToReset(value)} /> - + +
diff --git a/src/pages/auth/LoginMobilePage.tsx b/src/pages/auth/LoginMobilePage.tsx index 0608208..faceb95 100644 --- a/src/pages/auth/LoginMobilePage.tsx +++ b/src/pages/auth/LoginMobilePage.tsx @@ -1,15 +1,14 @@ -import { Loader2 } from "lucide-react" -import { useMemo, useState } from "react" +import { useMemo } from "react" import { Link, useNavigate } from "react-router-dom" import { toast } from "sonner" -import { sendOtp, startGoogleLogin } from "../../api/users" +import { startGoogleLogin } from "../../api/users" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { useAuthFlow } from "../../context/AuthFlowContext" import { useTranslation } from "../../hooks/useTranslation" import { AuthPanel } from "./AuthPanel" -import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" +import { formatCooldown } from "./utils" const GoogleIcon = () => (

{expiryMessage}

+ ) : null} + + + +
diff --git a/src/pages/auth/SignupMobilePage.tsx b/src/pages/auth/SignupMobilePage.tsx index 2381c90..f52bf6c 100644 --- a/src/pages/auth/SignupMobilePage.tsx +++ b/src/pages/auth/SignupMobilePage.tsx @@ -1,22 +1,19 @@ -import { Loader2 } from "lucide-react" -import { useMemo, useState } from "react" +import { useMemo } from "react" import { Link, useNavigate } from "react-router-dom" import { toast } from "sonner" -import { sendOtp } from "../../api/users" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { useAuthFlow } from "../../context/AuthFlowContext" import { useTranslation } from "../../hooks/useTranslation" import { AuthPanel } from "./AuthPanel" -import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" +import { formatCooldown } from "./utils" export function SignupMobilePage() { const navigate = useNavigate() const { t, lang } = useTranslation() - const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() + const { state, setMobile, markOtpSendPending, clearOtpDelivery } = useAuthFlow() const isRtl = lang === "fa" - const [loading, setLoading] = useState(false) const alert = useMemo(() => { if (state.cooldowns.signupOtpSend <= 0) { @@ -41,28 +38,9 @@ export function SignupMobilePage() { return } - setLoading(true) - try { - await sendOtp(state.signup.mobile, "register") - clearCooldown("signupOtpSend") - setCode("signup", "") - navigate("/auth/signup/verify") - toast.success(t.login.toasts.verifySent) - } catch (error) { - if ( - !handleThrottleError({ - error, - cooldownKey: "signupOtpSend", - setCooldown, - formatTime: (seconds) => formatCooldown(seconds, isRtl), - throttleCopy: t.login.throttle, - }) - ) { - toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) - } - } finally { - setLoading(false) - } + clearOtpDelivery("signup") + markOtpSendPending("signup") + navigate("/auth/signup/verify") } return ( @@ -78,7 +56,6 @@ export function SignupMobilePage() { type="tel" dir="ltr" maxLength={11} - disabled={loading} value={state.signup.mobile} onChange={(event) => setMobile("signup", event.target.value)} className={`h-11 ${isRtl ? "text-end" : "text-start"}`} @@ -86,10 +63,9 @@ export function SignupMobilePage() { diff --git a/src/pages/auth/SignupOtpPage.tsx b/src/pages/auth/SignupOtpPage.tsx index f5aee39..e1706a0 100644 --- a/src/pages/auth/SignupOtpPage.tsx +++ b/src/pages/auth/SignupOtpPage.tsx @@ -1,55 +1,161 @@ -import { useState } from "react" -import { Link, Navigate, useNavigate } from "react-router-dom" +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 { Input } from "../../components/ui/input" 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 } = useTranslation() - const { state, setCode } = useAuthFlow() - const [loading, setLoading] = useState(false) + 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 } - const handleContinue = async (event: React.FormEvent) => { - event.preventDefault() + useEffect(() => { + if (!state.signup.otpExpiresAt || getOtpRemainingSeconds(state.signup.otpExpiresAt) <= 0) { + return + } - if (!state.signup.code) { + 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 } - setLoading(true) + setIsContinuing(true) + setCode("signup", code) navigate("/auth/signup/password") } + const isBusy = isSendingOtp || isContinuing + return ( -
- { + event.preventDefault() + void continueToPassword(state.signup.code) + }} + className="grid gap-4" + > + setCode("signup", event.target.value)} - className="h-11 text-center text-lg tracking-widest" + disabled={isBusy} + onChange={(value) => setCode("signup", value)} + onComplete={(value) => void continueToPassword(value)} /> - + +
diff --git a/src/pages/auth/utils.ts b/src/pages/auth/utils.ts index 876c576..fa8339d 100644 --- a/src/pages/auth/utils.ts +++ b/src/pages/auth/utils.ts @@ -17,6 +17,14 @@ export const formatCooldown = (seconds: number, isRtl: boolean) => { return localizeDigits(base, isRtl) } +export const getOtpRemainingSeconds = (expiresAt: number | null) => { + if (!expiresAt) { + return 0 + } + + return Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)) +} + export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { if (error instanceof ApiError) { return error.message