- Fix seed-products.ts TS error (implicit any) and BOM handling - Re-run full seed to restore correct Persian encoding in DB - Fix productService query→search param mismatch - Fix IngredientWiki hardcoded port 4000→use settingsStore - Restore seed-products-data.json from git after accidental corruption
488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
"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<"login" | "register" | "otp-phone" | "otp">("login");
|
||
const [phoneNumber, setPhoneNumber] = useState("");
|
||
const [password, setPassword] = 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 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("login");
|
||
setPhoneNumber("");
|
||
setPassword("");
|
||
setEmail("");
|
||
setFirstName("");
|
||
setLastName("");
|
||
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 handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!phoneNumber || !password) return toast.error("شماره موبایل و رمز عبور الزامی است");
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await authService.login(phoneNumber, password);
|
||
if (res.success) {
|
||
await fetchProfile();
|
||
toast.success("با موفقیت وارد شدید");
|
||
onClose();
|
||
}
|
||
} catch (err: any) {
|
||
toast.error(err.message || "خطا در ورود");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleRegister = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!firstName || !lastName || !phoneNumber || !password) {
|
||
return toast.error("لطفاً فیلدهای الزامی را پر کنید");
|
||
}
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await authService.register({ firstName, lastName, mobile: phoneNumber, email, password });
|
||
if (res.success) {
|
||
await fetchProfile();
|
||
toast.success("ثبتنام با موفقیت انجام شد");
|
||
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 === "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-2">
|
||
<label className="text-xs font-bold text-medical-gray-500">شماره موبایل</label>
|
||
<input
|
||
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-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none font-bold text-left tracking-widest"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-medical-gray-500">رمز عبور</label>
|
||
<input
|
||
type="password"
|
||
placeholder="رمز عبور خود را وارد کنید"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
disabled={isLoading}
|
||
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-3 px-4 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("otp-phone")}
|
||
className="text-xs font-bold text-canina-blue hover:underline"
|
||
>
|
||
ورود با کد یکبار مصرف (OTP)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("register")}
|
||
className="text-xs font-bold text-canina-blue hover:underline"
|
||
>
|
||
حساب ندارید؟ ثبتنام
|
||
</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 === "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 className="text-xs font-bold text-medical-gray-500">نام</label>
|
||
<input
|
||
type="text"
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
disabled={isLoading}
|
||
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"
|
||
value={lastName}
|
||
onChange={(e) => setLastName(e.target.value)}
|
||
disabled={isLoading}
|
||
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 className="text-xs font-bold text-medical-gray-500">شماره موبایل *</label>
|
||
<input
|
||
type="tel"
|
||
maxLength={11}
|
||
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-xl py-2 px-3 focus:ring-2 focus:ring-canina-blue/20 outline-none text-left tracking-widest text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">ایمیل (اختیاری)</label>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
disabled={isLoading}
|
||
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-left text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-medical-gray-500">رمز عبور *</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
disabled={isLoading}
|
||
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-left text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-start pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setView("login")}
|
||
className="text-xs font-bold text-canina-blue hover:underline"
|
||
>
|
||
بازگشت به ورود
|
||
</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"
|
||
>
|
||
برگشت به صفحه ورود
|
||
</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("otp-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>
|
||
);
|
||
}
|