reservation-front/src/pages/LoginOtp.tsx
2026-07-20 13:32:01 +03:30

272 lines
9.2 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 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>
);
}