Fixing UI state so no hard refreshes on tiny changes

This commit is contained in:
Ritul Jadhav 2026-04-14 15:13:14 +05:30
parent 232d734c98
commit d322fbe2d4
8 changed files with 976 additions and 281 deletions

View File

@ -1,7 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Link, useParams } from 'react-router-dom';
import apiClient from '../api/client';
const PAGE_SIZE = 5;
const CHART_WIDTH = 720;
const CHART_HEIGHT = 280;
const CHART_PADDING = { top: 18, right: 18, bottom: 34, left: 40 };
const STATUS_SCOPE_OPTIONS = [
{ value: 'all', label: 'All Events' },
{ value: 'live', label: 'Live' },
{ value: 'paused', label: 'Paused' },
{ value: 'pending', label: 'Pending' },
];
function formatNumber(value) {
return new Intl.NumberFormat().format(Number(value || 0));
}
@ -11,6 +29,14 @@ function formatRate(value) {
return `${(value * 100).toFixed(1)}%`;
}
function formatFailureShare(triggeredCount, failedCount) {
const safeTriggeredCount = Number(triggeredCount || 0);
const safeFailedCount = Number(failedCount || 0);
if (safeTriggeredCount <= 0) return '—';
return `${((safeFailedCount / safeTriggeredCount) * 100).toFixed(1)}%`;
}
function formatLastTriggered(value) {
if (!value) return '—';
@ -26,6 +52,22 @@ function formatLastTriggered(value) {
}
}
function titleCaseFromSlug(value) {
return String(value || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function clampValue(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeSearchText(value) {
return String(value || '').trim().toLowerCase();
}
function buildLast30DaysSeries(rows = []) {
const rowByDate = new Map(
rows.map((row) => [String(row.date || ''), row])
@ -66,9 +108,28 @@ function getStatusAppearance(status) {
}
}
function getScopeChipAppearance(isActive) {
return isActive
? 'border-primary-blue bg-primary-blue text-white shadow-sm'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50';
}
function buildPaginationState(value = {}) {
const pageSize = Number(value.pageSize || PAGE_SIZE) || PAGE_SIZE;
const totalItems = Number(value.totalItems || 0) || 0;
const totalPages = Math.max(1, Number(value.totalPages || Math.ceil(totalItems / pageSize) || 1));
return {
page: Math.max(1, Number(value.page || 1) || 1),
pageSize,
totalItems,
totalPages,
};
}
function StatCard({ title, value, subtitle, accentClassName }) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm transition duration-200 hover:-translate-y-1 hover:shadow-xl">
<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>
@ -77,44 +138,87 @@ function StatCard({ title, value, subtitle, accentClassName }) {
);
}
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;
function AnalyticsTrendChart({ rows, hasFilters }) {
const innerWidth = CHART_WIDTH - CHART_PADDING.left - CHART_PADDING.right;
const innerHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
const svgRef = useRef(null);
const [hoverState, setHoverState] = useState(null);
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 points = useMemo(
() => rows.map((row, index) => {
const x = CHART_PADDING.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
const triggeredY = CHART_PADDING.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight;
const failedY = CHART_PADDING.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
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(' ');
return {
...row,
x,
triggeredY,
failedY,
};
}),
[innerHeight, innerWidth, maxValue, rows],
);
const triggeredPoints = points.map((point) => `${point.x},${point.triggeredY}`).join(' ');
const failedPoints = points.map((point) => `${point.x},${point.failedY}`).join(' ');
const gridLines = Array.from({ length: 4 }, (_, index) => {
const ratio = index / 3;
const y = padding.top + innerHeight - ratio * innerHeight;
const y = CHART_PADDING.top + innerHeight - ratio * innerHeight;
const label = Math.round(ratio * maxValue);
return { y, label };
});
const handlePointerMove = useCallback((event) => {
const chartElement = svgRef.current;
if (!chartElement || points.length === 0) return;
const rect = chartElement.getBoundingClientRect();
const relativeX = clampValue(event.clientX - rect.left, 0, rect.width);
const hoveredIndex = points.length === 1
? 0
: Math.round((relativeX / rect.width) * (points.length - 1));
const point = points[hoveredIndex];
if (!point) return;
const scaledX = (point.x / CHART_WIDTH) * rect.width;
const anchorY = Math.min(point.triggeredY, point.failedY);
const scaledY = (anchorY / CHART_HEIGHT) * rect.height;
const tooltipWidth = rect.width < 460 ? Math.max(164, rect.width - 24) : 208;
const tooltipHeight = 122;
const preferLeft = scaledX > rect.width * 0.62;
const left = preferLeft
? clampValue(scaledX - tooltipWidth - 18, 8, Math.max(8, rect.width - tooltipWidth - 8))
: clampValue(scaledX + 18, 8, Math.max(8, rect.width - tooltipWidth - 8));
const top = clampValue(scaledY - (tooltipHeight / 2), 8, Math.max(8, rect.height - tooltipHeight - 8));
setHoverState({
index: hoveredIndex,
left,
top,
width: tooltipWidth,
});
}, [points]);
const hoveredPoint = hoverState ? points[hoverState.index] : null;
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 className="rounded-[28px] border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<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>
<p className="mt-1 text-sm text-gray-500">
{hasFilters ? 'Triggered vs failed SMS attempts for the current filtered view.' : 'Triggered vs failed SMS attempts.'}
</p>
</div>
<div className="flex items-center gap-5 text-sm font-medium text-gray-500">
<div className="flex flex-wrap items-center gap-4 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
@ -126,111 +230,303 @@ function AnalyticsTrendChart({ rows }) {
</div>
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="h-[280px] w-full">
{gridLines.map((line) => (
<g key={line.y}>
<div className="relative overflow-visible">
<svg
ref={svgRef}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="h-[280px] w-full touch-none sm:h-[320px]"
onPointerMove={handlePointerMove}
onPointerDown={handlePointerMove}
onPointerLeave={() => setHoverState(null)}
>
{gridLines.map((line) => (
<g key={line.y}>
<line
x1={CHART_PADDING.left}
y1={line.y}
x2={CHART_WIDTH - CHART_PADDING.right}
y2={line.y}
stroke="#E5E7EB"
strokeDasharray="4 6"
/>
<text
x={CHART_PADDING.left - 10}
y={line.y + 4}
textAnchor="end"
fontSize="11"
fill="#94A3B8"
>
{line.label}
</text>
</g>
))}
{hoveredPoint && (
<line
x1={padding.left}
y1={line.y}
x2={width - padding.right}
y2={line.y}
stroke="#E5E7EB"
x1={hoveredPoint.x}
y1={CHART_PADDING.top}
x2={hoveredPoint.x}
y2={CHART_HEIGHT - CHART_PADDING.bottom}
stroke="#CBD5E1"
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}
/>
<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;
{points.map((point, index) => {
const showLabel = index % 5 === 0 || index === points.length - 1;
const isHovered = hoveredPoint?.key === point.key;
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>
return (
<g key={point.key}>
<circle cx={point.x} cy={point.triggeredY} r={isHovered ? '6' : '3.5'} fill="#3838C4" />
<circle cx={point.x} cy={point.failedY} r={isHovered ? '6' : '3.5'} fill="#F87171" />
{showLabel && (
<text
x={point.x}
y={CHART_HEIGHT - 8}
textAnchor="middle"
fontSize="11"
fill="#94A3B8"
>
{point.label}
</text>
)}
</g>
);
})}
</svg>
{hoveredPoint && hoverState && (
<div
className="pointer-events-none absolute z-20 rounded-2xl border border-gray-200 bg-white/95 p-4 shadow-2xl backdrop-blur-sm"
style={{
left: `${hoverState.left}px`,
top: `${hoverState.top}px`,
width: `${hoverState.width}px`,
}}
>
<p className="text-sm font-semibold text-gray-900">{hoveredPoint.label}</p>
<div className="mt-3 space-y-2 text-sm text-gray-600">
<div className="flex items-center justify-between gap-3">
<span>Triggered</span>
<span className="font-semibold text-gray-900">{formatNumber(hoveredPoint.triggeredCount)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Failed</span>
<span className="font-semibold text-gray-900">{formatNumber(hoveredPoint.failedCount)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Failure Share</span>
<span className="font-semibold text-gray-900">
{formatFailureShare(hoveredPoint.triggeredCount, hoveredPoint.failedCount)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function Analytics() {
const { businessId } = useParams();
const searchRef = useRef(null);
const hasLoadedAnalyticsRef = useRef(false);
const [overview, setOverview] = useState(null);
const [eventRows, setEventRows] = useState([]);
const [loading, setLoading] = useState(true);
const [allEventRows, setAllEventRows] = useState([]);
const [pagination, setPagination] = useState(buildPaginationState());
const [statusScope, setStatusScope] = useState('all');
const [selectedEventSlugs, setSelectedEventSlugs] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [page, setPage] = useState(1);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const loadAnalytics = useCallback(async () => {
setLoading(true);
setError('');
const selectedEventSet = useMemo(
() => new Set(selectedEventSlugs),
[selectedEventSlugs],
);
const eventOptionsBySlug = useMemo(
() => new Map(allEventRows.map((row) => [row.eventSlug, row])),
[allEventRows],
);
const selectedEventRows = useMemo(
() => selectedEventSlugs.map((slug) => (
eventOptionsBySlug.get(slug) || {
eventSlug: slug,
eventLabel: titleCaseFromSlug(slug),
status: 'not_configured',
statusLabel: 'Not Configured',
}
)),
[eventOptionsBySlug, selectedEventSlugs],
);
const suggestionRows = useMemo(() => {
const normalizedQuery = normalizeSearchText(deferredSearchQuery);
return allEventRows
.filter((row) => !selectedEventSet.has(row.eventSlug))
.filter((row) => statusScope === 'all' || row.status === statusScope)
.filter((row) => {
if (!normalizedQuery) return true;
return normalizeSearchText(row.eventLabel).includes(normalizedQuery)
|| normalizeSearchText(row.eventSlug).includes(normalizedQuery);
})
.slice(0, normalizedQuery ? 8 : 6);
}, [allEventRows, deferredSearchQuery, selectedEventSet, statusScope]);
const hasFilters = statusScope !== 'all' || selectedEventSlugs.length > 0;
const loadAnalytics = useCallback(async ({ background = false } = {}) => {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError('');
}
const params = {
page,
pageSize: PAGE_SIZE,
};
if (statusScope !== 'all') {
params.statusScope = statusScope;
}
if (selectedEventSlugs.length > 0) {
params.eventSlugs = selectedEventSlugs.join(',');
}
try {
const [overviewRes, eventsRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/analytics/overview`),
apiClient.get(`/api/businesses/${businessId}/analytics/events`),
apiClient.get(`/api/businesses/${businessId}/analytics/overview`, { params }),
apiClient.get(`/api/businesses/${businessId}/analytics/events`, { params }),
]);
setOverview(overviewRes.data);
setEventRows(eventsRes.data?.events || []);
setAllEventRows(eventsRes.data?.allEvents || eventsRes.data?.events || []);
setPagination(buildPaginationState(eventsRes.data?.pagination));
setError('');
} catch (err) {
setError(err.response?.data?.error || 'Failed to load analytics');
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedAnalyticsRef.current = true;
setInitialLoading(false);
}
}
}, [businessId]);
}, [businessId, page, selectedEventSlugs, statusScope]);
useEffect(() => {
loadAnalytics();
loadAnalytics({ background: hasLoadedAnalyticsRef.current });
}, [loadAnalytics]);
useEffect(() => {
function handlePointerDown(event) {
if (!searchRef.current?.contains(event.target)) {
setSearchOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, []);
const chartRows = useMemo(
() => buildLast30DaysSeries(overview?.chart || []),
[overview?.chart],
);
if (loading) {
const metrics = overview?.metrics || {};
const deliveryRateSubtitle = metrics.deliveryRateMode === 'send_fallback'
? 'Using send success until provider callbacks are connected'
: hasFilters
? 'Based on the currently selected event view'
: 'Based on delivery outcomes recorded so far';
const totalTriggerTitle = hasFilters ? 'Filtered Trigger Count' : 'Global Trigger Count';
const totalTriggerSubtitle = hasFilters
? 'All tracked executions in the current filtered view'
: 'All tracked event executions';
const triggeredTodaySubtitle = hasFilters
? 'Matching business events received today'
: 'Unique business events received today';
const activeEventsSubtitle = hasFilters
? `${formatNumber(metrics.totalEvents)} events in the current filtered view`
: `of ${formatNumber(metrics.totalEvents)} total events`;
const paginationStart = pagination.totalItems === 0 ? 0 : ((pagination.page - 1) * pagination.pageSize) + 1;
const paginationEnd = pagination.totalItems === 0
? 0
: Math.min(pagination.page * pagination.pageSize, pagination.totalItems);
function handleStatusScopeChange(nextScope) {
setStatusScope(nextScope);
setPage(1);
}
function handleSelectSuggestion(row) {
if (!row?.eventSlug || selectedEventSet.has(row.eventSlug)) return;
setSelectedEventSlugs((current) => [...current, row.eventSlug]);
setSearchQuery('');
setSearchOpen(false);
setPage(1);
}
function handleRemoveSelectedEvent(eventSlug) {
setSelectedEventSlugs((current) => current.filter((slug) => slug !== eventSlug));
setPage(1);
}
function handleClearAllFilters() {
setStatusScope('all');
setSelectedEventSlugs([]);
setSearchQuery('');
setSearchOpen(false);
setPage(1);
}
function handleSearchKeyDown(event) {
if (event.key === 'Escape') {
setSearchOpen(false);
return;
}
if (event.key === 'Enter' && suggestionRows.length > 0) {
event.preventDefault();
handleSelectSuggestion(suggestionRows[0]);
}
}
if (initialLoading) {
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" />
@ -248,31 +544,143 @@ export default function Analytics() {
);
}
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>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Analytics</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Updating view
</span>
)}
</div>
<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="rounded-[28px] border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div ref={searchRef} className="flex-1">
<label htmlFor="analytics-event-search" className="text-sm font-semibold text-gray-700">
Search And Select Events
</label>
<div className="relative mt-2">
<input
id="analytics-event-search"
type="text"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchOpen(true);
}}
onFocus={() => setSearchOpen(true)}
onKeyDown={handleSearchKeyDown}
placeholder="Search by event name or slug"
className="w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 shadow-sm outline-none transition focus:border-primary-blue focus:ring-2 focus:ring-primary-blue/20"
/>
{searchOpen && (
<div className="absolute left-0 right-0 top-[calc(100%+0.6rem)] z-20 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
{suggestionRows.length === 0 ? (
<div className="px-4 py-4 text-sm text-gray-500">
No events matched your search or current status scope.
</div>
) : (
<ul className="max-h-72 overflow-y-auto py-2">
{suggestionRows.map((row) => (
<li key={row.eventSlug}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelectSuggestion(row)}
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left transition hover:bg-gray-50"
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{row.eventLabel}</div>
<div className="mt-1 truncate font-mono text-xs text-gray-400">{row.eventSlug}</div>
</div>
<span className={`shrink-0 rounded-full border px-3 py-1 text-[11px] font-semibold ${getStatusAppearance(row.status)}`}>
{row.statusLabel}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
<p className="mt-2 text-xs text-gray-400">
Select one or more events to refresh the cards, chart, and table for that exact view.
</p>
</div>
<div className="xl:w-[340px]">
<p className="text-sm font-semibold text-gray-700">Status Scope</p>
<div className="mt-2 flex flex-wrap gap-2">
{STATUS_SCOPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleStatusScopeChange(option.value)}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${getScopeChipAppearance(statusScope === option.value)}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{selectedEventRows.length > 0 && (
<div className="mt-5 border-t border-gray-100 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-500">Selected:</span>
{selectedEventRows.map((row) => (
<span
key={row.eventSlug}
className="inline-flex items-center gap-2 rounded-full border border-primary-blue/15 bg-primary-blue/10 px-3 py-1.5 text-sm font-medium text-primary-blue"
>
<span>{row.eventLabel}</span>
<button
type="button"
onClick={() => handleRemoveSelectedEvent(row.eventSlug)}
className="rounded-full text-primary-blue/80 transition hover:text-primary-blue"
aria-label={`Remove ${row.eventLabel}`}
>
×
</button>
</span>
))}
{(selectedEventRows.length > 0 || statusScope !== 'all') && (
<button
type="button"
onClick={handleClearAllFilters}
className="ml-auto text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Clear all
</button>
)}
</div>
</div>
)}
</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"
subtitle={triggeredTodaySubtitle}
accentClassName="bg-primary-blue"
/>
<StatCard
title="Global Trigger Count"
title={totalTriggerTitle}
value={formatNumber(metrics.totalTriggered)}
subtitle="All tracked event executions"
subtitle={totalTriggerSubtitle}
accentClassName="bg-sky-500"
/>
<StatCard
@ -290,18 +698,24 @@ export default function Analytics() {
<StatCard
title="Active Events"
value={formatNumber(metrics.activeEvents)}
subtitle={`of ${formatNumber(metrics.totalEvents)} total events`}
subtitle={activeEventsSubtitle}
accentClassName="bg-slate-800"
/>
</div>
<AnalyticsTrendChart rows={chartRows} />
<AnalyticsTrendChart rows={chartRows} hasFilters={hasFilters} />
<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 className="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<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>
<p className="mt-1 text-sm text-gray-500">
{pagination.totalItems > 0
? `Showing ${paginationStart}-${paginationEnd} of ${formatNumber(pagination.totalItems)} events in this view`
: hasFilters
? 'No events match the current filter selection.'
: 'Per-event trigger counts and runtime status'}
</p>
</div>
<Link
to={`/${businessId}/events`}
@ -313,62 +727,105 @@ export default function Analytics() {
{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.
{hasFilters ? (
<div className="space-y-3">
<p>No events match the selected filters yet.</p>
<button
type="button"
onClick={handleClearAllFilters}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Clear filters
</button>
</div>
) : (
'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>
<>
<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>
))}
</tbody>
</table>
</div>
</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 className="flex flex-col gap-3 border-t border-gray-100 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-500">
Page {formatNumber(pagination.page)} of {formatNumber(pagination.totalPages)}
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={pagination.page <= 1}
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Prev
</button>
<button
type="button"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((currentPage) => Math.min(pagination.totalPages, currentPage + 1))}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</>
)}
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
@ -333,9 +333,11 @@ function UnifiedBusinessCard({
export default function Businesses() {
const navigate = useNavigate();
const { setActiveBusiness } = useBusiness();
const hasLoadedBusinessesPageRef = useRef(false);
const [businesses, setBusinesses] = useState([]);
const [salesChannels, setSalesChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
const [salesChannelQuery, setSalesChannelQuery] = useState('');
const [selectingBusinessId, setSelectingBusinessId] = useState('');
@ -416,37 +418,55 @@ export default function Businesses() {
setBusinesses(res.data.businesses || []);
}, []);
const loadSalesChannels = useCallback(async () => {
setSalesChannelsStatus('loading');
const loadSalesChannels = useCallback(async ({ background = false } = {}) => {
if (!background) {
setSalesChannelsStatus('loading');
}
const channels = await fetchActiveSalesChannels();
setSalesChannels(channels);
setSalesChannelsStatus('success');
}, []);
const load = useCallback(async () => {
setLoading(true);
setError('');
const load = useCallback(async ({ background = false } = {}) => {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError('');
}
try {
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
loadBusinesses(),
loadSalesChannels(),
loadSalesChannels({ background }),
]);
if (businessesRes.status === 'rejected') {
setError('Failed to load businesses');
} else {
setError('');
}
if (salesChannelsRes.status === 'rejected') {
setSalesChannels([]);
setSalesChannelsStatus('error');
if (!background) {
setSalesChannels([]);
setSalesChannelsStatus('error');
}
if (businessesRes.status !== 'rejected') {
setError('Failed to refresh sales channels');
}
}
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedBusinessesPageRef.current = true;
setInitialLoading(false);
}
}
}, [loadBusinesses, loadSalesChannels]);
useEffect(() => { load(); }, [load]);
useEffect(() => { load({ background: hasLoadedBusinessesPageRef.current }); }, [load]);
const handleBusinessCreated = useCallback(async (created) => {
setShowModal(false);
@ -454,12 +474,11 @@ export default function Businesses() {
setCreatedBusiness(created);
try {
await Promise.all([loadBusinesses(), loadSalesChannels()]);
setSalesChannelsStatus('success');
await load({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
}
}, [loadBusinesses, loadSalesChannels]);
}, [load]);
const handleBusinessJobStarted = useCallback(async (job) => {
setError('');
@ -571,7 +590,7 @@ export default function Businesses() {
try {
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
setDeleteTarget(null);
await load();
await load({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete business');
} finally {
@ -600,7 +619,7 @@ export default function Businesses() {
}
}
if (loading) {
if (initialLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
@ -613,9 +632,17 @@ export default function Businesses() {
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing list
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1">
{showUnifiedSalesChannelView
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
@ -732,7 +759,7 @@ export default function Businesses() {
{showModal && (
<RegisterBusinessModal
onClose={() => { setShowModal(false); load(); }}
onClose={() => { setShowModal(false); load({ background: true }); }}
onJobStarted={handleBusinessJobStarted}
/>
)}

View File

@ -657,8 +657,10 @@ function TemplateGenerationWorkspaceModal({
export default function Events() {
const { businessId } = useParams();
const { refreshOnboardingState } = useBusiness();
const hasLoadedEventsRef = useRef(false);
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false);
@ -731,8 +733,12 @@ export default function Events() {
};
}, [templateWorkspace.slug]);
const loadEvents = useCallback(async () => {
setLoading(true);
const loadEvents = useCallback(async ({ background = false } = {}) => {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`),
@ -755,15 +761,21 @@ export default function Events() {
setTemplateStatusBySlug(nextTemplateStatusBySlug);
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
setError('');
} catch {
setError('Failed to load events');
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedEventsRef.current = true;
setInitialLoading(false);
}
}
}, [businessId]);
useEffect(() => {
loadEvents();
loadEvents({ background: hasLoadedEventsRef.current });
}, [loadEvents]);
async function handleAddEvent(e) {
@ -775,7 +787,7 @@ export default function Events() {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel('');
setShowAddForm(false);
await loadEvents();
await loadEvents({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Failed to add event');
} finally {
@ -786,7 +798,7 @@ export default function Events() {
async function handleDelete(slug) {
try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
await loadEvents();
await loadEvents({ background: true });
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete event');
}
@ -1209,7 +1221,7 @@ export default function Events() {
handleGenerate(slug, { sessionId });
}
if (loading) {
if (initialLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
@ -1245,7 +1257,15 @@ export default function Events() {
<div className="max-w-4xl mx-auto">
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
<div>
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Updating events
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
@ -133,7 +133,9 @@ export default function GlobalSms() {
const { businessId } = useParams();
const navigate = useNavigate();
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
const [loading, setLoading] = useState(true);
const hasLoadedProfilesRef = useRef(false);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(null);
const [saving, setSaving] = useState(false);
@ -159,9 +161,13 @@ export default function GlobalSms() {
const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`;
const loadProfiles = useCallback(async () => {
const loadProfiles = useCallback(async ({ background = false } = {}) => {
try {
setLoading(true);
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = res.data?.activeProfileId || null;
@ -172,6 +178,7 @@ export default function GlobalSms() {
setActiveProfileId(nextActiveProfileId);
setHasGlobalSms(fetchedProfiles.length > 0);
setIsSetupComplete(nextIsSetupComplete);
setError('');
return {
activeProfile: nextActiveProfile,
@ -184,12 +191,17 @@ export default function GlobalSms() {
setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false };
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
}
}, [businessId, setHasGlobalSms, setIsSetupComplete]);
useEffect(() => {
loadProfiles();
loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]);
useEffect(() => {
@ -223,7 +235,7 @@ export default function GlobalSms() {
setFormSetActive(true);
setSuccess('Profile created successfully.');
const nextState = await loadProfiles();
const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
@ -241,7 +253,7 @@ export default function GlobalSms() {
try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
const nextState = await loadProfiles();
const nextState = await loadProfiles({ background: true });
setSuccess('Active profile updated.');
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
@ -295,7 +307,7 @@ export default function GlobalSms() {
const payload = buildProfilePatchPayload(missingInputs, inputForm);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
setSuccess('Required profile fields saved.');
const nextState = await loadProfiles();
const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
@ -338,7 +350,7 @@ export default function GlobalSms() {
delete nextState[deletePreview.profile.id];
return nextState;
});
await loadProfiles();
await loadProfiles({ background: true });
setSuccess('Profile deleted successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile');
@ -347,7 +359,7 @@ export default function GlobalSms() {
}
}
if (loading) {
if (initialLoading) {
return (
<div className="flex h-64 items-center justify-center">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-spinner-track border-t-primary-blue" />
@ -366,7 +378,15 @@ export default function GlobalSms() {
<div className="mx-auto max-w-4xl space-y-8 pb-12">
<div>
<h2 className="mb-2 text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
<div className="mb-2 flex flex-wrap items-center gap-3">
<h2 className="text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing profiles
</span>
)}
</div>
<p className="text-sm text-text-muted">
Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
</p>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
@ -206,7 +206,9 @@ export default function Providers() {
const { businessId } = useParams();
const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness();
const [loading, setLoading] = useState(true);
const hasLoadedProfilesRef = useRef(false);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState('');
@ -219,9 +221,13 @@ export default function Providers() {
const globalSmsPath = `/${businessId}/global-sms`;
const loadProfiles = useCallback(async () => {
const loadProfiles = useCallback(async ({ background = false } = {}) => {
try {
setLoading(true);
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = String(res.data?.activeProfileId || '');
@ -233,15 +239,21 @@ export default function Providers() {
? currentSelectedProfileId
: ''
));
setError('');
} catch (err) {
setError(err.response?.data?.error || 'Failed to load provider profiles');
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
}
}, [businessId]);
useEffect(() => {
loadProfiles();
loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]);
const selectedProfile = useMemo(
@ -300,7 +312,7 @@ export default function Providers() {
setSuccess('');
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
setSelectedProfileId(profile.id);
await loadProfiles();
await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`${profile.name} is now the active profile.`);
} catch (err) {
@ -358,7 +370,7 @@ export default function Providers() {
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
await loadProfiles();
await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
} catch (err) {
@ -368,7 +380,7 @@ export default function Providers() {
}
}
if (loading) {
if (initialLoading) {
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-indigo-600" />
@ -380,7 +392,15 @@ export default function Providers() {
<div className="mx-auto max-w-6xl space-y-6 pb-12">
<div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing profiles
</span>
)}
</div>
<p className="mt-1 text-sm font-medium text-gray-500">
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
</p>

View File

@ -82,9 +82,11 @@ function getTemplateSortRank(template) {
export default function Templates() {
const { businessId } = useParams();
const [searchParams] = useSearchParams();
const hasLoadedTemplatesRef = useRef(false);
const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = useState({});
const [loading, setLoading] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null);
@ -96,9 +98,13 @@ export default function Templates() {
const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef('');
const loadTemplates = useCallback(async () => {
setLoading(true);
setError('');
const loadTemplates = useCallback(async ({ background = false } = {}) => {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError('');
}
try {
const [templatesRes, profilesRes] = await Promise.all([
@ -111,15 +117,21 @@ export default function Templates() {
setTemplates(allTemplates);
setProfilesById(profileMap);
setError('');
} catch {
setError('Failed to load templates');
} finally {
setLoading(false);
if (background) {
setRefreshing(false);
} else {
hasLoadedTemplatesRef.current = true;
setInitialLoading(false);
}
}
}, [businessId]);
useEffect(() => {
loadTemplates();
loadTemplates({ background: hasLoadedTemplatesRef.current });
}, [loadTemplates]);
useEffect(() => () => {
@ -179,7 +191,7 @@ export default function Templates() {
async function handleWhitelistSuccess() {
setWhitelistTarget(null);
await loadTemplates();
await loadTemplates({ background: true });
}
async function handleRuntimeToggle(template) {
@ -207,7 +219,7 @@ export default function Templates() {
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
: null;
if (loading) {
if (initialLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
@ -218,7 +230,15 @@ export default function Templates() {
return (
<div className="mx-auto max-w-6xl">
<div className="mb-6 border-b border-gray-200 pb-5">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing templates
</span>
)}
</div>
<p className="mt-1 text-sm font-medium text-gray-500">
Manage template runtime, whitelisting, and testing from one place.
</p>

View File

@ -2436,6 +2436,108 @@ function getAnalyticsEventStatusLabel(status) {
}
}
const ANALYTICS_STATUS_SCOPES = new Set([
'all',
'live',
'paused',
'pending',
'custom',
'not_configured',
]);
function normalizeAnalyticsStatusScope(value) {
const normalizedValue = normalizeText(value).toLowerCase();
return ANALYTICS_STATUS_SCOPES.has(normalizedValue) ? normalizedValue : 'all';
}
function parseAnalyticsEventSlugs(value) {
const rawValues = Array.isArray(value) ? value : [value];
return [...new Set(
rawValues
.flatMap((entry) => String(entry || '').split(','))
.map((entry) => slugify(entry))
.filter(Boolean)
)];
}
function parsePaginationInteger(value, fallback, { min = 1, max = 100 } = {}) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function sortAnalyticsEventRows(rows = []) {
return [...rows].sort((left, right) => {
const statusRank = {
live: 0,
paused: 1,
pending: 2,
custom: 3,
not_configured: 4,
};
const rankDiff = (statusRank[left.status] ?? 99) - (statusRank[right.status] ?? 99);
if (rankDiff !== 0) return rankDiff;
const triggerDiff = (right.totalTriggerCount || 0) - (left.totalTriggerCount || 0);
if (triggerDiff !== 0) return triggerDiff;
return left.eventLabel.localeCompare(right.eventLabel);
});
}
function filterAnalyticsEventRows(rows = [], filters = {}) {
const statusScope = normalizeAnalyticsStatusScope(filters.statusScope);
const selectedEventSlugs = new Set(parseAnalyticsEventSlugs(filters.eventSlugs));
return rows.filter((row) => {
if (statusScope !== 'all' && row.status !== statusScope) {
return false;
}
if (selectedEventSlugs.size > 0 && !selectedEventSlugs.has(normalizeText(row.eventSlug))) {
return false;
}
return true;
});
}
function paginateAnalyticsRows(rows = [], page, pageSize) {
const safePageSize = parsePaginationInteger(pageSize, 5, { min: 1, max: 100 });
const totalItems = rows.length;
const totalPages = Math.max(1, Math.ceil(totalItems / safePageSize));
const currentPage = Math.min(parsePaginationInteger(page, 1, { min: 1, max: totalPages }), totalPages);
const startIndex = (currentPage - 1) * safePageSize;
return {
rows: rows.slice(startIndex, startIndex + safePageSize),
pagination: {
page: currentPage,
pageSize: safePageSize,
totalItems,
totalPages,
},
};
}
function hasExplicitAnalyticsFilter(filters = {}) {
return normalizeAnalyticsStatusScope(filters.statusScope) !== 'all'
|| parseAnalyticsEventSlugs(filters.eventSlugs).length > 0;
}
function buildEmptyOverviewMetrics() {
return {
triggeredToday: 0,
totalTriggered: 0,
failedLast24Hours: 0,
deliveryRate: {
rate: null,
mode: 'no_data',
},
chart: [],
};
}
async function loadBusinessTemplates(bizRoot) {
const templateFolder = `${bizRoot}/templates`;
const slugs = await listTemplateFiles(templateFolder).catch(() => []);
@ -2449,6 +2551,50 @@ async function loadBusinessTemplates(bizRoot) {
return templates;
}
async function buildAnalyticsEventRows({ companyId, businessId, bizRoot }) {
const [eventsData, templates, analyticsEventMetrics] = await Promise.all([
fetchJSON(bizRoot, 'events').catch(() => null),
loadBusinessTemplates(bizRoot),
getEventMetrics({ companyId, businessId }),
]);
const mergedEvents = mergeDefaultEvents(eventsData || {});
const templateBySlug = new Map(
templates.map((template) => [normalizeText(template?.eventSlug), template])
);
const analyticsBySlug = new Map(
analyticsEventMetrics.map((metric) => [normalizeText(metric.eventSlug), metric])
);
const rows = (mergedEvents.events || []).map((event) => {
const slug = normalizeText(event?.slug);
const template = templateBySlug.get(slug) || null;
const metric = analyticsBySlug.get(slug) || null;
const status = getAnalyticsEventStatus(event, template);
return {
eventSlug: slug,
eventLabel: normalizeText(event?.label) || titleCaseFromSlug(slug),
status,
statusLabel: getAnalyticsEventStatusLabel(status),
triggeredToday: metric?.triggeredToday || 0,
totalTriggerCount: metric?.totalTriggerCount || 0,
deliveryRate: metric?.deliveryRate?.rate ?? null,
deliveryRateMode: metric?.deliveryRate?.mode || 'no_data',
lastTriggeredAt: metric?.lastTriggeredAt || null,
actionPath: normalizeText(template?.selectedTemplate)
? `/${businessId}/templates?event=${encodeURIComponent(slug)}`
: `/${businessId}/events`,
};
});
return {
rows: sortAnalyticsEventRows(rows),
mergedEvents,
templates,
};
}
function hydrateProfile(profile = {}) {
const provider = normalizeProvider(profile.provider, profile.updatedAt);
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, provider);
@ -2864,17 +3010,23 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
const analyticsFilters = {
statusScope: req.query?.statusScope,
eventSlugs: req.query?.eventSlugs,
};
const [overviewMetrics, eventsData, templates] = await Promise.all([
getOverviewMetrics({ companyId, businessId }),
fetchJSON(bizRoot, 'events').catch(() => null),
loadBusinessTemplates(bizRoot),
]);
const mergedEvents = mergeDefaultEvents(eventsData || {});
const activeEventsCount = templates.filter((template) => (
template?.status === 'whitelisted' && template?.isRuntimeEnabled !== false
)).length;
const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
const scopedEventSlugs = filteredRows.map((row) => row.eventSlug);
const hasExplicitFilter = hasExplicitAnalyticsFilter(analyticsFilters);
const overviewMetrics = hasExplicitFilter && scopedEventSlugs.length === 0
? buildEmptyOverviewMetrics()
: await getOverviewMetrics({
companyId,
businessId,
eventSlugs: hasExplicitFilter ? scopedEventSlugs : [],
});
const activeEventsCount = filteredRows.filter((row) => row.status === 'live').length;
res.json({
metrics: {
@ -2884,7 +3036,7 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
deliveryRate: overviewMetrics.deliveryRate.rate,
deliveryRateMode: overviewMetrics.deliveryRate.mode,
activeEvents: activeEventsCount,
totalEvents: Array.isArray(mergedEvents.events) ? mergedEvents.events.length : 0,
totalEvents: filteredRows.length,
},
chart: overviewMetrics.chart,
});
@ -2900,59 +3052,22 @@ router.get('/:businessId/analytics/events', async (req, res) => {
const { businessId } = req.params;
const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId);
const analyticsFilters = {
statusScope: req.query?.statusScope,
eventSlugs: req.query?.eventSlugs,
};
const page = req.query?.page;
const pageSize = req.query?.pageSize;
const [eventsData, templates, analyticsEventMetrics] = await Promise.all([
fetchJSON(bizRoot, 'events').catch(() => null),
loadBusinessTemplates(bizRoot),
getEventMetrics({ companyId, businessId }),
]);
const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
const paginatedRows = paginateAnalyticsRows(filteredRows, page, pageSize);
const mergedEvents = mergeDefaultEvents(eventsData || {});
const templateBySlug = new Map(
templates.map((template) => [normalizeText(template?.eventSlug), template])
);
const analyticsBySlug = new Map(
analyticsEventMetrics.map((metric) => [normalizeText(metric.eventSlug), metric])
);
const rows = (mergedEvents.events || []).map((event) => {
const slug = normalizeText(event?.slug);
const template = templateBySlug.get(slug) || null;
const metric = analyticsBySlug.get(slug) || null;
const status = getAnalyticsEventStatus(event, template);
return {
eventSlug: slug,
eventLabel: normalizeText(event?.label) || titleCaseFromSlug(slug),
status,
statusLabel: getAnalyticsEventStatusLabel(status),
triggeredToday: metric?.triggeredToday || 0,
totalTriggerCount: metric?.totalTriggerCount || 0,
deliveryRate: metric?.deliveryRate?.rate ?? null,
deliveryRateMode: metric?.deliveryRate?.mode || 'no_data',
lastTriggeredAt: metric?.lastTriggeredAt || null,
actionPath: normalizeText(template?.selectedTemplate)
? `/${businessId}/templates?event=${encodeURIComponent(slug)}`
: `/${businessId}/events`,
};
}).sort((left, right) => {
const statusRank = {
live: 0,
paused: 1,
pending: 2,
custom: 3,
not_configured: 4,
};
const rankDiff = (statusRank[left.status] ?? 99) - (statusRank[right.status] ?? 99);
if (rankDiff !== 0) return rankDiff;
const triggerDiff = (right.totalTriggerCount || 0) - (left.totalTriggerCount || 0);
if (triggerDiff !== 0) return triggerDiff;
return left.eventLabel.localeCompare(right.eventLabel);
res.json({
events: paginatedRows.rows,
allEvents: allRows,
pagination: paginatedRows.pagination,
});
res.json({ events: rows });
} catch (err) {
const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500;
res.status(status).json({ error: err.message });

View File

@ -9,6 +9,16 @@ function normalizeInteger(value) {
return Number.isInteger(value) ? value : null;
}
function normalizeTextList(values = []) {
if (!Array.isArray(values)) return [];
return [...new Set(
values
.map((value) => normalizeText(value))
.filter(Boolean)
)];
}
function getConnectionString() {
return normalizeText(
process.env.FDK_STORAGE_CONNECTION_STRING
@ -123,7 +133,7 @@ function extractProviderMessageId(value, depth = 0) {
return '';
}
function buildExecutionFilters({ companyId, businessId }) {
function buildExecutionFilters({ companyId, businessId, eventSlugs }) {
const values = [];
const conditions = [];
@ -137,6 +147,12 @@ function buildExecutionFilters({ companyId, businessId }) {
conditions.push(`business_id = $${values.length}`);
}
const normalizedEventSlugs = normalizeTextList(eventSlugs);
if (normalizedEventSlugs.length > 0) {
values.push(normalizedEventSlugs);
conditions.push(`event_slug = ANY($${values.length})`);
}
if (conditions.length === 0) {
throw new Error('Analytics queries require at least one scope filter');
}