377 lines
13 KiB
JavaScript
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>
|
|
);
|
|
}
|