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 (
{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.
View Details
)}
{/* 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.
{isResending ? 'Sending...' : 'Resend verification email'}
{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.
Upgrade Now
)}
{/* Header */}
{/* Stats Grid */}
{stats.map((stat, index) => {
const Icon = stat.icon;
const isEmails = stat.name === 'Emails Sent';
const isOpenRate = stat.name === 'Open Rate';
return (
{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
View all
{!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
)}
>
);
}