feat(auth): improve otp delivery and verification flow
This commit is contained in:
132
src/pages/auth/AuthOtpInput.tsx
Normal file
132
src/pages/auth/AuthOtpInput.tsx
Normal file
@@ -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<Array<HTMLInputElement | null>>([])
|
||||
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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
event.preventDefault()
|
||||
const pasted = sanitizeOtp(event.clipboardData.getData("text"))
|
||||
if (!pasted) {
|
||||
return
|
||||
}
|
||||
onChange(pasted)
|
||||
focusIndex(Math.min(pasted.length, OTP_LENGTH - 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 sm:gap-3" dir="ltr">
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={`${id}-${index}`}
|
||||
ref={(element) => {
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user