Add analytics components and services

This commit is contained in:
2024-12-27 22:28:23 -05:00
parent 99e564792d
commit dc9a2f350a
23 changed files with 4552 additions and 0 deletions

Binary file not shown.

View File

@@ -24,6 +24,9 @@ const authEnv = loadEnvFile(path.resolve(__dirname, 'auth-server/.env'));
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env')); const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env')); const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env')); const metaEnv = loadEnvFile(path.resolve(__dirname, 'meta-server/.env'));
const googleAnalyticsEnv = require('dotenv').config({
path: path.resolve(__dirname, 'google-server/.env')
}).parsed || {};
// Common log settings for all apps // Common log settings for all apps
const logSettings = { const logSettings = {
@@ -145,6 +148,24 @@ module.exports = {
out_file: "./logs/gorgias-server-out.log", out_file: "./logs/gorgias-server-out.log",
log_file: "./logs/gorgias-server-combined.log", log_file: "./logs/gorgias-server-combined.log",
time: true time: true
},
{
...commonSettings,
name: 'google-analytics-server',
script: path.resolve(__dirname, './google-server/server.js'),
watch: false,
env: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007,
...googleAnalyticsEnv
},
error_file: path.resolve(__dirname, './google-server/logs/pm2/err.log'),
out_file: path.resolve(__dirname, './google-server/logs/pm2/out.log'),
log_file: path.resolve(__dirname, './google-server/logs/pm2/combined.log'),
env_production: {
NODE_ENV: 'production',
GOOGLE_ANALYTICS_PORT: 3007
}
} }
] ]
}; };

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,21 @@
{
"name": "google-analytics-server",
"version": "1.0.0",
"description": "Google Analytics server for dashboard",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"@google-analytics/data": "^4.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"redis": "^4.6.11",
"winston": "^3.11.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

Binary file not shown.

View File

@@ -0,0 +1,254 @@
const express = require('express');
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
const router = express.Router();
const logger = require('../utils/logger');
// Initialize GA4 client
const analyticsClient = new BetaAnalyticsDataClient({
credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON)
});
const propertyId = process.env.GA_PROPERTY_ID;
// Cache durations
const CACHE_DURATIONS = {
REALTIME_BASIC: 60, // 1 minute
REALTIME_DETAILED: 300, // 5 minutes
BASIC_METRICS: 3600, // 1 hour
USER_BEHAVIOR: 3600 // 1 hour
};
// Basic metrics endpoint
router.get('/metrics', async (req, res) => {
try {
const { startDate = '7daysAgo' } = req.query;
const cacheKey = `analytics:basic_metrics:${startDate}`;
// Check Redis cache
const cachedData = await req.redisClient.get(cacheKey);
if (cachedData) {
logger.info('Returning cached basic metrics data');
return res.json({ success: true, data: JSON.parse(cachedData) });
}
// Fetch from GA4
const [response] = await analyticsClient.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate, endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'activeUsers' },
{ name: 'newUsers' },
{ name: 'averageSessionDuration' },
{ name: 'screenPageViews' },
{ name: 'bounceRate' },
{ name: 'conversions' }
],
returnPropertyQuota: true
});
// Cache the response
await req.redisClient.set(cacheKey, JSON.stringify(response), {
EX: CACHE_DURATIONS.BASIC_METRICS
});
res.json({ success: true, data: response });
} catch (error) {
logger.error('Error fetching basic metrics:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Realtime basic data endpoint
router.get('/realtime/basic', async (req, res) => {
try {
const cacheKey = 'analytics:realtime:basic';
// Check Redis cache
const cachedData = await req.redisClient.get(cacheKey);
if (cachedData) {
logger.info('Returning cached realtime basic data');
return res.json({ success: true, data: JSON.parse(cachedData) });
}
// Fetch active users
const [userResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
metrics: [{ name: 'activeUsers' }],
returnPropertyQuota: true
});
// Fetch last 5 minutes
const [fiveMinResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
metrics: [{ name: 'activeUsers' }],
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }]
});
// Fetch time series data
const [timeSeriesResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
dimensions: [{ name: 'minutesAgo' }],
metrics: [{ name: 'activeUsers' }]
});
const response = {
userResponse,
fiveMinResponse,
timeSeriesResponse,
quotaInfo: {
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
daily: userResponse.propertyQuota.tokensPerDay,
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour
}
};
// Cache the response
await req.redisClient.set(cacheKey, JSON.stringify(response), {
EX: CACHE_DURATIONS.REALTIME_BASIC
});
res.json({ success: true, data: response });
} catch (error) {
logger.error('Error fetching realtime basic data:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// Realtime detailed data endpoint
router.get('/realtime/detailed', async (req, res) => {
try {
const cacheKey = 'analytics:realtime:detailed';
// Check Redis cache
const cachedData = await req.redisClient.get(cacheKey);
if (cachedData) {
logger.info('Returning cached realtime detailed data');
return res.json({ success: true, data: JSON.parse(cachedData) });
}
// Fetch current pages
const [pageResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
dimensions: [{ name: 'unifiedScreenName' }],
metrics: [{ name: 'screenPageViews' }],
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 25
});
// Fetch events
const [eventResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
dimensions: [{ name: 'eventName' }],
metrics: [{ name: 'eventCount' }],
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
limit: 25
});
// Fetch device categories
const [deviceResponse] = await analyticsClient.runRealtimeReport({
property: `properties/${propertyId}`,
dimensions: [{ name: 'deviceCategory' }],
metrics: [{ name: 'activeUsers' }],
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
limit: 10,
returnPropertyQuota: true
});
const response = {
pageResponse,
eventResponse,
sourceResponse: deviceResponse
};
// Cache the response
await req.redisClient.set(cacheKey, JSON.stringify(response), {
EX: CACHE_DURATIONS.REALTIME_DETAILED
});
res.json({ success: true, data: response });
} catch (error) {
logger.error('Error fetching realtime detailed data:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// User behavior endpoint
router.get('/user-behavior', async (req, res) => {
try {
const { timeRange = '30' } = req.query;
const cacheKey = `analytics:user_behavior:${timeRange}`;
// Check Redis cache
const cachedData = await req.redisClient.get(cacheKey);
if (cachedData) {
logger.info('Returning cached user behavior data');
return res.json({ success: true, data: JSON.parse(cachedData) });
}
// Fetch page data
const [pageResponse] = await analyticsClient.runReport({
property: `properties/${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
});
// Fetch device data
const [deviceResponse] = await analyticsClient.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'deviceCategory' }],
metrics: [
{ name: 'screenPageViews' },
{ name: 'sessions' }
]
});
// Fetch source data
const [sourceResponse] = await analyticsClient.runReport({
property: `properties/${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
});
const response = {
pageResponse,
deviceResponse,
sourceResponse
};
// Cache the response
await req.redisClient.set(cacheKey, JSON.stringify(response), {
EX: CACHE_DURATIONS.USER_BEHAVIOR
});
res.json({ success: true, data: response });
} catch (error) {
logger.error('Error fetching user behavior data:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,91 @@
const express = require('express');
const router = express.Router();
const analyticsService = require('../services/analytics.service');
// Basic metrics endpoint
router.get('/metrics', async (req, res) => {
try {
const { startDate = '7daysAgo' } = req.query;
console.log(`Fetching metrics with startDate: ${startDate}`);
const data = await analyticsService.getBasicMetrics(startDate);
res.json({ success: true, data });
} catch (error) {
console.error('Metrics error:', {
startDate: req.query.startDate,
error: error.message,
stack: error.stack
});
res.status(500).json({
success: false,
error: 'Failed to fetch metrics',
details: error.message
});
}
});
// Realtime basic data endpoint
router.get('/realtime/basic', async (req, res) => {
try {
console.log('Fetching realtime basic data');
const data = await analyticsService.getRealTimeBasicData();
res.json({ success: true, data });
} catch (error) {
console.error('Realtime basic error:', {
error: error.message,
stack: error.stack
});
res.status(500).json({
success: false,
error: 'Failed to fetch realtime basic data',
details: error.message
});
}
});
// Realtime detailed data endpoint
router.get('/realtime/detailed', async (req, res) => {
try {
console.log('Fetching realtime detailed data');
const data = await analyticsService.getRealTimeDetailedData();
res.json({ success: true, data });
} catch (error) {
console.error('Realtime detailed error:', {
error: error.message,
stack: error.stack
});
res.status(500).json({
success: false,
error: 'Failed to fetch realtime detailed data',
details: error.message
});
}
});
// User behavior endpoint
router.get('/user-behavior', async (req, res) => {
try {
const { timeRange = '30' } = req.query;
console.log(`Fetching user behavior with timeRange: ${timeRange}`);
const data = await analyticsService.getUserBehavior(timeRange);
res.json({ success: true, data });
} catch (error) {
console.error('User behavior error:', {
timeRange: req.query.timeRange,
error: error.message,
stack: error.stack
});
res.status(500).json({
success: false,
error: 'Failed to fetch user behavior data',
details: error.message
});
}
});
module.exports = router;

View File

@@ -0,0 +1,65 @@
const express = require('express');
const cors = require('cors');
const { createClient } = require('redis');
const analyticsRoutes = require('./routes/analytics.routes');
const app = express();
const port = process.env.GOOGLE_ANALYTICS_PORT || 3007;
// Redis client setup
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
redisClient.on('connect', () => console.log('Redis Client Connected'));
// Connect to Redis
(async () => {
try {
await redisClient.connect();
} catch (err) {
console.error('Redis connection error:', err);
}
})();
// Middleware
app.use(cors());
app.use(express.json());
// Make Redis client available in requests
app.use((req, res, next) => {
req.redisClient = redisClient;
next();
});
// Routes
app.use('/api/analytics', analyticsRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Server error:', err);
res.status(err.status || 500).json({
success: false,
message: err.message || 'Internal server error',
error: process.env.NODE_ENV === 'production' ? err : {}
});
});
// Start server
app.listen(port, () => {
console.log(`Google Analytics server running on port ${port}`);
});
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received. Shutting down gracefully...');
await redisClient.quit();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received. Shutting down gracefully...');
await redisClient.quit();
process.exit(0);
});

View File

@@ -0,0 +1,283 @@
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
const { createClient } = require('redis');
class AnalyticsService {
constructor() {
// Initialize Redis client
this.redis = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
this.redis.on('error', err => console.error('Redis Client Error:', err));
this.redis.connect().catch(err => console.error('Redis connection error:', err));
try {
// Initialize GA4 client
const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON;
this.analyticsClient = new BetaAnalyticsDataClient({
credentials: typeof credentials === 'string' ? JSON.parse(credentials) : credentials
});
this.propertyId = process.env.GA_PROPERTY_ID;
} catch (error) {
console.error('Failed to initialize GA4 client:', error);
throw error;
}
}
// Cache durations
CACHE_DURATIONS = {
REALTIME_BASIC: 60, // 1 minute
REALTIME_DETAILED: 300, // 5 minutes
BASIC_METRICS: 3600, // 1 hour
USER_BEHAVIOR: 3600 // 1 hour
};
async getBasicMetrics(startDate = '7daysAgo') {
const cacheKey = `analytics:basic_metrics:${startDate}`;
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log('Analytics metrics found in Redis cache');
return JSON.parse(cachedData);
}
// Fetch from GA4
console.log('Fetching fresh metrics data from GA4');
const [response] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate, endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'activeUsers' },
{ name: 'newUsers' },
{ name: 'averageSessionDuration' },
{ name: 'screenPageViews' },
{ name: 'bounceRate' },
{ name: 'conversions' }
],
returnPropertyQuota: true
});
// Cache the response
await this.redis.set(cacheKey, JSON.stringify(response), {
EX: this.CACHE_DURATIONS.BASIC_METRICS
});
return response;
} catch (error) {
console.error('Error fetching analytics metrics:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
async getRealTimeBasicData() {
const cacheKey = 'analytics:realtime:basic';
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log('Realtime basic data found in Redis cache');
return JSON.parse(cachedData);
}
console.log('Fetching fresh realtime data from GA4');
// Fetch active users
const [userResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
returnPropertyQuota: true
});
// Fetch last 5 minutes
const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }]
});
// Fetch time series data
const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'minutesAgo' }],
metrics: [{ name: 'activeUsers' }]
});
const response = {
userResponse,
fiveMinResponse,
timeSeriesResponse,
quotaInfo: {
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
daily: userResponse.propertyQuota.tokensPerDay,
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour
}
};
// Cache the response
await this.redis.set(cacheKey, JSON.stringify(response), {
EX: this.CACHE_DURATIONS.REALTIME_BASIC
});
return response;
} catch (error) {
console.error('Error fetching realtime basic data:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
async getRealTimeDetailedData() {
const cacheKey = 'analytics:realtime:detailed';
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log('Realtime detailed data found in Redis cache');
return JSON.parse(cachedData);
}
console.log('Fetching fresh realtime detailed data from GA4');
// Fetch current pages
const [pageResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'unifiedScreenName' }],
metrics: [{ name: 'screenPageViews' }],
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 25
});
// Fetch events
const [eventResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'eventName' }],
metrics: [{ name: 'eventCount' }],
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
limit: 25
});
// Fetch device categories
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
});
const response = {
pageResponse,
eventResponse,
sourceResponse: deviceResponse
};
// Cache the response
await this.redis.set(cacheKey, JSON.stringify(response), {
EX: this.CACHE_DURATIONS.REALTIME_DETAILED
});
return response;
} catch (error) {
console.error('Error fetching realtime detailed data:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
async getUserBehavior(timeRange = '30') {
const cacheKey = `analytics:user_behavior:${timeRange}`;
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log('User behavior data found in Redis cache');
return JSON.parse(cachedData);
}
console.log('Fetching fresh user behavior data from GA4');
// Fetch page data
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
});
// Fetch device data
const [deviceResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'deviceCategory' }],
metrics: [
{ name: 'screenPageViews' },
{ name: 'sessions' }
]
});
// Fetch source data
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
});
const response = {
pageResponse,
deviceResponse,
sourceResponse
};
// Cache the response
await this.redis.set(cacheKey, JSON.stringify(response), {
EX: this.CACHE_DURATIONS.USER_BEHAVIOR
});
return response;
} catch (error) {
console.error('Error fetching user behavior data:', {
error: error.message,
stack: error.stack
});
throw error;
}
}
}
module.exports = new AnalyticsService();

