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 { Link, useParams } from 'react-router-dom';
import apiClient from '../api/client'; 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) { function formatNumber(value) {
return new Intl.NumberFormat().format(Number(value || 0)); return new Intl.NumberFormat().format(Number(value || 0));
} }
@ -11,6 +29,14 @@ function formatRate(value) {
return `${(value * 100).toFixed(1)}%`; 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) { function formatLastTriggered(value) {
if (!value) return '—'; 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 = []) { function buildLast30DaysSeries(rows = []) {
const rowByDate = new Map( const rowByDate = new Map(
rows.map((row) => [String(row.date || ''), row]) 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 }) { function StatCard({ title, value, subtitle, accentClassName }) {
return ( 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}`} /> <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="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-4xl font-bold tracking-tight text-gray-900">{value}</p>
@ -77,44 +138,87 @@ function StatCard({ title, value, subtitle, accentClassName }) {
); );
} }
function AnalyticsTrendChart({ rows }) { function AnalyticsTrendChart({ rows, hasFilters }) {
const width = 720; const innerWidth = CHART_WIDTH - CHART_PADDING.left - CHART_PADDING.right;
const height = 280; const innerHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
const padding = { top: 18, right: 18, bottom: 34, left: 40 }; const svgRef = useRef(null);
const innerWidth = width - padding.left - padding.right; const [hoverState, setHoverState] = useState(null);
const innerHeight = height - padding.top - padding.bottom;
const maxValue = Math.max( const maxValue = Math.max(
1, 1,
...rows.flatMap((row) => [row.triggeredCount, row.failedCount]), ...rows.flatMap((row) => [row.triggeredCount, row.failedCount]),
); );
const triggeredPoints = rows.map((row, index) => { const points = useMemo(
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); () => rows.map((row, index) => {
const y = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight; const x = CHART_PADDING.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
return `${x},${y}`; const triggeredY = CHART_PADDING.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight;
}).join(' '); const failedY = CHART_PADDING.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
const failedPoints = rows.map((row, index) => { return {
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); ...row,
const y = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight; x,
return `${x},${y}`; triggeredY,
}).join(' '); 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 gridLines = Array.from({ length: 4 }, (_, index) => {
const ratio = index / 3; 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); const label = Math.round(ratio * maxValue);
return { y, label }; 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 ( return (
<div className="rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm"> <div className="rounded-[28px] border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h2 className="text-2xl font-bold text-gray-900">Trigger Volume, Last 30 Days</h2> <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>
<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"> <div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-primary-blue" /> <span className="h-2.5 w-2.5 rounded-full bg-primary-blue" />
Triggered Triggered
@ -126,19 +230,27 @@ function AnalyticsTrendChart({ rows }) {
</div> </div>
</div> </div>
<svg viewBox={`0 0 ${width} ${height}`} className="h-[280px] w-full"> <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) => ( {gridLines.map((line) => (
<g key={line.y}> <g key={line.y}>
<line <line
x1={padding.left} x1={CHART_PADDING.left}
y1={line.y} y1={line.y}
x2={width - padding.right} x2={CHART_WIDTH - CHART_PADDING.right}
y2={line.y} y2={line.y}
stroke="#E5E7EB" stroke="#E5E7EB"
strokeDasharray="4 6" strokeDasharray="4 6"
/> />
<text <text
x={padding.left - 10} x={CHART_PADDING.left - 10}
y={line.y + 4} y={line.y + 4}
textAnchor="end" textAnchor="end"
fontSize="11" fontSize="11"
@ -149,6 +261,17 @@ function AnalyticsTrendChart({ rows }) {
</g> </g>
))} ))}
{hoveredPoint && (
<line
x1={hoveredPoint.x}
y1={CHART_PADDING.top}
x2={hoveredPoint.x}
y2={CHART_HEIGHT - CHART_PADDING.bottom}
stroke="#CBD5E1"
strokeDasharray="4 6"
/>
)}
<polyline <polyline
fill="none" fill="none"
stroke="#3838C4" stroke="#3838C4"
@ -166,71 +289,244 @@ function AnalyticsTrendChart({ rows }) {
points={failedPoints} points={failedPoints}
/> />
{rows.map((row, index) => { {points.map((point, index) => {
const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); const showLabel = index % 5 === 0 || index === points.length - 1;
const triggeredY = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight; const isHovered = hoveredPoint?.key === point.key;
const failedY = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
const showLabel = index % 5 === 0 || index === rows.length - 1;
return ( return (
<g key={row.key}> <g key={point.key}>
<circle cx={x} cy={triggeredY} r="3.5" fill="#3838C4" /> <circle cx={point.x} cy={point.triggeredY} r={isHovered ? '6' : '3.5'} fill="#3838C4" />
<circle cx={x} cy={failedY} r="3.5" fill="#F87171" /> <circle cx={point.x} cy={point.failedY} r={isHovered ? '6' : '3.5'} fill="#F87171" />
{showLabel && ( {showLabel && (
<text <text
x={x} x={point.x}
y={height - 8} y={CHART_HEIGHT - 8}
textAnchor="middle" textAnchor="middle"
fontSize="11" fontSize="11"
fill="#94A3B8" fill="#94A3B8"
> >
{row.label} {point.label}
</text> </text>
)} )}
</g> </g>
); );
})} })}
</svg> </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> </div>
); );
} }
export default function Analytics() { export default function Analytics() {
const { businessId } = useParams(); const { businessId } = useParams();
const searchRef = useRef(null);
const hasLoadedAnalyticsRef = useRef(false);
const [overview, setOverview] = useState(null); const [overview, setOverview] = useState(null);
const [eventRows, setEventRows] = useState([]); 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 [error, setError] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const loadAnalytics = useCallback(async () => { const selectedEventSet = useMemo(
setLoading(true); () => 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(''); setError('');
}
const params = {
page,
pageSize: PAGE_SIZE,
};
if (statusScope !== 'all') {
params.statusScope = statusScope;
}
if (selectedEventSlugs.length > 0) {
params.eventSlugs = selectedEventSlugs.join(',');
}
try { try {
const [overviewRes, eventsRes] = await Promise.all([ const [overviewRes, eventsRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/analytics/overview`), apiClient.get(`/api/businesses/${businessId}/analytics/overview`, { params }),
apiClient.get(`/api/businesses/${businessId}/analytics/events`), apiClient.get(`/api/businesses/${businessId}/analytics/events`, { params }),
]); ]);
setOverview(overviewRes.data); setOverview(overviewRes.data);
setEventRows(eventsRes.data?.events || []); setEventRows(eventsRes.data?.events || []);
setAllEventRows(eventsRes.data?.allEvents || eventsRes.data?.events || []);
setPagination(buildPaginationState(eventsRes.data?.pagination));
setError('');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to load analytics'); setError(err.response?.data?.error || 'Failed to load analytics');
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedAnalyticsRef.current = true;
setInitialLoading(false);
} }
}, [businessId]); }
}, [businessId, page, selectedEventSlugs, statusScope]);
useEffect(() => { useEffect(() => {
loadAnalytics(); loadAnalytics({ background: hasLoadedAnalyticsRef.current });
}, [loadAnalytics]); }, [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( const chartRows = useMemo(
() => buildLast30DaysSeries(overview?.chart || []), () => buildLast30DaysSeries(overview?.chart || []),
[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 ( return (
<div className="flex h-64 items-center justify-center"> <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 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 ( return (
<div className="mx-auto max-w-7xl space-y-6"> <div className="mx-auto max-w-7xl space-y-6">
<div className="border-b border-gray-200 pb-5"> <div className="border-b border-gray-200 pb-5">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Analytics</h1> <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"> <p className="mt-1 text-sm font-medium text-gray-500">
Event trigger counts, operational health, and fallback delivery performance for this business. Event trigger counts, operational health, and fallback delivery performance for this business.
</p> </p>
</div> </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"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard <StatCard
title="Events Triggered Today" title="Events Triggered Today"
value={formatNumber(metrics.triggeredToday)} value={formatNumber(metrics.triggeredToday)}
subtitle="Unique business events received today" subtitle={triggeredTodaySubtitle}
accentClassName="bg-primary-blue" accentClassName="bg-primary-blue"
/> />
<StatCard <StatCard
title="Global Trigger Count" title={totalTriggerTitle}
value={formatNumber(metrics.totalTriggered)} value={formatNumber(metrics.totalTriggered)}
subtitle="All tracked event executions" subtitle={totalTriggerSubtitle}
accentClassName="bg-sky-500" accentClassName="bg-sky-500"
/> />
<StatCard <StatCard
@ -290,18 +698,24 @@ export default function Analytics() {
<StatCard <StatCard
title="Active Events" title="Active Events"
value={formatNumber(metrics.activeEvents)} value={formatNumber(metrics.activeEvents)}
subtitle={`of ${formatNumber(metrics.totalEvents)} total events`} subtitle={activeEventsSubtitle}
accentClassName="bg-slate-800" accentClassName="bg-slate-800"
/> />
</div> </div>
<AnalyticsTrendChart rows={chartRows} /> <AnalyticsTrendChart rows={chartRows} hasFilters={hasFilters} />
<div className="rounded-[28px] border border-gray-200 bg-white shadow-sm"> <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> <div>
<h2 className="text-2xl font-bold text-gray-900">Event Health</h2> <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> </div>
<Link <Link
to={`/${businessId}/events`} to={`/${businessId}/events`}
@ -313,9 +727,23 @@ export default function Analytics() {
{eventRows.length === 0 ? ( {eventRows.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-gray-500"> <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> </div>
) : ( ) : (
'No analytics have been recorded for this business yet.'
)}
</div>
) : (
<>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100"> <table className="min-w-full divide-y divide-gray-100">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
@ -350,7 +778,11 @@ export default function Analytics() {
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-sm font-semibold text-gray-900">{formatRate(row.deliveryRate)}</div> <div className="text-sm font-semibold text-gray-900">{formatRate(row.deliveryRate)}</div>
<div className="mt-1 text-xs text-gray-400"> <div className="mt-1 text-xs text-gray-400">
{row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'} {row.deliveryRateMode === 'send_fallback'
? 'Send fallback'
: row.deliveryRateMode === 'callback'
? 'Callback-based'
: 'No data'}
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500">
@ -369,6 +801,31 @@ export default function Analytics() {
</tbody> </tbody>
</table> </table>
</div> </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>
</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 { useNavigate } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
@ -333,9 +333,11 @@ function UnifiedBusinessCard({
export default function Businesses() { export default function Businesses() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setActiveBusiness } = useBusiness(); const { setActiveBusiness } = useBusiness();
const hasLoadedBusinessesPageRef = useRef(false);
const [businesses, setBusinesses] = useState([]); const [businesses, setBusinesses] = useState([]);
const [salesChannels, setSalesChannels] = 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 [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
const [salesChannelQuery, setSalesChannelQuery] = useState(''); const [salesChannelQuery, setSalesChannelQuery] = useState('');
const [selectingBusinessId, setSelectingBusinessId] = useState(''); const [selectingBusinessId, setSelectingBusinessId] = useState('');
@ -416,37 +418,55 @@ export default function Businesses() {
setBusinesses(res.data.businesses || []); setBusinesses(res.data.businesses || []);
}, []); }, []);
const loadSalesChannels = useCallback(async () => { const loadSalesChannels = useCallback(async ({ background = false } = {}) => {
if (!background) {
setSalesChannelsStatus('loading'); setSalesChannelsStatus('loading');
}
const channels = await fetchActiveSalesChannels(); const channels = await fetchActiveSalesChannels();
setSalesChannels(channels); setSalesChannels(channels);
setSalesChannelsStatus('success'); setSalesChannelsStatus('success');
}, []); }, []);
const load = useCallback(async () => { const load = useCallback(async ({ background = false } = {}) => {
setLoading(true); if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError(''); setError('');
}
try { try {
const [businessesRes, salesChannelsRes] = await Promise.allSettled([ const [businessesRes, salesChannelsRes] = await Promise.allSettled([
loadBusinesses(), loadBusinesses(),
loadSalesChannels(), loadSalesChannels({ background }),
]); ]);
if (businessesRes.status === 'rejected') { if (businessesRes.status === 'rejected') {
setError('Failed to load businesses'); setError('Failed to load businesses');
} else {
setError('');
} }
if (salesChannelsRes.status === 'rejected') { if (salesChannelsRes.status === 'rejected') {
if (!background) {
setSalesChannels([]); setSalesChannels([]);
setSalesChannelsStatus('error'); setSalesChannelsStatus('error');
} }
if (businessesRes.status !== 'rejected') {
setError('Failed to refresh sales channels');
}
}
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedBusinessesPageRef.current = true;
setInitialLoading(false);
}
} }
}, [loadBusinesses, loadSalesChannels]); }, [loadBusinesses, loadSalesChannels]);
useEffect(() => { load(); }, [load]); useEffect(() => { load({ background: hasLoadedBusinessesPageRef.current }); }, [load]);
const handleBusinessCreated = useCallback(async (created) => { const handleBusinessCreated = useCallback(async (created) => {
setShowModal(false); setShowModal(false);
@ -454,12 +474,11 @@ export default function Businesses() {
setCreatedBusiness(created); setCreatedBusiness(created);
try { try {
await Promise.all([loadBusinesses(), loadSalesChannels()]); await load({ background: true });
setSalesChannelsStatus('success');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.'); setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
} }
}, [loadBusinesses, loadSalesChannels]); }, [load]);
const handleBusinessJobStarted = useCallback(async (job) => { const handleBusinessJobStarted = useCallback(async (job) => {
setError(''); setError('');
@ -571,7 +590,7 @@ export default function Businesses() {
try { try {
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`); await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
setDeleteTarget(null); setDeleteTarget(null);
await load(); await load({ background: true });
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete business'); setError(err.response?.data?.error || 'Failed to delete business');
} finally { } finally {
@ -600,7 +619,7 @@ export default function Businesses() {
} }
} }
if (loading) { if (initialLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <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" /> <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="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight"> <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')} {showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
</h1> </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"> <p className="text-sm text-gray-500 mt-1">
{showUnifiedSalesChannelView {showUnifiedSalesChannelView
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.' ? '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 && ( {showModal && (
<RegisterBusinessModal <RegisterBusinessModal
onClose={() => { setShowModal(false); load(); }} onClose={() => { setShowModal(false); load({ background: true }); }}
onJobStarted={handleBusinessJobStarted} onJobStarted={handleBusinessJobStarted}
/> />
)} )}

View File

@ -657,8 +657,10 @@ function TemplateGenerationWorkspaceModal({
export default function Events() { export default function Events() {
const { businessId } = useParams(); const { businessId } = useParams();
const { refreshOnboardingState } = useBusiness(); const { refreshOnboardingState } = useBusiness();
const hasLoadedEventsRef = useRef(false);
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false); const [addingEvent, setAddingEvent] = useState(false);
@ -731,8 +733,12 @@ export default function Events() {
}; };
}, [templateWorkspace.slug]); }, [templateWorkspace.slug]);
const loadEvents = useCallback(async () => { const loadEvents = useCallback(async ({ background = false } = {}) => {
setLoading(true); if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
try { try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([ const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`), apiClient.get(`/api/businesses/${businessId}/events`),
@ -755,15 +761,21 @@ export default function Events() {
setTemplateStatusBySlug(nextTemplateStatusBySlug); setTemplateStatusBySlug(nextTemplateStatusBySlug);
setSelectedTemplateBySlug(nextSelectedTemplateBySlug); setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants)); setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
setError('');
} catch { } catch {
setError('Failed to load events'); setError('Failed to load events');
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedEventsRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadEvents(); loadEvents({ background: hasLoadedEventsRef.current });
}, [loadEvents]); }, [loadEvents]);
async function handleAddEvent(e) { async function handleAddEvent(e) {
@ -775,7 +787,7 @@ export default function Events() {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() }); await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel(''); setNewLabel('');
setShowAddForm(false); setShowAddForm(false);
await loadEvents(); await loadEvents({ background: true });
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to add event'); setError(err.response?.data?.error || 'Failed to add event');
} finally { } finally {
@ -786,7 +798,7 @@ export default function Events() {
async function handleDelete(slug) { async function handleDelete(slug) {
try { try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`); await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
await loadEvents(); await loadEvents({ background: true });
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete event'); setError(err.response?.data?.error || 'Failed to delete event');
} }
@ -1209,7 +1221,7 @@ export default function Events() {
handleGenerate(slug, { sessionId }); handleGenerate(slug, { sessionId });
} }
if (loading) { if (initialLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <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" /> <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="max-w-4xl mx-auto">
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200"> <div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1> <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> <p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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 { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
@ -133,7 +133,9 @@ export default function GlobalSms() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness(); 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 [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(null); const [activeProfileId, setActiveProfileId] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -159,9 +161,13 @@ export default function GlobalSms() {
const hasProfiles = profiles.length > 0; const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`; const eventsPath = `/${businessId}/events`;
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async ({ background = false } = {}) => {
try { try {
setLoading(true); if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || []; const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = res.data?.activeProfileId || null; const nextActiveProfileId = res.data?.activeProfileId || null;
@ -172,6 +178,7 @@ export default function GlobalSms() {
setActiveProfileId(nextActiveProfileId); setActiveProfileId(nextActiveProfileId);
setHasGlobalSms(fetchedProfiles.length > 0); setHasGlobalSms(fetchedProfiles.length > 0);
setIsSetupComplete(nextIsSetupComplete); setIsSetupComplete(nextIsSetupComplete);
setError('');
return { return {
activeProfile: nextActiveProfile, activeProfile: nextActiveProfile,
@ -184,12 +191,17 @@ export default function GlobalSms() {
setIsSetupComplete(false); setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false }; return { activeProfile: null, hasProfile: false, complete: false };
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId, setHasGlobalSms, setIsSetupComplete]); }, [businessId, setHasGlobalSms, setIsSetupComplete]);
useEffect(() => { useEffect(() => {
loadProfiles(); loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]); }, [loadProfiles]);
useEffect(() => { useEffect(() => {
@ -223,7 +235,7 @@ export default function GlobalSms() {
setFormSetActive(true); setFormSetActive(true);
setSuccess('Profile created successfully.'); setSuccess('Profile created successfully.');
const nextState = await loadProfiles(); const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
} }
@ -241,7 +253,7 @@ export default function GlobalSms() {
try { try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`); 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.'); setSuccess('Active profile updated.');
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
@ -295,7 +307,7 @@ export default function GlobalSms() {
const payload = buildProfilePatchPayload(missingInputs, inputForm); const payload = buildProfilePatchPayload(missingInputs, inputForm);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload); await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
setSuccess('Required profile fields saved.'); setSuccess('Required profile fields saved.');
const nextState = await loadProfiles(); const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) { if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath); navigate(eventsPath);
} }
@ -338,7 +350,7 @@ export default function GlobalSms() {
delete nextState[deletePreview.profile.id]; delete nextState[deletePreview.profile.id];
return nextState; return nextState;
}); });
await loadProfiles(); await loadProfiles({ background: true });
setSuccess('Profile deleted successfully.'); setSuccess('Profile deleted successfully.');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile'); setError(err.response?.data?.error || 'Failed to delete profile');
@ -347,7 +359,7 @@ export default function GlobalSms() {
} }
} }
if (loading) { if (initialLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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" /> <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 className="mx-auto max-w-4xl space-y-8 pb-12">
<div> <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"> <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. Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
</p> </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 { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
@ -206,7 +206,9 @@ export default function Providers() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness(); 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 [saving, setSaving] = useState(false);
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(''); const [activeProfileId, setActiveProfileId] = useState('');
@ -219,9 +221,13 @@ export default function Providers() {
const globalSmsPath = `/${businessId}/global-sms`; const globalSmsPath = `/${businessId}/global-sms`;
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async ({ background = false } = {}) => {
try { try {
setLoading(true); if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || []; const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = String(res.data?.activeProfileId || ''); const nextActiveProfileId = String(res.data?.activeProfileId || '');
@ -233,15 +239,21 @@ export default function Providers() {
? currentSelectedProfileId ? currentSelectedProfileId
: '' : ''
)); ));
setError('');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to load provider profiles'); setError(err.response?.data?.error || 'Failed to load provider profiles');
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadProfiles(); loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]); }, [loadProfiles]);
const selectedProfile = useMemo( const selectedProfile = useMemo(
@ -300,7 +312,7 @@ export default function Providers() {
setSuccess(''); setSuccess('');
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`); await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
setSelectedProfileId(profile.id); setSelectedProfileId(profile.id);
await loadProfiles(); await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null); await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`${profile.name} is now the active profile.`); setSuccess(`${profile.name} is now the active profile.`);
} catch (err) { } catch (err) {
@ -358,7 +370,7 @@ export default function Providers() {
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues); const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload); await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
await loadProfiles(); await loadProfiles({ background: true });
await refreshOnboardingState(businessId).catch(() => null); await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`); setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
} catch (err) { } catch (err) {
@ -368,7 +380,7 @@ export default function Providers() {
} }
} }
if (loading) { if (initialLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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" /> <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="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 className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1> <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"> <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. Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
</p> </p>

View File

@ -82,9 +82,11 @@ function getTemplateSortRank(template) {
export default function Templates() { export default function Templates() {
const { businessId } = useParams(); const { businessId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasLoadedTemplatesRef = useRef(false);
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = 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 [error, setError] = useState('');
const [whitelistTarget, setWhitelistTarget] = useState(null); const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null); const [testTarget, setTestTarget] = useState(null);
@ -96,9 +98,13 @@ export default function Templates() {
const highlightTimeoutRef = useRef(null); const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef(''); const handledFocusSlugRef = useRef('');
const loadTemplates = useCallback(async () => { const loadTemplates = useCallback(async ({ background = false } = {}) => {
setLoading(true); if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError(''); setError('');
}
try { try {
const [templatesRes, profilesRes] = await Promise.all([ const [templatesRes, profilesRes] = await Promise.all([
@ -111,15 +117,21 @@ export default function Templates() {
setTemplates(allTemplates); setTemplates(allTemplates);
setProfilesById(profileMap); setProfilesById(profileMap);
setError('');
} catch { } catch {
setError('Failed to load templates'); setError('Failed to load templates');
} finally { } finally {
setLoading(false); if (background) {
setRefreshing(false);
} else {
hasLoadedTemplatesRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadTemplates(); loadTemplates({ background: hasLoadedTemplatesRef.current });
}, [loadTemplates]); }, [loadTemplates]);
useEffect(() => () => { useEffect(() => () => {
@ -179,7 +191,7 @@ export default function Templates() {
async function handleWhitelistSuccess() { async function handleWhitelistSuccess() {
setWhitelistTarget(null); setWhitelistTarget(null);
await loadTemplates(); await loadTemplates({ background: true });
} }
async function handleRuntimeToggle(template) { async function handleRuntimeToggle(template) {
@ -207,7 +219,7 @@ export default function Templates() {
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null ? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
: null; : null;
if (loading) { if (initialLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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" /> <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 ( return (
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<div className="mb-6 border-b border-gray-200 pb-5"> <div className="mb-6 border-b border-gray-200 pb-5">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1> <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"> <p className="mt-1 text-sm font-medium text-gray-500">
Manage template runtime, whitelisting, and testing from one place. Manage template runtime, whitelisting, and testing from one place.
</p> </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) { async function loadBusinessTemplates(bizRoot) {
const templateFolder = `${bizRoot}/templates`; const templateFolder = `${bizRoot}/templates`;
const slugs = await listTemplateFiles(templateFolder).catch(() => []); const slugs = await listTemplateFiles(templateFolder).catch(() => []);
@ -2449,6 +2551,50 @@ async function loadBusinessTemplates(bizRoot) {
return templates; 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 = {}) { function hydrateProfile(profile = {}) {
const provider = normalizeProvider(profile.provider, profile.updatedAt); const provider = normalizeProvider(profile.provider, profile.updatedAt);
const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, provider); const curlAnalysis = normalizeCurlAnalysis(profile.curlAnalysis, provider);
@ -2864,17 +3010,23 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
const { businessId } = req.params; const { businessId } = req.params;
const companyId = getCompanyId(req); const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId); const bizRoot = businessRoot(companyId, businessId);
const analyticsFilters = {
statusScope: req.query?.statusScope,
eventSlugs: req.query?.eventSlugs,
};
const [overviewMetrics, eventsData, templates] = await Promise.all([ const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
getOverviewMetrics({ companyId, businessId }), const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
fetchJSON(bizRoot, 'events').catch(() => null), const scopedEventSlugs = filteredRows.map((row) => row.eventSlug);
loadBusinessTemplates(bizRoot), const hasExplicitFilter = hasExplicitAnalyticsFilter(analyticsFilters);
]); const overviewMetrics = hasExplicitFilter && scopedEventSlugs.length === 0
? buildEmptyOverviewMetrics()
const mergedEvents = mergeDefaultEvents(eventsData || {}); : await getOverviewMetrics({
const activeEventsCount = templates.filter((template) => ( companyId,
template?.status === 'whitelisted' && template?.isRuntimeEnabled !== false businessId,
)).length; eventSlugs: hasExplicitFilter ? scopedEventSlugs : [],
});
const activeEventsCount = filteredRows.filter((row) => row.status === 'live').length;
res.json({ res.json({
metrics: { metrics: {
@ -2884,7 +3036,7 @@ router.get('/:businessId/analytics/overview', async (req, res) => {
deliveryRate: overviewMetrics.deliveryRate.rate, deliveryRate: overviewMetrics.deliveryRate.rate,
deliveryRateMode: overviewMetrics.deliveryRate.mode, deliveryRateMode: overviewMetrics.deliveryRate.mode,
activeEvents: activeEventsCount, activeEvents: activeEventsCount,
totalEvents: Array.isArray(mergedEvents.events) ? mergedEvents.events.length : 0, totalEvents: filteredRows.length,
}, },
chart: overviewMetrics.chart, chart: overviewMetrics.chart,
}); });
@ -2900,59 +3052,22 @@ router.get('/:businessId/analytics/events', async (req, res) => {
const { businessId } = req.params; const { businessId } = req.params;
const companyId = getCompanyId(req); const companyId = getCompanyId(req);
const bizRoot = businessRoot(companyId, businessId); const bizRoot = businessRoot(companyId, businessId);
const analyticsFilters = {
const [eventsData, templates, analyticsEventMetrics] = await Promise.all([ statusScope: req.query?.statusScope,
fetchJSON(bizRoot, 'events').catch(() => null), eventSlugs: req.query?.eventSlugs,
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`,
}; };
}).sort((left, right) => { const page = req.query?.page;
const statusRank = { const pageSize = req.query?.pageSize;
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); const { rows: allRows } = await buildAnalyticsEventRows({ companyId, businessId, bizRoot });
if (triggerDiff !== 0) return triggerDiff; const filteredRows = filterAnalyticsEventRows(allRows, analyticsFilters);
const paginatedRows = paginateAnalyticsRows(filteredRows, page, pageSize);
return left.eventLabel.localeCompare(right.eventLabel); res.json({
events: paginatedRows.rows,
allEvents: allRows,
pagination: paginatedRows.pagination,
}); });
res.json({ events: rows });
} catch (err) { } catch (err) {
const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500; const status = /Analytics database is not configured/i.test(err.message) ? 503 : 500;
res.status(status).json({ error: err.message }); res.status(status).json({ error: err.message });

View File

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