272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
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<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)[]>([]);
|
||
|
||
// 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 "@<this site's domain> #<code>", 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<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);
|
||
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 (
|
||
<div className="min-h-screen relative overflow-hidden">
|
||
<img
|
||
src={role === "customer" ? GuestBanner : HostBanner}
|
||
alt="بنر"
|
||
className="w-full"
|
||
/>
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (canSubmit) handleSubmit();
|
||
}}
|
||
className="absolute left-0 right-0 top-1/2 -translate-y-1/2 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="ویرایش شماره تماس"
|
||
>
|
||
<Icon name="edit" 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"
|
||
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"
|
||
/>
|
||
))}
|
||
</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="submit"
|
||
disabled={!canSubmit}
|
||
className="mt-4 w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
||
>
|
||
{isSubmitting ? "در حال ورود..." : "ورود"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|