454 lines
15 KiB
React
454 lines
15 KiB
React
import React, { useState, useEffect } from "react";
|
|
import { apiFetch } from "@/utils/api";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
import { TrendingUp } from "lucide-react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
|
import {
|
|
DashboardSectionHeader,
|
|
DashboardStatCard,
|
|
DashboardStatCardSkeleton,
|
|
DashboardChartTooltip,
|
|
ChartSkeleton,
|
|
DashboardEmptyState,
|
|
MetricPill,
|
|
LegendChip,
|
|
PILL_TRIGGER_CLASS,
|
|
} from "@/components/dashboard/shared";
|
|
|
|
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
|
|
|
const SkeletonStats = () => (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
|
{[...Array(4)].map((_, i) => (
|
|
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Note: Using shared DashboardStatCard from @/components/dashboard/shared
|
|
|
|
// Add color constants
|
|
const METRIC_COLORS = {
|
|
activeUsers: {
|
|
color: "#6b5fc7",
|
|
className: "text-purple-600 dark:text-purple-400",
|
|
},
|
|
newUsers: {
|
|
color: "#0f8f77",
|
|
className: "text-emerald-600 dark:text-emerald-400",
|
|
},
|
|
pageViews: {
|
|
color: "#a87a24",
|
|
className: "text-amber-600 dark:text-amber-400",
|
|
},
|
|
conversions: {
|
|
color: "#e06a4e",
|
|
className: "text-blue-600 dark:text-blue-400",
|
|
},
|
|
};
|
|
|
|
export const AnalyticsDashboard = () => {
|
|
const [data, setData] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [timeRange, setTimeRange] = useState("30");
|
|
const [metrics, setMetrics] = useState({
|
|
activeUsers: true,
|
|
newUsers: true,
|
|
pageViews: true,
|
|
conversions: true,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiFetch(
|
|
`/api/dashboard-analytics/metrics?startDate=${timeRange}daysAgo`,
|
|
{
|
|
credentials: "include",
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to fetch metrics");
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result?.data?.rows) {
|
|
return;
|
|
}
|
|
|
|
const processedData = result.data.rows.map((row) => ({
|
|
date: formatGADate(row.dimensionValues[0].value),
|
|
activeUsers: parseInt(row.metricValues[0].value),
|
|
newUsers: parseInt(row.metricValues[1].value),
|
|
avgSessionDuration: parseFloat(row.metricValues[2].value),
|
|
pageViews: parseInt(row.metricValues[3].value),
|
|
bounceRate: parseFloat(row.metricValues[4].value) * 100,
|
|
conversions: parseInt(row.metricValues[5].value),
|
|
}));
|
|
|
|
const sortedData = processedData.sort((a, b) => a.date - b.date);
|
|
setData(sortedData);
|
|
} catch (error) {
|
|
console.error("Failed to fetch analytics:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [timeRange]);
|
|
|
|
const formatGADate = (gaDate) => {
|
|
const year = gaDate.substring(0, 4);
|
|
const month = gaDate.substring(4, 6);
|
|
const day = gaDate.substring(6, 8);
|
|
return new Date(year, month - 1, day);
|
|
};
|
|
|
|
const formatXAxis = (date) => {
|
|
if (!date) return "";
|
|
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
};
|
|
|
|
const calculateSummaryStats = () => {
|
|
if (!data.length) return null;
|
|
|
|
const totals = data.reduce(
|
|
(acc, day) => ({
|
|
activeUsers: acc.activeUsers + day.activeUsers,
|
|
newUsers: acc.newUsers + day.newUsers,
|
|
pageViews: acc.pageViews + day.pageViews,
|
|
conversions: acc.conversions + day.conversions,
|
|
}),
|
|
{
|
|
activeUsers: 0,
|
|
newUsers: 0,
|
|
pageViews: 0,
|
|
conversions: 0,
|
|
}
|
|
);
|
|
|
|
const averages = {
|
|
activeUsers: totals.activeUsers / data.length,
|
|
newUsers: totals.newUsers / data.length,
|
|
pageViews: totals.pageViews / data.length,
|
|
conversions: totals.conversions / data.length,
|
|
};
|
|
|
|
return { totals, averages };
|
|
};
|
|
|
|
const summaryStats = calculateSummaryStats();
|
|
|
|
// Time selector for DashboardSectionHeader
|
|
const timeSelector = (
|
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
|
<SelectValue placeholder="Select range" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="7">Last 7 days</SelectItem>
|
|
<SelectItem value="14">Last 14 days</SelectItem>
|
|
<SelectItem value="30">Last 30 days</SelectItem>
|
|
<SelectItem value="90">Last 90 days</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
// Header actions: Details dialog
|
|
const headerActions = !loading ? (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
|
|
Details
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className={`max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
|
|
<DialogHeader className="flex-none">
|
|
<DialogTitle className="text-foreground">Daily Details</DialogTitle>
|
|
<div className="flex items-center justify-center gap-2 pt-4">
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(metrics).map(([key, value]) => (
|
|
<Button
|
|
key={key}
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-7 rounded-full px-3 text-xs font-medium ${value ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
|
onClick={() =>
|
|
setMetrics((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}))
|
|
}
|
|
>
|
|
{key === "activeUsers" ? "Active Users" :
|
|
key === "newUsers" ? "New Users" :
|
|
key === "pageViews" ? "Page Views" :
|
|
"Conversions"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto mt-6">
|
|
<div className={`rounded-lg border ${CARD_STYLES.base} w-full`}>
|
|
<Table className="w-full">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-center whitespace-nowrap px-6 w-[120px]">Date</TableHead>
|
|
{metrics.activeUsers && (
|
|
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">Active Users</TableHead>
|
|
)}
|
|
{metrics.newUsers && (
|
|
<TableHead className="text-center whitespace-nowrap px-6 min-w-[100px]">New Users</TableHead>
|
|
)}
|
|
{metrics.pageViews && (
|
|
<TableHead className="text-center whitespace-nowrap px-6 min-w-[140px]">Page Views</TableHead>
|
|
)}
|
|
{metrics.conversions && (
|
|
<TableHead className="text-center whitespace-nowrap px-6 min-w-[120px]">Conversions</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.map((day) => (
|
|
<TableRow key={day.date}>
|
|
<TableCell className="text-center whitespace-nowrap px-6">{formatXAxis(day.date)}</TableCell>
|
|
{metrics.activeUsers && (
|
|
<TableCell className="text-center whitespace-nowrap px-6">
|
|
{day.activeUsers.toLocaleString()}
|
|
</TableCell>
|
|
)}
|
|
{metrics.newUsers && (
|
|
<TableCell className="text-center whitespace-nowrap px-6">
|
|
{day.newUsers.toLocaleString()}
|
|
</TableCell>
|
|
)}
|
|
{metrics.pageViews && (
|
|
<TableCell className="text-center whitespace-nowrap px-6">
|
|
{day.pageViews.toLocaleString()}
|
|
</TableCell>
|
|
)}
|
|
{metrics.conversions && (
|
|
<TableCell className="text-center whitespace-nowrap px-6">
|
|
{day.conversions.toLocaleString()}
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
) : <Skeleton className="h-9 w-20 bg-muted rounded-sm" />;
|
|
|
|
// Label formatter for chart tooltip
|
|
const analyticsLabelFormatter = (label) => {
|
|
const date = label instanceof Date ? label : new Date(label);
|
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
};
|
|
|
|
return (
|
|
<Card className={`w-full h-full ${CARD_STYLES.base}`}>
|
|
<DashboardSectionHeader
|
|
title="Analytics Overview"
|
|
size="large"
|
|
loading={loading}
|
|
timeSelector={timeSelector}
|
|
actions={headerActions}
|
|
/>
|
|
|
|
<CardContent className="p-4 pt-3 space-y-3">
|
|
{/* Stats cards */}
|
|
{loading ? (
|
|
<SkeletonStats />
|
|
) : summaryStats ? (
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 dashboard-stagger">
|
|
<DashboardStatCard
|
|
title="Active Users"
|
|
value={summaryStats.totals.activeUsers.toLocaleString()}
|
|
subtitle={`Avg: ${Math.round(
|
|
summaryStats.averages.activeUsers
|
|
).toLocaleString()} per day`}
|
|
size="compact"
|
|
/>
|
|
<DashboardStatCard
|
|
title="New Users"
|
|
value={summaryStats.totals.newUsers.toLocaleString()}
|
|
subtitle={`Avg: ${Math.round(
|
|
summaryStats.averages.newUsers
|
|
).toLocaleString()} per day`}
|
|
size="compact"
|
|
/>
|
|
<DashboardStatCard
|
|
title="Page Views"
|
|
value={summaryStats.totals.pageViews.toLocaleString()}
|
|
subtitle={`Avg: ${Math.round(
|
|
summaryStats.averages.pageViews
|
|
).toLocaleString()} per day`}
|
|
size="compact"
|
|
/>
|
|
<DashboardStatCard
|
|
title="Conversions"
|
|
value={summaryStats.totals.conversions.toLocaleString()}
|
|
subtitle={`Avg: ${Math.round(
|
|
summaryStats.averages.conversions
|
|
).toLocaleString()} per day`}
|
|
size="compact"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Metric toggle pills — the pill dot doubles as the series legend */}
|
|
<div className="flex flex-wrap items-center gap-0.5">
|
|
<MetricPill
|
|
active={metrics.activeUsers}
|
|
color={METRIC_COLORS.activeUsers.color}
|
|
onClick={() => setMetrics((prev) => ({ ...prev, activeUsers: !prev.activeUsers }))}
|
|
>
|
|
Active users
|
|
</MetricPill>
|
|
<MetricPill
|
|
active={metrics.newUsers}
|
|
color={METRIC_COLORS.newUsers.color}
|
|
onClick={() => setMetrics((prev) => ({ ...prev, newUsers: !prev.newUsers }))}
|
|
>
|
|
New users
|
|
</MetricPill>
|
|
<MetricPill
|
|
active={metrics.pageViews}
|
|
color={METRIC_COLORS.pageViews.color}
|
|
onClick={() => setMetrics((prev) => ({ ...prev, pageViews: !prev.pageViews }))}
|
|
>
|
|
Page views
|
|
</MetricPill>
|
|
<MetricPill
|
|
active={metrics.conversions}
|
|
color={METRIC_COLORS.conversions.color}
|
|
onClick={() => setMetrics((prev) => ({ ...prev, conversions: !prev.conversions }))}
|
|
>
|
|
Conversions
|
|
</MetricPill>
|
|
</div>
|
|
{loading ? (
|
|
<ChartSkeleton height="default" withCard={false} />
|
|
) : !data.length ? (
|
|
<DashboardEmptyState
|
|
icon={TrendingUp}
|
|
title="No analytics data available"
|
|
description="Try selecting a different time range"
|
|
/>
|
|
) : (
|
|
<div className="h-[340px] p-0 relative">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={data}
|
|
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-muted"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatXAxis}
|
|
className="text-xs text-muted-foreground"
|
|
tick={{ fill: "currentColor" }}
|
|
/>
|
|
<YAxis
|
|
yAxisId="left"
|
|
className="text-xs text-muted-foreground"
|
|
tick={{ fill: "currentColor" }}
|
|
/>
|
|
<YAxis
|
|
yAxisId="right"
|
|
orientation="right"
|
|
className="text-xs text-muted-foreground"
|
|
tick={{ fill: "currentColor" }}
|
|
/>
|
|
<Tooltip
|
|
content={
|
|
<DashboardChartTooltip
|
|
labelFormatter={analyticsLabelFormatter}
|
|
valueFormatter={(value) => value.toLocaleString()}
|
|
/>
|
|
}
|
|
/>
|
|
{metrics.activeUsers && (
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="activeUsers"
|
|
name="Active Users"
|
|
stroke={METRIC_COLORS.activeUsers.color}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
)}
|
|
{metrics.newUsers && (
|
|
<Line
|
|
yAxisId="left"
|
|
type="monotone"
|
|
dataKey="newUsers"
|
|
name="New Users"
|
|
stroke={METRIC_COLORS.newUsers.color}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
)}
|
|
{metrics.pageViews && (
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="pageViews"
|
|
name="Page Views"
|
|
stroke={METRIC_COLORS.pageViews.color}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
)}
|
|
{metrics.conversions && (
|
|
<Line
|
|
yAxisId="right"
|
|
type="monotone"
|
|
dataKey="conversions"
|
|
name="Conversions"
|
|
stroke={METRIC_COLORS.conversions.color}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
)}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default AnalyticsDashboard; |