320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
// LoginModal.tsx
|
|
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { X } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import { Input } from './ui/input';
|
|
import { Label } from './ui/label';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useLoginMutation, useVerifyOtpMutation } from '../Redux/services/auth.service';
|
|
import { toast } from 'sonner';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
interface LoginModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function LoginModal({ isOpen, onClose }: LoginModalProps) {
|
|
const [step, setStep] = useState<'email' | 'otp'>('email');
|
|
const [email, setEmail] = useState('');
|
|
const [otp, setOtp] = useState(['', '', '', '', '', '']);
|
|
const [countdown, setCountdown] = useState(0);
|
|
const [helperText, setHelperText] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
const { login } = useAuth();
|
|
const navigate = useNavigate()
|
|
|
|
const [sendOtp, { isLoading: isSendingOtp }] = useLoginMutation();
|
|
const [verifyOtp, { isLoading: isVerifying }] = useVerifyOtpMutation();
|
|
|
|
// Reset modal when closed
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
resetModal();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const resetModal = () => {
|
|
setStep('email');
|
|
setEmail('');
|
|
setOtp(['', '', '', '', '', '']);
|
|
setCountdown(0);
|
|
setHelperText('');
|
|
setError('');
|
|
};
|
|
|
|
// Countdown timer
|
|
useEffect(() => {
|
|
if (countdown > 0) {
|
|
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [countdown]);
|
|
|
|
// ==================== PASTE OTP FEATURE ====================
|
|
const handlePaste = (e: React.ClipboardEvent) => {
|
|
e.preventDefault();
|
|
const pastedData = e.clipboardData.getData('text').trim();
|
|
|
|
// Extract only digits
|
|
const digits = pastedData.replace(/\D/g, '').slice(0, 6);
|
|
|
|
if (digits.length === 0) return;
|
|
|
|
const newOtp = [...otp];
|
|
|
|
// Fill the OTP array with pasted digits
|
|
for (let i = 0; i < digits.length; i++) {
|
|
newOtp[i] = digits[i];
|
|
}
|
|
|
|
setOtp(newOtp);
|
|
|
|
// Auto-focus the next empty field or the last one
|
|
const nextIndex = digits.length < 6 ? digits.length : 5;
|
|
const nextInput = document.querySelector(
|
|
`input[data-otp-index="${nextIndex}"]`
|
|
) as HTMLInputElement;
|
|
|
|
nextInput?.focus();
|
|
};
|
|
|
|
const handleOTPChange = (index: number, value: string) => {
|
|
if (value.length > 1) return;
|
|
|
|
const newOtp = [...otp];
|
|
newOtp[index] = value;
|
|
setOtp(newOtp);
|
|
|
|
if (value && index < 5) {
|
|
const nextInput = document.querySelector(
|
|
`input[data-otp-index="${index + 1}"]`
|
|
) as HTMLInputElement;
|
|
nextInput?.focus();
|
|
}
|
|
};
|
|
|
|
const handleOTPKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
if (e.key === "Backspace" && !otp[index] && index > 0) {
|
|
const prevInput = document.querySelector(
|
|
`input[data-otp-index="${index - 1}"]`
|
|
) as HTMLInputElement;
|
|
prevInput?.focus();
|
|
}
|
|
|
|
// ✅ Trigger verify on Enter if all 6 digits are filled
|
|
if (e.key === "Enter") {
|
|
const otpString = otp.join("");
|
|
if (otpString.length === 6) {
|
|
handleVerifyLogin();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Rest of your functions remain the same
|
|
const handleSendOTP = async () => {
|
|
if (!email || !email.includes('@')) {
|
|
setError('Please enter a valid email address');
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
setHelperText('');
|
|
|
|
try {
|
|
await sendOtp({ emailAddress: email }).unwrap();
|
|
setStep('otp');
|
|
setCountdown(120);
|
|
setHelperText('OTP sent successfully to your email');
|
|
} catch (err: any) {
|
|
setError(err?.data?.message || 'Failed to send OTP. Please try again.');
|
|
}
|
|
};
|
|
|
|
const handleVerifyLogin = async () => {
|
|
const otpString = otp.join('');
|
|
if (otpString.length !== 6) {
|
|
setError('Please enter complete 6-digit OTP');
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
|
|
try {
|
|
const response = await verifyOtp({
|
|
emailAddress: email,
|
|
otp: otpString
|
|
}).unwrap();
|
|
|
|
|
|
if (!response?.userExists) {
|
|
localStorage.setItem("userEmail",email)
|
|
navigate("/register")
|
|
} else {
|
|
const userData = {
|
|
userId: response?.user?.id,
|
|
email: response?.email || email,
|
|
name: response?.name || email.split('@')[0].charAt(0).toUpperCase() + email.split('@')[0].slice(1),
|
|
accessToken: response?.accessToken,
|
|
};
|
|
|
|
login(userData);
|
|
toast.success("User Logged in successfully")
|
|
navigate("/passes")
|
|
}
|
|
onClose();
|
|
} catch (err: any) {
|
|
setError(err?.data?.message || 'Invalid OTP. Please try again.');
|
|
toast.error(err?.data?.message)
|
|
}
|
|
};
|
|
|
|
const formatCountdown = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
<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="fixed inset-0 flex items-center justify-center z-50 p-4"
|
|
>
|
|
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md mx-auto overflow-hidden">
|
|
<div className="relative px-8 pt-8 pb-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-6 right-6 w-8 h-8 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4 text-gray-600" />
|
|
</button>
|
|
|
|
<h2 className="font-merchant text-2xl font-semibold text-gray-900 mb-2">
|
|
Login
|
|
</h2>
|
|
<p className="font-poppins text-sm text-gray-600">
|
|
Enter your email and verify with OTP
|
|
</p>
|
|
</div>
|
|
|
|
<div className="px-8 pb-8">
|
|
{step === 'email' ? (
|
|
// ... Email step (unchanged)
|
|
<div className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label className="font-poppins text-sm font-medium text-gray-700">
|
|
Email Address
|
|
</Label>
|
|
<Input
|
|
type="email"
|
|
placeholder="name@example.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSendOTP()}
|
|
className="font-poppins text-base h-12 bg-gray-50 border-0 rounded-xl placeholder:text-gray-400"
|
|
/>
|
|
{error && <p className="text-red-500 text-xs">{error}</p>}
|
|
{helperText && <p className="text-green-600 text-xs">{helperText}</p>}
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleSendOTP}
|
|
disabled={isSendingOtp}
|
|
className="w-full h-12 bg-gray-800 hover:bg-gray-900 text-white font-poppins font-semibold rounded-xl cursor-pointer"
|
|
>
|
|
{isSendingOtp ? 'Sending OTP...' : 'Send OTP'}
|
|
</Button>
|
|
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* Email Display */}
|
|
<div className="space-y-2">
|
|
<Label className="font-poppins text-sm font-medium text-gray-700">Email</Label>
|
|
<div className="h-12 bg-gray-50 rounded-xl flex items-center px-4 font-poppins text-base text-gray-600">
|
|
{email}
|
|
</div>
|
|
</div>
|
|
|
|
{/* OTP Inputs with Paste Support */}
|
|
<div className="space-y-3">
|
|
<Label className="font-poppins text-sm font-medium text-gray-700">
|
|
Enter OTP
|
|
</Label>
|
|
<div className="flex gap-3 justify-between">
|
|
{otp.map((digit, index) => (
|
|
<input
|
|
key={index}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={digit}
|
|
onChange={(e) => handleOTPChange(index, e.target.value.replace(/\D/g, ''))}
|
|
onKeyDown={(e) => handleOTPKeyDown(index, e)}
|
|
onPaste={handlePaste} // ← Paste support added here
|
|
data-otp-index={index}
|
|
className="w-12 h-12 text-center font-poppins font-semibold text-lg bg-gray-300 border-0 rounded-xl focus:bg-white focus:ring-2 focus:ring-gray-800 focus:outline-none transition-all"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{countdown > 0 && (
|
|
<p className="text-center text-xs text-gray-500">
|
|
Resend OTP in {formatCountdown(countdown)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-red-500 text-xs text-center">{error}</p>}
|
|
|
|
<Button
|
|
onClick={handleVerifyLogin}
|
|
disabled={isVerifying || otp.join('').length !== 6}
|
|
className="w-full h-12 bg-gray-800 hover:bg-gray-900 text-white font-poppins font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{isVerifying ? 'Verifying...' : 'Verify & Login'}
|
|
</Button>
|
|
|
|
{countdown === 0 && (
|
|
<button
|
|
onClick={() => {
|
|
setStep('email');
|
|
setOtp(['', '', '', '', '', '']);
|
|
setHelperText("")
|
|
setError('');
|
|
}}
|
|
className="w-full text-sm text-gray-600 hover:text-gray-800 font-poppins cursor-pointer"
|
|
>
|
|
Didn't receive OTP?
|
|
<span className="text-primary font-semibold"> Send again</span>
|
|
{/* Send again */}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)
|
|
}
|
|
</AnimatePresence >
|
|
</>
|
|
);
|
|
} |