Fixing UI state so no hard refreshes on tiny changes
This commit is contained in:
parent
232d734c98
commit
d322fbe2d4
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user