507 lines
18 KiB
TypeScript
507 lines
18 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'
|
|
import { useTranslation } from '../../i18n/useTranslation'
|
|
|
|
export default function EmailVerifyPage() {
|
|
const { t } = useTranslation()
|
|
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: t('quickactionDashboard.emailVerified'),
|
|
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
|
|
})
|
|
} else {
|
|
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
|
|
setError(msg)
|
|
emailSentRef.current = false
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
|
message: msg
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Error sending initial verification email:', err)
|
|
const msg = t('quickactionDashboard.emailVerify.networkErrorTitle')
|
|
setError(msg)
|
|
emailSentRef.current = false
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
|
message: msg
|
|
})
|
|
}
|
|
}
|
|
|
|
sendInitialEmail()
|
|
}, [token, user, showToast, t])
|
|
|
|
// 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 = t('quickactionDashboard.emailVerify.invalidCode')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.invalidCode'),
|
|
message: msg
|
|
})
|
|
return
|
|
}
|
|
if (!token) {
|
|
const msg = t('quickactionDashboard.emailVerify.authError')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
|
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: t('quickactionDashboard.emailVerify.emailVerifiedTitle'),
|
|
message: t('quickactionDashboard.emailVerify.emailVerifiedMessage')
|
|
})
|
|
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 || t('quickactionDashboard.emailVerify.verificationFailedTitle')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
|
message: msg
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Email verification error:', err)
|
|
const msg = t('quickactionDashboard.uploadId.networkErrorMessage')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
|
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 = t('quickactionDashboard.emailVerify.authError')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
|
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: t('quickactionDashboard.emailVerified'),
|
|
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
|
|
})
|
|
} else {
|
|
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
|
message: msg
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Resend email error:', err)
|
|
const msg = t('quickactionDashboard.emailVerify.networkErrorTitle')
|
|
setError(msg)
|
|
showToast({
|
|
variant: 'error',
|
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
|
message: msg
|
|
})
|
|
}
|
|
}, [token, submitting, success, user, initialEmailSent, showToast, t])
|
|
|
|
// 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">{t('quickactionDashboard.redirecting')}</div>
|
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</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">
|
|
{t('quickactionDashboard.emailVerify.title')}
|
|
</h1>
|
|
<p className="mt-3 text-gray-700 text-sm sm:text-base">
|
|
{initialEmailSent ? (
|
|
<>
|
|
{t('quickactionDashboard.emailVerify.sentIntro')}{' '}
|
|
<span className="text-[#8D6B1D] font-medium">
|
|
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
|
|
</span>
|
|
. {t('quickactionDashboard.emailVerify.enterBelow')}
|
|
</>
|
|
) : (
|
|
<>
|
|
{t('quickactionDashboard.emailVerify.sendingIntro')}{' '}
|
|
<span className="text-[#8D6B1D] font-medium">
|
|
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
|
|
</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">
|
|
{t('quickactionDashboard.emailVerify.verifiedRedirecting')}
|
|
</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" />
|
|
{t('quickactionDashboard.emailVerify.verifying')}
|
|
</>
|
|
) : success ? t('quickactionDashboard.emailVerify.verified') : t('quickactionDashboard.emailVerify.confirmCode')}
|
|
</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
|
|
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendCooldown)}`
|
|
: t('quickactionDashboard.emailVerify.resendCode')}
|
|
</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"
|
|
>
|
|
{t('quickactionDashboard.goToDashboard')}
|
|
</button>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className="mt-8 text-center text-xs text-gray-500">
|
|
{t('quickactionDashboard.emailVerify.supportHint')}{' '}
|
|
<a href="mailto:test@test.com" className="text-[#8D6B1D] hover:underline">
|
|
{t('quickactionDashboard.emailVerify.contactSupport')}
|
|
</a>
|
|
.
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
</BlueBlurryBackground>
|
|
</PageLayout>
|
|
)
|
|
}
|