import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton, } from '@plunk/ui'; import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types'; import {animate, AnimatePresence, motion, useMotionValue, useTransform} from 'framer-motion'; import { AlertCircle, ArrowDownRight, ArrowUpRight, Calendar, Eye, Inbox, Mail, Minus, MousePointerClick, Send, ShieldCheck, TrendingUp, Users, Workflow, XCircle, Zap, } from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; import {useEffect, useMemo, useState} from 'react'; import useSWR from 'swr'; import {ApiKeyDisplay} from '../components/ApiKeyDisplay'; import {DashboardLayout} from '../components/DashboardLayout'; import {QuickStart} from '../components/QuickStart'; import {SecurityWarningBanner} from '../components/SecurityWarningBanner'; import {useActiveProject} from '../lib/contexts/ActiveProjectProvider'; import {useDashboardStats} from '../lib/hooks/useDashboardStats'; import {useOnboardingPath} from '../lib/hooks/useOnboardingPath'; import {useOnboardingStatus} from '../lib/hooks/useOnboardingStatus'; import {useProjectSetupState} from '../lib/hooks/useProjectSetupState'; import {useProjectSecurity} from '../lib/hooks/useProjectSecurity'; import {useConfig} from '../lib/hooks/useConfig'; import {useUser} from '../lib/hooks/useUser'; import {network} from '../lib/network'; function getGreeting(): string { const hour = new Date().getHours(); if (hour >= 23 || hour < 5) return 'Working late'; if (hour < 12) return 'Good morning'; if (hour < 18) return 'Good afternoon'; return 'Good evening'; } function relativeTime(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); if (seconds < 60) return 'just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d ago`; return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric'}); } type TrendDirection = 'up' | 'down' | 'flat' | 'new' | 'none'; interface TrendInfo { direction: TrendDirection; pct: number; } function computeTrend(current: number, previous: number): TrendInfo { if (previous === 0 && current === 0) return {direction: 'none', pct: 0}; if (previous === 0 && current > 0) return {direction: 'new', pct: 0}; const pct = ((current - previous) / Math.abs(previous)) * 100; if (Math.abs(pct) < 0.5) return {direction: 'flat', pct: 0}; return {direction: pct > 0 ? 'up' : 'down', pct: Math.abs(pct)}; } function TrendChip({trend, label}: {trend: TrendInfo; label?: string}) { if (trend.direction === 'none') { return (

{label ?? 'No data yet'}

); } const config = { up: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'}, down: {Icon: ArrowDownRight, color: 'text-red-700', bg: 'bg-red-50'}, flat: {Icon: Minus, color: 'text-neutral-600', bg: 'bg-neutral-100'}, new: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'}, }[trend.direction]; const {Icon, color, bg} = config; const text = trend.direction === 'new' ? 'New' : trend.direction === 'flat' ? 'No change' : `${trend.pct.toFixed(trend.pct >= 100 ? 0 : 1)}%`; return (
{text} vs previous 30d
); } function AnimatedNumber({value, format}: {value: number; format?: (n: number) => string}) { const motionValue = useMotionValue(0); const rounded = useTransform(motionValue, latest => format ? format(Math.round(latest)) : Math.round(latest).toLocaleString(), ); useEffect(() => { const controls = animate(motionValue, value, { duration: 1.1, ease: [0.22, 1, 0.36, 1], }); return () => controls.stop(); }, [value, motionValue]); return {rounded}; } interface ActivityVisual { icon: React.ComponentType<{className?: string}>; tone: 'neutral' | 'green' | 'blue' | 'amber' | 'red'; label: string; } function activityVisual(a: Activity): ActivityVisual { switch (a.type) { case 'email.sent': return {icon: Send, tone: 'neutral', label: 'Sent'}; case 'email.delivered': return {icon: Inbox, tone: 'green', label: 'Delivered'}; case 'email.opened': return {icon: Eye, tone: 'green', label: 'Opened'}; case 'email.clicked': return {icon: MousePointerClick, tone: 'blue', label: 'Clicked'}; case 'email.bounced': return {icon: XCircle, tone: 'red', label: 'Bounced'}; case 'email.complaint': return {icon: AlertCircle, tone: 'red', label: 'Complaint'}; case 'event.triggered': return {icon: Zap, tone: 'amber', label: 'Event'}; case 'campaign.sent': return {icon: Mail, tone: 'neutral', label: 'Campaign'}; case 'campaign.scheduled': return {icon: Calendar, tone: 'blue', label: 'Scheduled'}; case 'workflow.started': case 'workflow.completed': case 'workflow.email.scheduled': return {icon: Workflow, tone: 'amber', label: 'Workflow'}; default: return {icon: Zap, tone: 'neutral', label: 'Event'}; } } const TONE_CLASSES: Record = { neutral: {bg: 'bg-neutral-100', fg: 'text-neutral-700'}, green: {bg: 'bg-emerald-50', fg: 'text-emerald-700'}, blue: {bg: 'bg-sky-50', fg: 'text-sky-700'}, amber: {bg: 'bg-amber-50', fg: 'text-amber-700'}, red: {bg: 'bg-red-50', fg: 'text-red-700'}, }; function activityTitle(a: Activity): string { const m = a.metadata; if (typeof m.subject === 'string' && m.subject) return m.subject; if (typeof m.eventName === 'string' && m.eventName) return m.eventName; if (typeof m.campaignName === 'string' && m.campaignName) return m.campaignName; if (typeof m.workflowName === 'string' && m.workflowName) return m.workflowName; return activityVisual(a).label; } function LivePulse({count}: {count: number}) { const isLive = count > 0; return (
{isLive && ( )} {isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
); } function CompactActivityRow({activity}: {activity: Activity}) { const visual = activityVisual(activity); const Icon = visual.icon; const tone = TONE_CLASSES[visual.tone]; const title = activityTitle(activity); const subtitle = activity.contactEmail; return (

{title}

{visual.label}
{subtitle &&

{subtitle}

}
{relativeTime(new Date(activity.timestamp))}
); } export default function Index() { const {activeProject} = useActiveProject(); const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats(); const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id); const {securityMetrics} = useProjectSecurity(activeProject?.id); const {data: config} = useConfig(); const {data: user} = useUser(); const onboardingStatus = useOnboardingStatus(); const {path: onboardingPath} = useOnboardingPath(activeProject?.id); const bannerActive = onboardingStatus === 'show' && Boolean(onboardingPath); const [isResending, setIsResending] = useState(false); const [resendMessage, setResendMessage] = useState(''); // Previous-period stats (60d ago to 30d ago) for trend comparison. // Round to UTC day boundary so the URL — and therefore the Redis cache key — // is identical for every user on the same UTC day, letting the 5-minute // server-side stats cache actually be shared across the user base. const previousRangeUrl = useMemo(() => { const today = new Date(); today.setUTCHours(0, 0, 0, 0); const thirtyDaysAgo = new Date(today); thirtyDaysAgo.setUTCDate(today.getUTCDate() - 30); const sixtyDaysAgo = new Date(today); sixtyDaysAgo.setUTCDate(today.getUTCDate() - 60); return `/activity/stats?startDate=${encodeURIComponent(sixtyDaysAgo.toISOString())}&endDate=${encodeURIComponent(thirtyDaysAgo.toISOString())}`; }, []); const {data: previousStats} = useSWR(previousRangeUrl, { revalidateOnFocus: false, dedupingInterval: 5 * 60 * 1000, }); const emailsTrend = useMemo( () => computeTrend(totalEmailsSent, previousStats?.totalEmailsSent ?? 0), [totalEmailsSent, previousStats?.totalEmailsSent], ); const openRateTrend = useMemo( () => computeTrend(openRate, previousStats?.openRate ?? 0), [openRate, previousStats?.openRate], ); // Live pulse — refresh every 30s. This is the actual real-time signal, so it // gets the tightest cadence. Server-side it is backed by a short Redis cache // (see Activity controller) so the polling load stays bounded. const {data: recentCount} = useSWR<{count: number; minutes: number}>('/activity/recent-count?minutes=5', { refreshInterval: 30_000, revalidateOnFocus: false, dedupingInterval: 15_000, }); // Live activity feed — last 10 events, refresh every 60s. Slower than the // pulse because the heavier query doesn't need to be tracked second-by-second. // Sized to roughly match the Quick Start card's height in the side-by-side layout. const {data: recentActivity} = useSWR>('/activity?limit=10', { refreshInterval: 60_000, revalidateOnFocus: false, dedupingInterval: 30_000, }); const greeting = useMemo(() => getGreeting(), []); const subtitle = useMemo(() => { if (isLoading) return 'Catching up on the last 30 days.'; if (totalEmailsSent === 0) { if (totalContacts === 0) return `${activeProject?.name ?? 'Your project'} is fresh. Time to send the first email.`; return `${totalContacts.toLocaleString()} ${totalContacts === 1 ? 'contact' : 'contacts'} ready. Time to send something.`; } const projectLabel = activeProject?.name ? `${activeProject.name} sent` : 'You sent'; const base = `${projectLabel} ${totalEmailsSent.toLocaleString()} ${totalEmailsSent === 1 ? 'email' : 'emails'} in the last 30 days.`; if (openRate >= 40) return `${base} Open rate is well above average.`; if (openRate >= 25) return `${base} Open rate is healthy.`; return base; }, [isLoading, totalEmailsSent, totalContacts, openRate, activeProject?.name]); // Friendly console message for the developer audience. Once per session. useEffect(() => { if (typeof window === 'undefined') return; const w = window as unknown as {__plunkHi?: boolean}; if (w.__plunkHi) return; w.__plunkHi = true; // eslint-disable-next-line no-console console.log( '%cPlunk%c Built for developers who care about email.\nFound a rough edge? support@useplunk.com', 'font: 600 14px ui-sans-serif, system-ui; color: #0a0a0a; background: #f5f5f5; padding: 2px 8px; border-radius: 4px;', 'color: #525252; font: 12px ui-sans-serif, system-ui;', ); }, []); // totalCampaigns intentionally unused — replaced by Deliverability card below void totalCampaigns; const stats = [ { name: 'Total Contacts', value: totalContacts, icon: Users, format: (n: number) => n.toLocaleString(), }, { name: 'Emails Sent', value: totalEmailsSent, icon: Mail, format: (n: number) => n.toLocaleString(), }, { name: 'Open Rate', value: openRate, icon: TrendingUp, format: (n: number) => `${n.toFixed(1)}%`, }, ]; // Deliverability — prefer 7-day window, fall back to all-time when no 7-day sends const sevenDay = securityMetrics?.status.sevenDay; const allTime = securityMetrics?.status.allTime; const delivWindow = sevenDay && sevenDay.total > 0 ? sevenDay : allTime; const delivWindowLabel = sevenDay && sevenDay.total > 0 ? 'Last 7 days' : 'All time'; const deliveryRate = delivWindow && delivWindow.total > 0 ? ((delivWindow.total - delivWindow.bounces) / delivWindow.total) * 100 : 0; const bounceRate = delivWindow?.bounceRate ?? 0; const complaintRate = delivWindow?.complaintRate ?? 0; const hasDelivData = !!delivWindow && delivWindow.total > 0; const bounceLevel = securityMetrics?.levels.bounce7Day ?? 'healthy'; const complaintLevel = securityMetrics?.levels.complaint7Day ?? 'healthy'; const worstLevel: 'healthy' | 'warning' | 'critical' = bounceLevel === 'critical' || complaintLevel === 'critical' ? 'critical' : bounceLevel === 'warning' || complaintLevel === 'warning' ? 'warning' : 'healthy'; const healthLabel = !hasDelivData ? 'No data yet' : worstLevel === 'healthy' ? 'Healthy' : worstLevel === 'warning' ? 'Watch' : 'Critical'; const healthDot = !hasDelivData ? 'bg-neutral-300' : worstLevel === 'healthy' ? 'bg-emerald-500' : worstLevel === 'warning' ? 'bg-amber-500' : 'bg-red-500'; const healthText = !hasDelivData ? 'text-neutral-500' : worstLevel === 'healthy' ? 'text-emerald-700' : worstLevel === 'warning' ? 'text-amber-700' : 'text-red-700'; async function handleResendVerification() { setIsResending(true); setResendMessage(''); try { const response = await network.fetch<{success: boolean}>('POST', '/auth/request-verification'); if (response.success) { setResendMessage('Verification email sent! Please check your inbox.'); } else { setResendMessage('Failed to send verification email. Please try again.'); } } catch { setResendMessage('Failed to send verification email. Please try again.'); } finally { setIsResending(false); } } const recentItems = recentActivity?.data ?? []; const liveCount = recentCount?.count ?? 0; return ( <>
{/* Project Disabled Banner */} {activeProject && activeProject.disabled && ( Project Disabled - Read-Only Mode

This project has been disabled and is now in read-only mode. You can view your data but cannot create, update, or delete anything.

Please contact support for more details and to get your project re-enabled.

)} {/* Email Verification Banner */} {user && user.type === 'PASSWORD' && !user.emailVerified && ( Verify your email address Please verify your email address to unlock all features. Check your inbox for the verification link.
{resendMessage && (

{resendMessage}

)}
)} {/* Security Warning Banner */} {activeProject && !activeProject.disabled && securityMetrics && ( )} {/* Subscription Warning Banner */} {activeProject && !activeProject.disabled && !activeProject.subscription && config?.features.billing.enabled && ( Upgrade to remove Plunk branding Your emails currently include Plunk branding. Upgrade to a subscription to remove it. )} {/* Header */}

{greeting}

{subtitle}

{/* Stats Grid */}
{stats.map((stat, index) => { const Icon = stat.icon; const isEmails = stat.name === 'Emails Sent'; const isOpenRate = stat.name === 'Open Rate'; return (
{stat.name}
{isLoading ? ( ) : ( )} {isEmails && !isLoading && previousStats && } {isOpenRate && !isLoading && previousStats && }
); })} {/* Deliverability health */}
Deliverability
{hasDelivData && worstLevel === 'healthy' && ( )} {healthLabel}
{!securityMetrics ? ( ) : hasDelivData ? ( `${n.toFixed(1)}%`} /> ) : ( )}

{hasDelivData ? 'delivered' : 'No emails sent yet'} {hasDelivData && · {delivWindowLabel}}

{hasDelivData && (
Bounce {bounceRate.toFixed(2)}% Complaint {complaintRate.toFixed(3)}%
)}
{/* Quick Start + Recent Activity — 50/50 working area with a fixed row height so the layout doesn't reflow as Quick Start steps are completed. Both cards scroll internally. */}
{!bannerActive && }
Recent activity Live feed of what’s happening across your project
{!recentActivity ? (
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
))}
) : recentItems.length === 0 ? (

Nothing has happened yet

Send your first email or trigger an event and you’ll see it land here in real time.

) : (
{recentItems.map(activity => ( ))}
)}
{/* API Keys — full-width slim band with the two keys side-by-side */} API Keys Use these keys to integrate with Plunk {activeProject ? (
) : (

No project selected

)}
); }