profit-planet-frontend/src/app/quickaction-dashboard/register-email-verify/page.tsx
DeathKaioken 7559466c27 i18
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 21:00:08 +02:00

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>
)
}