canina/frontend/application/components/AuthModal.tsx
parsa aghaei 7615aa0e51 fix: resolve seed encoding, search param, and wiki data source issues
- 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
2026-07-11 15:40:49 +03:30

488 lines
21 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<"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>
);
}