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 (

{title}

{value}

{subtitle}

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

Trigger Volume, Last 30 Days

Triggered vs failed SMS attempts

Triggered
Failed
{gridLines.map((line) => ( {line.label} ))} {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 ( {showLabel && ( {row.label} )} ); })}
); } 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 (
); } if (error) { return (
{error}
); } 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 (

Analytics

Event trigger counts, operational health, and fallback delivery performance for this business.

Event Health

Per-event trigger counts and runtime status

View all events
{eventRows.length === 0 ? (
No analytics have been recorded for this business yet.
) : (
{eventRows.map((row) => ( ))}
Event Status Triggered Today Total Trigger Count Delivery Rate Last Triggered Action
{row.eventLabel}
{row.eventSlug}
{row.statusLabel} {formatNumber(row.triggeredToday)} {formatNumber(row.totalTriggerCount)}
{formatRate(row.deliveryRate)}
{row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'}
{formatLastTriggered(row.lastTriggeredAt)} Manage
)}
); }