990 lines
44 KiB
TypeScript
990 lines
44 KiB
TypeScript
"use client";
|
||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||
import { motion, AnimatePresence } from "motion/react";
|
||
import {
|
||
X,
|
||
Building2,
|
||
ShieldCheck,
|
||
Phone,
|
||
Key,
|
||
LogIn,
|
||
ArrowRight,
|
||
RefreshCw,
|
||
Lock,
|
||
Upload,
|
||
CheckCircle2,
|
||
Eye,
|
||
EyeOff,
|
||
} from "lucide-react";
|
||
import { useUserStore } from "../lib/store/userStore";
|
||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||
import { authService } from "../lib/services/authService";
|
||
import { toast } from "sonner";
|
||
import { toPersian, toEnglishDigits } from "../lib/utils";
|
||
|
||
interface AuthModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
export default function AuthModal({ isOpen, onClose }: AuthModalProps) {
|
||
const { fetchProfile } = useUserStore();
|
||
const [view, setView] = useState<
|
||
| "login"
|
||
| "register"
|
||
| "otp-phone"
|
||
| "otp"
|
||
| "forgot-password"
|
||
| "forgot-otp"
|
||
| "reset-password"
|
||
| "wholesale-request"
|
||
>("login");
|
||
const [medicalLicense, setMedicalLicense] = useState("");
|
||
const [businessName, setBusinessName] = useState("");
|
||
const [documentUploaded, setDocumentUploaded] = useState(false);
|
||
const [phoneNumber, setPhoneNumber] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [newPassword, setNewPassword] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [firstName, setFirstName] = useState("");
|
||
const [lastName, setLastName] = useState("");
|
||
const [otpCode, setOtpCode] = useState("");
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [countdown, setCountdown] = useState(0);
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||
const otpInputRef = useRef<HTMLInputElement>(null);
|
||
const phoneNumberRef = useRef(phoneNumber);
|
||
const isB2BEnabled = useSettingsStore((s) => s.getBoolean('b2bRegistrationOpen', true) && s.getBoolean('b2b_enabled', true));
|
||
|
||
useEffect(() => {
|
||
phoneNumberRef.current = phoneNumber;
|
||
}, [phoneNumber]);
|
||
|
||
// Robust focus retry on view === "otp"
|
||
useEffect(() => {
|
||
if (view === "otp") {
|
||
const timers = [50, 150, 300, 500].map((delay) =>
|
||
setTimeout(() => {
|
||
if (otpInputRef.current) {
|
||
otpInputRef.current.focus({ preventScroll: true });
|
||
}
|
||
}, delay),
|
||
);
|
||
return () => timers.forEach((t) => clearTimeout(t));
|
||
}
|
||
}, [view]);
|
||
|
||
// Reset modal state on open/close
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
Promise.resolve().then(() => {
|
||
setView("login");
|
||
setPhoneNumber("");
|
||
setPassword("");
|
||
setNewPassword("");
|
||
setEmail("");
|
||
setFirstName("");
|
||
setLastName("");
|
||
setOtpCode("");
|
||
setIsLoading(false);
|
||
setCountdown(0);
|
||
setShowPassword(false);
|
||
setShowNewPassword(false);
|
||
});
|
||
}
|
||
}, [isOpen]);
|
||
|
||
// Cooldown countdown timer
|
||
useEffect(() => {
|
||
if (countdown > 0) {
|
||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [countdown]);
|
||
|
||
const triggerVerifyOtp = useCallback(
|
||
async (codeToVerify: string) => {
|
||
const cleanCode = extractOtpFromText(codeToVerify);
|
||
const cleanPhone = toEnglishDigits(phoneNumberRef.current).replace(/[^0-9]/g, "");
|
||
if (cleanCode.length !== 5 || !cleanPhone) return;
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
const response = await authService.verifyOtp(cleanPhone, cleanCode);
|
||
if (response.success) {
|
||
await fetchProfile();
|
||
toast.success("ورود با موفقیت انجام شد");
|
||
onClose();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "کد تایید نامعتبر است");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
},
|
||
[fetchProfile, onClose],
|
||
);
|
||
|
||
// WebOTP API SMS Auto-read
|
||
useEffect(() => {
|
||
if (view !== "otp") return;
|
||
|
||
let isMounted = true;
|
||
const ac = new AbortController();
|
||
|
||
if (typeof window !== "undefined" && "OTPCredential" in window) {
|
||
(navigator as unknown as { credentials: { get: (opts: unknown) => Promise<{ code?: string }> } }).credentials
|
||
?.get({
|
||
otp: { transport: ["sms"] },
|
||
signal: ac.signal,
|
||
})
|
||
.then((otp) => {
|
||
if (!isMounted) return;
|
||
if (otp && typeof otp.code === "string") {
|
||
const clean = extractOtpFromText(otp.code);
|
||
if (clean) {
|
||
setOtpCode(clean);
|
||
if (otpInputRef.current) {
|
||
otpInputRef.current.value = clean;
|
||
}
|
||
if (clean.length === 5) {
|
||
triggerVerifyOtp(clean);
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Direct DOM input / change listener for mobile keyboard autofill compatibility
|
||
const inputEl = otpInputRef.current;
|
||
const handleDomAutofill = (e: Event) => {
|
||
const target = e.target as HTMLInputElement;
|
||
if (target && target.value) {
|
||
const clean = extractOtpFromText(target.value);
|
||
setOtpCode(clean);
|
||
if (clean.length === 5) {
|
||
triggerVerifyOtp(clean);
|
||
}
|
||
}
|
||
};
|
||
|
||
if (inputEl) {
|
||
inputEl.addEventListener("input", handleDomAutofill);
|
||
inputEl.addEventListener("change", handleDomAutofill);
|
||
}
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
ac.abort();
|
||
if (inputEl) {
|
||
inputEl.removeEventListener("input", handleDomAutofill);
|
||
inputEl.removeEventListener("change", handleDomAutofill);
|
||
}
|
||
};
|
||
}, [view, triggerVerifyOtp]);
|
||
|
||
const handleOtpChange = (val: string) => {
|
||
const clean = extractOtpFromText(val);
|
||
setOtpCode(clean);
|
||
if (otpInputRef.current && otpInputRef.current.value !== clean) {
|
||
otpInputRef.current.value = clean;
|
||
}
|
||
if (clean.length === 5) {
|
||
triggerVerifyOtp(clean);
|
||
}
|
||
};
|
||
|
||
const handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const cleanPhone = toEnglishDigits(phoneNumber).trim();
|
||
if (!cleanPhone || !password) return toast.error("شماره موبایل و رمز عبور الزامی است");
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await authService.login(cleanPhone, password.trim());
|
||
if (res.success) {
|
||
await fetchProfile();
|
||
toast.success("با موفقیت وارد شدید");
|
||
onClose();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "خطا در ورود");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRegister = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const cleanPhone = toEnglishDigits(phoneNumber).trim();
|
||
if (!firstName || !lastName || !cleanPhone || !password) {
|
||
return toast.error("لطفاً فیلدهای الزامی را پر کنید");
|
||
}
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await authService.register({
|
||
firstName: firstName.trim(),
|
||
lastName: lastName.trim(),
|
||
mobile: cleanPhone,
|
||
email: email ? email.trim() : undefined,
|
||
password: password.trim(),
|
||
});
|
||
if (res.success) {
|
||
await fetchProfile();
|
||
toast.success("ثبتنام با موفقیت انجام شد");
|
||
onClose();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "خطا در ثبتنام");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSendOtp = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const cleanPhone = toEnglishDigits(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("کد تایید پیامک شد");
|
||
}
|
||
setView("otp");
|
||
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();
|
||
triggerVerifyOtp(otpCode);
|
||
};
|
||
|
||
const handleResendOtp = async () => {
|
||
if (countdown > 0) return;
|
||
setIsLoading(true);
|
||
try {
|
||
await authService.sendOtp(toEnglishDigits(phoneNumber).trim());
|
||
toast.success("کد تایید جدید ارسال شد");
|
||
setCountdown(120);
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "خطا در ارسال مجدد کد");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleForgotSendOtp = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const cleanPhone = toEnglishDigits(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("کد بازیابی رمز پیامک شد");
|
||
}
|
||
setView("forgot-otp");
|
||
setCountdown(120);
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "خطا در ارسال کد");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleForgotVerifyOtp = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
const cleanCode = toEnglishDigits(otpCode).trim();
|
||
if (cleanCode.length !== 5) {
|
||
toast.error("کد باید ۵ رقم باشد");
|
||
return;
|
||
}
|
||
setIsLoading(true);
|
||
try {
|
||
await authService.verifyOtp(toEnglishDigits(phoneNumber).trim(), cleanCode);
|
||
setView("reset-password");
|
||
} catch (err: unknown) {
|
||
const errObj = err as { message?: string };
|
||
toast.error(errObj.message || "کد تایید نامعتبر است");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleResetPassword = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newPassword || newPassword.length < 6) {
|
||
return toast.error("رمز عبور جدید باید حداقل ۶ کاراکتر باشد");
|
||
}
|
||
setIsLoading(true);
|
||
try {
|
||
await authService.updateProfile({ password: newPassword.trim() });
|
||
await fetchProfile();
|
||
toast.success("رمز عبور جدید با موفقیت تنظیم شد");
|
||
onClose();
|
||
} 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-50 flex items-center justify-center p-4">
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-medical-gray-900/60 backdrop-blur-sm"
|
||
/>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||
className="relative w-full max-w-md bg-white rounded-[2.5rem] shadow-2xl overflow-hidden border border-medical-gray-100 z-10"
|
||
>
|
||
{/* Header */}
|
||
<div className="p-6 pb-0 flex justify-between items-center">
|
||
<div className="flex items-center gap-2 text-canina-blue font-black text-sm">
|
||
<ShieldCheck className="w-5 h-5" />
|
||
<span>ورود امن به کنینا</span>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="w-10 h-10 rounded-full bg-medical-gray-50 flex items-center justify-center text-medical-gray-400 hover:bg-medical-gray-100 transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-8">
|
||
<AnimatePresence mode="wait">
|
||
{view === "login" && (
|
||
<motion.form
|
||
key="login-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleLogin}
|
||
className="space-y-4"
|
||
>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between items-center">
|
||
<label className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("otp-phone")}
|
||
className="text-xs font-bold text-canina-blue hover:underline"
|
||
>
|
||
ورود با کد پیامکی (OTP)
|
||
</button>
|
||
</div>
|
||
<div className="relative">
|
||
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||
<input
|
||
autoFocus
|
||
type="tel"
|
||
maxLength={11}
|
||
required
|
||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||
value={phoneNumber}
|
||
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between items-center">
|
||
<label className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
|
||
<button
|
||
data-testid="forgot-password-link"
|
||
type="button"
|
||
onClick={() => setView("forgot-password")}
|
||
className="text-xs font-bold text-medical-gray-400 hover:text-canina-blue"
|
||
>
|
||
فراموشی رمز عبور؟
|
||
</button>
|
||
</div>
|
||
<div className="relative">
|
||
<Lock className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
|
||
<input
|
||
type={showPassword ? "text" : "password"}
|
||
autoComplete="current-password"
|
||
required
|
||
placeholder="رمز عبور..."
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 p-1.5 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
|
||
tabIndex={-1}
|
||
title={showPassword ? "مخفی کردن رمز" : "نمایش رمز"}
|
||
>
|
||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-xs pt-1">
|
||
<div className="flex items-center gap-1 text-medical-gray-400">
|
||
<span>حساب کاربری ندارید؟</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("register")}
|
||
className="font-bold text-canina-blue hover:underline"
|
||
>
|
||
ثبتنام عادی
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isB2BEnabled && (
|
||
<div className="pt-3 border-t border-medical-gray-100">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("wholesale-request")}
|
||
className="w-full py-3 bg-amber-50 text-amber-900 border border-amber-200 rounded-xl text-xs font-black flex items-center justify-center gap-2 hover:bg-amber-100 transition-colors cursor-pointer"
|
||
>
|
||
<Building2 className="w-4 h-4 text-amber-600" />
|
||
درخواست حساب خریدار عمده (پتشاپ / کلینیک)
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !phoneNumber || !password}
|
||
className="w-full py-4 mt-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>ورود به حساب</>}
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "wholesale-request" && (
|
||
<motion.form
|
||
key="wholesale-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
toast.success(
|
||
"درخواست احراز هویت خریدار عمده با موفقیت ارسال شد و پس از بررسی ادمین تایید خواهد شد.",
|
||
);
|
||
setView("login");
|
||
}}
|
||
className="space-y-3"
|
||
>
|
||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-xs text-canina-blue font-bold">
|
||
ثبت شماره نظام دامپزشکی یا پروانه کسب جهت دسترسی به قیمتها و سفارش حجمی الزامی است.
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">نام کلینیک / داروخانه / مجموعه *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={businessName}
|
||
onChange={(e) => setBusinessName(e.target.value)}
|
||
placeholder="مثال: کلینیک دامپزشکی رازی"
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">شماره نظام دامپزشکی / پروانه کسب *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={medicalLicense}
|
||
onChange={(e) => setMedicalLicense(e.target.value)}
|
||
placeholder="مثال: ۹۸۷۶۵۴۳۲۱"
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">موبایل مسئول *</label>
|
||
<input
|
||
type="tel"
|
||
required
|
||
maxLength={11}
|
||
value={phoneNumber}
|
||
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||
placeholder="۰۹۱۲..."
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
onClick={() => {
|
||
setDocumentUploaded(true);
|
||
toast.success("تصویر پروانه با موفقیت بارگذاری شد.");
|
||
}}
|
||
className={`p-4 border-2 border-dashed rounded-xl text-center cursor-pointer transition-all ${
|
||
documentUploaded ? "border-green-500 bg-green-50" : "border-medical-gray-300 hover:border-canina-blue"
|
||
}`}
|
||
>
|
||
{documentUploaded ? (
|
||
<div className="flex items-center justify-center gap-2 text-green-700 font-bold text-xs">
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
تصویر مدرک بارگذاری شد
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center gap-1 text-medical-gray-500 text-xs">
|
||
<Upload className="w-5 h-5 text-canina-blue" />
|
||
<span>بارگذاری تصویر کارت نظام / پروانه</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-start pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("login")}
|
||
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
|
||
>
|
||
بازگشت به ورود
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!businessName || !medicalLicense || !phoneNumber}
|
||
className="w-full py-3 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all cursor-pointer"
|
||
>
|
||
ارسال درخواست بررسی به ادمین
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "register" && (
|
||
<motion.form
|
||
key="register-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleRegister}
|
||
className="space-y-3"
|
||
>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label htmlFor="reg-fname" className="text-xs font-bold text-medical-gray-500">نام</label>
|
||
<input
|
||
id="reg-fname"
|
||
type="text"
|
||
required
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label htmlFor="reg-lname" className="text-xs font-bold text-medical-gray-500">نام خانوادگی</label>
|
||
<input
|
||
id="reg-lname"
|
||
type="text"
|
||
required
|
||
value={lastName}
|
||
onChange={(e) => setLastName(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label htmlFor="reg-phone" className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
|
||
<input
|
||
id="reg-phone"
|
||
type="tel"
|
||
required
|
||
maxLength={11}
|
||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||
value={phoneNumber}
|
||
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label htmlFor="reg-email" className="text-xs font-bold text-medical-gray-500">ایمیل (اختیاری)</label>
|
||
<input
|
||
id="reg-email"
|
||
type="email"
|
||
placeholder="name@example.com"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label htmlFor="reg-pass" className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
|
||
<div className="relative">
|
||
<input
|
||
id="reg-pass"
|
||
type={showPassword ? "text" : "password"}
|
||
autoComplete="new-password"
|
||
required
|
||
placeholder="حداقل ۶ کاراکتر"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-2 pr-3 pl-10 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
className="absolute left-2.5 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
|
||
tabIndex={-1}
|
||
>
|
||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-start pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("login")}
|
||
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
|
||
>
|
||
بازگشت به ورود
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || !phoneNumber || !password || !firstName || !lastName}
|
||
className="w-full py-3 mt-2 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{isLoading ? <RefreshCw className="w-4 h-4 animate-spin" /> : <>ثبتنام</>}
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "otp-phone" && (
|
||
<motion.form
|
||
key="otp-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("login")}
|
||
className="text-[10px] font-black text-canina-blue hover:underline cursor-pointer"
|
||
>
|
||
برگشت به صفحه ورود
|
||
</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 font-mono tracking-widest"
|
||
dir="ltr"
|
||
/>
|
||
</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("otp-phone")}
|
||
className="text-[10px] font-black text-canina-blue flex items-center gap-1 hover:underline cursor-pointer"
|
||
>
|
||
<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 pointer-events-none" />
|
||
<input
|
||
ref={otpInputRef}
|
||
id="otp-code-input"
|
||
name="one-time-code"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
placeholder="کد ۵ رقمی"
|
||
value={otpCode}
|
||
onChange={(e) => handleOtpChange(e.target.value)}
|
||
onInput={(e) => handleOtpChange((e.target as HTMLInputElement).value)}
|
||
onPaste={(e) => {
|
||
e.preventDefault();
|
||
const pasted = e.clipboardData.getData('text');
|
||
handleOtpChange(pasted);
|
||
}}
|
||
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-center text-2xl font-mono tracking-[0.5em]"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
<p className="text-[11px] text-medical-gray-400 text-center font-medium pt-1">
|
||
کد پیامکشده به صورت خودکار شناسایی و وارد میشود.
|
||
</p>
|
||
</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" />
|
||
) : (
|
||
<>
|
||
<ShieldCheck className="w-6 h-6" />
|
||
تایید و ورود
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
<div className="text-center pt-2">
|
||
{countdown > 0 ? (
|
||
<span className="text-xs font-bold text-medical-gray-400">
|
||
ارسال مجدد کد تا {toPersian(countdown.toString())} ثانیه دیگر
|
||
</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={handleResendOtp}
|
||
className="text-xs font-black text-canina-blue hover:underline cursor-pointer"
|
||
>
|
||
ارسال مجدد کد تایید
|
||
</button>
|
||
)}
|
||
</div>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "forgot-password" && (
|
||
<motion.form
|
||
key="forgot-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleForgotSendOtp}
|
||
className="space-y-4"
|
||
>
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">شماره موبایل حساب</label>
|
||
<div className="relative">
|
||
<Phone className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400" />
|
||
<input
|
||
autoFocus
|
||
type="tel"
|
||
maxLength={11}
|
||
required
|
||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||
value={phoneNumber}
|
||
onChange={(e) => setPhoneNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono tracking-wider"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-start pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("login")}
|
||
className="text-xs font-bold text-canina-blue hover:underline cursor-pointer"
|
||
>
|
||
بازگشت به ورود
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || phoneNumber.length < 11}
|
||
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>ارسال کد بازیابی</>}
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "forgot-otp" && (
|
||
<motion.form
|
||
key="forgot-otp-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleForgotVerifyOtp}
|
||
className="space-y-4"
|
||
>
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">کد تایید بازیابی</label>
|
||
<div className="relative">
|
||
<Key className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
placeholder="کد ۵ رقمی"
|
||
value={otpCode}
|
||
onChange={(e) => setOtpCode(toEnglishDigits(e.target.value).replace(/[^0-9]/g, "").slice(0, 5))}
|
||
onInput={(e) => setOtpCode(toEnglishDigits((e.target as HTMLInputElement).value).replace(/[^0-9]/g, "").slice(0, 5))}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-center font-mono font-bold text-xl tracking-[0.4em]"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || otpCode.length < 5}
|
||
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>تایید و ادامه</>}
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
|
||
{view === "reset-password" && (
|
||
<motion.form
|
||
key="reset-pass-view"
|
||
initial={{ opacity: 0, x: 20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
exit={{ opacity: 0, x: -20 }}
|
||
onSubmit={handleResetPassword}
|
||
className="space-y-4"
|
||
>
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">رمز عبور جدید</label>
|
||
<div className="relative">
|
||
<Lock className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-medical-gray-400 pointer-events-none" />
|
||
<input
|
||
autoFocus
|
||
type={showNewPassword ? "text" : "password"}
|
||
autoComplete="new-password"
|
||
required
|
||
placeholder="حداقل ۶ کاراکتر"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 pr-11 pl-11 focus:ring-2 focus:ring-canina-blue/20 outline-none text-sm text-left font-mono"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 p-1 text-medical-gray-400 hover:text-medical-gray-700 transition-colors cursor-pointer"
|
||
tabIndex={-1}
|
||
>
|
||
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={isLoading || newPassword.length < 6}
|
||
className="w-full py-4 bg-canina-blue text-white rounded-xl font-black hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-canina-blue/20 flex items-center justify-center gap-2 cursor-pointer"
|
||
>
|
||
{isLoading ? <RefreshCw className="w-5 h-5 animate-spin" /> : <>ثبت رمز عبور جدید</>}
|
||
</button>
|
||
</motion.form>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|