- 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
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
"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]);
|
||
|
||
// Reset modal state when closed or opened
|
||
useEffect(() => {
|
||
if (!isOpen) {
|
||
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);
|
||
if ((res as any).code) {
|
||
toast.info(`کد تایید (تست): ${(res as any).code}`, { duration: 10000 });
|
||
} else {
|
||
toast.success("کد تایید پیامک شد");
|
||
}
|
||
setStep("otp");
|
||
setCountdown(120); // 2 minutes cooldown
|
||
} 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("ورود با موفقیت انجام شد");
|
||
onLogin();
|
||
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 && (
|
||
<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}
|
||
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 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>
|
||
);
|
||
}
|