Binary file not shown.

View File

@@ -0,0 +1,35 @@
const winston = require('winston');
const path = require('path');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../logs/pm2/error.log'),
level: 'error',
maxsize: 10485760, // 10MB
maxFiles: 5
}),
new winston.transports.File({
filename: path.join(__dirname, '../logs/pm2/combined.log'),
maxsize: 10485760, // 10MB
maxFiles: 5
})
]
});
// Add console transport in development
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;

View File

@@ -24,6 +24,9 @@ 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"; import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
// Public layout // Public layout
const PublicLayout = () => ( const PublicLayout = () => (
@@ -90,6 +93,9 @@ const DashboardLayout = () => {
<Header /> <Header />
</div> </div>
<Navigation /> <Navigation />
<AnalyticsDashboard />
<UserBehaviorDashboard />
<RealtimeAnalytics />
<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">

View File

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

View File

@@ -0,0 +1,494 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import { Loader2, AlertTriangle } from "lucide-react";
import {
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableHeader,
TableHead,
TableBody,
TableRow,
TableCell,
} from "@/components/ui/table";
import { format } from "date-fns";
const formatNumber = (value, decimalPlaces = 0) => {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(value || 0);
};
const formatPercent = (value, decimalPlaces = 1) =>
`${(value || 0).toFixed(decimalPlaces)}%`;
const summaryCard = (label, sublabel, value, options = {}) => {
const {
isMonetary = false,
isPercentage = false,
decimalPlaces = 0,
} = options;
let displayValue;
if (isMonetary) {
displayValue = formatCurrency(value, decimalPlaces);
} else if (isPercentage) {
displayValue = formatPercent(value, decimalPlaces);
} else {
displayValue = formatNumber(value, decimalPlaces);
}
return (
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-800">
<div className="text-lg md:text-sm lg:text-lg text-gray-900 dark:text-gray-100">
{label}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{displayValue}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{sublabel}</div>
</div>
);
};
const QuotaInfo = ({ tokenQuota }) => {
// Add early return if tokenQuota is null or undefined
if (!tokenQuota || typeof tokenQuota !== "object") return null;
const {
projectHourly = {},
daily = {},
serverErrors = {},
thresholdedRequests = {},
} = tokenQuota;
// Add null checks and default values for all properties
const {
remaining: projectHourlyRemaining = 0,
consumed: projectHourlyConsumed = 0,
} = projectHourly;
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
serverErrors;
const {
remaining: thresholdRemaining = 120,
consumed: thresholdConsumed = 0,
} = thresholdedRequests;
// Calculate percentages with safe math
const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1);
const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1);
const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1);
const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1);
// Determine color based on remaining percentage
const getStatusColor = (percentage) => {
const numericPercentage = parseFloat(percentage);
if (isNaN(numericPercentage) || numericPercentage < 20)
return "text-red-500 dark:text-red-400";
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
return "text-green-500 dark:text-green-400";
};
return (
<TooltipProvider>
<UITooltip>
<TooltipTrigger>
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
<span>Quota:</span>
<span className={`font-medium ${getStatusColor(hourlyPercentage)}`}>
{hourlyPercentage}%
</span>
</div>
</TooltipTrigger>
<TooltipContent className="bg-white dark:bg-gray-800 border dark:border-gray-700">
<div className="space-y-3 p-2">
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Project Hourly
</div>
<div className={`${getStatusColor(hourlyPercentage)}`}>
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Daily
</div>
<div className={`${getStatusColor(dailyPercentage)}`}>
{dailyRemaining.toLocaleString()} / 200,000 remaining
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Server Errors
</div>
<div className={`${getStatusColor(errorPercentage)}`}>
{errorsConsumed} / 10 used this hour
</div>
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
Thresholded Requests
</div>
<div className={`${getStatusColor(thresholdPercentage)}`}>
{thresholdConsumed} / 120 used this hour
</div>
</div>
</div>
</TooltipContent>
</UITooltip>
</TooltipProvider>
);
};
export const RealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({
last30MinUsers: 0,
last5MinUsers: 0,
byMinute: [],
tokenQuota: null,
lastUpdated: null,
});
const [detailedData, setDetailedData] = useState({
currentPages: [],
sources: [],
recentEvents: [],
lastUpdated: null,
});
const [loading, setLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [error, setError] = useState(null);
const processBasicData = (data) => {
const last30MinUsers = parseInt(
data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const last5MinUsers = parseInt(
data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0
);
const byMinute = Array.from({ length: 30 }, (_, i) => {
const matchingRow = data.timeSeriesResponse?.rows?.find(
(row) => parseInt(row.dimensionValues[0].value) === i
);
const users = matchingRow ? parseInt(matchingRow.metricValues[0].value) : 0;
const timestamp = new Date(Date.now() - i * 60000);
return {
minute: -i,
users,
timestamp: timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
}).reverse();
const tokenQuota = data.quotaInfo
? {
projectHourly: data.quotaInfo.projectHourly || {},
daily: data.quotaInfo.daily || {},
serverErrors: data.quotaInfo.serverErrors || {},
thresholdedRequests: data.quotaInfo.thresholdedRequests || {},
}
: null;
return {
last30MinUsers,
last5MinUsers,
byMinute,
tokenQuota,
lastUpdated: new Date().toISOString(),
};
};
const processDetailedData = (data) => {
return {
currentPages:
data.pageResponse?.rows?.map((row) => ({
path: row.dimensionValues[0].value,
activeUsers: parseInt(row.metricValues[0].value),
})) || [],
sources:
data.sourceResponse?.rows?.map((row) => ({
source: row.dimensionValues[0].value,
activeUsers: parseInt(row.metricValues[0].value),
})) || [],
recentEvents:
data.eventResponse?.rows
?.filter(
(row) =>
!["session_start", "(other)"].includes(row.dimensionValues[0].value)
)
.map((row) => ({
event: row.dimensionValues[0].value,
count: parseInt(row.metricValues[0].value),
})) || [],
lastUpdated: new Date().toISOString(),
};
};
useEffect(() => {
let basicInterval;
let detailedInterval;
const fetchBasicData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch basic realtime data");
}
const result = await response.json();
const processed = processBasicData(result.data);
setBasicData(processed);
setError(null);
} catch (error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
response: error.response,
});
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
}
};
const fetchDetailedData = async () => {
if (isPaused) return;
try {
const response = await fetch("/api/analytics/realtime/detailed", {
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to fetch detailed realtime data");
}
const result = await response.json();
const processed = processDetailedData(result.data);
setDetailedData(processed);
} catch (error) {
console.error("Failed to fetch detailed realtime data:", error);
if (error.message === "QUOTA_EXCEEDED") {
setError("Quota exceeded. Analytics paused until manually resumed.");
setIsPaused(true);
} else {
setError("Failed to fetch analytics data");
}
} finally {
setLoading(false);
}
};
// Initial fetches
fetchBasicData();
fetchDetailedData();
// Set up intervals
basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds
detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes
return () => {
clearInterval(basicInterval);
clearInterval(detailedInterval);
};
}, [isPaused]);
const togglePause = () => {
setIsPaused(!isPaused);
};
if (loading && !basicData && !detailedData) {
return (
<Card className="bg-white dark:bg-gray-900">
<CardContent className="h-96 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</CardContent>
</Card>
);
}
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Real-Time Analytics
</CardTitle>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Last updated: {format(new Date(basicData.lastUpdated), "HH:mm:ss")}
</div>
</div>
<div className="flex items-center space-x-4">
<QuotaInfo tokenQuota={basicData.tokenQuota} />
<Button
variant={isPaused ? "default" : "secondary"}
size="sm"
onClick={togglePause}
>
{isPaused ? "Resume" : "Pause"}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{summaryCard(
"Active Users",
"Last 30 minutes",
basicData.last30MinUsers
)}
{summaryCard(
"Recent Activity",
"Last 5 minutes",
basicData.last5MinUsers
)}
</div>
<Tabs defaultValue="activity" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="pages">Current Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
</TabsList>
<TabsContent value="activity">
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={basicData.byMinute}>
<XAxis
dataKey="minute"
tickFormatter={(value) => value + "m ago"}
className="text-gray-600 dark:text-gray-300"
/>
<YAxis className="text-gray-600 dark:text-gray-300" />
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{payload[0].payload.minute}m ago
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{payload[0].value} active users
</p>
</div>
);
}
return null;
}}
/>
<Bar dataKey="users" fill="#8b5cf6" />
</BarChart>
</ResponsiveContainer>
</div>
</TabsContent>
<TabsContent value="pages">
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.currentPages.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page.path}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{page.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="sources">
<div className="space-y-2 h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Source
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Active Users
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailedData.sources.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{source.source}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{source.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default RealtimeAnalytics;

View File

@@ -0,0 +1,349 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import { Loader2 } from "lucide-react";
export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState("30");
const processPageData = (data) => {
if (!data?.rows) {
console.log("No rows in page data");
return [];
}
return data.rows.map((row) => ({
path: row.dimensionValues[0].value || "Unknown",
pageViews: parseInt(row.metricValues[0].value || 0),
avgSessionDuration: parseFloat(row.metricValues[1].value || 0),
bounceRate: parseFloat(row.metricValues[2].value || 0) * 100,
engagedSessions: parseInt(row.metricValues[3].value || 0),
}));
};
const processDeviceData = (data) => {
if (!data?.rows) {
console.log("No rows in device data");
return [];
}
return data.rows
.filter((row) => {
const device = (row.dimensionValues[0].value || "").toLowerCase();
return ["desktop", "mobile", "tablet"].includes(device);
})
.map((row) => {
const device = row.dimensionValues[0].value || "Unknown";
return {
device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(),
pageViews: parseInt(row.metricValues[0].value || 0),
sessions: parseInt(row.metricValues[1].value || 0),
};
})
.sort((a, b) => b.pageViews - a.pageViews);
};
const processSourceData = (data) => {
if (!data?.rows) {
console.log("No rows in source data");
return [];
}
return data.rows.map((row) => ({
source: row.dimensionValues[0].value || "Unknown",
sessions: parseInt(row.metricValues[0].value || 0),
conversions: parseInt(row.metricValues[1].value || 0),
}));
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/analytics/user-behavior?timeRange=${timeRange}`,
{
credentials: "include",
}
);
if (!response.ok) {
throw new Error("Failed to fetch user behavior");
}
const result = await response.json();
console.log("Raw user behavior response:", result);
if (!result?.success) {
throw new Error("Invalid response structure");
}
// Handle both data structures
const rawData = result.data?.data || result.data;
// Try to access the data differently based on the structure
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
console.log("Extracted responses:", {
pageResponse,
deviceResponse,
sourceResponse,
});
const processed = {
success: true,
data: {
pageData: {
pageData: processPageData(pageResponse),
deviceData: processDeviceData(deviceResponse),
},
sourceData: processSourceData(sourceResponse),
},
};
console.log("Final processed data:", processed);
setData(processed);
} catch (error) {
console.error("Failed to fetch behavior data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeRange]);
if (loading) {
return (
<Card className="bg-white dark:bg-gray-900">
<CardContent className="h-96 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</CardContent>
</Card>
);
}
const COLORS = {
desktop: "#8b5cf6", // Purple
mobile: "#10b981", // Green
tablet: "#f59e0b", // Yellow
};
const deviceData = data?.data?.pageData?.deviceData || [];
const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0);
const totalSessions = deviceData.reduce(
(sum, item) => sum + item.sessions,
0
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(
1
);
return (
<div className="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{data.device}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{data.pageViews.toLocaleString()} views ({percentage}%)
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
</p>
</div>
);
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
<Card className="bg-white dark:bg-gray-900">
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
User Behavior Analysis
</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 bg-white dark:bg-gray-800">
<SelectValue>
{timeRange === "7" && "Last 7 days"}
{timeRange === "14" && "Last 14 days"}
{timeRange === "30" && "Last 30 days"}
{timeRange === "90" && "Last 90 days"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="pages">Top Pages</TabsTrigger>
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent
value="pages"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page Path
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Views
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Bounce Rate
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Avg. Duration
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.pageData?.pageData.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{page.path}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{page.pageViews.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{page.bounceRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{formatDuration(page.avgSessionDuration)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="sources"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Source
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Sessions
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Conversions
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
Conv. Rate
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.sourceData?.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{source.source}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{source.sessions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{source.conversions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-gray-600 dark:text-gray-300">
{((source.conversions / source.sessions) * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent
value="devices"
className="mt-4 space-y-2 h-full max-h-[440px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
>
<div className="h-60">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData}
dataKey="pageViews"
nameKey="device"
cx="50%"
cy="50%"
outerRadius={80}
labelLine={false}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(1)}%`
}
>
{deviceData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.device.toLowerCase()]}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};
export default UserBehaviorDashboard;

View File

@@ -25,3 +25,31 @@ location /api/gorgias/ {
return 204; return 204;
} }
} }
# Google Analytics API endpoints
location /api/analytics/ {
proxy_pass http://localhost:3007/api/analytics/;
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';
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';
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;
}
}