Files
resolutionflow/frontend/src/components/layout/EmailVerificationBanner.tsx
Michael Chihlas f1be3abcc5
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00

102 lines
3.3 KiB
TypeScript

import { useState, useEffect } from 'react'
import { AlertTriangle, X, Loader2 } from 'lucide-react'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* Whole days elapsed between an ISO timestamp and now (floored).
*
* Mirrors the helper in `EmailVerificationGate` — keep the two in sync so the
* banner hides on the same day the wall appears (Day 7+ unverified). Defensive
* on bad timestamps: treats unparseable input as "just signed up" so we never
* accidentally hide the banner on a real unverified user.
*/
function daysSince(iso: string, now: number = Date.now()): number {
const created = Date.parse(iso)
if (Number.isNaN(created)) return 0
return Math.floor((now - created) / MS_PER_DAY)
}
interface EmailVerificationBannerProps {
/**
* Override the grace period (in days). Day `gracePeriodDays + 1` and beyond
* suppress the banner — `EmailVerificationGate` shows the wall instead.
* Defaults to 6 (matches the gate).
*/
gracePeriodDays?: number
}
/**
* Top-of-dashboard bar shown to users who signed up but haven't verified their
* email yet. Hides itself once the grace period expires (the wall takes over)
* and once the user dismisses it for the session.
*/
export function EmailVerificationBanner({
gracePeriodDays = 6,
}: EmailVerificationBannerProps = {}) {
const user = useAuthStore((s) => s.user)
const [dismissed, setDismissed] = useState(false)
const [isSending, setIsSending] = useState(false)
const [verificationEnabled, setVerificationEnabled] = useState(true)
useEffect(() => {
authApi.getVerificationStatus()
.then((data) => setVerificationEnabled(data.enabled))
.catch(() => {})
}, [])
if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null
// Past grace period: the wall takes over inside <EmailVerificationGate>.
// Keep the banner out of the way so we don't double-show messaging.
const elapsed = daysSince(user.created_at)
if (elapsed > gracePeriodDays) return null
const handleResend = async () => {
setIsSending(true)
try {
await authApi.sendVerificationEmail()
toast.success('Verification email sent')
} catch {
toast.error('Failed to send verification email')
} finally {
setIsSending(false)
}
}
return (
<div
data-testid="email-verification-banner"
className="flex items-center gap-3 border-b border-warning/20 bg-warning-dim px-4 py-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-foreground">
Your email is not verified.
</span>
<button
type="button"
onClick={handleResend}
disabled={isSending}
data-testid="banner-resend-button"
className={cn(
'text-warning underline hover:opacity-80 disabled:opacity-50',
)}
>
{isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'}
</button>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="ml-auto text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
)
}