canina/frontend/application/components/LoginModal.tsx
parsa aghaei c57f775cb4
Some checks failed
Deploy Canina / deploy (push) Successful in 3m53s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 0s
fix(otp): support WebOTP sms pattern with {0} and {1} and robust code extraction
2026-09-05 19:36:49 +03:30

391 lines
15 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.

"use client";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
import { authService } from "../lib/services/authService";
import { useUserStore } from "../lib/store/userStore";
import { toast } from "sonner";
import { toPersian, toEnglishDigits } from "../lib/utils";
import OtpInput5 from "./OtpInput5";
function extractOtpFromText(text: string): string {
if (!text) return "";
const clean = toEnglishDigits(text);
const match = clean.match(/(\d{5})/);
if (match) return match[1];
return clean.replace(/[^0-9]/g, "").slice(0, 5);
}
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
petName?: string;
isAdvisorContext?: boolean;
}
const loginOtpCooldownExpiries: Record<string, number> = {};
export default function LoginModal({ isOpen, onClose, onLogin, petName, isAdvisorContext }: LoginModalProps) {
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phoneNumber, setPhoneNumber] = useState("");
const [otpCode, setOtpCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const { fetchProfile } = useUserStore();
const stepRef = useRef(step);
const isVerifyingRef = useRef(false);
useEffect(() => {
stepRef.current = step;
}, [step]);
// Handle hardware / Android back button
// IMPORTANT: only depend on isOpen — NOT step, to avoid stacking extra history entries
useEffect(() => {
if (!isOpen) return;
window.history.pushState({ __loginModalOpen: true }, "");
const handlePopState = () => {
if (stepRef.current === "otp") {
setStep("phone");
// Push sentinel again so next back also hits this handler
window.history.pushState({ __loginModalOpen: true }, "");
} else {
onClose();
}
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleVerifyOtpWithCode = React.useCallback(async (code: string) => {
const cleanCode = code.trim();
if (cleanCode.length !== 5) {
toast.error("کد تایید باید ۵ رقم باشد");
return;
}
// Guard against double-fire from onComplete + WebOTP
if (isVerifyingRef.current) return;
isVerifyingRef.current = true;
setIsLoading(true);
try {
const response = await authService.verifyOtp(phoneNumber.trim(), cleanCode);
if (response.success) {
await fetchProfile();
toast.success("ورود با موفقیت انجام شد");
onLogin();
onClose();
}
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "کد تایید اشتباه است");
isVerifyingRef.current = false;
} finally {
setIsLoading(false);
}
}, [phoneNumber, fetchProfile, onLogin, onClose]);
// NOTE: We intentionally do NOT use navigator.credentials.get (WebOTP API) here.
// autoComplete="one-time-code" on the first OTP input provides the native
// suggestion bar above the keyboard on Android without any permission dialog.
// Keep a stable ref so the WebOTP effect below doesn't need handleVerifyOtpWithCode
// as a dependency — that was causing the AbortController to fire prematurely.
const verifyOtpRef = useRef(handleVerifyOtpWithCode);
useEffect(() => { verifyOtpRef.current = handleVerifyOtpWithCode; }, [handleVerifyOtpWithCode]);
// WebOTP API - auto-fill OTP from incoming SMS.
// Only depends on [step] to avoid premature abort when callback references change.
useEffect(() => {
if (step !== "otp") return;
if (typeof window === "undefined" || !("OTPCredential" in window)) return;
let isMounted = true;
const ac = new AbortController();
const credApi = (navigator as unknown as {
credentials?: { get?: (opts: unknown) => Promise<{ code?: string }> };
}).credentials;
credApi?.get?.({
otp: { transport: ["sms"] },
signal: ac.signal,
}).then((otp) => {
if (!isMounted || !otp) return;
const rawCode = (otp as { code?: string })?.code || (otp as { id?: string })?.id || "";
const digits = extractOtpFromText(rawCode);
if (digits.length === 5) {
setOtpCode(digits);
setTimeout(() => {
if (isMounted) verifyOtpRef.current(digits);
}, 400);
}
}).catch(() => {});
return () => {
isMounted = false;
ac.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
useEffect(() => {
if (!isOpen) {
Promise.resolve().then(() => {
setStep("phone");
setPhoneNumber("");
setOtpCode("");
setIsLoading(false);
setCountdown(0);
});
}
}, [isOpen]);
// Countdown timer for OTP resend
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanPhone = phoneNumber.trim();
if (!/^09\d{9}$/.test(cleanPhone)) {
toast.error("شماره موبایل باید با ۰۹ شروع شده و ۱۱ رقم باشد");
return;
}
const now = Date.now();
const existingExpiry = loginOtpCooldownExpiries[cleanPhone] || 0;
const remainingSeconds = Math.max(0, Math.ceil((existingExpiry - now) / 1000));
if (remainingSeconds > 0) {
setCountdown(remainingSeconds);
setStep("otp");
return;
}
setIsLoading(true);
try {
const res = await authService.sendOtp(cleanPhone);
const resObj = res as unknown as { code?: string };
if (resObj.code) {
toast.info(`کد تایید (تست): ${resObj.code}`, { duration: 10000 });
} else {
toast.success("کد تایید پیامک شد");
}
setStep("otp");
loginOtpCooldownExpiries[cleanPhone] = Date.now() + 120 * 1000;
setCountdown(120);
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "خطا در ارسال کد تایید");
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
handleVerifyOtpWithCode(otpCode);
};
const handleResendOtp = async () => {
if (countdown > 0) return;
const cleanPhone = phoneNumber.trim();
setIsLoading(true);
try {
await authService.sendOtp(cleanPhone);
toast.success("کد تایید جدید ارسال شد");
loginOtpCooldownExpiries[cleanPhone] = Date.now() + 120 * 1000;
setCountdown(120);
} catch (err: unknown) {
const errObj = err as { message?: string };
toast.error(errObj.message || "خطا در ارسال مجدد کد تایید");
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-md"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white w-full max-w-md rounded-[3.5rem] p-10 relative z-10 shadow-2xl overflow-hidden font-vazir border border-medical-gray-100"
dir="rtl"
>
{/* Top color indicator */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-8 left-8 p-2 rounded-full hover:bg-medical-gray-50 text-medical-gray-400 hover:text-medical-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Header info */}
<div className="text-center mb-8">
<div className="w-20 h-20 bg-canina-blue/10 rounded-[2.5rem] flex items-center justify-center mx-auto mb-6 shadow-inner">
<ShieldCheck className="w-10 h-10 text-canina-blue" />
</div>
<h3 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
ورود به کنینا
</h3>
{isAdvisorContext ? (
<p className="text-medical-gray-500 font-bold leading-relaxed px-2 text-sm">
تحلیل سلامت <span className="text-canina-blue">{petName || "همدم شما"}</span> آماده است! برای مشاهده رژیم مکمل پیشنهادی و ذخیره شناسنامه، وارد شوید.
</p>
) : (
<p className="text-medical-gray-500 font-bold text-sm">برای دسترسی به پنل مدیریت سلامت، وارد شوید</p>
)}
</div>
{/* Step-based Forms */}
<AnimatePresence mode="wait">
{step === "phone" ? (
<motion.form
key="phone-form"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleSendOtp}
className="space-y-6"
>
<div className="space-y-2">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">شماره موبایل</label>
<div className="relative">
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
autoFocus
type="tel"
maxLength={11}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ''))}
disabled={isLoading}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-4 pr-12 pl-4 focus:ring-2 focus:ring-canina-blue/20 focus:border-canina-blue outline-none font-bold text-lg text-left tracking-widest"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || phoneNumber.length < 11}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
ارسال کد تایید
</>
)}
</button>
</motion.form>
) : (
<motion.form
key="otp-form"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onSubmit={handleVerifyOtp}
className="space-y-6"
>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest">کد تایید پیامکی</label>
<button
type="button"
onClick={() => setStep("phone")}
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline"
>
<ArrowRight className="w-3 h-3" />
ویرایش شماره
</button>
</div>
<div>
<OtpInput5
value={otpCode}
onChange={(val) => setOtpCode(val)}
disabled={isLoading}
autoFocus={true}
onComplete={(code) => handleVerifyOtpWithCode(code)}
/>
</div>
<p className="text-[10px] text-medical-gray-400 font-bold text-center mt-2">
کد تایید به شماره {toPersian(phoneNumber)} ارسال گردید.
</p>
</div>
<div className="flex justify-center">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400 font-vazir">
ارسال مجدد کد پس از {toPersian(countdown)} ثانیه
</span>
) : (
<button
type="button"
onClick={handleResendOtp}
disabled={isLoading}
className="text-xs font-black text-canina-blue hover:underline flex items-center gap-1.5"
>
<RefreshCw className="w-3.5 h-3.5" />
ارسال مجدد کد تایید
</button>
)}
</div>
<button
type="submit"
disabled={isLoading || otpCode.length < 5}
className="w-full py-5 bg-canina-blue text-white rounded-2xl font-black text-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-xl shadow-canina-blue/20 flex items-center justify-center gap-3 cursor-pointer"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 animate-spin" />
) : (
<>
<LogIn className="w-6 h-6" />
ورود و تایید حساب
</>
)}
</button>
</motion.form>
)}
</AnimatePresence>
</motion.div>
</div>
)}
</AnimatePresence>
);
}