Added extra analytics

This commit is contained in:
Dries Augustyns
2025-12-02 11:10:30 +01:00
parent 2ef463ce5e
commit 2745c1bf5d
4 changed files with 783 additions and 262 deletions
+50 -2
View File
@@ -21,7 +21,7 @@ export class Analytics {
@Get('timeseries') @Get('timeseries')
@Middleware([requireAuth]) @Middleware([requireAuth])
@CatchAsync @CatchAsync
public async getTimeSeries(req: Request, res: Response, next: NextFunction) { public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -43,7 +43,7 @@ export class Analytics {
@Get('top-campaigns') @Get('top-campaigns')
@Middleware([requireAuth]) @Middleware([requireAuth])
@CatchAsync @CatchAsync
public async getTopCampaigns(req: Request, res: Response, next: NextFunction) { public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
@@ -53,4 +53,52 @@ export class Analytics {
return res.status(200).json(topCampaigns); return res.status(200).json(topCampaigns);
} }
/**
* GET /analytics/campaign-stats
* Get campaign overview statistics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns aggregate stats: total campaigns, active, completed, average rates
*/
@Get('campaign-stats')
@Middleware([requireAuth])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const campaignStats = await AnalyticsService.getCampaignStats(auth.projectId, startDate, endDate);
return res.status(200).json(campaignStats);
}
/**
* GET /analytics/top-events
* Get most frequently triggered events
*
* Query params:
* - limit: number (default 5, max 20)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns events sorted by frequency with trend data
*/
@Get('top-events')
@Middleware([requireAuth])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const topEvents = await AnalyticsService.getTopEvents(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topEvents);
}
} }
+208
View File
@@ -165,6 +165,214 @@ export class AnalyticsService {
})); }));
} }
/**
* Get campaign statistics overview
* Returns aggregate stats for all campaigns in date range
*
* Performance: O(1) with indexed queries
* - Uses aggregation on indexed fields
* - Cached in Redis for 15 minutes
*/
public static async getCampaignStats(
projectId: string,
startDate?: Date,
endDate?: Date,
): Promise<{
total: number;
active: number;
completed: number;
averageOpenRate: number;
averageClickRate: number;
}> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Get all campaigns in date range
const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([
// Total campaigns created in date range
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
}),
// Active campaigns (not sent yet)
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: {
in: ['DRAFT', 'SCHEDULED'],
},
},
}),
// Sent campaigns with stats
prisma.campaign.findMany({
where: {
projectId,
sentAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: 'SENT',
},
select: {
sentCount: true,
openedCount: true,
clickedCount: true,
},
}),
]);
// Calculate averages
let totalSent = 0;
let totalOpened = 0;
let totalClicked = 0;
sentCampaigns.forEach(campaign => {
totalSent += campaign.sentCount || 0;
totalOpened += campaign.openedCount || 0;
totalClicked += campaign.clickedCount || 0;
});
const completed = sentCampaigns.length;
const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0;
const stats = {
total: totalCampaigns,
active: activeCampaigns,
completed,
averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal
averageClickRate: Math.round(averageClickRate * 10) / 10,
};
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats));
return stats;
}
/**
* Get top events by frequency with trend data
*
* Performance: O(n log n) where n = number of unique events
* - Groups events and counts occurrences
* - Compares with previous period for trend calculation
* - Cached in Redis for 15 minutes
*/
public static async getTopEvents(
projectId: string,
limit = 5,
startDate?: Date,
endDate?: Date,
): Promise<
{
name: string;
count: number;
trend: number;
}[]
> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Calculate previous period for trend comparison
const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime();
const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength);
const previousEndDate = new Date(effectiveStartDate.getTime());
// Get current period events
const currentEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
},
take: limit,
});
// Get previous period events for trend calculation
const previousEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
name: {
in: currentEvents.map(e => e.name),
},
createdAt: {
gte: previousStartDate,
lte: previousEndDate,
},
},
_count: {
id: true,
},
});
// Create map of previous counts
const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id]));
// Calculate trends
const topEvents = currentEvents.map(event => {
const currentCount = event._count.id;
const previousCount = previousCountMap.get(event.name) || 0;
// Calculate percentage change
let trend = 0;
if (previousCount > 0) {
trend = Math.round(((currentCount - previousCount) / previousCount) * 100);
} else if (currentCount > 0) {
trend = 100; // New event, 100% increase
}
return {
name: event.name,
count: currentCount,
trend,
};
});
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents));
return topEvents;
}
/** /**
* Fill in missing dates in time series with zero values * Fill in missing dates in time series with zero values
* Ensures consistent daily data points even when no emails were sent * Ensures consistent daily data points even when no emails were sent
+511 -249
View File
@@ -15,35 +15,47 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue
} from '@plunk/ui'; } from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {useAnalytics} from '../../lib/hooks/useAnalytics'; import {useAnalytics} from '../../lib/hooks/useAnalytics';
import {AlertCircle, BarChart3, CheckCircle2, Eye, Mail, MousePointerClick, Send, TrendingUp, Zap} from 'lucide-react'; import useSWR from 'swr';
import {
Activity,
AlertCircle,
BarChart3,
CheckCircle2,
Eye,
Mail,
Megaphone,
MousePointerClick,
Send,
Zap
} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts'; import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts'; // Chart configurations with sleek blue theme
// Chart configurations // Chart configurations with sleek blue theme
const volumeChartConfig = { const volumeChartConfig = {
emails: { emails: {
label: 'Emails Sent', label: 'Emails Sent',
color: 'hsl(var(--chart-1))', color: 'hsl(221.2 83.2% 53.3%)', // Vibrant blue
}, },
opens: { opens: {
label: 'Opens', label: 'Opens',
color: 'hsl(var(--chart-2))', color: 'hsl(142.1 76.2% 36.3%)', // Green
}, },
clicks: { clicks: {
label: 'Clicks', label: 'Clicks',
color: 'hsl(var(--chart-3))', color: 'hsl(262.1 83.3% 57.8%)', // Purple
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
const engagementChartConfig = { const engagementChartConfig = {
openRate: { openRate: {
label: 'Open Rate', label: 'Open Rate',
color: 'hsl(var(--chart-2))', color: 'hsl(221.2 83.2% 53.3%)', // Vibrant blue to match
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
@@ -53,12 +65,55 @@ export default function AnalyticsPage() {
const {stats, timeSeries, isLoading, error} = useAnalytics({days}); const {stats, timeSeries, isLoading, error} = useAnalytics({days});
// Calculate engagement rate // Calculate start and end dates for additional API calls
const engagementRate = useMemo(() => { const {startDate, endDate} = useMemo(() => {
if (!stats?.totalEmailsSent) return 0; const end = new Date();
const engaged = stats.totalEmailsOpened + stats.totalEmailsClicked; const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000);
return (engaged / stats.totalEmailsSent) * 100; return {startDate: start.toISOString(), endDate: end.toISOString()};
}, [stats]); }, [days]);
// Fetch campaign stats from API
const {data: campaignStats} = useSWR<{
total: number;
active: number;
completed: number;
averageOpenRate: number;
averageClickRate: number;
}>(`/analytics/campaign-stats?startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000,
dedupingInterval: 10000,
});
// Fetch top events from API
const {data: topEvents} = useSWR<
{
name: string;
count: number;
trend: number;
}[]
>(`/analytics/top-events?limit=5&startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000,
dedupingInterval: 10000,
});
// Fetch top campaigns from API
const {data: topCampaigns} = useSWR<
{
id: string;
subject: string;
sentCount: number;
openedCount: number;
clickedCount: number;
openRate: number;
clickRate: number;
}[]
>(`/analytics/top-campaigns?limit=10&startDate=${startDate}&endDate=${endDate}`, {
revalidateOnFocus: false,
refreshInterval: 300000,
dedupingInterval: 10000,
});
// Process time series data for charts // Process time series data for charts
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -122,13 +177,31 @@ export default function AnalyticsPage() {
trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral', trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral',
}, },
{ {
name: 'Engagement', name: 'Active Campaigns',
value: `${engagementRate.toFixed(1)}%`, value: campaignStats?.active?.toLocaleString() || '0',
icon: TrendingUp, icon: Megaphone,
description: 'Opens + Clicks', description: `${campaignStats?.total || 0} total campaigns`,
color: 'text-indigo-600',
bgColor: 'bg-indigo-100',
trend: campaignStats?.active ? 'positive' : 'neutral',
},
{
name: 'Workflows',
value: stats?.totalWorkflowsStarted?.toLocaleString() || '0',
icon: Activity,
description: 'Automations triggered',
color: 'text-cyan-600',
bgColor: 'bg-cyan-100',
trend: stats?.totalWorkflowsStarted && stats.totalWorkflowsStarted > 0 ? 'positive' : 'neutral',
},
{
name: 'Total Events',
value: stats?.totalEvents?.toLocaleString() || '0',
icon: Zap,
description: 'Custom events tracked',
color: 'text-orange-600', color: 'text-orange-600',
bgColor: 'bg-orange-100', bgColor: 'bg-orange-100',
trend: engagementRate > 20 ? 'positive' : 'neutral', trend: stats?.totalEvents && stats.totalEvents > 0 ? 'positive' : 'neutral',
}, },
]; ];
@@ -137,252 +210,441 @@ export default function AnalyticsPage() {
<NextSeo title="Analytics" /> <NextSeo title="Analytics" />
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-neutral-900">Analytics</h1> <h1 className="text-3xl font-bold text-neutral-900">Analytics</h1>
<p className="text-neutral-500 mt-2"> <p className="text-neutral-500 mt-2">
Comprehensive insights into your email performance, engagement metrics, and delivery statistics. Comprehensive insights into your email performance, engagement metrics, and delivery statistics.
</p> </p>
</div>
<div className="flex gap-3">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<div className="flex gap-3">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Error State */} {/* Error State */}
{error && ( {error && (
<Card className="border-red-200 bg-red-50"> <Card className="border-red-200 bg-red-50">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-3 text-red-700"> <div className="flex items-center gap-3 text-red-700">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
<span>Failed to load analytics data. Please try again.</span> <span>Failed to load analytics data. Please try again.</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{statsCards.map(stat => { {statsCards.map(stat => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<Card key={stat.name}> <Card key={stat.name}>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription> <CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}> <div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} /> <Icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div> </div>
<CardTitle className="text-2xl">{isLoading ? '-' : stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
{/* Email Volume Chart */}
<Card>
<CardHeader>
<CardTitle>Email Volume Trends</CardTitle>
<CardDescription>Daily email sends, opens, and clicks over the selected period</CardDescription>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center">
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-2 text-sm text-muted-foreground">Send your first email to see analytics here</p>
</div> </div>
<CardTitle className="text-2xl">{isLoading ? '-' : stat.value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-neutral-500">{stat.description}</p>
</CardContent>
</Card>
);
})}
</div>
{/* Email Volume Chart */}
<Card>
<CardHeader>
<CardTitle>Email Volume Trends</CardTitle>
<CardDescription>Daily email sends, opens, and clicks over the selected period</CardDescription>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center">
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-2 text-sm text-muted-foreground">Send your first email to see analytics here</p>
</div> </div>
</div> ) : (
) : ( <ChartContainer config={volumeChartConfig} className="h-[400px] w-full">
<ChartContainer config={volumeChartConfig} className="h-[400px] w-full"> <AreaChart data={chartData} margin={{top: 10, right: 30, left: 0, bottom: 0}}>
<AreaChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}> <defs>
<defs> <linearGradient id="fillEmails" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="fillEmails" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-emails)" stopOpacity={0.3} />
<stop offset="5%" stopColor="var(--color-emails)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-emails)" stopOpacity={0.05} />
<stop offset="95%" stopColor="var(--color-emails)" stopOpacity={0.1} /> </linearGradient>
</linearGradient> <linearGradient id="fillOpens" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="fillOpens" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-opens)" stopOpacity={0.3} />
<stop offset="5%" stopColor="var(--color-opens)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-opens)" stopOpacity={0.05} />
<stop offset="95%" stopColor="var(--color-opens)" stopOpacity={0.1} /> </linearGradient>
</linearGradient> <linearGradient id="fillClicks" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="fillClicks" x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-clicks)" stopOpacity={0.3} />
<stop offset="5%" stopColor="var(--color-clicks)" stopOpacity={0.8} /> <stop offset="95%" stopColor="var(--color-clicks)" stopOpacity={0.05} />
<stop offset="95%" stopColor="var(--color-clicks)" stopOpacity={0.1} /> </linearGradient>
</linearGradient> </defs>
</defs> <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-muted" />
<CartesianGrid vertical={false} /> <XAxis
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} /> dataKey="date"
<YAxis tickLine={false} axisLine={false} tickMargin={8} /> tickLine={false}
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" />} /> axisLine={false}
<Area tickMargin={8}
dataKey="emails" minTickGap={32}
type="monotone" tick={{fontSize: 12}}
fill="url(#fillEmails)" className="text-muted-foreground"
stroke="var(--color-emails)" />
stackId="a" <YAxis
/> tickLine={false}
<Area axisLine={false}
dataKey="opens" tickMargin={8}
type="monotone" tick={{fontSize: 12}}
fill="url(#fillOpens)" className="text-muted-foreground"
stroke="var(--color-opens)" domain={[0, 'auto']}
stackId="a" />
/> <ChartTooltip
<Area content={
dataKey="clicks" <ChartTooltipContent
type="monotone" className="w-[180px]"
fill="url(#fillClicks)" labelFormatter={(value: any) => {
stroke="var(--color-clicks)" return value;
stackId="a" }}
/> />
<ChartLegend content={ChartLegendContent as any} /> }
</AreaChart> />
</ChartContainer> <Area
)} dataKey="emails"
</CardContent> type="monotone"
</Card> fill="url(#fillEmails)"
stroke="var(--color-emails)"
{/* Engagement Rate Chart */} strokeWidth={2}
<Card> dot={false}
<CardHeader> activeDot={{
<CardTitle>Engagement Rate Trends</CardTitle> r: 4,
<CardDescription>Open rate percentage over time</CardDescription> fill: 'var(--color-emails)',
</CardHeader> stroke: 'white',
<CardContent> strokeWidth: 2,
{!hasData ? ( }}
<div className="flex h-[300px] w-full items-center justify-center"> />
<div className="text-center"> <Area
<Eye className="mx-auto h-12 w-12 text-muted-foreground/50" /> dataKey="opens"
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3> type="monotone"
<p className="mt-2 text-sm text-muted-foreground"> fill="url(#fillOpens)"
Engagement metrics will appear once emails are opened stroke="var(--color-opens)"
</p> strokeWidth={2}
</div> dot={false}
</div> activeDot={{
) : ( r: 4,
<ChartContainer config={engagementChartConfig} className="h-[300px] w-full"> fill: 'var(--color-opens)',
<LineChart data={chartData} margin={{top: 10, right: 10, left: 10, bottom: 0}}> stroke: 'white',
<CartesianGrid vertical={false} /> strokeWidth: 2,
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} /> }}
<YAxis />
domain={[0, 100]} <Area
tickLine={false} dataKey="clicks"
axisLine={false} type="monotone"
tickMargin={8} fill="url(#fillClicks)"
tickFormatter={value => `${value}%`} stroke="var(--color-clicks)"
/> strokeWidth={2}
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" hideLabel />} cursor={false} /> dot={false}
<Line activeDot={{
dataKey="openRate" r: 4,
type="monotone" fill: 'var(--color-clicks)',
stroke="var(--color-openRate)" stroke: 'white',
strokeWidth={2} strokeWidth: 2,
dot={{ }}
fill: 'var(--color-openRate)', />
r: 4, <ChartLegend content={<ChartLegendContent />} verticalAlign="top" height={36} />
}} </AreaChart>
activeDot={{ </ChartContainer>
r: 6, )}
}}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
{/* Key Insights */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Performance Insights</CardTitle>
<CardDescription>Key metrics and recommendations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-4 w-4 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Open Rate</p>
<p className="text-sm text-neutral-500">
{stats?.openRate && stats.openRate > 20
? 'Your open rate is above industry average!'
: 'Consider improving subject lines to increase open rates.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Click Rate</p>
<p className="text-sm text-neutral-500">
{stats?.clickRate && stats.clickRate > 3
? 'Great click-through performance!'
: 'Add more compelling calls-to-action to boost clicks.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Engagement</p>
<p className="text-sm text-neutral-500">
{stats?.totalWorkflowsStarted
? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started`
: 'Set up workflows to automate your email campaigns.'}
</p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Engagement Rate Chart */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Event Activity</CardTitle> <CardTitle>Engagement Rate Trends</CardTitle>
<CardDescription>Custom events and triggers</CardDescription> <CardDescription>Open rate percentage over time</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent>
<div className="flex items-center justify-between"> {!hasData ? (
<div> <div className="flex h-[300px] w-full items-center justify-center">
<p className="text-2xl font-bold text-neutral-900">{stats?.totalEvents?.toLocaleString() || '0'}</p> <div className="text-center">
<p className="text-sm text-neutral-500">Total Events</p> <Eye className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-2 text-sm text-muted-foreground">
Engagement metrics will appear once emails are opened
</p>
</div>
</div> </div>
<div className="h-12 w-12 rounded-lg bg-orange-100 flex items-center justify-center"> ) : (
<Zap className="h-6 w-6 text-orange-600" /> <ChartContainer config={engagementChartConfig} className="h-[300px] w-full">
</div> <LineChart data={chartData} margin={{top: 20, right: 30, left: 0, bottom: 0}}>
</div> <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-muted" />
<div className="pt-4 border-t"> <XAxis
<p className="text-sm text-neutral-500"> dataKey="date"
Events triggered by your contacts over the last {days} days. These can trigger workflows and tickLine={false}
automations. axisLine={false}
</p> tickMargin={8}
</div> minTickGap={32}
tick={{fontSize: 12}}
className="text-muted-foreground"
/>
<YAxis
domain={[0, 100]}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{fontSize: 12}}
tickFormatter={value => `${value}%`}
className="text-muted-foreground"
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
labelFormatter={(value: any) => value}
formatter={(value: any) => [`${value}%`, 'Open Rate']}
/>
}
cursor={{
stroke: 'hsl(var(--border))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
/>
<Line
dataKey="openRate"
type="monotone"
stroke="var(--color-openRate)"
strokeWidth={2.5}
dot={{
fill: 'var(--color-openRate)',
stroke: 'white',
strokeWidth: 2,
r: 4,
}}
activeDot={{
r: 6,
fill: 'var(--color-openRate)',
stroke: 'white',
strokeWidth: 2,
}}
/>
</LineChart>
</ChartContainer>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Key Insights */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Performance Insights</CardTitle>
<CardDescription>Key metrics and recommendations</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-4 w-4 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Open Rate</p>
<p className="text-sm text-neutral-500">
{stats?.openRate && stats.openRate > 20
? 'Your open rate is above industry average!'
: 'Consider improving subject lines to increase open rates.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Click Rate</p>
<p className="text-sm text-neutral-500">
{stats?.clickRate && stats.clickRate > 3
? 'Great click-through performance!'
: 'Add more compelling calls-to-action to boost clicks.'}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Engagement</p>
<p className="text-sm text-neutral-500">
{stats?.totalWorkflowsStarted
? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started`
: 'Set up workflows to automate your email campaigns.'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Event Activity</CardTitle>
<CardDescription>Custom events and triggers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-2xl font-bold text-neutral-900">{stats?.totalEvents?.toLocaleString() || '0'}</p>
<p className="text-sm text-neutral-500">Total Events</p>
</div>
<div className="h-12 w-12 rounded-lg bg-orange-100 flex items-center justify-center">
<Zap className="h-6 w-6 text-orange-600" />
</div>
</div>
<div className="pt-4 border-t">
<p className="text-sm text-neutral-500">
Events triggered by your contacts over the last {days} days. These can trigger workflows and
automations.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Campaign Performance */}
{topCampaigns && topCampaigns.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Campaign Performance</CardTitle>
<CardDescription>Top performing campaigns in the last {days} days</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">Campaign</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">Sent</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">Opened</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">Clicked</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">Open Rate</th>
<th className="text-right py-3 px-4 text-sm font-medium text-muted-foreground">Click Rate</th>
</tr>
</thead>
<tbody>
{topCampaigns.map((campaign, idx) => (
<tr key={campaign.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-6 h-6 rounded bg-neutral-100 text-neutral-600 font-semibold text-xs">
{idx + 1}
</div>
<span className="text-sm font-medium text-neutral-900">{campaign.subject}</span>
</div>
</td>
<td className="text-right py-3 px-4 text-sm text-neutral-600">
{campaign.sentCount.toLocaleString()}
</td>
<td className="text-right py-3 px-4 text-sm text-neutral-600">
{campaign.openedCount.toLocaleString()}
</td>
<td className="text-right py-3 px-4 text-sm text-neutral-600">
{campaign.clickedCount.toLocaleString()}
</td>
<td className="text-right py-3 px-4">
<span
className={`text-sm font-medium ${
campaign.openRate > 30
? 'text-green-600'
: campaign.openRate > 20
? 'text-blue-600'
: 'text-neutral-600'
}`}
>
{campaign.openRate.toFixed(1)}%
</span>
</td>
<td className="text-right py-3 px-4">
<span
className={`text-sm font-medium ${
campaign.clickRate > 5
? 'text-green-600'
: campaign.clickRate > 3
? 'text-blue-600'
: 'text-neutral-600'
}`}
>
{campaign.clickRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Top Events */}
{topEvents && topEvents.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Top Events</CardTitle>
<CardDescription>Most frequently triggered events in the last {days} days</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{topEvents.map((event, index) => (
<div key={event.name} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-neutral-100 text-neutral-600 font-semibold text-sm">
{index + 1}
</div>
<div>
<p className="text-sm font-medium text-neutral-900">{event.name}</p>
<p className="text-xs text-muted-foreground">{event.count.toLocaleString()} occurrences</p>
</div>
</div>
<div className="flex items-center gap-2">
<div
className={`text-xs font-medium px-2 py-1 rounded-full ${
event.trend > 0
? 'bg-green-100 text-green-700'
: event.trend < 0
? 'bg-red-100 text-red-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{event.trend > 0 ? '+' : ''}
{event.trend}%
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div> </div>
</div> </DashboardLayout>
</DashboardLayout>
</> </>
); );
} }
+14 -11
View File
@@ -83,13 +83,16 @@ const ChartStyle = ({id, config}: {id: string; config: ChartConfig}) => {
return ( return (
<style <style
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: Object.entries(config) __html: `[data-chart="${id}"] {
.filter(([_, config]) => config.theme || config.color) ${Object.entries(config)
.map(([key, itemConfig]) => { .filter(([_, config]) => config.theme || config.color)
const color = typeof itemConfig.color === 'string' ? itemConfig.color : itemConfig.color; .map(([key, itemConfig]) => {
return color ? `--color-${key}: ${color};` : null; const color = typeof itemConfig.color === 'string' ? itemConfig.color : itemConfig.color;
}) return color ? ` --color-${key}: ${color};` : null;
.join('\n'), })
.filter(Boolean)
.join('\n')}
}`,
}} }}
/> />
); );
@@ -260,7 +263,7 @@ const ChartLegendContent = React.forwardRef<HTMLDivElement, ChartLegendContentPr
return ( return (
<div <div
ref={ref} ref={ref}
className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)} className={cn('flex items-center justify-center gap-6', verticalAlign === 'top' ? 'pb-4' : 'pt-4', className)}
> >
{payload.map((item: any) => { {payload.map((item: any) => {
const key = `${nameKey || item.dataKey || 'value'}`; const key = `${nameKey || item.dataKey || 'value'}`;
@@ -269,19 +272,19 @@ const ChartLegendContent = React.forwardRef<HTMLDivElement, ChartLegendContentPr
return ( return (
<div <div
key={item.value} key={item.value}
className={cn('flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground')} className={cn('flex items-center gap-2 [&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:text-muted-foreground')}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
<div <div
className="h-2 w-2 shrink-0 rounded-[2px]" className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ style={{
backgroundColor: item.color, backgroundColor: item.color,
}} }}
/> />
)} )}
<span className="text-muted-foreground">{itemConfig?.label || item.value}</span> <span className="text-xs font-medium text-muted-foreground">{itemConfig?.label || item.value}</span>
</div> </div>
); );
})} })}