Merge branch 'Add-gorgias-api'

This commit is contained in:
2024-12-27 21:18:39 -05:00
30 changed files with 4366 additions and 3 deletions

Binary file not shown.

View File

@@ -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
} }
] ]
}; };

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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;

View 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;

View 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();

View File

@@ -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>

View File

@@ -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()}

View 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;

View File

@@ -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
View 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 };

View File

@@ -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,
});
});
},
} }
}, },
}, },

View File

@@ -0,0 +1,365 @@
//src/components/dashboard/AnalyticsDashboard.jsx
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Loader2 } from "lucide-react";
import { googleAnalyticsService } from "../../services/googleAnalyticsService";
export const AnalyticsDashboard = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const result = await googleAnalyticsService.getBasicMetrics({
startDate: `${timeRange}daysAgo`,
});
if (result) {
const processedData = result.map((item) => ({
...item,
date: formatGADate(item.date),
}));
const sortedData = processedData.sort((a, b) => a.date - b.date);
setData(sortedData);
} else {
console.log("No result data received");
}
} catch (error) {
console.error("Failed to fetch analytics:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeRange]);
const formatGADate = (gaDate) => {
const year = gaDate.substring(0, 4);
const month = gaDate.substring(4, 6);
const day = gaDate.substring(6, 8);
return new Date(year, month - 1, day);
};
const [selectedMetrics, setSelectedMetrics] = useState({
activeUsers: true,
newUsers: true,
pageViews: true,
conversions: true,
});
const MetricToggle = ({ label, checked, onChange }) => (
<div className="flex items-center space-x-2">
<Checkbox
id={label}
checked={checked}
onCheckedChange={onChange}
className="dark:border-gray-600"
/>
<label
htmlFor={label}
className="text-sm font-medium leading-none text-gray-900 dark:text-gray-200 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</div>
);
const CustomLegend = ({ metrics, selectedMetrics }) => {
// Separate items for left and right axes
const leftAxisItems = Object.entries(metrics).filter(
([key, metric]) => metric.yAxis === "left" && selectedMetrics[key]
);
const rightAxisItems = Object.entries(metrics).filter(
([key, metric]) => metric.yAxis === "right" && selectedMetrics[key]
);
return (
<div className="flex justify-between mt-4">
<div className="flex flex-col space-y-2">
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
Left Axis
</h4>
{leftAxisItems.map(([key, metric]) => (
<div key={key} className="flex items-center space-x-2">
<div
style={{ backgroundColor: metric.color }}
className="w-3 h-3 rounded-full"
></div>
<span className="text-gray-900 dark:text-gray-100">
{metric.label}
</span>
</div>
))}
</div>
<div className="flex flex-col space-y-2">
<h4 className="font-semibold text-gray-700 dark:text-gray-300">
Right Axis
</h4>
{rightAxisItems.map(([key, metric]) => (
<div key={key} className="flex items-center space-x-2">
<div
style={{ backgroundColor: metric.color }}
className="w-3 h-3 rounded-full"
></div>
<span className="text-gray-900 dark:text-gray-100">
{metric.label}
</span>
</div>
))}
</div>
</div>
);
};
const metrics = {
activeUsers: { label: "Active Users", color: "#8b5cf6" },
newUsers: { label: "New Users", color: "#10b981" },
pageViews: { label: "Page Views", color: "#f59e0b" },
conversions: { label: "Conversions", color: "#3b82f6" },
};
const calculateSummary = () => {
if (!data.length) return null;
const totals = data.reduce(
(acc, day) => ({
activeUsers: acc.activeUsers + (Number(day.activeUsers) || 0),
newUsers: acc.newUsers + (Number(day.newUsers) || 0),
pageViews: acc.pageViews + (Number(day.pageViews) || 0),
conversions: acc.conversions + (Number(day.conversions) || 0),
avgSessionDuration:
acc.avgSessionDuration + (Number(day.avgSessionDuration) || 0),
bounceRate: acc.bounceRate + (Number(day.bounceRate) || 0),
}),
{
activeUsers: 0,
newUsers: 0,
pageViews: 0,
conversions: 0,
avgSessionDuration: 0,
bounceRate: 0,
}
);
return {
...totals,
avgSessionDuration: totals.avgSessionDuration / data.length,
bounceRate: totals.bounceRate / data.length,
};
};
const summary = calculateSummary();
if (loading) {
return (
<Card className="bg-white dark:bg-gray-900">
<CardContent className="h-96 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</CardContent>
</Card>
);
}
const formatXAxisDate = (date) => {
if (!(date instanceof Date)) return "";
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
<p className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100">
{label instanceof Date ? label.toLocaleDateString() : label}
</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{`${entry.name}: ${Number(entry.value).toLocaleString()}`}
</p>
))}
</div>
);
}
return null;
};
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Analytics Overview
</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">
Total Users
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{summary.activeUsers.toLocaleString()}
</div>
</div>
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">
New Users
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{summary.newUsers.toLocaleString()}
</div>
</div>
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">
Page Views
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{summary.pageViews.toLocaleString()}
</div>
</div>
<div className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-500 dark:text-gray-400">
Conversions
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{summary.conversions.toLocaleString()}
</div>
</div>
</div>
)}
<div className="flex flex-wrap gap-4 mt-4">
<MetricToggle
label="Active Users"
checked={selectedMetrics.activeUsers}
onChange={(checked) =>
setSelectedMetrics((prev) => ({ ...prev, activeUsers: checked }))
}
/>
<MetricToggle
label="New Users"
checked={selectedMetrics.newUsers}
onChange={(checked) =>
setSelectedMetrics((prev) => ({ ...prev, newUsers: checked }))
}
/>
<MetricToggle
label="Page Views"
checked={selectedMetrics.pageViews}
onChange={(checked) =>
setSelectedMetrics((prev) => ({ ...prev, pageViews: checked }))
}
/>
<MetricToggle
label="Conversions"
checked={selectedMetrics.conversions}
onChange={(checked) =>
setSelectedMetrics((prev) => ({ ...prev, conversions: checked }))
}
/>
</div>
</CardHeader>
<CardContent>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 5, right: 5, left: -10, bottom: 5 }}
animationBegin={0}
animationDuration={1000}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e5e7eb"
className="dark:stroke-gray-800"
/>
<XAxis
dataKey="date"
tickFormatter={formatXAxisDate}
type="category"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
/>
<YAxis
yAxisId="left"
orientation="left"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: "#6b7280" }}
stroke="#9ca3af"
className="dark:text-gray-400"
domain={[0, "auto"]}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value) => (
<span className="text-gray-900 dark:text-gray-100">
{value}
</span>
)}
/>
{/* Always render Lines and control visibility */}
{Object.entries(metrics).map(([key, { color, label }]) => (
<Line
key={key}
yAxisId={key === "pageViews" ? "right" : "left"}
type="monotone"
dataKey={key}
stroke={color}
name={label}
strokeWidth={2}
dot={{ strokeWidth: 2, r: 2, fill: color }}
activeDot={{ r: 6, fill: color }}
hide={!selectedMetrics[key]} // Control visibility
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
};
export default AnalyticsDashboard;

View File

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

View File

@@ -0,0 +1,529 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import { Loader2, AlertTriangle } from "lucide-react";
import {
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/components/ui/table";
import { googleAnalyticsService } from "../../services/googleAnalyticsService";
import { format } from "date-fns";
const formatNumber = (value, decimalPlaces = 0) => {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value || 0);
};
const formatPercent = (value, decimalPlaces = 1) =>
`${(value || 0).toFixed(decimalPlaces)}%`;
const summaryCard = (label, sublabel, value, options = {}) => {
const {
isMonetary = false,
isPercentage = false,
decimalPlaces = 0,
} = options;
let displayValue;
if (isMonetary) {
displayValue = formatCurrency(value, decimalPlaces);
} else if (isPercentage) {
displayValue = formatPercent(value, decimalPlaces);
} else {
displayValue = formatNumber(value, decimalPlaces);
}
return (
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-lg md:text-sm lg:text-lg text-gray-900 dark:text-gray-100">
{label}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{displayValue}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{sublabel}</div>
</div>
);
};
const QuotaInfo = ({ tokenQuota }) => {
// Add early return if tokenQuota is null or undefined
if (!tokenQuota || typeof tokenQuota !== "object") return null;
const {
projectHourly = {},
daily = {},
serverErrors = {},
thresholdedRequests = {},
} = tokenQuota;
// Add null checks and default values for all properties
const {
remaining: projectHourlyRemaining = 0,
consumed: projectHourlyConsumed = 0,
} = projectHourly;
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
serverErrors;
const {
remaining: thresholdRemaining = 120,
consumed: thresholdConsumed = 0,
} = thresholdedRequests;
// Calculate percentages with safe math
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
// Determine color based on remaining percentage
const getStatusColor = (percentage) => {
const numericPercentage = parseFloat(percentage);
if (isNaN(numericPercentage) || numericPercentage < 20)
return "text-red-500 dark:text-red-400";
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
return "text-green-500 dark:text-green-400";
};
return (
<TooltipProvider>
<UITooltip>
<TooltipTrigger>
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
<span>Quota:</span>
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
{hourlyPercentage}%
</span>
</div>
</TooltipTrigger>
<TooltipContent className="bg-white dark:bg-gray-800 border dark:border-gray-700">
<div className="space-y-3 p-2">
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Project Hourly
</div>
<div className={`${getStatusColor(hourlyPercentage)}`}>
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Daily
</div>
<div className={`${getStatusColor(dailyPercentage)}`}>
{dailyRemaining.toLocaleString()} / 200,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Server Errors
</div>
<div className={`${getStatusColor(errorPercentage)}`}>
{errorsConsumed} / 10 used this hour
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Thresholded Requests
</div>
<div className={`${getStatusColor(thresholdPercentage)}`}>
{thresholdConsumed} / 120 used this hour
</div>
</div>
</div>
</TooltipContent>
</UITooltip>
</TooltipProvider>
);
};
const RealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({
last30MinUsers: 0,
last5MinUsers: 0,
byMinute: [],
tokenQuota: null,
lastUpdated: null,
});
const [detailedData, setDetailedData] = useState({
currentPages: [],
sources: [],
recentEvents: [],
lastUpdated: null,
});
const [loading, setLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let basicInterval;
let detailedInterval;
const fetchBasicData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/analytics/realtime/basic", {
credentials: "include",
});
const result = await response.json();
const processed = await googleAnalyticsService.getRealTimeBasicData();
setBasicData(processed);
setError(null);
} catch (error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
response: error.response,
});
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
}
};
const fetchDetailedData = async () => {
if (isPaused) return;
try {
const result = await googleAnalyticsService.getRealTimeDetailedData();
setDetailedData(result);
} catch (error) {
console.error("Failed to fetch detailed realtime data:", error);
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
} finally {
setLoading(false);
}
};
// Initial fetches
fetchBasicData();
fetchDetailedData();
// Set up intervals
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
return () => {
clearInterval(basicInterval);
clearInterval(detailedInterval);
};
}, []);
const togglePause = () => {
setIsPaused(!isPaused);
};
if (loading && !basicData && !detailedData) {
return (
<Card className="bg-white dark:bg-gray-900">
<CardContent className="h-96 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</CardContent>
</Card>
);
}
// Pie chart colors
const COLORS = [
"#8b5cf6",
"#10b981",
"#f59e0b",
"#3b82f6",
"#0088FE",
"#00C49F",
"#FFBB28",
];
// Handle 'other' in data
const totalUsers = detailedData.sources.reduce(
(sum, source) => sum + source.users,
0
);
const sourcesData = detailedData.sources.map((source) => {
const percent = (source.users / totalUsers) * 100;
return { ...source, percent };
});
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Realtime Analytics
</CardTitle>
<div className="flex items-center space-x-4">
{basicData?.data?.quotaInfo && (
<QuotaInfo tokenQuota={basicData.data.quotaInfo} />
)}
<div className="text-xs text-gray-500 dark:text-gray-400">
Last updated:{" "}
{basicData.lastUpdated
? format(new Date(basicData.lastUpdated), "p")
: "N/A"}
</div>
</div>{" "}
</div>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 mt-4">
{[
{
label: "Last 30 Minutes",
sublabel: "Active Users",
value: basicData.last30MinUsers,
},
{
label: "Last 5 Minutes",
sublabel: "Active Users",
value: basicData.last5MinUsers,
},
].map((card) => (
<div key={card.label}>
{summaryCard(card.label, card.sublabel, card.value)}
</div>
))}
</div>
</CardHeader>
<CardContent className="p-4">
{/* User Activity Chart */}
<div className="mb-6">
<Card className="bg-white dark:bg-gray-900">
<CardHeader className="pb-1">
<CardTitle className="text-md font-bold">
Active Users Per Minute
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={basicData.byMinute}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
<p className="text-gray-900 dark:text-gray-100">{`${payload[0].value} active users`}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{payload[0].payload.timestamp}
</p>
</div>
);
}
return null;
}}
/>{" "}
<Bar dataKey="users" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Tabs for Detailed Data */}
<Tabs defaultValue="pages" className="w-full">
<TabsList className="p-0 sm:p-1 md:p-0 lg:p-1 -ml-4 sm:ml-0 md:-ml-4 lg:ml-0">
<TabsTrigger value="pages">Current Pages</TabsTrigger>
<TabsTrigger value="events">Recent Events</TabsTrigger>
<TabsTrigger value="sources">Active Devices</TabsTrigger>
</TabsList>
{/* Current Pages Tab */}
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl:max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Views
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.currentPages.map(({ page, views }, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatNumber(views)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
{/* Recent Events Tab */}
<TabsContent
value="events"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl:max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Event
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Count
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.recentEvents.map(({ event, count }, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{event}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatNumber(count)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
{/* Active Devices Tab */}
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[400px] md:max-h[400px] lg:max-h-[540px] xl:max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 text-xs dark:hover:scrollbar-thumb-gray-600 pr-2"
>
{" "}
<div className="text-xs text-right text-gray-500 dark:text-gray-400">
Last updated:{" "}
{detailedData.lastUpdated
? format(new Date(detailedData.lastUpdated), "p")
: "N/A"}
</div>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={sourcesData}
dataKey="users"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ device, percent }) =>
`${device}: ${percent.toFixed(0)}%`
}
>
{sourcesData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 p-2 rounded shadow">
<p className="text-gray-900 dark:text-gray-100">
{payload[0].payload.device}
</p>
<p className="text-gray-600 dark:text-gray-300">{`${formatNumber(
payload[0].value
)} users`}</p>
</div>
);
}
return null;
}}
/>
</PieChart>{" "}
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
{sourcesData.map((source, index) => (
<div
key={index}
className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div className="text-lg font-medium text-gray-900 dark:text-gray-100">
{source.device}
</div>
<div className="mt-1">
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatNumber(source.users)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{source.percent.toFixed(0)}% of users
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default RealtimeAnalytics;

View File

@@ -0,0 +1,297 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import { Loader2 } from "lucide-react";
import { googleAnalyticsService } from "../../services/googleAnalyticsService";
export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const result = await googleAnalyticsService.getUserBehavior(timeRange);
if (result) {
setData(result);
}
} catch (error) {
console.error("Failed to fetch behavior data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeRange]);
if (loading) {
return (
<Card className="bg-white dark:bg-gray-900">
<CardContent className="h-96 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</CardContent>
</Card>
);
}
const COLORS = {
desktop: "#8b5cf6", // Purple
mobile: "#10b981", // Green
tablet: "#f59e0b", // Yellow
};
const deviceData = data?.data?.pageData?.deviceData || [];
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
const totalSessions = deviceData.reduce(
(sum, item) => sum + item.sessions,
0
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(
1
);
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{data.device}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{data.pageViews.toLocaleString()} views ({percentage}%)
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
</p>
</div>
);
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
User Behavior Analysis
</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
<SelectValue>
{timeRange === "7" && "Last 7 days"}
{timeRange === "14" && "Last 14 days"}
{timeRange === "30" && "Last 30 days"}
{timeRange === "90" && "Last 90 days"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="pages">Top Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page Path
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Views
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Bounce Rate
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Avg. Duration
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.pageData?.pageData.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page.path}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{page.pageViews.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{page.bounceRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatDuration(page.avgSessionDuration)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Source
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Sessions
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Conversions
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Conv. Rate
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.sourceData?.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{source.source}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{source.sessions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{source.conversions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{((source.conversions / source.sessions) * 100).toFixed(
1
)}
%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="devices"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData}
dataKey="pageViews"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(1)}%`
}
>
{deviceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.device.toLowerCase()]}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
formatter={(value) => (
<span className="text-gray-900 dark:text-gray-100">
{value}
</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
{deviceData.map((device) => (
<div
key={device.device}
className="p-4 rounded-lg bg-gray-50 dark:bg-gray-800"
>
<div className="text-lg font-medium text-gray-900 dark:text-gray-100">
{device.device}
</div>
<div className="mt-0">
<div className="text-xl font-bold text-gray-900 dark:text-gray-100">
{device.pageViews.toLocaleString()}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{((device.pageViews / totalViews) * 100).toFixed(1)}% of
views
</div>
</div>
<div className="mt-1">
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{device.sessions.toLocaleString()}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{((device.sessions / totalSessions) * 100).toFixed(1)}% of
sessions
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

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

View File

@@ -0,0 +1,292 @@
// src/services/googleAnalyticsService.js
class GoogleAnalyticsService {
constructor() {
this.baseUrl = "/api/analytics"; // This matches your NGINX config
}
async getBasicMetrics({ startDate = "7daysAgo" } = {}) {
try {
const response = await fetch(
`${this.baseUrl}/metrics?startDate=${startDate}`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch metrics");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
return this.processMetricsData(result.data);
} catch (error) {
console.error("Failed to fetch basic metrics:", error);
throw error;
}
}
async getRealTimeBasicData() {
try {
const response = await fetch(`${this.baseUrl}/realtime/basic`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch basic realtime data");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
const processed = this.processRealTimeBasicData(result.data);
return {
...processed,
lastUpdated: result.lastUpdated || new Date().toISOString(),
};
} catch (error) {
console.error("Failed to fetch basic realtime data:", error);
throw error;
}
}
async getRealTimeDetailedData() {
try {
const response = await fetch(`${this.baseUrl}/realtime/detailed`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch detailed realtime data");
}
const result = await response.json();
if (!result?.data) {
throw new Error("No data received");
}
const processed = this.processRealTimeDetailedData(result.data);
return {
...processed,
lastUpdated: result.lastUpdated || new Date().toISOString(),
};
} catch (error) {
console.error("Failed to fetch detailed realtime data:", error);
throw error;
}
}
async getUserBehavior(timeRange = "30") {
try {
const response = await fetch(
`${this.baseUrl}/user-behavior?timeRange=${timeRange}`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch user behavior");
}
const result = await response.json();
console.log("Raw user behavior response:", result);
if (!result?.success) {
throw new Error("Invalid response structure");
}
// Handle both data structures
const rawData = result.data?.data || result.data;
// Try to access the data differently based on the structure
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
console.log("Extracted responses:", {
pageResponse,
deviceResponse,
sourceResponse,
});
const processed = {
success: true,
data: {
pageData: {
pageData: this.processPageData(pageResponse),
deviceData: this.processDeviceData(deviceResponse),
},
sourceData: this.processSourceData(sourceResponse),
},
};
console.log("Final processed data:", processed);
return processed;
} catch (error) {
console.error("Failed to fetch user behavior:", error);
throw error;
}
}
processMetricsData(data) {
if (!data?.rows) {
console.log("No rows found in data");
return [];
}
return data.rows.map((row) => ({
date: row.dimensionValues[0].value,
activeUsers: parseInt(row.metricValues[0].value),
newUsers: parseInt(row.metricValues[1].value),
avgSessionDuration: parseFloat(row.metricValues[2].value),
pageViews: parseInt(row.metricValues[3].value),
bounceRate: parseFloat(row.metricValues[4].value) * 100,
conversions: parseInt(row.metricValues[5].value),
}));
}
processRealTimeBasicData(data) {
const last30MinUsers = parseInt(
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const last5MinUsers = parseInt(
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const byMinute = Array.from({ length: 30 }, (_, i) => {
const matchingRow = data.timeSeriesResponse?.rows?.find(
(row) => parseInt(row.dimensionValues[0].value) === i
);
const users = matchingRow
? parseInt(matchingRow.metricValues[0].value)
: 0;
const timestamp = new Date(Date.now() - i * 60000);
return {
minute: -i,
users,
timestamp: timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
}).reverse();
const tokenQuota = data.quotaInfo
? {
projectHourly: data.quotaInfo.projectHourly || {},
daily: data.quotaInfo.daily || {},
serverErrors: data.quotaInfo.serverErrors || {},
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
}
: null;
return {
last30MinUsers,
last5MinUsers,
byMinute,
tokenQuota,
};
}
processRealTimeDetailedData(data) {
return {
currentPages:
data.pageResponse?.rows?.map((row) => ({
page: row.dimensionValues[0].value,
views: parseInt(row.metricValues[0].value),
})) || [],
sources:
data.sourceResponse?.rows?.map((row) => ({
device: row.dimensionValues[0].value,
users: parseInt(row.metricValues[0].value),
})) || [],
recentEvents:
data.eventResponse?.rows
?.filter(
(row) =>
!["session_start", "(other)"].includes(
row.dimensionValues[0].value
)
)
.map((row) => ({
event: row.dimensionValues[0].value,
count: parseInt(row.metricValues[0].value),
})) || [],
};
}
processPageData(data) {
console.log("Processing page data input:", data);
if (!data?.rows) {
console.log("No rows in page data");
return [];
}
const processed = data.rows.map((row) => ({
path: row.dimensionValues[0].value || "Unknown",
pageViews: parseInt(row.metricValues[0].value || 0),
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
engagedSessions: parseInt(row.metricValues[3].value || 0),
}));
console.log("Processed page data:", processed);
return processed;
}
processDeviceData(data) {
console.log("Processing device data input:", data);
if (!data?.rows) {
console.log("No rows in device data");
return [];
}
const processed = data.rows
.filter((row) => {
const device = (row.dimensionValues[0].value || "").toLowerCase();
return ["desktop", "mobile", "tablet"].includes(device);
})
.map((row) => {
const device = row.dimensionValues[0].value || "Unknown";
return {
device:
device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
pageViews: parseInt(row.metricValues[0].value || 0),
sessions: parseInt(row.metricValues[1].value || 0),
};
})
.sort((a, b) => b.pageViews - a.pageViews);
console.log("Processed device data:", processed);
return processed;
}
processSourceData(data) {
console.log("Processing source data input:", data);
if (!data?.rows) {
console.log("No rows in source data");
return [];
}
const processed = data.rows.map((row) => ({
source: row.dimensionValues[0].value || "Unknown",
sessions: parseInt(row.metricValues[0].value || 0),
conversions: parseInt(row.metricValues[1].value || 0),
}));
console.log("Processed source data:", processed);
return processed;
}
}
// Create a single instance
const service = new GoogleAnalyticsService();
// Export both the instance and the class
export { service as googleAnalyticsService, GoogleAnalyticsService };

View File

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

View File

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

27
nginx.conf Normal file
View 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;
}
}