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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user