feat: implement caching for recent activity count to optimize performance and reduce database load
This commit is contained in:
@@ -222,11 +222,34 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Short Redis TTL for recent-count results. Short enough to feel live on the
|
||||
* dashboard live-pulse, long enough to absorb the polling load when many
|
||||
* tabs are open across the user base.
|
||||
*/
|
||||
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
|
||||
|
||||
/**
|
||||
* Get recent activity count (for real-time updates)
|
||||
* Returns count of activities in the last N minutes
|
||||
*
|
||||
* Backed by a short Redis cache because the dashboard polls this endpoint
|
||||
* every 30 seconds per open tab; without the cache, every poll would run
|
||||
* three COUNT queries against the events, emails, and workflow_executions
|
||||
* tables.
|
||||
*/
|
||||
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return parseInt(cached, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
||||
|
||||
@@ -245,7 +268,15 @@ export class ActivityService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return eventCount + emailCount + workflowCount;
|
||||
const total = eventCount + emailCount + workflowCount;
|
||||
|
||||
try {
|
||||
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,6 +53,9 @@ export const Keys = {
|
||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||
},
|
||||
recentCount(projectId: string, minutes: number): string {
|
||||
return `activity:recent-count:${projectId}:${minutes}`;
|
||||
},
|
||||
},
|
||||
Analytics: {
|
||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||
|
||||
+550
-53
@@ -10,10 +10,31 @@ import {
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||
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,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
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';
|
||||
@@ -28,6 +49,207 @@ import {useConfig} from '../lib/hooks/useConfig';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
const MILESTONES = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000];
|
||||
|
||||
function nextMilestone(value: number): number {
|
||||
return MILESTONES.find(m => m > value) ?? value;
|
||||
}
|
||||
|
||||
function formatCompact(value: number): string {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}K`;
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
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 (
|
||||
<p className="mt-1 text-xs text-neutral-400 tabular-nums">{label ?? 'No data yet'}</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium tabular-nums ${bg} ${color}`}>
|
||||
<Icon className="h-3 w-3" strokeWidth={2.5} />
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-neutral-400">vs previous 30d</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <motion.span>{rounded}</motion.span>;
|
||||
}
|
||||
|
||||
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<ActivityVisual['tone'], {bg: string; fg: string}> = {
|
||||
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 (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700">
|
||||
<span className="relative flex h-2 w-2">
|
||||
{isLive && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
)}
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${isLive ? 'bg-emerald-500' : 'bg-neutral-300'}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{opacity: 0, y: -8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: 8}}
|
||||
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md ${tone.bg}`}>
|
||||
<Icon className={`h-4 w-4 ${tone.fg}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium text-neutral-900">{title}</p>
|
||||
<span className="flex-shrink-0 text-[11px] text-neutral-400">{visual.label}</span>
|
||||
</div>
|
||||
{subtitle && <p className="truncate text-xs text-neutral-500">{subtitle}</p>}
|
||||
</div>
|
||||
<span className="flex-shrink-0 tabular-nums text-xs text-neutral-400">
|
||||
{relativeTime(new Date(activity.timestamp))}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||
@@ -41,29 +263,137 @@ export default function Index() {
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendMessage, setResendMessage] = useState<string>('');
|
||||
|
||||
// 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<ActivityStats>(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<CursorPaginatedResponse<Activity>>('/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? [email protected]',
|
||||
'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.toLocaleString(),
|
||||
value: totalContacts,
|
||||
icon: Users,
|
||||
format: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'Emails Sent',
|
||||
value: totalEmailsSent.toLocaleString(),
|
||||
value: totalEmailsSent,
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
name: 'Campaigns',
|
||||
value: totalCampaigns.toLocaleString(),
|
||||
icon: Send,
|
||||
format: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'Open Rate',
|
||||
value: `${openRate.toFixed(1)}%`,
|
||||
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';
|
||||
|
||||
const emailsTarget = nextMilestone(totalEmailsSent);
|
||||
const prevMilestone = MILESTONES.filter(m => m <= totalEmailsSent).pop() ?? 0;
|
||||
const milestoneProgress =
|
||||
emailsTarget > prevMilestone
|
||||
? Math.min(100, ((totalEmailsSent - prevMilestone) / (emailsTarget - prevMilestone)) * 100)
|
||||
: 0;
|
||||
const showMilestone = !isLoading && totalEmailsSent > 0 && emailsTarget > totalEmailsSent;
|
||||
|
||||
async function handleResendVerification() {
|
||||
setIsResending(true);
|
||||
setResendMessage('');
|
||||
@@ -82,6 +412,9 @@ export default function Index() {
|
||||
}
|
||||
}
|
||||
|
||||
const recentItems = recentActivity?.data ?? [];
|
||||
const liveCount = recentCount?.count ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Dashboard" />
|
||||
@@ -162,64 +495,228 @@ export default function Index() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 tracking-tight">{greeting}</h1>
|
||||
<p className="mt-1.5 text-sm text-neutral-500">{subtitle}</p>
|
||||
</div>
|
||||
<LivePulse count={liveCount} />
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map(stat => {
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isEmails = stat.name === 'Emails Sent';
|
||||
const isOpenRate = stat.name === 'Open Rate';
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<motion.div
|
||||
key={stat.name}
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.05 + index * 0.06,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
whileHover={{y: -2}}
|
||||
className="group"
|
||||
>
|
||||
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-neutral-50 border border-neutral-200/60 transition-colors duration-200 group-hover:bg-neutral-900 group-hover:border-neutral-900">
|
||||
<Icon className="h-3.5 w-3.5 text-neutral-500 transition-colors duration-200 group-hover:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-16" />
|
||||
) : (
|
||||
<AnimatedNumber value={stat.value} format={stat.format} />
|
||||
)}
|
||||
</CardTitle>
|
||||
{isEmails && !isLoading && previousStats && <TrendChip trend={emailsTrend} />}
|
||||
{isOpenRate && !isLoading && previousStats && <TrendChip trend={openRateTrend} />}
|
||||
</CardHeader>
|
||||
{isEmails && showMilestone && (
|
||||
<div className="px-6 pb-4 -mt-1">
|
||||
<div className="flex items-center justify-between text-[11px] text-neutral-500 mb-1.5">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-neutral-400" />
|
||||
Next milestone
|
||||
</span>
|
||||
<span className="tabular-nums font-medium text-neutral-700">
|
||||
{formatCompact(totalEmailsSent)} / {formatCompact(emailsTarget)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-neutral-900"
|
||||
initial={{width: 0}}
|
||||
animate={{width: `${milestoneProgress}%`}}
|
||||
transition={{duration: 1.2, ease: [0.22, 1, 0.36, 1], delay: 0.4}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Deliverability health */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, delay: 0.05 + stats.length * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
whileHover={{y: -2}}
|
||||
className="group"
|
||||
>
|
||||
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>Deliverability</CardDescription>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-neutral-50 border border-neutral-200/60 px-2 py-0.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
{hasDelivData && worstLevel === 'healthy' && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
)}
|
||||
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${healthDot}`} />
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${healthText}`}>{healthLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{!securityMetrics ? (
|
||||
<Skeleton className="h-7 w-20" />
|
||||
) : hasDelivData ? (
|
||||
<AnimatedNumber value={deliveryRate} format={n => `${n.toFixed(1)}%`} />
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{hasDelivData ? 'delivered' : 'No emails sent yet'}
|
||||
{hasDelivData && <span className="text-neutral-400"> · {delivWindowLabel}</span>}
|
||||
</p>
|
||||
</CardHeader>
|
||||
{hasDelivData && (
|
||||
<div className="px-6 pb-4 -mt-1">
|
||||
<div className="flex items-center gap-4 text-[11px] text-neutral-500 tabular-nums">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-3 w-3 text-neutral-400" />
|
||||
Bounce <span className="font-medium text-neutral-700">{bounceRate.toFixed(2)}%</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 text-neutral-400" />
|
||||
Complaint <span className="font-medium text-neutral-700">{complaintRate.toFixed(3)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
{/* Quick Start + Recent Activity — 50/50 working area.
|
||||
Falls back to full-width Recent Activity when the persistent
|
||||
onboarding banner is suppressing Quick Start.
|
||||
At lg+, Recent Activity is absolutely positioned inside its grid
|
||||
cell so its natural content height does not influence the row
|
||||
height. The row height is dictated by Quick Start, and Recent
|
||||
Activity fills that height with the activity list scrolling
|
||||
inside the card. */}
|
||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activeProject ? (
|
||||
<>
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</>
|
||||
<div className={!bannerActive ? 'lg:relative' : ''}>
|
||||
<Card
|
||||
className={`flex flex-col h-full ${
|
||||
!bannerActive ? 'lg:absolute lg:inset-0' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Recent activity</CardTitle>
|
||||
<CardDescription>Live feed of what’s happening across your project</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/activity">View all</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||
{!recentActivity ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-2/5" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100">
|
||||
<Inbox className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700">Nothing has happened yet</p>
|
||||
<p className="text-xs text-neutral-500 max-w-xs">
|
||||
Send your first email or trigger an event and you’ll see it land here in real time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
<div className="space-y-0.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{recentItems.map(activity => (
|
||||
<CompactActivityRow key={activity.id} activity={activity} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys — full-width slim band with the two keys side-by-side */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeProject ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user