Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
102 lines
3.3 KiB
TypeScript
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>
|
|
)
|
|
}
|