Add gorgias component and services

This commit is contained in:
2024-12-27 16:51:19 -05:00
parent 6b7eae3473
commit 9e0a6a9b6a
24 changed files with 4287 additions and 1 deletions

View File

@@ -0,0 +1,365 @@
//src/components/dashboard/AnalyticsDashboard.jsx
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";
import { googleAnalyticsService } from "../../services/googleAnalyticsService";
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 result = await googleAnalyticsService.getBasicMetrics({
startDate: `${timeRange}daysAgo`,
});
if (result) {
const processedData = result.map((item) => ({
...item,
date: formatGADate(item.date),
}));
const sortedData = processedData.sort((a, b) => a.date - b.date);
setData(sortedData);
} else {
console.log("No result data received");
}
} 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" },
newUsers: { label: "New Users", color: "#10b981" },
pageViews: { label: "Page Views", color: "#f59e0b" },
conversions: { label: "Conversions", color: "#3b82f6" },
};
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>
)}
<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>
</CardHeader>
<CardContent>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 5, left: -10, bottom: 5 }}
animationBegin={0}
animationDuration={1000}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e5e7eb"
className="dark:stroke-gray-800"
/>
<XAxis
dataKey="date"
tickFormatter={formatXAxisDate}
type="category"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
/>
<YAxis
yAxisId="left"
orientation="left"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
domain={[0, "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value) => (
<span className="text-gray-900 dark:text-gray-100">
{value}
</span>
)}
/>
{/* Always render Lines and control visibility */}
{Object.entries(metrics).map(([key, { color, label }]) => (
<Line
key={key}
yAxisId={key === "pageViews" ? "right" : "left"}
type="monotone"
dataKey={key}
stroke={color}
name={label}
strokeWidth={2}
dot={{ strokeWidth: 2, r: 2, fill: color }}
activeDot={{ r: 6, fill: color }}
hide={!selectedMetrics[key]} // Control visibility
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
};
export default AnalyticsDashboard;

View File

@@ -0,0 +1,401 @@
import React, { useState, useEffect, useCallback } from "react";
import gorgiasService from "../../services/gorgiasService";
import { getDateRange } from "../../utils/dateUtils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Clock, Star, Users, MessageSquare, Mail, Send } from "lucide-react";
const TIME_RANGES = {
7: "Last 7 Days",
14: "Last 14 Days",
30: "Last 30 Days",
90: "Last 90 Days",
};
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
const MetricCard = ({
title,
value,
delta,
suffix = "",
icon: Icon,
colorClass = "blue",
more_is_better = true,
loading = false,
}) => {
const getDeltaColor = (d) => {
if (d === 0) return "text-gray-600 dark:text-gray-400";
const isPositive = d > 0;
return isPositive === more_is_better
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
};
const formatDelta = (d) => {
if (d === undefined || d === null) return null;
if (d === 0) return "0";
return (d > 0 ? "+" : "") + d + suffix;
};
const colorMapping = {
blue: "bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/50 text-blue-600 dark:text-blue-400",
green:
"bg-green-50 dark:bg-green-900/20 border-green-100 dark:border-green-800/50 text-green-600 dark:text-green-400",
purple:
"bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/50 text-purple-600 dark:text-purple-400",
indigo:
"bg-indigo-50 dark:bg-indigo-900/20 border-indigo-100 dark:border-indigo-800/50 text-indigo-600 dark:text-indigo-400",
orange:
"bg-orange-50 dark:bg-orange-900/20 border-orange-100 dark:border-orange-800/50 text-orange-600 dark:text-orange-400",
teal: "bg-teal-50 dark:bg-teal-900/20 border-teal-100 dark:border-teal-800/50 text-teal-600 dark:text-teal-400",
cyan: "bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800/50 text-cyan-600 dark:text-cyan-400",
};
const baseColors = colorMapping[colorClass];
return (
<div className={`p-3 rounded-lg border transition-colors ${baseColors}`}>
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300">
{title}
</h3>
<div className="flex items-baseline gap-2 mt-1">
{loading ? (
<Skeleton className="h-8 w-24 dark:bg-gray-700" />
) : (
<>
<div className="flex items-center gap-2">
{Icon && <Icon className="w-4 h-4" />}
<p className="text-xl font-bold">
{typeof value === "number"
? value.toLocaleString() + suffix
: value}
</p>
</div>
{delta !== undefined && (
<p className={`text-xs font-medium ${getDeltaColor(delta)}`}>
{formatDelta(delta)}
</p>
)}
</>
)}
</div>
</div>
);
};
const TableSkeleton = () => (
<div className="space-y-2">
<Skeleton className="h-8 w-full dark:bg-gray-700" />
<Skeleton className="h-8 w-full dark:bg-gray-700" />
<Skeleton className="h-8 w-full dark:bg-gray-700" />
<Skeleton className="h-8 w-full dark:bg-gray-700" />
</div>
);
const GorgiasSummary = () => {
const [timeRange, setTimeRange] = useState(7);
const [data, setData] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadStats = useCallback(async () => {
setLoading(true);
const filters = getDateRange(timeRange);
try {
const [overview, channelStats, agentStats, satisfaction, selfService] =
await Promise.all([
gorgiasService.fetchStatistics("overview", filters),
gorgiasService.fetchStatistics(
"tickets-created-per-channel",
filters
),
gorgiasService.fetchStatistics("tickets-closed-per-agent", filters),
gorgiasService.fetchStatistics("satisfaction-surveys", filters),
gorgiasService.fetchStatistics("self-service-overview", filters),
]);
setData({
overview: overview.data.data.data || [],
channels: channelStats.data.data.data.lines || [],
agents: agentStats.data.data.data.lines || [],
satisfaction: satisfaction.data.data.data || [],
selfService: selfService.data.data.data || [],
});
setError(null);
} catch (err) {
console.error("Error loading stats:", err);
setError(err.message);
} finally {
setLoading(false);
}
}, [timeRange]);
useEffect(() => {
loadStats();
// Set up auto-refresh every 5 minutes
const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadStats]);
// Convert overview array to object for easier access
const stats =
data.overview?.reduce((acc, item) => {
acc[item.name] = item;
return acc;
}, {}) || {};
// Process satisfaction data
const satisfactionStats =
data.satisfaction?.reduce((acc, item) => {
acc[item.name] = item;
return acc;
}, {}) || {};
// Process self-service data
const selfServiceStats =
data.selfService?.reduce((acc, item) => {
acc[item.name] = item;
return acc;
}, {}) || {};
if (error) return <p className="text-red-500">Error: {error}</p>;
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader>
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Customer Service
</h2>
<div className="flex items-center gap-2">
<Select
value={String(timeRange)}
onValueChange={(value) => setTimeRange(Number(value))}
>
<SelectTrigger className="w-[140px] bg-white dark:bg-gray-800">
{TIME_RANGES[timeRange]}
</SelectTrigger>
<SelectContent>
{Object.entries(TIME_RANGES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Message & Response Metrics */}
<MetricCard
title="Messages Received"
value={stats.total_messages_received?.value}
delta={stats.total_messages_received?.delta}
icon={Mail}
colorClass="blue"
loading={loading}
/>
<MetricCard
title="Messages Sent"
value={stats.total_messages_sent?.value}
delta={stats.total_messages_sent?.delta}
icon={Send}
colorClass="green"
loading={loading}
/>
<MetricCard
title="First Response"
value={formatDuration(stats.median_first_response_time?.value)}
delta={stats.median_first_response_time?.delta}
icon={Clock}
colorClass="purple"
more_is_better={false}
loading={loading}
/>
<MetricCard
title="One-Touch Rate"
value={stats.total_one_touch_tickets?.value}
delta={stats.total_one_touch_tickets?.delta}
suffix="%"
icon={MessageSquare}
colorClass="indigo"
loading={loading}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Satisfaction & Efficiency */}
<MetricCard
title="Customer Satisfaction"
value={`${satisfactionStats.average_rating?.value}/5`}
delta={satisfactionStats.average_rating?.delta}
suffix="%"
icon={Star}
colorClass="orange"
loading={loading}
/>
<MetricCard
title="Survey Response Rate"
value={satisfactionStats.response_rate?.value}
delta={satisfactionStats.response_rate?.delta}
suffix="%"
colorClass="orange"
loading={loading}
/>
<MetricCard
title="Resolution Time"
value={formatDuration(stats.median_resolution_time?.value)}
delta={stats.median_resolution_time?.delta}
icon={Clock}
colorClass="teal"
more_is_better={false}
loading={loading}
/>
<MetricCard
title="Self-Service Rate"
value={selfServiceStats.self_service_automation_rate?.value}
delta={selfServiceStats.self_service_automation_rate?.delta}
suffix="%"
colorClass="cyan"
loading={loading}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Channel Distribution */}
<div className="bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800 p-4 pt-0">
<div className="p-4 pl-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Channel Distribution
</h3>
</div>
{loading ? (
<div className="p-4">
<TableSkeleton />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead>Channel</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">%</TableHead>
<TableHead className="text-right">Δ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.channels
.sort((a, b) => b[1].value - a[1].value)
.map((line, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="dark:text-gray-300">
{line[0].value}
</TableCell>
<TableCell className="text-right dark:text-gray-300">
{line[1].value}
</TableCell>
<TableCell className="text-right dark:text-gray-300">
{line[2].value}%
</TableCell>
<TableCell
className={`text-right ${
line[3].value > 0
? "text-green-600 dark:text-green-500"
: line[3].value < 0
? "text-red-600 dark:text-red-500"
: "dark:text-gray-300"
}`}
>
{line[3].value > 0 ? "+" : ""}
{line[3].value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{/* Agent Performance - Same dark mode updates */}
<div className="bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800 p-4 pt-0">
<div className="p-4 pl-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Agent Performance
</h3>
</div>
{loading ? (
<div className="p-4">
<TableSkeleton />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead>Agent</TableHead>
<TableHead className="text-right">Closed</TableHead>
<TableHead className="text-right">Rating</TableHead>
<TableHead className="text-right">Δ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.agents
.filter((line) => line[0].value !== "Unassigned")
.map((line, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="dark:text-gray-300">
{line[0].value}
</TableCell>
<TableCell className="text-right dark:text-gray-300">
{line[1].value}
</TableCell>
<TableCell className="text-right dark:text-gray-300">
{line[2].value ? `${line[2].value}/5` : "-"}
</TableCell>
<TableCell
className={`text-right ${
line[4].value > 0
? "text-green-600 dark:text-green-500"
: line[4].value < 0
? "text-red-600 dark:text-red-500"
: "dark:text-gray-300"
}`}
>
{line[4].value > 0 ? "+" : ""}
{line[4].value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</CardContent>
</Card>
);
};
export default GorgiasSummary;

View File

@@ -0,0 +1,529 @@
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 { googleAnalyticsService } from "../../services/googleAnalyticsService";
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>
);
};
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);
useEffect(() => {
let basicInterval;
let detailedInterval;
const fetchBasicData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/analytics/realtime/basic", {
credentials: "include",
});
const result = await response.json();
const processed = await googleAnalyticsService.getRealTimeBasicData();
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 result = await googleAnalyticsService.getRealTimeDetailedData();
setDetailedData(result);
} 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);
};
}, []);
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>
);
}
// Pie chart colors
const COLORS = [
"#8b5cf6",
"#10b981",
"#f59e0b",
"#3b82f6",
"#0088FE",
"#00C49F",
"#FFBB28",
];
// Handle 'other' in data
const totalUsers = detailedData.sources.reduce(
(sum, source) => sum + source.users,
0
);
const sourcesData = detailedData.sources.map((source) => {
const percent = (source.users / totalUsers) * 100;
return { ...source, percent };
});
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Realtime Analytics
</CardTitle>
<div className="flex items-center space-x-4">
{basicData?.data?.quotaInfo && (
<QuotaInfo tokenQuota={basicData.data.quotaInfo} />
)}
<div className="text-xs text-gray-500 dark:text-gray-400">
Last updated:{" "}
{basicData.lastUpdated
? format(new Date(basicData.lastUpdated), "p")
: "N/A"}
</div>
</div>{" "}
</div>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 mt-4">
{[
{
label: "Last 30 Minutes",
sublabel: "Active Users",
value: basicData.last30MinUsers,
},
{
label: "Last 5 Minutes",
sublabel: "Active Users",
value: basicData.last5MinUsers,
},
].map((card) => (
<div key={card.label}>
{summaryCard(card.label, card.sublabel, card.value)}
</div>
))}
</div>
</CardHeader>
<CardContent className="p-4">
{/* User Activity Chart */}
<div className="mb-6">
<Card className="bg-white dark:bg-gray-900">
<CardHeader className="pb-1">
<CardTitle className="text-md font-bold">
Active Users Per Minute
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={basicData.byMinute}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
<p className="text-gray-900 dark:text-gray-100">{`${payload[0].value} active users`}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{payload[0].payload.timestamp}
</p>
</div>
);
}
return null;
}}
/>{" "}
<Bar dataKey="users" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Tabs for Detailed Data */}
<Tabs defaultValue="pages" className="w-full">
<TabsList className="p-0 sm:p-1 md:p-0 lg:p-1 -ml-4 sm:ml-0 md:-ml-4 lg:ml-0">
<TabsTrigger value="pages">Current Pages</TabsTrigger>
<TabsTrigger value="events">Recent Events</TabsTrigger>
<TabsTrigger value="sources">Active Devices</TabsTrigger>
</TabsList>
{/* Current Pages Tab */}
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<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">
Views
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.currentPages.map(({ page, views }, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatNumber(views)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
{/* Recent Events Tab */}
<TabsContent
value="events"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Event
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Count
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.recentEvents.map(({ event, count }, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{event}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatNumber(count)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
{/* Active Devices Tab */}
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl: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 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
{" "}
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={sourcesData}
dataKey="users"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ device, percent }) =>
`${device}: ${percent.toFixed(0)}%`
}
>
{sourcesData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
<p className="text-gray-900 dark:text-gray-100">
{payload[0].payload.device}
</p>
<p className="text-gray-600 dark:text-gray-300">{`${formatNumber(
payload[0].value
)} users`}</p>
</div>
);
}
return null;
}}
/>
</PieChart>{" "}
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
{sourcesData.map((source, index) => (
<div
key={index}
className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div className="text-lg font-medium text-gray-900 dark:text-gray-100">
{source.device}
</div>
<div className="mt-1">
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatNumber(source.users)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{source.percent.toFixed(0)}% of users
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default RealtimeAnalytics;

View File

@@ -0,0 +1,297 @@
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";
import { googleAnalyticsService } from "../../services/googleAnalyticsService";
export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const result = await googleAnalyticsService.getUserBehavior(timeRange);
if (result) {
setData(result);
}
} 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 />} />
<Legend
formatter={(value) => (
<span className="text-gray-900 dark:text-gray-100">
{value}
</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
{deviceData.map((device) => (
<div
key={device.device}
className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div className="text-lg font-medium text-gray-900 dark:text-gray-100">
{device.device}
</div>
<div className="mt-0">
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
{device.pageViews.toLocaleString()}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{((device.pageViews / totalViews) * 100).toFixed(1)}% of
views
</div>
</div>
<div className="mt-1">
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{device.sessions.toLocaleString()}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{((device.sessions / totalSessions) * 100).toFixed(1)}% of
sessions
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,338 @@
// services/analytics.service.js
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
const Analytics = require('../models/analytics.model');
const { createClient } = require('redis');
const logger = require('../utils/logger');
class AnalyticsService {
constructor() {
// Initialize Redis client
this.redis = createClient({
url: process.env.REDIS_URL
});
this.redis.on('error', err => logger.error('Redis Client Error:', err));
this.redis.connect().catch(err => logger.error('Redis connection error:', err));
// Initialize GA4 client
this.analyticsClient = new BetaAnalyticsDataClient({
credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON)
});
this.propertyId = process.env.GA_PROPERTY_ID;
}
async getBasicMetrics(params = {}) {
const cacheKey = `analytics:basic_metrics:${JSON.stringify(params)}`;
logger.info(`Fetching basic metrics with params:`, params);
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
logger.info('Analytics metrics found in Redis cache');
return JSON.parse(cachedData);
}
// Check MongoDB using new findValidCache method
const mongoData = await Analytics.findValidCache('basic_metrics', params);
if (mongoData) {
logger.info('Analytics metrics found in MongoDB');
const formattedData = mongoData.formatResponse();
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('basic_metrics')
});
return formattedData;
}
// Fetch fresh data from GA4
logger.info('Fetching fresh metrics data from GA4');
const [response] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{
startDate: params.startDate || '7daysAgo',
endDate: 'today'
}],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'activeUsers' },
{ name: 'newUsers' },
{ name: 'averageSessionDuration' },
{ name: 'screenPageViews' },
{ name: 'bounceRate' },
{ name: 'conversions' }
],
returnPropertyQuota: true
});
// Create new Analytics document with fresh data
const analyticsDoc = await Analytics.create({
type: 'basic_metrics',
params,
data: response,
quotaInfo: response.propertyQuota
});
const formattedData = analyticsDoc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('basic_metrics')
});
return formattedData;
} catch (error) {
logger.error('Error fetching analytics metrics:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
async getRealTimeBasicData() {
const cacheKey = 'analytics:realtime:basic';
logger.info('Fetching realtime basic data');
try {
// Try Redis first
const [cachedData, lastUpdated] = await Promise.all([
this.redis.get(cacheKey),
this.redis.get(`${cacheKey}:lastUpdated`)
]);
if (cachedData) {
logger.info('Realtime basic data found in Redis cache:', cachedData);
return {
...JSON.parse(cachedData),
lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : new Date().toISOString()
};
}
// Fetch fresh data
logger.info(`Fetching fresh realtime data from GA4 server`);
const [userResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
returnPropertyQuota: true
});
logger.info('GA4 user response:', userResponse);
const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }]
});
const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'minutesAgo' }],
metrics: [{ name: 'activeUsers' }]
});
// Create new Analytics document
const analyticsDoc = await Analytics.create({
type: 'realtime_basic',
data: {
userResponse,
fiveMinResponse,
timeSeriesResponse,
quotaInfo: {
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
daily: userResponse.propertyQuota.tokensPerDay,
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour
}
},
quotaInfo: userResponse.propertyQuota
});
const formattedData = analyticsDoc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('realtime_basic')
});
return formattedData;
} catch (error) {
logger.error('Detailed error in getRealTimeBasicData:', {
message: error.message,
stack: error.stack,
code: error.code,
response: error.response?.data
});
throw error;
}
}
async getRealTimeDetailedData() {
const cacheKey = 'analytics:realtime:detailed';
logger.info('Fetching realtime detailed data');
try {
// Check Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
logger.info('Realtime detailed data found in Redis cache');
return JSON.parse(cachedData);
}
// Fetch fresh data from GA4
const [pageResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'unifiedScreenName' }],
metrics: [{ name: 'screenPageViews' }],
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 25
});
const [eventResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'eventName' }],
metrics: [{ name: 'eventCount' }],
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
limit: 25
});
const [deviceResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'deviceCategory' }],
metrics: [{ name: 'activeUsers' }],
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
limit: 10,
returnPropertyQuota: true
});
// Create new Analytics document
const analyticsDoc = await Analytics.create({
type: 'realtime_detailed',
data: {
pageResponse,
eventResponse,
sourceResponse: deviceResponse
},
quotaInfo: deviceResponse.propertyQuota
});
const formattedData = analyticsDoc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('realtime_detailed')
});
return formattedData;
} catch (error) {
logger.error('Error fetching realtime detailed data:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
async getUserBehavior(params = {}) {
const cacheKey = `analytics:user_behavior:${JSON.stringify(params)}`;
const timeRange = params.timeRange || '7';
logger.info('Fetching user behavior data', { params });
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
logger.info('User behavior data found in Redis cache');
return JSON.parse(cachedData);
}
// Check MongoDB using new findValidCache method
const mongoData = await Analytics.findValidCache('user_behavior', params);
if (mongoData) {
logger.info('User behavior data found in MongoDB');
const formattedData = mongoData.formatResponse();
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('user_behavior')
});
return formattedData;
}
// Fetch fresh data from GA4
logger.info('Fetching fresh user behavior data from GA4');
const [pageResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'pagePath' }],
metrics: [
{ name: 'screenPageViews' },
{ name: 'averageSessionDuration' },
{ name: 'bounceRate' },
{ name: 'sessions' }
],
orderBy: [{
metric: { metricName: 'screenPageViews' },
desc: true
}],
limit: 25
});
const [deviceResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'deviceCategory' }],
metrics: [
{ name: 'screenPageViews' },
{ name: 'sessions' }
]
});
const [sourceResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'sessionSource' }],
metrics: [
{ name: 'sessions' },
{ name: 'conversions' }
],
orderBy: [{
metric: { metricName: 'sessions' },
desc: true
}],
limit: 25,
returnPropertyQuota: true
});
// Create new Analytics document
const analyticsDoc = await Analytics.create({
type: 'user_behavior',
params,
data: {
pageResponse,
deviceResponse,
sourceResponse
},
quotaInfo: sourceResponse.propertyQuota
});
const formattedData = analyticsDoc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Analytics.getCacheDuration('user_behavior')
});
return formattedData;
} catch (error) {
logger.error('Error fetching user behavior data:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
}
module.exports = new AnalyticsService();

View File

@@ -0,0 +1,292 @@
// src/services/googleAnalyticsService.js
class GoogleAnalyticsService {
constructor() {
this.baseUrl = "/api/analytics"; // This matches your NGINX config
}
async getBasicMetrics({ startDate = "7daysAgo" } = {}) {
try {
const response = await fetch(
`${this.baseUrl}/metrics?startDate=${startDate}`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch metrics");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
return this.processMetricsData(result.data);
} catch (error) {
console.error("Failed to fetch basic metrics:", error);
throw error;
}
}
async getRealTimeBasicData() {
try {
const response = await fetch(`${this.baseUrl}/realtime/basic`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch basic realtime data");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
const processed = this.processRealTimeBasicData(result.data);
return {
...processed,
lastUpdated: result.lastUpdated || new Date().toISOString(),
};
} catch (error) {
console.error("Failed to fetch basic realtime data:", error);
throw error;
}
}
async getRealTimeDetailedData() {
try {
const response = await fetch(`${this.baseUrl}/realtime/detailed`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch detailed realtime data");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
const processed = this.processRealTimeDetailedData(result.data);
return {
...processed,
lastUpdated: result.lastUpdated || new Date().toISOString(),
};
} catch (error) {
console.error("Failed to fetch detailed realtime data:", error);
throw error;
}
}
async getUserBehavior(timeRange = "30") {
try {
const response = await fetch(
`${this.baseUrl}/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: this.processPageData(pageResponse),
deviceData: this.processDeviceData(deviceResponse),
},
sourceData: this.processSourceData(sourceResponse),
},
};
console.log("Final processed data:", processed);
return processed;
} catch (error) {
console.error("Failed to fetch user behavior:", error);
throw error;
}
}
processMetricsData(data) {
if (!data?.rows) {
console.log("No rows found in data");
return [];
}
return data.rows.map((row) => ({
date: 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),
}));
}
processRealTimeBasicData(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,
};
}
processRealTimeDetailedData(data) {
return {
currentPages:
data.pageResponse?.rows?.map((row) => ({
page: row.dimensionValues[0].value,
views: parseInt(row.metricValues[0].value),
})) || [],
sources:
data.sourceResponse?.rows?.map((row) => ({
device: row.dimensionValues[0].value,
users: 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),
})) || [],
};
}
processPageData(data) {
console.log("Processing page data input:", data);
if (!data?.rows) {
console.log("No rows in page data");
return [];
}
const processed = 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),
}));
console.log("Processed page data:", processed);
return processed;
}
processDeviceData(data) {
console.log("Processing device data input:", data);
if (!data?.rows) {
console.log("No rows in device data");
return [];
}
const processed = 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);
console.log("Processed device data:", processed);
return processed;
}
processSourceData(data) {
console.log("Processing source data input:", data);
if (!data?.rows) {
console.log("No rows in source data");
return [];
}
const processed = data.rows.map((row) => ({
source: row.dimensionValues[0].value || "Unknown",
sessions: parseInt(row.metricValues[0].value || 0),
conversions: parseInt(row.metricValues[1].value || 0),
}));
console.log("Processed source data:", processed);
return processed;
}
}
// Create a single instance
const service = new GoogleAnalyticsService();
// Export both the instance and the class
export { service as googleAnalyticsService, GoogleAnalyticsService };

View File

@@ -0,0 +1,129 @@
const axios = require('axios');
const { createClient } = require('redis');
const Gorgias = require('../models/gorgias.model');
const logger = require('../utils/logger');
class GorgiasService {
constructor() {
this.redis = createClient({
url: process.env.REDIS_URL
});
this.redis.on('error', err => logger.error('Redis Client Error:', err));
this.redis.connect().catch(err => logger.error('Redis connection error:', err));
this.apiClient = axios.create({
baseURL: 'https://acherryontop.gorgias.com/api',
auth: {
username: process.env.GORGIAS_API_USERNAME,
password: process.env.GORGIAS_API_PASSWORD
}
});
}
async getStatistics(name, filters = {}) {
const cacheKey = `gorgias:stats:${name}:${JSON.stringify(filters)}`;
logger.info(`Attempting to fetch statistics for ${name}`, {
filters,
cacheKey
});
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
logger.info(`Statistics ${name} found in Redis cache`);
return JSON.parse(cachedData);
}
// Check MongoDB
const mongoData = await Gorgias.findValidCache(name, filters);
if (mongoData) {
logger.info(`Statistics ${name} found in MongoDB`);
const formattedData = mongoData.formatResponse();
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Gorgias.getCacheDuration(name)
});
return formattedData;
}
// Fetch from API
const response = await this.apiClient.post(`/stats/${name}`, { filters });
// Save to MongoDB
const doc = await Gorgias.create({
type: name,
params: filters,
data: response.data
});
const formattedData = doc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Gorgias.getCacheDuration(name)
});
return formattedData;
} catch (error) {
logger.error(`Error in getStatistics for ${name}:`, {
error: error.message,
stack: error.stack,
filters,
response: error.response?.data
});
throw error;
}
}
async getTickets() {
const cacheKey = 'gorgias:tickets';
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
logger.info('Tickets found in Redis cache');
return JSON.parse(cachedData);
}
// Check MongoDB
const mongoData = await Gorgias.findValidCache('tickets');
if (mongoData) {
logger.info('Tickets found in MongoDB');
const formattedData = mongoData.formatResponse();
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Gorgias.getCacheDuration('tickets')
});
return formattedData;
}
// Fetch from API
const response = await this.apiClient.get('/tickets');
// Save to MongoDB
const doc = await Gorgias.create({
type: 'tickets',
data: response.data
});
const formattedData = doc.formatResponse();
// Save to Redis
await this.redis.set(cacheKey, JSON.stringify(formattedData), {
EX: Gorgias.getCacheDuration('tickets')
});
return formattedData;
} catch (error) {
logger.error('Error fetching tickets:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
}
module.exports = new GorgiasService();

View File

@@ -0,0 +1,39 @@
// src/services/gorgiasService.js
import axios from "axios";
const API_BASE_URL = "/api/gorgias";
// Helper function for consistent error handling
const handleError = (error, context) => {
console.error(`Error ${context}:`, error.response?.data || error.message);
throw error;
};
// Export the service object directly
const gorgiasService = {
async fetchTickets() {
try {
const response = await axios.get(`${API_BASE_URL}/tickets`);
return response.data.data || [];
} catch (error) {
handleError(error, "fetching tickets");
}
},
async fetchStatistics(name, filters = {}) {
if (!name) {
throw new Error("Statistic name is required");
}
try {
const response = await axios.post(`${API_BASE_URL}/stats/${name}`, {
filters,
});
return response.data;
} catch (error) {
handleError(error, `fetching statistics: ${name}`);
}
},
};
export default gorgiasService;