Added extra analytics
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user