import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import Icon from "../components/Icon"; import { requestOtp, verifyOtp } from "../services/authApi"; import { fetchMyCafes } from "../services/campaignApi"; import { storeAuthTokens } from "../utils/authToken"; import { clearCafeUuid, setCafeUuid } from "../utils/cafe"; import { setUserPhone } from "../utils/userPhone"; import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber"; import { toE164 } from "../utils/phone"; import { getUserRole, setUserRole, UserRole } from "../utils/role"; import GuestBanner from "../assets/images/banners/Guest-banner.jpg"; import HostBanner from "../assets/images/banners/Host-banner.jpg"; const OTP_LENGTH = 5; const DEFAULT_RESEND_SECONDS = 120; // not yet in TS's DOM lib — minimal shape for the WebOTP API result interface OTPCredential extends Credential { code: string; } 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; role?: UserRole; } | null; const phone = state?.phone ?? ""; // the role this OTP attempt is for — passed explicitly from Login.tsx rather than // re-read from the global getUserRole(), which could've drifted (e.g. another tab/flow // overwriting it in between) and silently route a cafe owner through the customer path const role = state?.role ?? getUserRole(); const loginPath = role === "cafe_owner" ? "/cafe/login" : "/login"; const [otp, setOtp] = useState(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)[]>([]); // fills consecutive boxes starting at startIndex — used by both the WebOTP // listener below and a multi-digit autofill landing in a single box (e.g. iOS's // autocomplete="one-time-code" suggestion pastes the whole code into one input) const fillOtp = (startIndex: number, rawValue: string) => { const digits = toEnglishDigits(rawValue) .replace(/[^0-9]/g, "") .slice(0, OTP_LENGTH - startIndex); if (!digits) return; setOtp((prev) => { const next = [...prev]; digits.split("").forEach((d, i) => { next[startIndex + i] = d; }); return next; }); setError(""); inputRefs.current[ Math.min(startIndex + digits.length, OTP_LENGTH) - 1 ]?.focus(); }; useEffect(() => { if (!phone) { navigate(loginPath, { replace: true }); } }, [phone, loginPath, navigate]); useEffect(() => { inputRefs.current[0]?.focus(); }, []); // Android/Chrome: WebOTP lets the browser read the code straight off an incoming // SMS without the user opening the messaging app — only fires if the SMS body ends // with a line like "@ #", which the backend's SMS // template doesn't currently send, so this is a no-op until that's added. useEffect(() => { if (!("OTPCredential" in window)) return; const abortController = new AbortController(); navigator.credentials .get({ otp: { transport: ["sms"] }, signal: abortController.signal, } as unknown as CredentialRequestOptions) .then((credential) => { const code = (credential as OTPCredential | null)?.code; if (code) fillOtp(0, code); }) .catch(() => {}); return () => abortController.abort(); }, []); 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 digits = toEnglishDigits(rawValue).replace(/[^0-9]/g, ""); // a whole code landing in one box — e.g. iOS's autocomplete="one-time-code" suggestion if (digits.length > 1) { fillOtp(index, digits); return; } setOtp((prev) => { const next = [...prev]; next[index] = digits; return next; }); setError(""); if (digits && index < OTP_LENGTH - 1) { inputRefs.current[index + 1]?.focus(); } }; const handleOtpKeyDown = ( index: number, e: React.KeyboardEvent, ) => { 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); setUserPhone(phone); // re-sync in case the global value drifted since Login.tsx set it — `role` (from // route state) is the source of truth for this submission, not getUserRole() setUserRole(role); if (role === "cafe_owner") { const myCafes = await fetchMyCafes().catch(() => []); if (myCafes.length > 0) { setCafeUuid(myCafes[0].uuid); navigate("/owner/panel"); } else { clearCafeUuid(); navigate("/owner/register"); } } else { navigate("/home"); } } catch { setError("کد وارد شده صحیح نیست"); } finally { setIsSubmitting(false); } }; return (
بنر
{ e.preventDefault(); if (canSubmit) handleSubmit(); }} className="absolute left-0 right-0 top-1/2 -translate-y-1/2 px-6" >

کد ارسال شده را وارد کنید:

{phone}
{otp.map((digit, index) => ( { inputRefs.current[index] = el; }} type="text" inputMode="numeric" autoComplete="one-time-code" enterKeyHint="go" // no maxLength — the browser would truncate a pasted/autofilled multi-digit // code to 1 char before onChange ever sees it; handleOtpChange enforces the // single-digit display for normal typing instead 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" /> ))}
{secondsLeft > 0 ? (

ارسال مجدد کد: {formatCountdown(secondsLeft)}

) : ( )}
{error && (

{error}

)}
); }