391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|