diff --git a/client/src/pages/Analytics.jsx b/client/src/pages/Analytics.jsx index 60b2958..779bb84 100644 --- a/client/src/pages/Analytics.jsx +++ b/client/src/pages/Analytics.jsx @@ -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 ( -
+

{title}

{value}

@@ -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 ( -
-
+
+

Trigger Volume, Last 30 Days

-

Triggered vs failed SMS attempts

+

+ {hasFilters ? 'Triggered vs failed SMS attempts for the current filtered view.' : 'Triggered vs failed SMS attempts.'} +

-
+
Triggered @@ -126,111 +230,303 @@ function AnalyticsTrendChart({ rows }) {
- - {gridLines.map((line) => ( - +
+ setHoverState(null)} + > + {gridLines.map((line) => ( + + + + {line.label} + + + ))} + + {hoveredPoint && ( - - {line.label} - - - ))} + )} - - + + - {rows.map((row, index) => { - const x = padding.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth); - const triggeredY = padding.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight; - const failedY = padding.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight; - const showLabel = index % 5 === 0 || index === rows.length - 1; + {points.map((point, index) => { + const showLabel = index % 5 === 0 || index === points.length - 1; + const isHovered = hoveredPoint?.key === point.key; - return ( - - - - {showLabel && ( - - {row.label} - - )} - - ); - })} - + return ( + + + + {showLabel && ( + + {point.label} + + )} + + ); + })} + + + {hoveredPoint && hoverState && ( +
+

{hoveredPoint.label}

+
+
+ Triggered + {formatNumber(hoveredPoint.triggeredCount)} +
+
+ Failed + {formatNumber(hoveredPoint.failedCount)} +
+
+ Failure Share + + {formatFailureShare(hoveredPoint.triggeredCount, hoveredPoint.failedCount)} + +
+
+
+ )} +
); } 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 (
@@ -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 (
-

Analytics

+
+

Analytics

+ {refreshing && ( + + + Updating view + + )} +

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

+
+
+
+ +
+ { + 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 && ( +
+ {suggestionRows.length === 0 ? ( +
+ No events matched your search or current status scope. +
+ ) : ( +
    + {suggestionRows.map((row) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+

+ Select one or more events to refresh the cards, chart, and table for that exact view. +

+
+ +
+

Status Scope

+
+ {STATUS_SCOPE_OPTIONS.map((option) => ( + + ))} +
+
+
+ + {selectedEventRows.length > 0 && ( +
+
+ Selected: + {selectedEventRows.map((row) => ( + + {row.eventLabel} + + + ))} + + {(selectedEventRows.length > 0 || statusScope !== 'all') && ( + + )} +
+
+ )} +
+
- +
-
+

Event Health

-

Per-event trigger counts and runtime status

+

+ {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'} +

- No analytics have been recorded for this business yet. + {hasFilters ? ( +
+

No events match the selected filters yet.

+ +
+ ) : ( + 'No analytics have been recorded for this business yet.' + )}
) : ( -
- - - - - - - - - - - - - - {eventRows.map((row) => ( - - - - - - - - + <> +
+
EventStatusTriggered TodayTotal Trigger CountDelivery RateLast TriggeredAction
-
{row.eventLabel}
-
{row.eventSlug}
-
- - {row.statusLabel} - - - {formatNumber(row.triggeredToday)} - - {formatNumber(row.totalTriggerCount)} - -
{formatRate(row.deliveryRate)}
-
- {row.deliveryRateMode === 'send_fallback' ? 'Send fallback' : row.deliveryRateMode === 'callback' ? 'Callback-based' : 'No data'} -
-
- {formatLastTriggered(row.lastTriggeredAt)} - - - Manage - -
+ + + + + + + + + - ))} - -
EventStatusTriggered TodayTotal Trigger CountDelivery RateLast TriggeredAction
-
+ + + {eventRows.map((row) => ( + + +
{row.eventLabel}
+
{row.eventSlug}
+ + + + {row.statusLabel} + + + + {formatNumber(row.triggeredToday)} + + + {formatNumber(row.totalTriggerCount)} + + +
{formatRate(row.deliveryRate)}
+
+ {row.deliveryRateMode === 'send_fallback' + ? 'Send fallback' + : row.deliveryRateMode === 'callback' + ? 'Callback-based' + : 'No data'} +
+ + + {formatLastTriggered(row.lastTriggeredAt)} + + + + Manage + + + + ))} + + +
+ +
+

+ Page {formatNumber(pagination.page)} of {formatNumber(pagination.totalPages)} +

+
+ + +
+
+ )}
diff --git a/client/src/pages/Businesses.jsx b/client/src/pages/Businesses.jsx index ed2e4b7..3b3d36b 100644 --- a/client/src/pages/Businesses.jsx +++ b/client/src/pages/Businesses.jsx @@ -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 (
@@ -613,9 +632,17 @@ export default function Businesses() {
-

- {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')} +

+ {refreshing && ( + + + Refreshing list + + )} +

{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 && ( { setShowModal(false); load(); }} + onClose={() => { setShowModal(false); load({ background: true }); }} onJobStarted={handleBusinessJobStarted} /> )} diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index f8df962..ee18b58 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -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 (

@@ -1245,7 +1257,15 @@ export default function Events() {
-

Events

+
+

Events

+ {refreshing && ( + + + Updating events + + )} +

Generate SMS templates for customer-facing lifecycle events.

diff --git a/client/src/pages/GlobalSms.jsx b/client/src/pages/GlobalSms.jsx index 260eb98..c721d51 100644 --- a/client/src/pages/GlobalSms.jsx +++ b/client/src/pages/GlobalSms.jsx @@ -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 (
@@ -366,7 +378,15 @@ export default function GlobalSms() {
-

Omni-channel SMS

+
+

Omni-channel SMS

+ {refreshing && ( + + + Refreshing profiles + + )} +

Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.

diff --git a/client/src/pages/Providers.jsx b/client/src/pages/Providers.jsx index 3ba93e0..035ec90 100644 --- a/client/src/pages/Providers.jsx +++ b/client/src/pages/Providers.jsx @@ -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 (
@@ -380,7 +392,15 @@ export default function Providers() {
-

Provider Configuration

+
+

Provider Configuration

+ {refreshing && ( + + + Refreshing profiles + + )} +

Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.

diff --git a/client/src/pages/Templates.jsx b/client/src/pages/Templates.jsx index 1aa81fe..da52ad7 100644 --- a/client/src/pages/Templates.jsx +++ b/client/src/pages/Templates.jsx @@ -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 (
@@ -218,7 +230,15 @@ export default function Templates() { return (
-

Templates

+
+

Templates

+ {refreshing && ( + + + Refreshing templates + + )} +

Manage template runtime, whitelisting, and testing from one place.

diff --git a/server/routes/businesses.js b/server/routes/businesses.js index 5636ccf..fd7218c 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -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 }); diff --git a/server/services/analyticsStore.js b/server/services/analyticsStore.js index a7dcb36..2f54a3a 100644 --- a/server/services/analyticsStore.js +++ b/server/services/analyticsStore.js @@ -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'); }