Add gorgias component and services

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

View File

@@ -23,6 +23,7 @@ import ProductGrid from "./components/dashboard/ProductGrid";
import SalesChart from "./components/dashboard/SalesChart";
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
// Public layout
const PublicLayout = () => (
@@ -90,7 +91,7 @@ const DashboardLayout = () => {
</div>
<Navigation />
<div className="p-4 space-y-4">
<GorgiasOverview />
<div className="grid grid-cols-1 xl:grid-cols-6 gap-4">
<div className="xl:col-span-4 col-span-6">
<div className="space-y-4 h-full w-full">

View File

@@ -0,0 +1,477 @@
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,
Users,
MessageSquare,
Mail,
Send,
Loader2,
} from "lucide-react";
import axios from "axios";
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 getDateRange = (days) => {
const end = new Date();
end.setUTCHours(23, 59, 59, 999);
const start = new Date();
start.setDate(start.getDate() - days);
start.setUTCHours(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 (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 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={String(timeRange)}
onValueChange={(value) => setTimeRange(Number(value))}
>
<SelectTrigger className="w-[140px] bg-white dark:bg-gray-800">
<SelectValue placeholder="Select range">
{TIME_RANGES[timeRange]}
</SelectValue>
</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>
{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"
}`}
>
{channel.delta > 0 ? "+" : ""}
{channel.delta}
</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">Δ</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"
}`}
>
{agent.delta > 0 ? "+" : ""}
{agent.delta}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</CardContent>
</Card>
);
};
export default GorgiasOverview;

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