Merge branch 'Add-gorgias-api'
This commit is contained in:
BIN
dashboard-server/._gorgias-server
Normal file
BIN
dashboard-server/._gorgias-server
Normal file
Binary file not shown.
@@ -129,6 +129,22 @@ module.exports = {
|
|||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 3005
|
PORT: 3005
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gorgias-server",
|
||||||
|
script: "./gorgias-server/server.js",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "development",
|
||||||
|
PORT: 3006
|
||||||
|
},
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
PORT: 3006
|
||||||
|
},
|
||||||
|
error_file: "./logs/gorgias-server-error.log",
|
||||||
|
out_file: "./logs/gorgias-server-out.log",
|
||||||
|
log_file: "./logs/gorgias-server-combined.log",
|
||||||
|
time: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
dashboard-server/gorgias-server/._.env
Normal file
BIN
dashboard-server/gorgias-server/._.env
Normal file
Binary file not shown.
BIN
dashboard-server/gorgias-server/._package-lock.json
generated
Normal file
BIN
dashboard-server/gorgias-server/._package-lock.json
generated
Normal file
Binary file not shown.
BIN
dashboard-server/gorgias-server/._package.json
Normal file
BIN
dashboard-server/gorgias-server/._package.json
Normal file
Binary file not shown.
BIN
dashboard-server/gorgias-server/._routes
Normal file
BIN
dashboard-server/gorgias-server/._routes
Normal file
Binary file not shown.
BIN
dashboard-server/gorgias-server/._server.js
Normal file
BIN
dashboard-server/gorgias-server/._server.js
Normal file
Binary file not shown.
BIN
dashboard-server/gorgias-server/._services
Normal file
BIN
dashboard-server/gorgias-server/._services
Normal file
Binary file not shown.
1036
dashboard-server/gorgias-server/package-lock.json
generated
Normal file
1036
dashboard-server/gorgias-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
dashboard-server/gorgias-server/package.json
Normal file
19
dashboard-server/gorgias-server/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "gorgias-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"redis": "^4.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dashboard-server/gorgias-server/routes/._gorgias.routes.js
Normal file
BIN
dashboard-server/gorgias-server/routes/._gorgias.routes.js
Normal file
Binary file not shown.
119
dashboard-server/gorgias-server/routes/gorgias.routes.js
Normal file
119
dashboard-server/gorgias-server/routes/gorgias.routes.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const gorgiasService = require('../services/gorgias.service');
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
router.post('/stats/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const filters = req.body;
|
||||||
|
|
||||||
|
console.log(`Fetching ${name} statistics with filters:`, filters);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing statistic name',
|
||||||
|
details: 'The name parameter is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await gorgiasService.getStatistics(name, filters);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'No data found',
|
||||||
|
details: `No statistics found for ${name}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Statistics error:', {
|
||||||
|
name: req.params.name,
|
||||||
|
filters: req.body,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
response: error.response?.data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle specific error cases
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Authentication failed',
|
||||||
|
details: 'Invalid Gorgias API credentials'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Not found',
|
||||||
|
details: `Statistics type '${req.params.name}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid request',
|
||||||
|
details: error.response?.data?.message || 'The request was invalid',
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch statistics',
|
||||||
|
details: error.response?.data?.message || error.message,
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get tickets
|
||||||
|
router.get('/tickets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await gorgiasService.getTickets(req.query);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tickets error:', {
|
||||||
|
params: req.query,
|
||||||
|
error: error.message,
|
||||||
|
response: error.response?.data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Authentication failed',
|
||||||
|
details: 'Invalid Gorgias API credentials'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid request',
|
||||||
|
details: error.response?.data?.message || 'The request was invalid',
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch tickets',
|
||||||
|
details: error.response?.data?.message || error.message,
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get customer satisfaction
|
||||||
|
router.get('/satisfaction', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await gorgiasService.getCustomerSatisfaction(req.query);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Satisfaction error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch customer satisfaction',
|
||||||
|
details: error.response?.data || error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
31
dashboard-server/gorgias-server/server.js
Normal file
31
dashboard-server/gorgias-server/server.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({
|
||||||
|
path: path.resolve(__dirname, '.env')
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.PORT || 3006;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Import routes
|
||||||
|
const gorgiasRoutes = require('./routes/gorgias.routes');
|
||||||
|
|
||||||
|
// Use routes
|
||||||
|
app.use('/api/gorgias', gorgiasRoutes);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({ error: 'Something went wrong!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Gorgias API server running on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
BIN
dashboard-server/gorgias-server/services/._gorgias.service.js
Normal file
BIN
dashboard-server/gorgias-server/services/._gorgias.service.js
Normal file
Binary file not shown.
119
dashboard-server/gorgias-server/services/gorgias.service.js
Normal file
119
dashboard-server/gorgias-server/services/gorgias.service.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
|
||||||
|
class GorgiasService {
|
||||||
|
constructor() {
|
||||||
|
this.redis = createClient({
|
||||||
|
url: process.env.REDIS_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
this.redis.on('error', err => console.error('Redis Client Error:', err));
|
||||||
|
this.redis.connect().catch(err => console.error('Redis connection error:', err));
|
||||||
|
|
||||||
|
// Create base64 encoded auth string
|
||||||
|
const auth = Buffer.from(`${process.env.GORGIAS_API_USERNAME}:${process.env.GORGIAS_API_KEY}`).toString('base64');
|
||||||
|
|
||||||
|
this.apiClient = axios.create({
|
||||||
|
baseURL: `https://${process.env.GORGIAS_DOMAIN}.gorgias.com/api`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatistics(name, filters = {}) {
|
||||||
|
const cacheKey = `gorgias:stats:${name}:${JSON.stringify(filters)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log(`Statistics ${name} found in Redis cache`);
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching ${name} statistics with filters:`, filters);
|
||||||
|
|
||||||
|
// Convert dates to UTC midnight if not already set
|
||||||
|
if (!filters.start_datetime || !filters.end_datetime) {
|
||||||
|
const start = new Date(filters.start_datetime || filters.start_date);
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(filters.end_datetime || filters.end_date);
|
||||||
|
end.setUTCHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
...filters,
|
||||||
|
start_datetime: start.toISOString(),
|
||||||
|
end_datetime: end.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
const response = await this.apiClient.post(`/stats/${name}`, filters);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// Save to Redis with 5 minute expiry
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(data), {
|
||||||
|
EX: 300 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in getStatistics for ${name}:`, {
|
||||||
|
error: error.message,
|
||||||
|
filters,
|
||||||
|
response: error.response?.data
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTickets(params = {}) {
|
||||||
|
const cacheKey = `gorgias:tickets:${JSON.stringify(params)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try Redis first
|
||||||
|
const cachedData = await this.redis.get(cacheKey);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('Tickets found in Redis cache');
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert dates to UTC midnight
|
||||||
|
const formattedParams = { ...params };
|
||||||
|
if (params.start_date) {
|
||||||
|
const start = new Date(params.start_date);
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
formattedParams.start_datetime = start.toISOString();
|
||||||
|
delete formattedParams.start_date;
|
||||||
|
}
|
||||||
|
if (params.end_date) {
|
||||||
|
const end = new Date(params.end_date);
|
||||||
|
end.setUTCHours(23, 59, 59, 999);
|
||||||
|
formattedParams.end_datetime = end.toISOString();
|
||||||
|
delete formattedParams.end_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
const response = await this.apiClient.get('/tickets', { params: formattedParams });
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// Save to Redis with 5 minute expiry
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(data), {
|
||||||
|
EX: 300 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tickets:', {
|
||||||
|
error: error.message,
|
||||||
|
params,
|
||||||
|
response: error.response?.data
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new GorgiasService();
|
||||||
@@ -23,6 +23,7 @@ import ProductGrid from "./components/dashboard/ProductGrid";
|
|||||||
import SalesChart from "./components/dashboard/SalesChart";
|
import SalesChart from "./components/dashboard/SalesChart";
|
||||||
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
|
||||||
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
|
||||||
|
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
|
||||||
|
|
||||||
// Public layout
|
// Public layout
|
||||||
const PublicLayout = () => (
|
const PublicLayout = () => (
|
||||||
@@ -90,7 +91,6 @@ const DashboardLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
|
||||||
<div className="xl:col-span-4 col-span-6">
|
<div className="xl:col-span-4 col-span-6">
|
||||||
<div className="space-y-4 h-full w-full">
|
<div className="space-y-4 h-full w-full">
|
||||||
@@ -122,6 +122,9 @@ const DashboardLayout = () => {
|
|||||||
<div id="meta-campaigns">
|
<div id="meta-campaigns">
|
||||||
<MetaCampaigns />
|
<MetaCampaigns />
|
||||||
</div>
|
</div>
|
||||||
|
<div id="gorgias-overview">
|
||||||
|
<GorgiasOverview />
|
||||||
|
</div>
|
||||||
<div id="calls">
|
<div id="calls">
|
||||||
<AircallDashboard />
|
<AircallDashboard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ const AircallDashboard = () => {
|
|||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Aircall Analytics</CardTitle>
|
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Calls</CardTitle>
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-1">
|
||||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
|
|||||||
551
dashboard/src/components/dashboard/GorgiasOverview.jsx
Normal file
551
dashboard/src/components/dashboard/GorgiasOverview.jsx
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
MessageSquare,
|
||||||
|
Mail,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Zap,
|
||||||
|
Timer,
|
||||||
|
BarChart3,
|
||||||
|
Bot,
|
||||||
|
ClipboardCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const TIME_RANGES = {
|
||||||
|
"today": "Today",
|
||||||
|
"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 getDateRange = (days) => {
|
||||||
|
// Create date in Eastern Time
|
||||||
|
const now = new Date();
|
||||||
|
const easternTime = new Date(
|
||||||
|
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (days === "today") {
|
||||||
|
// For today, set the range to be the current day in Eastern Time
|
||||||
|
const start = new Date(easternTime);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const end = new Date(easternTime);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start_datetime: start.toISOString(),
|
||||||
|
end_datetime: end.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other periods, calculate from end of previous day
|
||||||
|
const end = new Date(easternTime);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const start = new Date(easternTime);
|
||||||
|
start.setDate(start.getDate() - Number(days));
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start_datetime: start.toISOString(),
|
||||||
|
end_datetime: end.toISOString()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Math.abs(d) + suffix;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardContent className="pt-6 h-full">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-24 dark:bg-gray-700" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{typeof value === "number"
|
||||||
|
? value.toLocaleString() + suffix
|
||||||
|
: value}
|
||||||
|
</p>
|
||||||
|
{delta !== undefined && delta !== 0 && (
|
||||||
|
<div className={`flex items-center ${getDeltaColor(delta)}`}>
|
||||||
|
{delta > 0 ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{formatDelta(delta)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${colorClass === "blue" ? "text-blue-500" :
|
||||||
|
colorClass === "green" ? "text-green-500" :
|
||||||
|
colorClass === "purple" ? "text-purple-500" :
|
||||||
|
colorClass === "indigo" ? "text-indigo-500" :
|
||||||
|
colorClass === "orange" ? "text-orange-500" :
|
||||||
|
colorClass === "teal" ? "text-teal-500" :
|
||||||
|
colorClass === "cyan" ? "text-cyan-500" :
|
||||||
|
"text-blue-500"}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 GorgiasOverview = () => {
|
||||||
|
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([
|
||||||
|
axios.post('/api/gorgias/stats/overview', filters)
|
||||||
|
.then(res => res.data?.data?.data?.data || []),
|
||||||
|
axios.post('/api/gorgias/stats/tickets-created-per-channel', filters)
|
||||||
|
.then(res => res.data?.data?.data?.data?.lines || []),
|
||||||
|
axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters)
|
||||||
|
.then(res => res.data?.data?.data?.data?.lines || []),
|
||||||
|
axios.post('/api/gorgias/stats/satisfaction-surveys', filters)
|
||||||
|
.then(res => res.data?.data?.data?.data || []),
|
||||||
|
axios.post('/api/gorgias/stats/self-service-overview', filters)
|
||||||
|
.then(res => res.data?.data?.data?.data || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('Raw API responses:', {
|
||||||
|
overview,
|
||||||
|
channelStats,
|
||||||
|
agentStats,
|
||||||
|
satisfaction,
|
||||||
|
selfService
|
||||||
|
});
|
||||||
|
|
||||||
|
setData({
|
||||||
|
overview,
|
||||||
|
channels: channelStats,
|
||||||
|
agents: agentStats,
|
||||||
|
satisfaction,
|
||||||
|
selfService,
|
||||||
|
});
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading stats:", err);
|
||||||
|
const errorMessage = err.response?.data?.error || err.message;
|
||||||
|
setError(errorMessage);
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
setError('Authentication failed. Please check your Gorgias API credentials.');
|
||||||
|
}
|
||||||
|
} 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 stats format
|
||||||
|
const stats = (data.overview || []).reduce((acc, item) => {
|
||||||
|
acc[item.name] = {
|
||||||
|
value: item.value || 0,
|
||||||
|
delta: item.delta || 0,
|
||||||
|
type: item.type,
|
||||||
|
more_is_better: item.more_is_better
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
console.log('Processed stats:', stats);
|
||||||
|
|
||||||
|
// Process satisfaction data
|
||||||
|
const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => {
|
||||||
|
if (item.name !== 'response_distribution') {
|
||||||
|
acc[item.name] = {
|
||||||
|
value: item.value || 0,
|
||||||
|
delta: item.delta || 0,
|
||||||
|
type: item.type,
|
||||||
|
more_is_better: item.more_is_better
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
console.log('Processed satisfaction stats:', satisfactionStats);
|
||||||
|
|
||||||
|
// Process self-service data
|
||||||
|
const selfServiceStats = (data.selfService || []).reduce((acc, item) => {
|
||||||
|
acc[item.name] = {
|
||||||
|
value: item.value || 0,
|
||||||
|
delta: item.delta || 0,
|
||||||
|
type: item.type,
|
||||||
|
more_is_better: item.more_is_better
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
console.log('Processed self-service stats:', selfServiceStats);
|
||||||
|
|
||||||
|
// Process channel data
|
||||||
|
const channels = data.channels?.map(line => ({
|
||||||
|
name: line[0]?.value || '',
|
||||||
|
total: line[1]?.value || 0,
|
||||||
|
percentage: line[2]?.value || 0,
|
||||||
|
delta: line[3]?.value || 0
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
console.log('Processed channels:', channels);
|
||||||
|
|
||||||
|
// Process agent data
|
||||||
|
const agents = data.agents?.map(line => ({
|
||||||
|
name: line[0]?.value || '',
|
||||||
|
closed: line[1]?.value || 0,
|
||||||
|
rating: line[2]?.value,
|
||||||
|
percentage: line[3]?.value || 0,
|
||||||
|
delta: line[4]?.value || 0
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
console.log('Processed agents:', agents);
|
||||||
|
|
||||||
|
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={timeRange}
|
||||||
|
onValueChange={(value) => setTimeRange(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] bg-white dark:bg-gray-800">
|
||||||
|
<SelectValue placeholder="Select range">
|
||||||
|
{TIME_RANGES[timeRange]}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[
|
||||||
|
["today", "Today"],
|
||||||
|
["7", "Last 7 Days"],
|
||||||
|
["14", "Last 14 Days"],
|
||||||
|
["30", "Last 30 Days"],
|
||||||
|
["90", "Last 90 Days"],
|
||||||
|
].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 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Message & Response Metrics */}
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Messages Received"
|
||||||
|
value={stats.total_messages_received?.value}
|
||||||
|
delta={stats.total_messages_received?.delta}
|
||||||
|
icon={Mail}
|
||||||
|
colorClass="blue"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Messages Sent"
|
||||||
|
value={stats.total_messages_sent?.value}
|
||||||
|
delta={stats.total_messages_sent?.delta}
|
||||||
|
icon={Send}
|
||||||
|
colorClass="green"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="First Response"
|
||||||
|
value={formatDuration(stats.median_first_response_time?.value)}
|
||||||
|
delta={stats.median_first_response_time?.delta}
|
||||||
|
icon={Zap}
|
||||||
|
colorClass="purple"
|
||||||
|
more_is_better={false}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="One-Touch Rate"
|
||||||
|
value={stats.total_one_touch_tickets?.value}
|
||||||
|
delta={stats.total_one_touch_tickets?.delta}
|
||||||
|
suffix="%"
|
||||||
|
icon={BarChart3}
|
||||||
|
colorClass="indigo"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Satisfaction & Efficiency */}
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Customer Satisfaction"
|
||||||
|
value={`${satisfactionStats.average_rating?.value}/5`}
|
||||||
|
delta={satisfactionStats.average_rating?.delta}
|
||||||
|
suffix="%"
|
||||||
|
icon={Star}
|
||||||
|
colorClass="orange"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Survey Response Rate"
|
||||||
|
value={satisfactionStats.response_rate?.value}
|
||||||
|
delta={satisfactionStats.response_rate?.delta}
|
||||||
|
suffix="%"
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
colorClass="pink"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Resolution Time"
|
||||||
|
value={formatDuration(stats.median_resolution_time?.value)}
|
||||||
|
delta={stats.median_resolution_time?.delta}
|
||||||
|
icon={Timer}
|
||||||
|
colorClass="teal"
|
||||||
|
more_is_better={false}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-full">
|
||||||
|
<MetricCard
|
||||||
|
title="Self-Service Rate"
|
||||||
|
value={selfServiceStats.self_service_automation_rate?.value}
|
||||||
|
delta={selfServiceStats.self_service_automation_rate?.delta}
|
||||||
|
suffix="%"
|
||||||
|
icon={Bot}
|
||||||
|
colorClass="cyan"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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">Change</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{channels
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map((channel, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="dark:text-gray-300">
|
||||||
|
{channel.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right dark:text-gray-300">
|
||||||
|
{channel.total}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right dark:text-gray-300">
|
||||||
|
{channel.percentage}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`text-right ${
|
||||||
|
channel.delta > 0
|
||||||
|
? "text-green-600 dark:text-green-500"
|
||||||
|
: channel.delta < 0
|
||||||
|
? "text-red-600 dark:text-red-500"
|
||||||
|
: "dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
|
{channel.delta !== 0 && (
|
||||||
|
<>
|
||||||
|
{channel.delta > 0 ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span>{Math.abs(channel.delta)}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Performance */}
|
||||||
|
<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">Change</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{agents
|
||||||
|
.filter((agent) => agent.name !== "Unassigned")
|
||||||
|
.map((agent, index) => (
|
||||||
|
<TableRow key={index} className="dark:border-gray-800">
|
||||||
|
<TableCell className="dark:text-gray-300">
|
||||||
|
{agent.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right dark:text-gray-300">
|
||||||
|
{agent.closed}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right dark:text-gray-300">
|
||||||
|
{agent.rating ? `${agent.rating}/5` : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`text-right ${
|
||||||
|
agent.delta > 0
|
||||||
|
? "text-green-600 dark:text-green-500"
|
||||||
|
: agent.delta < 0
|
||||||
|
? "text-red-600 dark:text-red-500"
|
||||||
|
: "dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
|
{agent.delta !== 0 && (
|
||||||
|
<>
|
||||||
|
{agent.delta > 0 ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span>{Math.abs(agent.delta)}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GorgiasOverview;
|
||||||
@@ -28,7 +28,8 @@ const Navigation = () => {
|
|||||||
{ id: "sales", label: "Sales Chart" },
|
{ id: "sales", label: "Sales Chart" },
|
||||||
{ id: "campaigns", label: "Campaigns" },
|
{ id: "campaigns", label: "Campaigns" },
|
||||||
{ id: "meta-campaigns", label: "Meta Ads" },
|
{ id: "meta-campaigns", label: "Meta Ads" },
|
||||||
{ id: "calls", label: "Aircall" },
|
{ id: "gorgias-overview", label: "Customer Service" },
|
||||||
|
{ id: "calls", label: "Calls" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sortSections = (sections) => {
|
const sortSections = (sections) => {
|
||||||
|
|||||||
15
dashboard/src/lib/api.js
Normal file
15
dashboard/src/lib/api.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Create base64 encoded auth string
|
||||||
|
const auth = Buffer.from(`${process.env.REACT_APP_GORGIAS_API_USERNAME}:${process.env.REACT_APP_GORGIAS_API_KEY}`).toString('base64');
|
||||||
|
|
||||||
|
// Create axios instance for Gorgias API
|
||||||
|
const gorgiasApi = axios.create({
|
||||||
|
baseURL: `https://${process.env.REACT_APP_GORGIAS_DOMAIN}.gorgias.com/api`,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { gorgiasApi };
|
||||||
@@ -167,6 +167,42 @@ export default defineConfig(({ mode }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
"/api/gorgias": {
|
||||||
|
target: "https://dashboard.kent.pw",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/gorgias/, "/api/gorgias"),
|
||||||
|
configure: (proxy, _options) => {
|
||||||
|
proxy.on("error", (err, req, res) => {
|
||||||
|
console.error("Gorgias proxy error:", err);
|
||||||
|
res.writeHead(500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Proxy Error",
|
||||||
|
message: err.message,
|
||||||
|
details: err.stack
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
proxy.on("proxyReq", (proxyReq, req, _res) => {
|
||||||
|
console.log("Outgoing Gorgias request:", {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
path: proxyReq.path,
|
||||||
|
headers: proxyReq.getHeaders(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
proxy.on("proxyRes", (proxyRes, req, _res) => {
|
||||||
|
console.log("Gorgias proxy response:", {
|
||||||
|
statusCode: proxyRes.statusCode,
|
||||||
|
url: req.url,
|
||||||
|
headers: proxyRes.headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
365
examples DO NOT USE OR EDIT/EXAMPLE ONLY AnalyticsDashboard.jsx
Normal file
365
examples DO NOT USE OR EDIT/EXAMPLE ONLY AnalyticsDashboard.jsx
Normal 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;
|
||||||
401
examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx
Normal file
401
examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx
Normal 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;
|
||||||
529
examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx
Normal file
529
examples DO NOT USE OR EDIT/EXAMPLE ONLY RealtimeAnalytics.jsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
338
examples DO NOT USE OR EDIT/EXAMPLE ONLY analytics.service.js
Normal file
338
examples DO NOT USE OR EDIT/EXAMPLE ONLY analytics.service.js
Normal 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();
|
||||||
@@ -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 };
|
||||||
129
examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgias.service.js
Normal file
129
examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgias.service.js
Normal 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();
|
||||||
39
examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgiasService.js
Normal file
39
examples DO NOT USE OR EDIT/EXAMPLE ONLY gorgiasService.js
Normal 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;
|
||||||
27
nginx.conf
Normal file
27
nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Gorgias API endpoints
|
||||||
|
location /api/gorgias/ {
|
||||||
|
proxy_pass http://localhost:3006/api/gorgias/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# CORS headers
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
|
||||||
|
# Handle OPTIONS method
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user