- Add responsive CSS breakpoints (480px/768px/1024px) - Auto-adjust viewDays based on screen width - Keep time column visible on mobile (compact) - Add boot-update.sh for auto-update on reboot - Fix Suspense boundary in verify-email page - Bump version to 1.7.0
280 lines
8.5 KiB
TypeScript
280 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef, useEffect, Suspense } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
|
|
function VerifyEmailContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const email = searchParams.get('email') || '';
|
|
const error = searchParams.get('error');
|
|
const [code, setCode] = useState(['', '', '', '', '', '']);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [isResending, setIsResending] = useState(false);
|
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (error === 'invalid_token') {
|
|
setErrorMsg('Verification link is invalid or expired. Please enter your code manually or request a new one.');
|
|
} else if (error === 'missing_token') {
|
|
setErrorMsg('Invalid verification link.');
|
|
} else if (error === 'server_error') {
|
|
setErrorMsg('Something went wrong. Please try again.');
|
|
}
|
|
}, [error]);
|
|
|
|
// Auto-focus first input
|
|
useEffect(() => {
|
|
inputRefs.current[0]?.focus();
|
|
}, []);
|
|
|
|
const handleInput = (index: number, value: string) => {
|
|
// Handle paste of full code
|
|
if (value.length > 1) {
|
|
const digits = value.replace(/\D/g, '').slice(0, 6).split('');
|
|
const newCode = [...code];
|
|
digits.forEach((digit, i) => {
|
|
if (index + i < 6) newCode[index + i] = digit;
|
|
});
|
|
setCode(newCode);
|
|
const nextIndex = Math.min(index + digits.length, 5);
|
|
inputRefs.current[nextIndex]?.focus();
|
|
// Auto-submit if all 6 digits entered
|
|
if (newCode.every(d => d !== '')) {
|
|
submitCode(newCode.join(''));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const digit = value.replace(/\D/g, '');
|
|
const newCode = [...code];
|
|
newCode[index] = digit;
|
|
setCode(newCode);
|
|
|
|
if (digit && index < 5) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
|
|
// Auto-submit if all 6 digits entered
|
|
if (newCode.every(d => d !== '')) {
|
|
submitCode(newCode.join(''));
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const submitCode = async (fullCode: string) => {
|
|
setIsLoading(true);
|
|
setErrorMsg(null);
|
|
setMessage(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/verify-email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, code: fullCode }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
setErrorMsg(data.error || 'Verification failed');
|
|
setCode(['', '', '', '', '', '']);
|
|
inputRefs.current[0]?.focus();
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setMessage('Email verified! Redirecting to login...');
|
|
setTimeout(() => {
|
|
router.push('/auth/login?verified=true');
|
|
}, 1500);
|
|
} catch {
|
|
setErrorMsg('Something went wrong. Please try again.');
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResend = async () => {
|
|
setIsResending(true);
|
|
setErrorMsg(null);
|
|
setMessage(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/auth/resend-verification', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
setErrorMsg(data.error || 'Failed to resend code');
|
|
} else {
|
|
setMessage('A new verification code has been sent to your email.');
|
|
}
|
|
} catch {
|
|
setErrorMsg('Failed to resend code. Please try again.');
|
|
} finally {
|
|
setIsResending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="weekly-auth-container">
|
|
<div className="weekly-auth-card">
|
|
{/* Logo */}
|
|
<div className="weekly-auth-logo">
|
|
My Weekly ToDo's
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h2 style={{ color: '#333', fontSize: '1.25rem', fontWeight: 600, textAlign: 'center', margin: '0 0 8px' }}>
|
|
Verify your email
|
|
</h2>
|
|
<p style={{ color: '#666', fontSize: '0.875rem', textAlign: 'center', margin: '0 0 24px' }}>
|
|
We sent a 6-digit code to <strong style={{ color: '#667eea' }}>{email}</strong>
|
|
</p>
|
|
|
|
{/* Messages */}
|
|
{errorMsg && (
|
|
<div className="weekly-auth-error">
|
|
{errorMsg}
|
|
</div>
|
|
)}
|
|
{message && (
|
|
<div style={{
|
|
background: 'rgba(52, 211, 153, 0.1)',
|
|
border: '1px solid rgba(52, 211, 153, 0.3)',
|
|
borderRadius: '8px',
|
|
padding: '12px 16px',
|
|
color: '#059669',
|
|
fontSize: '0.875rem',
|
|
marginBottom: '16px',
|
|
textAlign: 'center',
|
|
}}>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Code Input */}
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: '8px',
|
|
justifyContent: 'center',
|
|
marginBottom: '24px',
|
|
}}>
|
|
{code.map((digit, index) => (
|
|
<input
|
|
key={index}
|
|
ref={el => { inputRefs.current[index] = el; }}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={digit}
|
|
onChange={e => handleInput(index, e.target.value)}
|
|
onKeyDown={e => handleKeyDown(index, e)}
|
|
onPaste={e => {
|
|
e.preventDefault();
|
|
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
|
if (pasted) handleInput(index, pasted);
|
|
}}
|
|
disabled={isLoading}
|
|
style={{
|
|
width: '48px',
|
|
height: '56px',
|
|
textAlign: 'center',
|
|
fontSize: '1.5rem',
|
|
fontWeight: 700,
|
|
fontFamily: "'Courier New', monospace",
|
|
background: '#f8f9fa',
|
|
border: digit ? '2px solid #667eea' : '2px solid #d0d5dd',
|
|
borderRadius: '10px',
|
|
color: '#667eea',
|
|
outline: 'none',
|
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
|
caretColor: '#667eea',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
onFocus={e => {
|
|
e.target.style.borderColor = '#667eea';
|
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.15)';
|
|
}}
|
|
onBlur={e => {
|
|
e.target.style.borderColor = digit ? '#667eea' : '#d0d5dd';
|
|
e.target.style.boxShadow = 'none';
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Verify Button */}
|
|
<button
|
|
onClick={() => submitCode(code.join(''))}
|
|
disabled={isLoading || code.some(d => d === '')}
|
|
className="weekly-auth-button primary"
|
|
style={{ marginBottom: '16px' }}
|
|
>
|
|
{isLoading ? 'Verifying...' : 'Verify Email'}
|
|
</button>
|
|
|
|
{/* Resend */}
|
|
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
|
<span style={{ color: '#666', fontSize: '0.875rem' }}>
|
|
Didn't receive the code?{' '}
|
|
</span>
|
|
<button
|
|
onClick={handleResend}
|
|
disabled={isResending}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: '#667eea',
|
|
cursor: 'pointer',
|
|
fontSize: '0.875rem',
|
|
fontWeight: 500,
|
|
textDecoration: 'underline',
|
|
opacity: isResending ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{isResending ? 'Sending...' : 'Resend code'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Back to login */}
|
|
<div className="weekly-auth-links">
|
|
<Link href="/auth/login" className="weekly-auth-link">
|
|
← Back to login
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="weekly-auth-footer">
|
|
<p>Simple. Beautiful. Yours.</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function VerifyEmailPage() {
|
|
return (
|
|
<Suspense fallback={
|
|
<div className="weekly-auth-container">
|
|
<div className="weekly-auth-card">
|
|
<div className="weekly-auth-logo">My Weekly ToDo's</div>
|
|
<p style={{ textAlign: 'center', color: '#666' }}>Loading...</p>
|
|
</div>
|
|
</div>
|
|
}>
|
|
<VerifyEmailContent />
|
|
</Suspense>
|
|
);
|
|
} |