reservation-front/src/pages/LoginOtp.tsx
2026-06-24 14:37:26 +03:30

182 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { requestOtp, verifyOtp } from '../services/authApi'
import { fetchMyCafes } from '../services/campaignApi'
import { storeAuthTokens } from '../utils/authToken'
import { setCafeUuid } from '../utils/cafe'
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
import { toE164 } from '../utils/phone'
import { getUserRole } from '../utils/role'
const OTP_LENGTH = 5
const DEFAULT_RESEND_SECONDS = 120
function EditIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path
strokeMiterlimit={10}
d="M13.2594 3.59924L5.04936 12.2892C4.73936 12.6192 4.43936 13.2692 4.37936 13.7192L4.00936 16.9592C3.87936 18.1292 4.71936 18.9292 5.87936 18.7292L9.09936 18.1792C9.54936 18.0992 10.1794 17.7692 10.4894 17.4292L18.6994 8.73924C20.1194 7.23924 20.7594 5.52924 18.5494 3.43924C16.3494 1.36924 14.6794 2.09924 13.2594 3.59924Z"
/>
<path strokeMiterlimit={10} d="M11.8906 5.05078C12.3206 7.81078 14.5606 9.92078 17.3406 10.2008" />
<path strokeMiterlimit={10} d="M3 22H21" />
</svg>
)
}
function formatCountdown(seconds: number) {
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
return toPersianDigits(`${mm}:${ss}`)
}
export default function LoginOtp() {
const navigate = useNavigate()
const location = useLocation()
const state = location.state as { phone?: string; ttl?: number } | null
const phone = state?.phone ?? ''
const loginPath = getUserRole() === 'cafe_owner' ? '/cafe/login' : '/login'
const [otp, setOtp] = useState<string[]>(Array(OTP_LENGTH).fill(''))
const [secondsLeft, setSecondsLeft] = useState(Math.round(state?.ttl ?? DEFAULT_RESEND_SECONDS))
const [resendCycle, setResendCycle] = useState(0)
const [error, setError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isResending, setIsResending] = useState(false)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
useEffect(() => {
if (!phone) {
navigate(loginPath, { replace: true })
}
}, [phone, loginPath, navigate])
useEffect(() => {
const timer = setInterval(() => {
setSecondsLeft((s) => {
if (s <= 1) {
clearInterval(timer)
return 0
}
return s - 1
})
}, 1000)
return () => clearInterval(timer)
}, [resendCycle])
const handleOtpChange = (index: number, rawValue: string) => {
const digit = toEnglishDigits(rawValue).replace(/[^0-9]/g, '').slice(-1)
setOtp((prev) => {
const next = [...prev]
next[index] = digit
return next
})
setError('')
if (digit && index < OTP_LENGTH - 1) {
inputRefs.current[index + 1]?.focus()
}
}
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !otp[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handleResend = async () => {
setError('')
setIsResending(true)
try {
const { ttl } = await requestOtp(toE164(phone))
setOtp(Array(OTP_LENGTH).fill(''))
inputRefs.current[0]?.focus()
setSecondsLeft(Math.round(ttl))
setResendCycle((c) => c + 1)
} catch {
setError('ارسال مجدد کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
} finally {
setIsResending(false)
}
}
const canSubmit = otp.every((digit) => digit !== '') && !isSubmitting
const handleSubmit = async () => {
setError('')
setIsSubmitting(true)
try {
const tokens = await verifyOtp(toE164(phone), otp.join(''))
storeAuthTokens(tokens.access_token, tokens.refresh_token)
const role = getUserRole()
if (role === 'cafe_owner') {
const myCafes = await fetchMyCafes().catch(() => [])
if (myCafes.length > 0) {
setCafeUuid(myCafes[0].uuid)
navigate('/owner/panel')
} else {
navigate('/owner/register')
}
} else {
navigate('/')
}
} catch {
setError('کد وارد شده صحیح نیست')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen flex-col justify-center px-6">
<p className="text-right text-gray-900">کد ارسال شده را وارد کنید:</p>
<div className="mt-2 flex items-center justify-start gap-2">
<span className="text-[#223D5D]">{phone}</span>
<button type="button" onClick={() => navigate(loginPath, { state: { phone } })} aria-label="ویرایش شماره تماس">
<EditIcon className="h-5 w-5 text-[#223D5D]" />
</button>
</div>
<div dir="ltr" className="mt-8 flex items-center justify-center gap-4">
{otp.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleOtpKeyDown(index, e)}
className="h-14 w-14 rounded-lg border border-gray-200 bg-[#F8F8F8] text-center text-xl font-semibold text-gray-900 focus:border-[#223D5D] focus:outline-none"
/>
))}
</div>
<div className="mt-4 text-center">
{secondsLeft > 0 ? (
<p className="text-gray-500">
ارسال مجدد کد: {formatCountdown(secondsLeft)}
</p>
) : (
<button type="button" onClick={handleResend} disabled={isResending} className="text-[#376195] underline disabled:opacity-50">
{isResending ? 'در حال ارسال...' : 'ارسال مجدد کد'}
</button>
)}
</div>
{error && <p className="mt-2 text-center text-sm text-red-500">{error}</p>}
<button
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
>
{isSubmitting ? 'در حال ورود...' : 'ورود'}
</button>
</div>
)
}