diff --git a/dashboard/src/components/EventsListSkeleton.tsx b/dashboard/src/components/EventsListSkeleton.tsx
new file mode 100644
index 0000000..7b10a87
--- /dev/null
+++ b/dashboard/src/components/EventsListSkeleton.tsx
@@ -0,0 +1,32 @@
+interface EventsListSkeletonProps {
+ rows?: number;
+}
+
+function SkeletonLine({ width }: { width: string }) {
+ return ;
+}
+
+export function EventsListSkeleton({ rows = 8 }: EventsListSkeletonProps) {
+ return (
+
+
+ {Array.from({ length: rows }).map((_, index) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/dashboard/src/components/NotificationHealthPanel.tsx b/dashboard/src/components/NotificationHealthPanel.tsx
index 4fead57..25ffc9a 100644
--- a/dashboard/src/components/NotificationHealthPanel.tsx
+++ b/dashboard/src/components/NotificationHealthPanel.tsx
@@ -120,6 +120,7 @@ export function NotificationHealthPanel(props: { healthUrl: string; pollInterval
setLastUpdated(Date.now());
} catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return;
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : String(err));
} finally {
diff --git a/dashboard/src/components/NotificationSearchSkeleton.tsx b/dashboard/src/components/NotificationSearchSkeleton.tsx
new file mode 100644
index 0000000..0531bab
--- /dev/null
+++ b/dashboard/src/components/NotificationSearchSkeleton.tsx
@@ -0,0 +1,50 @@
+interface NotificationSearchSkeletonProps {
+ cards?: number;
+}
+
+function SkeletonLine({ width, height = '12px' }: { width: string; height?: string }) {
+ return (
+
+ );
+}
+
+function NotificationResultCardSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function NotificationSearchSkeleton({ cards = 4 }: NotificationSearchSkeletonProps) {
+ return (
+
+ {Array.from({ length: cards }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
index b4fad84..4290c05 100644
--- a/dashboard/src/index.css
+++ b/dashboard/src/index.css
@@ -753,6 +753,34 @@ body {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
+.event-row--skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 88px;
+ justify-content: center;
+}
+
+.event-row--skeleton .event-row__primary,
+.event-row--skeleton .event-row__meta,
+.event-row--skeleton .event-row__details {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: center;
+}
+
+.event-panel--skeleton .event-list {
+ overflow: hidden;
+}
+
+.skeleton-block--inline {
+ display: inline-block;
+ height: 12px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
.event-row__primary {
display: flex;
justify-content: space-between;
@@ -4784,6 +4812,34 @@ body {
padding: 14px 16px;
}
+.notif-result-card--skeleton {
+ pointer-events: none;
+}
+
+.notif-result-card__fields--skeleton {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 8px 16px;
+ align-items: center;
+}
+
+.templates-list__items--skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.templates-list__item--skeleton {
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+
+.templates-list__item--skeleton .skeleton-block--row {
+ height: 96px;
+ width: 100%;
+}
+
.notif-result-card__header {
display: flex;
gap: 8px;
diff --git a/dashboard/src/pages/EventsPage.test.tsx b/dashboard/src/pages/EventsPage.test.tsx
new file mode 100644
index 0000000..e33b972
--- /dev/null
+++ b/dashboard/src/pages/EventsPage.test.tsx
@@ -0,0 +1,80 @@
+import '@testing-library/jest-dom';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import { EventsPage } from './EventsPage';
+import { useEventStore } from '../store/eventStore';
+import { generateMockEvents } from '../utils/eventData';
+import { fetchEvents } from '../services/eventsApi';
+
+jest.mock('../services/eventsApi', () => ({
+ fetchEvents: jest.fn(),
+}));
+
+jest.mock('../services/wallet', () => ({
+ restoreWalletSession: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock('../components/WalletConnectButton', () => ({
+ WalletConnectButton: () => ,
+}));
+
+const mockedFetchEvents = fetchEvents as jest.MockedFunction;
+
+describe('EventsPage loading skeletons', () => {
+ beforeEach(() => {
+ useEventStore.setState({
+ events: [],
+ filters: {
+ search: '',
+ contractAddress: 'all',
+ eventType: 'all',
+ status: 'all',
+ dateFrom: '',
+ dateTo: '',
+ },
+ isLoading: false,
+ error: null,
+ lastFetchedAt: 0,
+ });
+ mockedFetchEvents.mockReset();
+ });
+
+ it('shows event list skeletons while loading and hides loading text', async () => {
+ mockedFetchEvents.mockReturnValue(new Promise(() => {}));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/loading events/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/loading events\.\.\./i)).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/loading events/i)).toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('replaces skeletons with event content once data is available', async () => {
+ const events = generateMockEvents(3);
+ let resolveFetch!: (value: typeof events) => void;
+ mockedFetchEvents.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/loading events/i)).toBeInTheDocument();
+ });
+
+ await act(async () => {
+ resolveFetch(events);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByLabelText(/loading events/i)).not.toBeInTheDocument();
+ });
+
+ expect(screen.getAllByRole('article').length).toBeGreaterThan(0);
+ expect(screen.queryByText(/no events match/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx
index 3848850..96eb890 100644
--- a/dashboard/src/pages/EventsPage.tsx
+++ b/dashboard/src/pages/EventsPage.tsx
@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { EventFiltersBar } from '../components/EventFiltersBar';
import { EventListPanel } from '../components/EventListPanel';
+import { EventsListSkeleton } from '../components/EventsListSkeleton';
import { WalletConnectButton } from '../components/WalletConnectButton';
+import { getEventsApiBaseUrl } from '../config/eventsApiUrl';
import { useEventLoadingState } from '../hooks/useEventSelectors';
import { useEventStore } from '../store/eventStore';
import { fetchEvents } from '../services/eventsApi';
@@ -9,8 +11,7 @@ import { generateMockEvents } from '../utils/eventData';
import { restoreWalletSession } from '../services/wallet';
const DEFAULT_EVENT_COUNT = 5000;
-const API_URL =
- import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events';
+const API_URL = `${getEventsApiBaseUrl()}/api/events`;
const POLL_INTERVAL_MS = 15_000;
export function EventsPage() {
@@ -91,14 +92,11 @@ export function EventsPage() {
-
- {isLoading &&
Loading events...
}
-
-
+ {isLoading ? : }
);
}
\ No newline at end of file
diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx
new file mode 100644
index 0000000..bfc57c3
--- /dev/null
+++ b/dashboard/src/pages/NotificationSearchPage.test.tsx
@@ -0,0 +1,100 @@
+import '@testing-library/jest-dom';
+import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
+import { NotificationSearchPage } from './NotificationSearchPage';
+import { searchNotifications } from '../services/eventsApi';
+import type { NotificationSearchResponse } from '../services/eventsApi';
+
+jest.mock('../services/eventsApi', () => ({
+ searchNotifications: jest.fn(),
+}));
+
+const mockedSearch = searchNotifications as jest.MockedFunction;
+
+const mockResult: NotificationSearchResponse = {
+ results: [
+ {
+ id: 1,
+ source: 'scheduled',
+ eventId: 'evt-abc',
+ txHash: '0xdeadbeef',
+ contractAddress: 'CABCDEF',
+ notificationType: 'email',
+ targetRecipient: 'user@example.com',
+ status: 'PENDING',
+ createdAt: '2026-01-15T12:00:00.000Z',
+ payload: null,
+ },
+ ],
+ total: 1,
+ limit: 20,
+ offset: 0,
+ itemCount: 1,
+ totalPages: 1,
+};
+
+describe('NotificationSearchPage loading skeletons', () => {
+ beforeEach(() => {
+ mockedSearch.mockReset();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('shows result-card skeletons while searching and hides Searching text', async () => {
+ mockedSearch.mockReturnValue(new Promise(() => {}));
+
+ render();
+
+ fireEvent.change(screen.getByLabelText(/free-text search/i), {
+ target: { value: 'payment' },
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(300);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/searching notifications/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/searching…/i)).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/searching notifications/i)).toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('replaces skeletons with result cards once search completes', async () => {
+ let resolveSearch!: (value: NotificationSearchResponse) => void;
+ mockedSearch.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSearch = resolve;
+ })
+ );
+
+ render();
+
+ fireEvent.change(screen.getByLabelText(/free-text search/i), {
+ target: { value: 'payment' },
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(300);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/searching notifications/i)).toBeInTheDocument();
+ });
+
+ await act(async () => {
+ resolveSearch(mockResult);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByLabelText(/searching notifications/i)).not.toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/1 result/i)).toBeInTheDocument();
+ expect(screen.getByText('evt-abc')).toBeInTheDocument();
+ expect(document.querySelector('.notif-result-card__status')).toHaveTextContent('PENDING');
+ });
+});
diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx
index 28a59ab..9aa24f1 100644
--- a/dashboard/src/pages/NotificationSearchPage.tsx
+++ b/dashboard/src/pages/NotificationSearchPage.tsx
@@ -1,4 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react';
+import { NotificationSearchSkeleton } from '../components/NotificationSearchSkeleton';
+import { getEventsApiBaseUrl } from '../config/eventsApiUrl';
import { useDebounce } from '../hooks/useDebounce';
import {
searchNotifications,
@@ -7,10 +9,7 @@ import {
} from '../services/eventsApi';
const PAGE_SIZE = 20;
-const API_BASE = (import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events').replace(
- '/api/events',
- ''
-);
+const API_BASE = getEventsApiBaseUrl();
const STATUS_OPTIONS = ['', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'PROCESSED'];
@@ -83,8 +82,7 @@ export function NotificationSearchPage() {
useEffect(() => {
runSearch();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [filtersKey, page]);
+ }, [filtersKey, page, runSearch]);
function clearAll() {
setQuery('');
@@ -198,9 +196,7 @@ export function NotificationSearchPage() {
{/* Results area */}
- {loading && (
- Searching…
- )}
+ {loading && }
{error && !loading && (
diff --git a/dashboard/src/pages/TemplatesPage.tsx b/dashboard/src/pages/TemplatesPage.tsx
index 20e932d..10b23ff 100644
--- a/dashboard/src/pages/TemplatesPage.tsx
+++ b/dashboard/src/pages/TemplatesPage.tsx
@@ -209,7 +209,22 @@ export function TemplatesPage() {
}
function renderList() {
- if (loading) return
Loading templates...
;
+ if (loading) {
+ return (
+
+
+
Templates
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+ );
+ }
return (