canina/frontend/application/components/AuthModal.tsx

389 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, User, Building2, Heart, ShieldCheck, Phone, Key, LogIn, ArrowRight, RefreshCw } from "lucide-react";
import { useUserStore, UserRole } from "../lib/store/userStore";
import { authService } from "../lib/services/authService";
import { toast } from "sonner";
import { toPersian } from "../lib/utils";
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
const { setRole, setLoggedIn, fetchProfile } = useUserStore();
const [view, setView] = useState<"quick" | "phone" | "otp">("quick");
const [phoneNumber, setPhoneNumber] = useState("");
const [otpCode, setOtpCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
const otpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (view === "otp") {
const timer = setTimeout(() => {
otpInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}, [view]);
// Reset modal state on open/close
useEffect(() => {
if (!isOpen) {
setView("quick");
setPhoneNumber("");
setOtpCode("");
setIsLoading(false);
setCountdown(0);
}
}, [isOpen]);
// Cooldown countdown timer
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
const handleQuickLogin = async (targetRole: UserRole) => {
if (targetRole === "User_Guest") {
setRole("User_Guest");
setLoggedIn(false);
toast.success("به عنوان کاربر مهمان وارد شدید.");
onClose();
return;
}
setIsLoading(true);
const testPhone = targetRole === "User_PetOwner" ? "09121111111" : "09122222222";
try {
// Step 1: Send OTP to test phone
const sendRes = await authService.sendOtp(testPhone);
const code = (sendRes as any).code;
if (!code) {
throw new Error("کد تایید تستی تولید نشد");
}
// Step 2: Verify OTP
const verifyRes = await authService.verifyOtp(testPhone, code);
if (verifyRes.success) {
// Force state sync and fetch profile
setRole(targetRole);
await fetchProfile();
toast.success(`ورود سریع موفق! در نقش ${targetRole === "User_Partner" ? 'همکار' : 'صاحب پت'} وارد شدید.`);
onClose();
}
} catch (err: any) {
toast.error(`خطا در ورود سریع: ${err.message}`);
} finally {
setIsLoading(false);
}
};
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);
if ((res as any).code) {
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
} else {
toast.success("کد تایید پیامک شد");
}
setView("otp");
setCountdown(120);
} catch (err: any) {
toast.error(err.message || "خطا در ارسال کد تایید");
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const cleanCode = otpCode.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("ورود موفقیت‌آمیز بود");
onClose();
}
} catch (err: any) {
toast.error(err.message || "کد تایید نامعتبر است");
} finally {
setIsLoading(false);
}
};
const handleResendOtp = async () => {
if (countdown > 0) return;
setIsLoading(true);
try {
await authService.sendOtp(phoneNumber.trim());
toast.success("کد تایید جدید ارسال شد");
setCountdown(120);
} catch (err: any) {
toast.error(err.message || "خطا در ارسال مجدد کد");
} finally {
setIsLoading(false);
}
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
onClick={onClose}
/>
{/* Modal content */}
<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="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl z-[70] overflow-hidden border border-medical-gray-100 font-vazir"
dir="rtl"
>
{/* Top highlight bar */}
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-canina-blue to-indigo-500" />
<div className="p-8 relative">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-6 left-6 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>
{/* Title Header */}
<div className="flex flex-col items-center text-center mb-8 pt-4">
<div className="w-20 h-20 bg-canina-blue/10 rounded-3xl flex items-center justify-center mb-4 shadow-inner">
<ShieldCheck className="w-10 h-10 text-canina-blue" />
</div>
<h2 className="text-3xl font-black text-medical-gray-900 mb-2 italic">
ورود به دنیای کانینا
</h2>
<p className="text-medical-gray-500 text-sm font-bold max-w-[280px]">
{view === "otp"
? `کد تایید ارسال شده به شماره ${toPersian(phoneNumber)} را وارد کنید`
: "برای دسترسی به پرونده سلامت و سفارشات وارد شوید"}
</p>
</div>
{/* Content Panel */}
<AnimatePresence mode="wait">
{view === "quick" && (
<motion.div
key="quick-view"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6"
>
<button
onClick={() => setView("phone")}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl p-5 flex items-center justify-between group hover:border-canina-blue transition-all cursor-pointer shadow-sm"
>
<div className="flex items-center gap-3">
<Phone className="w-5 h-5 text-canina-blue" />
<span className="font-black text-sm text-medical-gray-700">ورود با شماره موبایل</span>
</div>
<div className="w-8 h-8 rounded-xl bg-white border border-medical-gray-100 flex items-center justify-center shadow-sm text-canina-blue group-hover:bg-canina-blue group-hover:text-white transition-all"></div>
</button>
<div className="pt-6 border-t border-medical-gray-100">
<p className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest text-center mb-4">ورود سریع تستی (با دیتابیس واقعی)</p>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => handleQuickLogin("User_Guest")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
<User className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
<span className="text-[10px] font-black text-medical-gray-700">مهمان</span>
</button>
<button
onClick={() => handleQuickLogin("User_PetOwner")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
) : (
<Heart className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
)}
<span className="text-[10px] font-black text-medical-gray-700">صاحب پت</span>
</button>
<button
onClick={() => handleQuickLogin("User_Partner")}
disabled={isLoading}
className="flex flex-col items-center gap-2 p-3 rounded-2xl border border-medical-gray-100 hover:bg-medical-gray-50 transition-all group cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<RefreshCw className="w-6 h-6 text-canina-blue animate-spin" />
) : (
<Building2 className="w-6 h-6 text-medical-gray-400 group-hover:text-canina-blue" />
)}
<span className="text-[10px] font-black text-medical-gray-700">همکار</span>
</button>
</div>
</div>
</motion.div>
)}
{view === "phone" && (
<motion.form
key="phone-view"
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">
<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={() => setView("quick")}
className="text-[10px] font-black text-canina-blue hover:underline"
>
برگشت به ورود سریع
</button>
</div>
<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 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 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>
)}
{view === "otp" && (
<motion.form
key="otp-view"
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={() => setView("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}
placeholder="کد ۵ رقمی"
value={otpCode}
onChange={(e) => setOtpCode(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 outline-none font-black text-2xl text-center tracking-[0.5em]"
/>
</div>
</div>
<div className="text-center">
{countdown > 0 ? (
<span className="text-xs font-bold text-medical-gray-400">
ارسال مجدد کد پس از {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 justify-center mx-auto"
>
<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 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>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}