canina/frontend/application/components/LoginModal.tsx
parsa aghaei 8d1b786a24
All checks were successful
Deploy Canina / deploy (push) Successful in 1m50s
chore(brand): rename all Persian occurrences of کانینا to کنینا across the project
2026-08-16 16:40:08 +03:30

342 lines
14 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 } from "../lib/utils";
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onLogin: () => void;
petName?: string;
isAdvisorContext?: boolean;
}
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 otpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (step === "otp") {
const timer = setTimeout(() => {
otpInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [step]);
const handleVerifyOtpWithCode = React.useCallback(async (code: string) => {
const cleanCode = code.trim();
if (cleanCode.length !== 5) {
toast.error("کد تایید باید ۵ رقم باشد");
return;
}
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 || "کد تایید اشتباه است");
} finally {
setIsLoading(false);
}
}, [phoneNumber, fetchProfile, onLogin, onClose]);
// WebOTP API & Auto-Submit
useEffect(() => {
if (step === "otp" && typeof window !== "undefined" && "OTPCredential" in window) {
const ac = new AbortController();
const creds = (navigator as unknown as { credentials?: { get?: (opt: unknown) => Promise<{ code?: string }> } }).credentials;
if (creds && creds.get) {
creds.get({
otp: { transport: ["sms"] },
signal: ac.signal,
})
.then((otp) => {
if (otp && otp.code) {
setOtpCode(otp.code);
handleVerifyOtpWithCode(otp.code);
}
})
.catch(() => {});
}
return () => ac.abort();
}
}, [step, handleVerifyOtpWithCode]);
const handleOtpInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value.replace(/[^0-9]/g, "").slice(0, 5);
setOtpCode(val);
if (val.length === 5) {
handleVerifyOtpWithCode(val);
}
};
const handleOtpPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData("text").replace(/[^0-9]/g, "").slice(0, 5);
if (pasted.length === 5) {
e.preventDefault();
setOtpCode(pasted);
handleVerifyOtpWithCode(pasted);
}
};
// Reset modal state when closed or opened
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;
}
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");
setCountdown(120); // 2 minutes cooldown
} 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;
setIsLoading(true);
try {
await authService.sendOtp(phoneNumber.trim());
toast.success("کد تایید جدید ارسال شد");
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 className="relative">
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 text-medical-gray-400" />
<input
ref={otpInputRef}
type="text"
maxLength={5}
autoComplete="one-time-code"
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={handleOtpInputChange}
onPaste={handleOtpPaste}
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-black text-2xl text-center tracking-[0.5em]"
/>
</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>
);
}