profit-planet-frontend/src/app/quickaction-dashboard/register-email-verify/page.tsx

505 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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">
Didnt 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>
)
}