Frontend UI Components
Phase 5: Frontend UI Components
Overview
Build the user-facing components for email verification: the verification page that handles the token, resend functionality, and verification status indicators.
Requirements
Functional:
- β’Verification page handles token from URL
- β’Shows clear success/error states
- β’Resend button with cooldown
- β’Verification banner for unverified users
Non-functional:
- β’Loading states for all async actions
- β’Accessible error messages
- β’Mobile-responsive design
Implementation
Verification Page
// src/app/verify-email/page.tsx
"use client"
import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import Link from 'next/link'
type Status = 'loading' | 'success' | 'error' | 'expired' | 'no-token'
export default function VerifyEmailPage() {
const searchParams = useSearchParams()
const router = useRouter()
const [status, setStatus] = useState<Status>('loading')
const [message, setMessage] = useState('')
useEffect(() => {
const token = searchParams.get('token')
if (!token) {
setStatus('no-token')
setMessage('No verification token provided')
return
}
verifyToken(token)
}, [searchParams])
async function verifyToken(token: string) {
try {
const res = await fetch('/api/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
const data = await res.json()
if (res.ok) {
setStatus('success')
setMessage('Your email has been verified!')
// Redirect to dashboard after success
setTimeout(() => router.push('/dashboard'), 3000)
} else {
if (data.error?.includes('expired')) {
setStatus('expired')
setMessage('This verification link has expired')
} else {
setStatus('error')
setMessage(data.error || 'Verification failed')
}
}
} catch {
setStatus('error')
setMessage('Something went wrong. Please try again.')
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
{status === 'loading' && <LoadingState />}
{status === 'success' && <SuccessState message={message} />}
{status === 'error' && <ErrorState message={message} />}
{status === 'expired' && <ExpiredState />}
{status === 'no-token' && <NoTokenState />}
</div>
</div>
)
}
function LoadingState() {
return (
<>
<div className="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto" />
<p className="text-gray-600">Verifying your email...</p>
</>
)
}
function SuccessState({ message }: { message: string }) {
return (
<>
<div className="h-16 w-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-gray-900">{message}</h1>
<p className="text-gray-600">Redirecting you to the dashboard...</p>
</>
)
}
function ErrorState({ message }: { message: string }) {
return (
<>
<div className="h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="h-8 w-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-gray-900">Verification Failed</h1>
<p className="text-gray-600">{message}</p>
<Link href="/login" className="text-blue-600 hover:underline">
Return to login
</Link>
</>
)
}
function ExpiredState() {
return (
<>
<div className="h-16 w-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto">
<svg className="h-8 w-8 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-gray-900">Link Expired</h1>
<p className="text-gray-600">This verification link has expired. Please request a new one.</p>
<ResendButton />
</>
)
}
function NoTokenState() {
return (
<>
<h1 className="text-2xl font-semibold text-gray-900">Missing Token</h1>
<p className="text-gray-600">
No verification token found. Please check your email for the verification link.
</p>
<Link href="/login" className="text-blue-600 hover:underline">
Return to login
</Link>
</>
)
}
Resend Button Component
// src/components/auth/ResendButton.tsx
"use client"
import { useState, useEffect } from 'react'
const COOLDOWN_SECONDS = 60
export function ResendButton() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [cooldown, setCooldown] = useState(0)
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown(c => c - 1), 1000)
return () => clearTimeout(timer)
}
}, [cooldown])
async function handleResend() {
if (cooldown > 0) return
setStatus('loading')
setErrorMessage('')
try {
const res = await fetch('/api/auth/send-verification', {
method: 'POST',
})
const data = await res.json()
if (res.ok) {
setStatus('success')
setCooldown(COOLDOWN_SECONDS)
} else {
setStatus('error')
setErrorMessage(data.error || 'Failed to send email')
}
} catch {
setStatus('error')
setErrorMessage('Something went wrong')
}
}
const isDisabled = status === 'loading' || cooldown > 0
return (
<div className="space-y-2">
<button
onClick={handleResend}
disabled={isDisabled}
className={`
px-4 py-2 rounded-lg font-medium transition-colors
${isDisabled
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{status === 'loading' && 'Sending...'}
{status === 'success' && cooldown > 0 && `Resend in ${cooldown}s`}
{status === 'error' && 'Try Again'}
{status === 'idle' && 'Resend Verification Email'}
{status === 'success' && cooldown === 0 && 'Resend Again'}
</button>
{status === 'success' && (
<p className="text-sm text-green-600">Verification email sent! Check your inbox.</p>
)}
{status === 'error' && (
<p className="text-sm text-red-600">{errorMessage}</p>
)}
</div>
)
}
Verification Banner
// src/components/auth/VerificationBanner.tsx
"use client"
import { useState } from 'react'
import { ResendButton } from './ResendButton'
interface VerificationBannerProps {
email?: string
}
export function VerificationBanner({ email }: VerificationBannerProps) {
const [dismissed, setDismissed] = useState(false)
if (dismissed) return null
return (
<div className="bg-yellow-50 border-b border-yellow-100">
<div className="max-w-7xl mx-auto px-4 py-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-yellow-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="text-sm font-medium text-yellow-800">
Please verify your email
</p>
<p className="text-sm text-yellow-700">
{email
? `We sent a verification link to ${email}`
: 'Check your email for the verification link'
}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<ResendButton />
<button
onClick={() => setDismissed(true)}
className="text-yellow-600 hover:text-yellow-800"
aria-label="Dismiss"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}
Hook for Verification Status
// src/hooks/useVerificationStatus.ts
"use client"
import { useState, useEffect } from 'react'
export function useVerificationStatus() {
const [isVerified, setIsVerified] = useState<boolean | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
checkStatus()
}, [])
async function checkStatus() {
try {
const res = await fetch('/api/auth/verification-status')
const data = await res.json()
setIsVerified(data.verified)
} catch {
setIsVerified(null)
} finally {
setIsLoading(false)
}
}
return { isVerified, isLoading, refresh: checkStatus }
}
Integration Example
// src/app/dashboard/layout.tsx
import { getServerSession } from '@/lib/auth'
import { VerificationBanner } from '@/components/auth/VerificationBanner'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
const isVerified = session?.user?.emailVerifiedAt !== null
return (
<div>
{!isVerified && <VerificationBanner email={session?.user?.email} />}
{children}
</div>
)
}
Styling Notes
The examples use Tailwind CSS. Adapt the classes to your design system:
| Tailwind | CSS Equivalent |
|---|---|
bg-green-100 | background: #dcfce7 |
text-green-500 | color: #22c55e |
rounded-full | border-radius: 9999px |
animate-spin | animation: spin 1s linear infinite |
Success Criteria
- β’ Token from URL is captured and sent to API
- β’ Loading state shown during verification
- β’ Success redirects to dashboard
- β’ Expired link shows resend option
- β’ Resend has cooldown to prevent spam
- β’ Banner shows for unverified users
- β’ All states are accessible (aria labels, focus states)
Related Files
- β’Create:
src/app/verify-email/page.tsx - β’Create:
src/components/auth/ResendButton.tsx - β’Create:
src/components/auth/VerificationBanner.tsx - β’Create:
src/hooks/useVerificationStatus.ts - β’Modify: Dashboard layout for banner integration