Files
rentall-app/frontend/src/components/VerificationCodeModal.tsx
2025-12-25 23:09:10 -05:00

248 lines
7.2 KiB
TypeScript

import React, { useState, useRef, useEffect } from "react";
import { authAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
interface VerificationCodeModalProps {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}
const VerificationCodeModal: React.FC<VerificationCodeModalProps> = ({
show,
onHide,
email,
onVerified,
}) => {
const [code, setCode] = useState<string[]>(["", "", "", "", "", ""]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [resending, setResending] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [resendSuccess, setResendSuccess] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const { checkAuth } = useAuth();
// Handle resend cooldown timer
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(
() => setResendCooldown(resendCooldown - 1),
1000
);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
// Focus first input on mount
useEffect(() => {
if (show && inputRefs.current[0]) {
inputRefs.current[0].focus();
}
}, [show]);
const handleInputChange = (index: number, value: string) => {
// Only allow digits
if (value && !/^\d$/.test(value)) return;
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
setError("");
setResendSuccess(false);
// Auto-advance to next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
// Auto-submit when all 6 digits entered
if (value && index === 5 && newCode.every((d) => d !== "")) {
handleVerify(newCode.join(""));
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (pastedData.length === 6) {
const newCode = pastedData.split("");
setCode(newCode);
handleVerify(pastedData);
}
};
const handleVerify = async (verificationCode: string) => {
setLoading(true);
setError("");
setResendSuccess(false);
try {
await authAPI.verifyEmail(verificationCode);
await checkAuth(); // Refresh user data
onVerified();
onHide();
} catch (err: any) {
const errorData = err.response?.data;
if (errorData?.code === "TOO_MANY_ATTEMPTS") {
setError("Too many attempts. Please request a new code below.");
} else if (errorData?.code === "VERIFICATION_EXPIRED") {
setError("Code expired. Please request a new code below.");
} else if (errorData?.code === "VERIFICATION_INVALID") {
setError(
"That code didn't match. Please try again or request a new code below."
);
} else {
setError(errorData?.error || "Verification failed. Please try again.");
}
// Clear code on error
setCode(["", "", "", "", "", ""]);
inputRefs.current[0]?.focus();
} finally {
setLoading(false);
}
};
const handleResend = async () => {
setResending(true);
setError("");
setResendSuccess(false);
try {
await authAPI.resendVerification();
setResendCooldown(60); // 60 second cooldown
setResendSuccess(true);
setCode(["", "", "", "", "", ""]);
inputRefs.current[0]?.focus();
} catch (err: any) {
if (err.response?.status === 429) {
setError("Please wait before requesting another code.");
} else {
setError("Failed to resend code. Please try again.");
}
} finally {
setResending(false);
}
};
if (!show) return null;
return (
<div
className="modal show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header border-0 pb-0">
<button
type="button"
className="btn-close"
onClick={onHide}
aria-label="Close"
></button>
</div>
<div className="modal-body px-4 pb-4 text-center">
<i
className="bi bi-envelope-check text-success"
style={{ fontSize: "3rem" }}
></i>
<h4 className="mt-3">Verify Your Email</h4>
<p className="text-muted">
We sent a 6-digit code to <strong>{email}</strong>
</p>
{error && (
<div className="alert alert-danger py-2" role="alert">
{error}
</div>
)}
{resendSuccess && (
<div className="alert alert-success py-2" role="alert">
New code sent! Check your email.
</div>
)}
{/* 6-digit code input */}
<div
className="d-flex justify-content-center gap-2 my-4"
onPaste={handlePaste}
>
{code.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleInputChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="form-control text-center"
style={{
width: "50px",
height: "60px",
fontSize: "24px",
fontWeight: "bold",
}}
disabled={loading}
/>
))}
</div>
<button
className="btn btn-success w-100 py-3"
onClick={() => handleVerify(code.join(""))}
disabled={loading || code.some((d) => d === "")}
>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Verifying...
</>
) : (
"Verify Email"
)}
</button>
<div className="mt-4">
<p className="text-muted small mb-2">Didn't receive the code?</p>
<button
className="btn btn-link text-decoration-none p-0"
onClick={handleResend}
disabled={resending || resendCooldown > 0}
>
{resendCooldown > 0
? `Send in ${resendCooldown}s`
: resending
? "Sending..."
: "Send Code"}
</button>
</div>
<p className="text-muted small mt-3">
Check your spam folder if you don't see the email.
</p>
</div>
</div>
</div>
</div>
);
};
export default VerificationCodeModal;