sms-extension/client/src/pages/Analytics.jsx

377 lines
13 KiB
JavaScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import apiClient from '../api/client';
function formatNumber(value) {
return new Intl.NumberFormat().format(Number(value || 0));
}
function formatRate(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
}
function formatLastTriggered(value) {
if (!value) return '—';
try {
return new Date(value).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
} catch {
return '—';
}
}
function buildLast30DaysSeries(rows = []) {
const rowByDate = new Map(
rows.map((row) => [String(row.date || ''), row])
);
const output = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let offset = 29; offset >= 0; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
const key = date.toISOString().slice(0, 10);
const row = rowByDate.get(key);
output.push({
key,
label: date.toLocaleDateString([], { month: 'short', day: 'numeric' }),
triggeredCount: Number(row?.triggeredCount || 0),
failedCount: Number(row?.failedCount || 0),
});
}
return output;
}
function getStatusAppearance(status) {
switch (status) {
case 'live':
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
case 'paused':
return 'border-slate-200 bg-slate-50 text-slate-600';
case 'pending':
return 'border-amber-200 bg-amber-50 text-amber-700';
case 'custom':
return 'border-violet-200 bg-violet-50 text-violet-700';
default:
return 'border-gray-200 bg-gray-50 text-gray-500';
}
}
function StatCard({ title, value, subtitle, accentClassName }) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className={`mb-4 h-1.5 w-20 rounded-full ${accentClassName}`} />
<p className="text-sm font-semibold text-gray-500">{title}</p>
<p className="mt-3 text-4xl font-bold tracking-tight text-gray-900">{value}</p>
<p className="mt-3 text-sm text-gray-500">{subtitle}</p>
</div>
);
}
function AnalyticsTrendChart({ rows }) {
const width = 720;
const height = 280;
const padding = { top: 18, right: 18, bottom: 34, left: 40 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(
1,
...rows.flatMap((row) => [row.triggeredCount, row.failedCount]),
);
const triggeredPoints = rows.map((row, index) => {
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
const y = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight;
return `${x},${y}`;
}).join(' ');
const failedPoints = rows.map((row, index) => {
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
const y = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
return `${x},${y}`;
}).join(' ');
const gridLines = Array.from({ length: 4 }, (_, index) => {
const ratio = index / 3;
const y = padding.top + innerHeight - ratio * innerHeight;
const label = Math.round(ratio * maxValue);
return { y, label };
});
return (
<div className="rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-900">Trigger Volume, Last 30 Days</h2>
<p className="mt-1 text-sm text-gray-500">Triggered vs failed SMS attempts</p>
</div>
<div className="flex items-center gap-5 text-sm font-medium text-gray-500">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-primary-blue" />
Triggered
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
Failed
</div>
</div>
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="h-[280px] w-full">
{gridLines.map((line) => (
<g key={line.y}>
<line
x1={padding.left}
y1={line.y}
x2={width - padding.right}
y2={line.y}
stroke="#E5E7EB"
strokeDasharray="4 6"
/>
<text
x={padding.left - 10}
y={line.y + 4}
textAnchor="end"
fontSize="11"
fill="#94A3B8"
>
{line.label}
</text>
</g>
))}
<polyline
fill="none"
stroke="#3838C4"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
points={triggeredPoints}
/>
<polyline
fill="none"
stroke="#F87171"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
points={failedPoints}
/>
{rows.map((row, index) => {
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
const triggeredY = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight;
const failedY = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
const showLabel = index % 5 === 0 || index === rows.length - 1;
return (
<g key={row.key}>
<circle cx={x} cy={triggeredY} r="3.5" fill="#3838C4" />
<circle cx={x} cy={failedY} r="3.5" fill="#F87171" />
{showLabel && (
<text
x={x}
y={height - 8}
textAnchor="middle"
fontSize="11"
fill="#94A3B8"
>
{row.label}
</text>
)}
</g>
);
})}
</svg>
</div>
);
}
export default function Analytics() {
const { businessId } = useParams();
const [overview, setOverview] = useState(null);
const [eventRows, setEventRows] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loadAnalytics = useCallback(async () => {
setLoading(true);
setError('');
try {
const [overviewRes, eventsRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/analytics/overview`),
apiClient.get(`/api/businesses/${businessId}/analytics/events`),
]);
setOverview(overviewRes.data);
setEventRows(eventsRes.data?.events || []);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load analytics');
} finally {
setLoading(false);
}
}, [businessId]);
useEffect(() => {
loadAnalytics();
}, [loadAnalytics]);
const chartRows = useMemo(
() => buildLast30DaysSeries(overview?.chart || []),
[overview?.chart],
);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-primary-blue" />
</div>
);
}
if (error) {
return (
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm font-medium text-red-700">
{error}
</div>
</div>
);
}
const metrics = overview?.metrics || {};
const deliveryRateSubtitle = metrics.deliveryRateMode === 'send_fallback'
? 'Using send success until provider callbacks are connected'
: 'Based on delivery outcomes recorded so far';
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="border-b border-gray-200 pb-5">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Analytics</h1>
<p className="mt-1 text-sm font-medium text-gray-500">
Event trigger counts, operational health, and fallback delivery performance for this business.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard
title="Events Triggered Today"
value={formatNumber(metrics.triggeredToday)}
subtitle="Unique business events received today"
accentClassName="bg-primary-blue"
/>
<StatCard
title="Global Trigger Count"
value={formatNumber(metrics.totalTriggered)}
subtitle="All tracked event executions"
accentClassName="bg-sky-500"
/>
<StatCard
title="Delivery Rate"
value={formatRate(metrics.deliveryRate)}
subtitle={deliveryRateSubtitle}
accentClassName="bg-emerald-500"
/>
<StatCard
title="Failed (24h)"
value={formatNumber(metrics.failedLast24Hours)}
subtitle="Send failures and failed delivery outcomes"
accentClassName="bg-red-500"
/>
<StatCard
title="Active Events"
value={formatNumber(metrics.activeEvents)}
subtitle={`of ${formatNumber(metrics.totalEvents)} total events`}
accentClassName="bg-slate-800"
/>
</div>
<AnalyticsTrendChart rows={chartRows} />
<div className="rounded-[28px] border border-gray-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-5">
<div>
<h2 className="text-2xl font-bold text-gray-900">Event Health</h2>
<p className="mt-1 text-sm text-gray-500">Per-event trigger counts and runtime status</p>
</div>
<Link
to={`/${businessId}/events`}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
View all events
</Link>
</div>
{eventRows.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-gray-500">
No analytics have been recorded for this business yet.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">
<th className="px-6 py-4">Event</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Triggered Today</th>
<th className="px-6 py-4">Total Trigger Count</th>
<th className="px-6 py-4">Delivery Rate</th>
<th className="px-6 py-4">Last Triggered</th>
<th className="px-6 py-4 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{eventRows.map((row) => (
<tr key={row.eventSlug} className="hover:bg-gray-50/70">
<td className="px-6 py-4">
<div className="font-semibold text-gray-900">{row.eventLabel}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{row.eventSlug}</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${getStatusAppearance(row.status)}`}>
{row.statusLabel}
</span>
</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
{formatNumber(row.triggeredToday)}
</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
{formatNumber(row.totalTriggerCount)}
</td>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-gray-900">{formatRate(row.deliveryRate)}</div>
<div className="mt-1 text-xs text-gray-400">
{row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatLastTriggered(row.lastTriggeredAt)}
</td>
<td className="px-6 py-4 text-right">
<Link
to={row.actionPath}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Manage
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}