505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import PageLayout from '../../components/PageLayout'
|
||
import BlueBlurryBackground from '../../components/background/blueblurry' // NEW
|
||
import useAuthStore from '../../store/authStore'
|
||
import { useUserStatus } from '../../hooks/useUserStatus'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useToast } from '../../components/toast/toastComponent'
|
||
|
||
export default function EmailVerifyPage() {
|
||
const user = useAuthStore(s => s.user)
|
||
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
||
const token = useAuthStore(s => s.accessToken)
|
||
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED
|
||
const [code, setCode] = useState(['', '', '', '', '', ''])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState(false)
|
||
const [resendCooldown, setResendCooldown] = useState(0)
|
||
const [initialEmailSent, setInitialEmailSent] = useState(false)
|
||
const inputsRef = useRef<Array<HTMLInputElement | null>>([])
|
||
const emailSentRef = useRef(false)
|
||
const router = useRouter()
|
||
const { showToast } = useToast()
|
||
|
||
// NEW: resend and validity windows
|
||
const RESEND_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
|
||
const CODE_VALIDITY_MS = 15 * 60 * 1000 // 15 minutes (informational)
|
||
|
||
// NEW: helpers to persist per-user last sent timestamp
|
||
const getStorageKey = (email?: string | null) => `emailVerify:lastSent:${email || 'anon'}`
|
||
const getLastSentAt = (email?: string | null) => {
|
||
try { return parseInt(localStorage.getItem(getStorageKey(email)) || '0', 10) || 0 } catch { return 0 }
|
||
}
|
||
const setLastSentAt = (ts: number, email?: string | null) => {
|
||
try { localStorage.setItem(getStorageKey(email), String(ts)) } catch {}
|
||
}
|
||
|
||
// Send verification email automatically on page load (respect cooldown)
|
||
useEffect(() => {
|
||
if (!token || emailSentRef.current) return
|
||
|
||
const now = Date.now()
|
||
const last = getLastSentAt(user?.email)
|
||
const remaining = RESEND_INTERVAL_MS - (now - last)
|
||
|
||
if (last && remaining > 0) {
|
||
// Already sent recently: honor cooldown, don't resend
|
||
setInitialEmailSent(true)
|
||
setResendCooldown(Math.ceil(remaining / 1000))
|
||
return
|
||
}
|
||
|
||
const sendInitialEmail = async () => {
|
||
emailSentRef.current = true
|
||
try {
|
||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
const data = await response.json()
|
||
if (response.ok && data.success) {
|
||
setInitialEmailSent(true)
|
||
setLastSentAt(Date.now(), user?.email)
|
||
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
|
||
showToast({
|
||
variant: 'success',
|
||
title: 'Verification email sent',
|
||
message: `We sent a verification email to ${user?.email || 'your email'}.`
|
||
})
|
||
} else {
|
||
const msg = data?.message || 'Error sending the verification email.'
|
||
setError(msg)
|
||
emailSentRef.current = false
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Email not sent',
|
||
message: msg
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('Error sending initial verification email:', err)
|
||
const msg = 'Network error while sending the verification email.'
|
||
setError(msg)
|
||
emailSentRef.current = false
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Network error',
|
||
message: msg
|
||
})
|
||
}
|
||
}
|
||
|
||
sendInitialEmail()
|
||
}, [token, user, showToast])
|
||
|
||
// Cooldown timer
|
||
useEffect(() => {
|
||
if (!resendCooldown) return
|
||
const t = setInterval(() => {
|
||
setResendCooldown(c => (c > 0 ? c - 1 : 0))
|
||
}, 1000)
|
||
return () => clearInterval(t)
|
||
}, [resendCooldown])
|
||
|
||
// Replace handleChange to support multi-digit input distribution
|
||
const handleChange = (idx: number, val: string) => {
|
||
const digits = (val || '').replace(/\D/g, '')
|
||
if (digits.length === 0) {
|
||
const next = [...code]
|
||
next[idx] = ''
|
||
setCode(next)
|
||
setError('')
|
||
return
|
||
}
|
||
|
||
// Distribute digits across fields starting from idx
|
||
const next = [...code]
|
||
let i = idx
|
||
for (const ch of digits) {
|
||
if (i > 5) break
|
||
next[i] = ch
|
||
i++
|
||
}
|
||
setCode(next)
|
||
setError('')
|
||
|
||
// Focus next empty or last filled
|
||
const focusTo = Math.min(i, 5)
|
||
inputsRef.current[focusTo]?.focus()
|
||
}
|
||
|
||
// New: paste handler to fill all fields
|
||
const handlePaste = (idx: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||
e.preventDefault()
|
||
const pasted = e.clipboardData.getData('text') || ''
|
||
const digits = pasted.replace(/\D/g, '').slice(0, 6)
|
||
if (!digits) return
|
||
|
||
const next = [...code]
|
||
let i = idx
|
||
for (const ch of digits) {
|
||
if (i > 5) break
|
||
next[i] = ch
|
||
i++
|
||
}
|
||
setCode(next)
|
||
|
||
// Move focus to last populated field
|
||
const focusTo = Math.min(i - 1, 5)
|
||
if (focusTo >= 0) inputsRef.current[focusTo]?.focus()
|
||
}
|
||
|
||
const handleKeyDown = (idx: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Backspace') {
|
||
e.preventDefault()
|
||
const next = [...code]
|
||
if (next[idx]) {
|
||
// If current has a value, clear it and stay
|
||
next[idx] = ''
|
||
setCode(next)
|
||
setError('')
|
||
inputsRef.current[idx]?.focus()
|
||
} else if (idx > 0) {
|
||
// If empty, move to previous and clear it
|
||
const prev = idx - 1
|
||
next[prev] = ''
|
||
setCode(next)
|
||
setError('')
|
||
inputsRef.current[prev]?.focus()
|
||
}
|
||
return
|
||
}
|
||
|
||
if (e.key === 'ArrowLeft' && idx > 0) {
|
||
e.preventDefault()
|
||
inputsRef.current[idx - 1]?.focus()
|
||
return
|
||
}
|
||
|
||
if (e.key === 'ArrowRight' && idx < 5) {
|
||
e.preventDefault()
|
||
inputsRef.current[idx + 1]?.focus()
|
||
return
|
||
}
|
||
}
|
||
|
||
const fullCode = code.join('')
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (fullCode.length !== 6) {
|
||
const msg = 'Please enter the 6-digit code.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Invalid code',
|
||
message: msg
|
||
})
|
||
return
|
||
}
|
||
if (!token) {
|
||
const msg = 'Not authenticated. Please log in again.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Authentication error',
|
||
message: msg
|
||
})
|
||
return
|
||
}
|
||
|
||
setSubmitting(true)
|
||
setError('')
|
||
try {
|
||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/verify-email-code`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ code: fullCode })
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (response.ok && data.success) {
|
||
setSuccess(true)
|
||
showToast({
|
||
variant: 'success',
|
||
title: 'Email verified',
|
||
message: 'Your email has been verified successfully.'
|
||
})
|
||
await refreshStatus()
|
||
// Guests go directly to dashboard after email verification
|
||
const isGuest = user?.role === 'guest'
|
||
window.location.href = isGuest ? '/dashboard' : '/quickaction-dashboard?tutorial=true'
|
||
} else {
|
||
const msg = data.error || 'Verification failed. Please try again.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Verification failed',
|
||
message: msg
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('Email verification error:', err)
|
||
const msg = 'Network error. Please try again.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Network error',
|
||
message: msg
|
||
})
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
// Resend (respect 10min interval even across navigation)
|
||
const handleResend = useCallback(async () => {
|
||
if (submitting || success) return
|
||
|
||
const now = Date.now()
|
||
const last = getLastSentAt(user?.email)
|
||
const remaining = RESEND_INTERVAL_MS - (now - last)
|
||
|
||
if (remaining > 0) {
|
||
setResendCooldown(Math.ceil(remaining / 1000))
|
||
return
|
||
}
|
||
if (!token) {
|
||
const msg = 'Not authenticated. Please log in again.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Authentication error',
|
||
message: msg
|
||
})
|
||
return
|
||
}
|
||
|
||
setError('')
|
||
|
||
try {
|
||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/send-verification-email`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (response.ok && data.success) {
|
||
setLastSentAt(Date.now(), user?.email)
|
||
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
|
||
if (!initialEmailSent) setInitialEmailSent(true)
|
||
showToast({
|
||
variant: 'success',
|
||
title: 'Verification email sent',
|
||
message: `We sent a new verification email to ${user?.email || 'your email'}.`
|
||
})
|
||
} else {
|
||
const msg = data?.message || 'Error sending the email.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Email not sent',
|
||
message: msg
|
||
})
|
||
}
|
||
} catch (err) {
|
||
console.error('Resend email error:', err)
|
||
const msg = 'Network error while sending the email.'
|
||
setError(msg)
|
||
showToast({
|
||
variant: 'error',
|
||
title: 'Network error',
|
||
message: msg
|
||
})
|
||
}
|
||
}, [token, submitting, success, user, initialEmailSent, showToast])
|
||
|
||
// NEW: format seconds to m:ss
|
||
const formatMmSs = (total: number) => {
|
||
const m = Math.floor(total / 60)
|
||
const s = total % 60
|
||
return `${m}:${String(s).padStart(2, '0')}`
|
||
}
|
||
|
||
// NEW: hard block if step already done OR all steps done
|
||
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
||
const redirectOnceRef = useRef(false)
|
||
const smoothReplace = useCallback((to: string) => {
|
||
if (redirectOnceRef.current) return
|
||
redirectOnceRef.current = true
|
||
setRedirectTo(to)
|
||
window.setTimeout(() => router.replace(to), 200)
|
||
}, [router])
|
||
|
||
useEffect(() => {
|
||
if (statusLoading || !userStatus) return
|
||
const isGuest = user?.role === 'guest'
|
||
const allDone = isGuest
|
||
? !!userStatus.email_verified
|
||
: !!userStatus.email_verified &&
|
||
!!userStatus.documents_uploaded &&
|
||
!!userStatus.profile_completed &&
|
||
!!userStatus.contract_signed
|
||
|
||
if (allDone) {
|
||
smoothReplace('/dashboard')
|
||
} else if (userStatus.email_verified) {
|
||
// Regular users go back to quickaction dashboard for remaining steps
|
||
// Guests should never reach here since allDone covers them
|
||
smoothReplace('/quickaction-dashboard')
|
||
}
|
||
}, [statusLoading, userStatus, user, smoothReplace])
|
||
|
||
// NEW: must be logged in
|
||
useEffect(() => {
|
||
if (!isAuthReady) return
|
||
if (!user || !token) smoothReplace('/login')
|
||
}, [isAuthReady, user, token, smoothReplace])
|
||
|
||
return (
|
||
<PageLayout>
|
||
{/* NEW: smooth redirect overlay */}
|
||
{redirectTo && (
|
||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
|
||
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
|
||
<div className="text-sm font-medium text-gray-900">Redirecting…</div>
|
||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<BlueBlurryBackground>
|
||
<main className="flex flex-col flex-1 w-full px-4 sm:px-6 py-16 sm:py-24">
|
||
<div className="max-w-xl mx-auto">
|
||
<div className="text-center mb-10">
|
||
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
|
||
Verify your email
|
||
</h1>
|
||
<p className="mt-3 text-gray-700 text-sm sm:text-base">
|
||
{initialEmailSent ? (
|
||
<>
|
||
We sent a 6-digit code to{' '}
|
||
<span className="text-[#8D6B1D] font-medium">
|
||
{user?.email || 'your email'}
|
||
</span>
|
||
. Enter it below.
|
||
</>
|
||
) : (
|
||
<>
|
||
Sending verification email to{' '}
|
||
<span className="text-[#8D6B1D] font-medium">
|
||
{user?.email || 'your email'}
|
||
</span>
|
||
...
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="bg-white/95 backdrop-blur rounded-2xl shadow-xl ring-1 ring-black/5 px-6 py-8 sm:px-10 sm:py-10"
|
||
>
|
||
<fieldset disabled={submitting || success} className="space-y-8">
|
||
<div className="flex justify-center gap-2 sm:gap-3">
|
||
{code.map((v, i) => (
|
||
<input
|
||
key={i}
|
||
ref={el => { inputsRef.current[i] = el }}
|
||
inputMode="numeric"
|
||
aria-label={`Code digit ${i + 1}`}
|
||
autoComplete="one-time-code"
|
||
maxLength={1}
|
||
value={v}
|
||
onChange={e => handleChange(i, e.target.value)}
|
||
onKeyDown={e => handleKeyDown(i, e)}
|
||
onPaste={e => handlePaste(i, e)}
|
||
className={`w-12 h-14 sm:w-14 sm:h-16 text-center text-2xl font-semibold rounded-lg border transition-colors outline-none
|
||
${v
|
||
? 'border-[#8D6B1D] ring-2 ring-[#8D6B1D]/25 bg-white text-gray-900'
|
||
: 'border-gray-300 bg-white/80 text-gray-700'}
|
||
focus:ring-2 focus:ring-[#8D6B1D] focus:border-[#8D6B1D]`}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
||
Verified! Redirecting shortly...
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<button
|
||
type="submit"
|
||
className="w-full sm:w-auto inline-flex justify-center items-center rounded-lg px-6 py-3 font-semibold text-white
|
||
bg-[#8D6B1D] hover:bg-[#7A5E1A]
|
||
focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2
|
||
disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{submitting ? (
|
||
<>
|
||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
||
Verifying...
|
||
</>
|
||
) : success ? 'Verified' : 'Confirm code'}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={handleResend}
|
||
disabled={!!resendCooldown || submitting || success}
|
||
className="text-sm font-medium text-[#8D6B1D] hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
|
||
>
|
||
{resendCooldown
|
||
? `Resend in ${formatMmSs(resendCooldown)}`
|
||
: 'Resend code'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-1 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => router.push('/quickaction-dashboard')}
|
||
className="text-sm font-medium text-[#8D6B1D] hover:underline"
|
||
>
|
||
Go to Dashboard
|
||
</button>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<div className="mt-8 text-center text-xs text-gray-500">
|
||
Didn’t receive the email? Please check your junk/spam folder. Still having issues?{' '}
|
||
<a href="mailto:test@test.com" className="text-[#8D6B1D] hover:underline">
|
||
Contact support
|
||
</a>
|
||
.
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</main>
|
||
</BlueBlurryBackground>
|
||
</PageLayout>
|
||
)
|
||
}
|