Add analytics components and services
This commit is contained in:
@@ -24,6 +24,9 @@ import SalesChart from "./components/dashboard/SalesChart";
|
||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
||||
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
||||
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
||||
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||
|
||||
// Public layout
|
||||
const PublicLayout = () => (
|
||||
@@ -90,6 +93,9 @@ const DashboardLayout = () => {
|
||||
<Header />
|
||||
</div>
|
||||
<Navigation />
|
||||
<AnalyticsDashboard />
|
||||
<UserBehaviorDashboard />
|
||||
<RealtimeAnalytics />
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||
<div className="xl:col-span-4 col-span-6">
|
||||
|
||||
399
dashboard/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
399
dashboard/src/components/dashboard/AnalyticsDashboard.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const AnalyticsDashboard = () => {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState("30");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/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) {
|
||||
console.log("No result data received");
|
||||
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 [selectedMetrics, setSelectedMetrics] = useState({
|
||||
activeUsers: true,
|
||||
newUsers: true,
|
||||
pageViews: true,
|
||||
conversions: true,
|
||||
});
|
||||
|
||||
const MetricToggle = ({ label, checked, onChange }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={label}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="dark:border-gray-600"
|
||||
/>
|
||||
<label
|
||||
htmlFor={label}
|
||||
className="text-sm font-medium leading-none text-gray-900 dark:text-gray-200 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CustomLegend = ({ metrics, selectedMetrics }) => {
|
||||
// Separate items for left and right axes
|
||||
const leftAxisItems = Object.entries(metrics).filter(
|
||||
([key, metric]) => metric.yAxis === "left" && selectedMetrics[key]
|
||||
);
|
||||
|
||||
const rightAxisItems = Object.entries(metrics).filter(
|
||||
([key, metric]) => metric.yAxis === "right" && selectedMetrics[key]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between mt-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
|
||||
Left Axis
|
||||
</h4>
|
||||
{leftAxisItems.map(([key, metric]) => (
|
||||
<div key={key} className="flex items-center space-x-2">
|
||||
<div
|
||||
style={{ backgroundColor: metric.color }}
|
||||
className="w-3 h-3 rounded-full"
|
||||
></div>
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
|
||||
Right Axis
|
||||
</h4>
|
||||
{rightAxisItems.map(([key, metric]) => (
|
||||
<div key={key} className="flex items-center space-x-2">
|
||||
<div
|
||||
style={{ backgroundColor: metric.color }}
|
||||
className="w-3 h-3 rounded-full"
|
||||
></div>
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const metrics = {
|
||||
activeUsers: {
|
||||
label: "Active Users",
|
||||
color: "#8b5cf6",
|
||||
yAxis: "left"
|
||||
},
|
||||
newUsers: {
|
||||
label: "New Users",
|
||||
color: "#10b981",
|
||||
yAxis: "left"
|
||||
},
|
||||
pageViews: {
|
||||
label: "Page Views",
|
||||
color: "#f59e0b",
|
||||
yAxis: "right"
|
||||
},
|
||||
conversions: {
|
||||
label: "Conversions",
|
||||
color: "#3b82f6",
|
||||
yAxis: "right"
|
||||
},
|
||||
};
|
||||
|
||||
const calculateSummary = () => {
|
||||
if (!data.length) return null;
|
||||
|
||||
const totals = data.reduce(
|
||||
(acc, day) => ({
|
||||
activeUsers: acc.activeUsers + (Number(day.activeUsers) || 0),
|
||||
newUsers: acc.newUsers + (Number(day.newUsers) || 0),
|
||||
pageViews: acc.pageViews + (Number(day.pageViews) || 0),
|
||||
conversions: acc.conversions + (Number(day.conversions) || 0),
|
||||
avgSessionDuration:
|
||||
acc.avgSessionDuration + (Number(day.avgSessionDuration) || 0),
|
||||
bounceRate: acc.bounceRate + (Number(day.bounceRate) || 0),
|
||||
}),
|
||||
{
|
||||
activeUsers: 0,
|
||||
newUsers: 0,
|
||||
pageViews: 0,
|
||||
conversions: 0,
|
||||
avgSessionDuration: 0,
|
||||
bounceRate: 0,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
...totals,
|
||||
avgSessionDuration: totals.avgSessionDuration / data.length,
|
||||
bounceRate: totals.bounceRate / data.length,
|
||||
};
|
||||
};
|
||||
|
||||
const summary = calculateSummary();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardContent className="h-96 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const formatXAxisDate = (date) => {
|
||||
if (!(date instanceof Date)) return "";
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||
<p className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||
{label instanceof Date ? label.toLocaleDateString() : label}
|
||||
</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{`${entry.name}: ${Number(entry.value).toLocaleString()}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Analytics Overview
|
||||
</CardTitle>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Total Users
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{summary.activeUsers.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
New Users
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{summary.newUsers.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page Views
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{summary.pageViews.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Conversions
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{summary.conversions.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="h-[400px] mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-gray-200 dark:stroke-gray-700"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatXAxisDate}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend content={<CustomLegend metrics={metrics} selectedMetrics={selectedMetrics} />} />
|
||||
{selectedMetrics.activeUsers && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
name="Active Users"
|
||||
stroke={metrics.activeUsers.color}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{selectedMetrics.newUsers && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="newUsers"
|
||||
name="New Users"
|
||||
stroke={metrics.newUsers.color}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{selectedMetrics.pageViews && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="pageViews"
|
||||
name="Page Views"
|
||||
stroke={metrics.pageViews.color}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{selectedMetrics.conversions && (
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="conversions"
|
||||
name="Conversions"
|
||||
stroke={metrics.conversions.color}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
<MetricToggle
|
||||
label="Active Users"
|
||||
checked={selectedMetrics.activeUsers}
|
||||
onChange={(checked) =>
|
||||
setSelectedMetrics((prev) => ({ ...prev, activeUsers: checked }))
|
||||
}
|
||||
/>
|
||||
<MetricToggle
|
||||
label="New Users"
|
||||
checked={selectedMetrics.newUsers}
|
||||
onChange={(checked) =>
|
||||
setSelectedMetrics((prev) => ({ ...prev, newUsers: checked }))
|
||||
}
|
||||
/>
|
||||
<MetricToggle
|
||||
label="Page Views"
|
||||
checked={selectedMetrics.pageViews}
|
||||
onChange={(checked) =>
|
||||
setSelectedMetrics((prev) => ({ ...prev, pageViews: checked }))
|
||||
}
|
||||
/>
|
||||
<MetricToggle
|
||||
label="Conversions"
|
||||
checked={selectedMetrics.conversions}
|
||||
onChange={(checked) =>
|
||||
setSelectedMetrics((prev) => ({ ...prev, conversions: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsDashboard;
|
||||
494
dashboard/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
494
dashboard/src/components/dashboard/RealtimeAnalytics.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Tooltip as UITooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { format } from "date-fns";
|
||||
|
||||
const formatNumber = (value, decimalPlaces = 0) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(value || 0);
|
||||
};
|
||||
|
||||
const formatPercent = (value, decimalPlaces = 1) =>
|
||||
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||
|
||||
const summaryCard = (label, sublabel, value, options = {}) => {
|
||||
const {
|
||||
isMonetary = false,
|
||||
isPercentage = false,
|
||||
decimalPlaces = 0,
|
||||
} = options;
|
||||
|
||||
let displayValue;
|
||||
if (isMonetary) {
|
||||
displayValue = formatCurrency(value, decimalPlaces);
|
||||
} else if (isPercentage) {
|
||||
displayValue = formatPercent(value, decimalPlaces);
|
||||
} else {
|
||||
displayValue = formatNumber(value, decimalPlaces);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-lg md:text-sm lg:text-lg text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{displayValue}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{sublabel}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QuotaInfo = ({ tokenQuota }) => {
|
||||
// Add early return if tokenQuota is null or undefined
|
||||
if (!tokenQuota || typeof tokenQuota !== "object") return null;
|
||||
|
||||
const {
|
||||
projectHourly = {},
|
||||
daily = {},
|
||||
serverErrors = {},
|
||||
thresholdedRequests = {},
|
||||
} = tokenQuota;
|
||||
|
||||
// Add null checks and default values for all properties
|
||||
const {
|
||||
remaining: projectHourlyRemaining = 0,
|
||||
consumed: projectHourlyConsumed = 0,
|
||||
} = projectHourly;
|
||||
|
||||
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
|
||||
|
||||
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
|
||||
serverErrors;
|
||||
|
||||
const {
|
||||
remaining: thresholdRemaining = 120,
|
||||
consumed: thresholdConsumed = 0,
|
||||
} = thresholdedRequests;
|
||||
|
||||
// Calculate percentages with safe math
|
||||
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
|
||||
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
|
||||
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
|
||||
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
|
||||
|
||||
// Determine color based on remaining percentage
|
||||
const getStatusColor = (percentage) => {
|
||||
const numericPercentage = parseFloat(percentage);
|
||||
if (isNaN(numericPercentage) || numericPercentage < 20)
|
||||
return "text-red-500 dark:text-red-400";
|
||||
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
|
||||
return "text-green-500 dark:text-green-400";
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
|
||||
<span>Quota:</span>
|
||||
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
|
||||
{hourlyPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-white dark:bg-gray-800 border dark:border-gray-700">
|
||||
<div className="space-y-3 p-2">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Project Hourly
|
||||
</div>
|
||||
<div className={`${getStatusColor(hourlyPercentage)}`}>
|
||||
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Daily
|
||||
</div>
|
||||
<div className={`${getStatusColor(dailyPercentage)}`}>
|
||||
{dailyRemaining.toLocaleString()} / 200,000 remaining
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Server Errors
|
||||
</div>
|
||||
<div className={`${getStatusColor(errorPercentage)}`}>
|
||||
{errorsConsumed} / 10 used this hour
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Thresholded Requests
|
||||
</div>
|
||||
<div className={`${getStatusColor(thresholdPercentage)}`}>
|
||||
{thresholdConsumed} / 120 used this hour
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const RealtimeAnalytics = () => {
|
||||
const [basicData, setBasicData] = useState({
|
||||
last30MinUsers: 0,
|
||||
last5MinUsers: 0,
|
||||
byMinute: [],
|
||||
tokenQuota: null,
|
||||
lastUpdated: null,
|
||||
});
|
||||
|
||||
const [detailedData, setDetailedData] = useState({
|
||||
currentPages: [],
|
||||
sources: [],
|
||||
recentEvents: [],
|
||||
lastUpdated: null,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const processBasicData = (data) => {
|
||||
const last30MinUsers = parseInt(
|
||||
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||
);
|
||||
const last5MinUsers = parseInt(
|
||||
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
|
||||
);
|
||||
|
||||
const byMinute = Array.from({ length: 30 }, (_, i) => {
|
||||
const matchingRow = data.timeSeriesResponse?.rows?.find(
|
||||
(row) => parseInt(row.dimensionValues[0].value) === i
|
||||
);
|
||||
const users = matchingRow ? parseInt(matchingRow.metricValues[0].value) : 0;
|
||||
const timestamp = new Date(Date.now() - i * 60000);
|
||||
return {
|
||||
minute: -i,
|
||||
users,
|
||||
timestamp: timestamp.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
};
|
||||
}).reverse();
|
||||
|
||||
const tokenQuota = data.quotaInfo
|
||||
? {
|
||||
projectHourly: data.quotaInfo.projectHourly || {},
|
||||
daily: data.quotaInfo.daily || {},
|
||||
serverErrors: data.quotaInfo.serverErrors || {},
|
||||
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
last30MinUsers,
|
||||
last5MinUsers,
|
||||
byMinute,
|
||||
tokenQuota,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const processDetailedData = (data) => {
|
||||
return {
|
||||
currentPages:
|
||||
data.pageResponse?.rows?.map((row) => ({
|
||||
path: row.dimensionValues[0].value,
|
||||
activeUsers: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
|
||||
sources:
|
||||
data.sourceResponse?.rows?.map((row) => ({
|
||||
source: row.dimensionValues[0].value,
|
||||
activeUsers: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
|
||||
recentEvents:
|
||||
data.eventResponse?.rows
|
||||
?.filter(
|
||||
(row) =>
|
||||
!["session_start", "(other)"].includes(row.dimensionValues[0].value)
|
||||
)
|
||||
.map((row) => ({
|
||||
event: row.dimensionValues[0].value,
|
||||
count: parseInt(row.metricValues[0].value),
|
||||
})) || [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let basicInterval;
|
||||
let detailedInterval;
|
||||
|
||||
const fetchBasicData = async () => {
|
||||
if (isPaused) return;
|
||||
try {
|
||||
const response = await fetch("/api/analytics/realtime/basic", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch basic realtime data");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const processed = processBasicData(result.data);
|
||||
setBasicData(processed);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response,
|
||||
});
|
||||
if (error.message === "QUOTA_EXCEEDED") {
|
||||
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||
setIsPaused(true);
|
||||
} else {
|
||||
setError("Failed to fetch analytics data");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDetailedData = async () => {
|
||||
if (isPaused) return;
|
||||
try {
|
||||
const response = await fetch("/api/analytics/realtime/detailed", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch detailed realtime data");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const processed = processDetailedData(result.data);
|
||||
setDetailedData(processed);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch detailed realtime data:", error);
|
||||
if (error.message === "QUOTA_EXCEEDED") {
|
||||
setError("Quota exceeded. Analytics paused until manually resumed.");
|
||||
setIsPaused(true);
|
||||
} else {
|
||||
setError("Failed to fetch analytics data");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetches
|
||||
fetchBasicData();
|
||||
fetchDetailedData();
|
||||
|
||||
// Set up intervals
|
||||
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
|
||||
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
|
||||
|
||||
return () => {
|
||||
clearInterval(basicInterval);
|
||||
clearInterval(detailedInterval);
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const togglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
if (loading && !basicData && !detailedData) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardContent className="h-96 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Real-Time Analytics
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last updated: {format(new Date(basicData.lastUpdated), "HH:mm:ss")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<QuotaInfo tokenQuota={basicData.tokenQuota} />
|
||||
<Button
|
||||
variant={isPaused ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={togglePause}
|
||||
>
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{summaryCard(
|
||||
"Active Users",
|
||||
"Last 30 minutes",
|
||||
basicData.last30MinUsers
|
||||
)}
|
||||
{summaryCard(
|
||||
"Recent Activity",
|
||||
"Last 5 minutes",
|
||||
basicData.last5MinUsers
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="activity" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={basicData.byMinute}>
|
||||
<XAxis
|
||||
dataKey="minute"
|
||||
tickFormatter={(value) => value + "m ago"}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
<YAxis className="text-gray-600 dark:text-gray-300" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{payload[0].payload.minute}m ago
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{payload[0].value} active users
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="users" fill="#8b5cf6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pages">
|
||||
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Page
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.currentPages.map((page, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{page.path}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{page.activeUsers}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources">
|
||||
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Source
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.sources.map((source, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{source.source}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{source.activeUsers}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RealtimeAnalytics;
|
||||
349
dashboard/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
349
dashboard/src/components/dashboard/UserBehaviorDashboard.jsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export const UserBehaviorDashboard = () => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState("30");
|
||||
|
||||
const processPageData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in page data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows.map((row) => ({
|
||||
path: row.dimensionValues[0].value || "Unknown",
|
||||
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
|
||||
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
|
||||
engagedSessions: parseInt(row.metricValues[3].value || 0),
|
||||
}));
|
||||
};
|
||||
|
||||
const processDeviceData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in device data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows
|
||||
.filter((row) => {
|
||||
const device = (row.dimensionValues[0].value || "").toLowerCase();
|
||||
return ["desktop", "mobile", "tablet"].includes(device);
|
||||
})
|
||||
.map((row) => {
|
||||
const device = row.dimensionValues[0].value || "Unknown";
|
||||
return {
|
||||
device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
|
||||
pageViews: parseInt(row.metricValues[0].value || 0),
|
||||
sessions: parseInt(row.metricValues[1].value || 0),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.pageViews - a.pageViews);
|
||||
};
|
||||
|
||||
const processSourceData = (data) => {
|
||||
if (!data?.rows) {
|
||||
console.log("No rows in source data");
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.rows.map((row) => ({
|
||||
source: row.dimensionValues[0].value || "Unknown",
|
||||
sessions: parseInt(row.metricValues[0].value || 0),
|
||||
conversions: parseInt(row.metricValues[1].value || 0),
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/analytics/user-behavior?timeRange=${timeRange}`,
|
||||
{
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch user behavior");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Raw user behavior response:", result);
|
||||
|
||||
if (!result?.success) {
|
||||
throw new Error("Invalid response structure");
|
||||
}
|
||||
|
||||
// Handle both data structures
|
||||
const rawData = result.data?.data || result.data;
|
||||
|
||||
// Try to access the data differently based on the structure
|
||||
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
|
||||
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
|
||||
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
|
||||
|
||||
console.log("Extracted responses:", {
|
||||
pageResponse,
|
||||
deviceResponse,
|
||||
sourceResponse,
|
||||
});
|
||||
|
||||
const processed = {
|
||||
success: true,
|
||||
data: {
|
||||
pageData: {
|
||||
pageData: processPageData(pageResponse),
|
||||
deviceData: processDeviceData(deviceResponse),
|
||||
},
|
||||
sourceData: processSourceData(sourceResponse),
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Final processed data:", processed);
|
||||
setData(processed);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch behavior data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardContent className="h-96 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
desktop: "#8b5cf6", // Purple
|
||||
mobile: "#10b981", // Green
|
||||
tablet: "#f59e0b", // Yellow
|
||||
};
|
||||
|
||||
const deviceData = data?.data?.pageData?.deviceData || [];
|
||||
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
|
||||
const totalSessions = deviceData.reduce(
|
||||
(sum, item) => sum + item.sessions,
|
||||
0
|
||||
);
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
|
||||
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(
|
||||
1
|
||||
);
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{data.device}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{data.pageViews.toLocaleString()} views ({percentage}%)
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
User Behavior Analysis
|
||||
</CardTitle>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
|
||||
<SelectValue>
|
||||
{timeRange === "7" && "Last 7 days"}
|
||||
{timeRange === "14" && "Last 14 days"}
|
||||
{timeRange === "30" && "Last 30 days"}
|
||||
{timeRange === "90" && "Last 90 days"}
|
||||
</SelectValue>
|
||||
</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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="pages" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="pages">Top Pages</TabsTrigger>
|
||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value="pages"
|
||||
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Page Path
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Views
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Bounce Rate
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Avg. Duration
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data?.pageData?.pageData.map((page, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{page.path}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{page.pageViews.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{page.bounceRate.toFixed(1)}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{formatDuration(page.avgSessionDuration)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="sources"
|
||||
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
Source
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Sessions
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Conversions
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
Conv. Rate
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data?.sourceData?.map((source, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{source.source}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{source.sessions.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{source.conversions.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600 dark:text-gray-300">
|
||||
{((source.conversions / source.sessions) * 100).toFixed(1)}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="devices"
|
||||
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
||||
>
|
||||
<div className="h-60">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={deviceData}
|
||||
dataKey="pageViews"
|
||||
nameKey="device"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
labelLine={false}
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(1)}%`
|
||||
}
|
||||
>
|
||||
{deviceData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[entry.device.toLowerCase()]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBehaviorDashboard;
|
||||
Reference in New Issue
Block a user