diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index cf25acd..c69f122 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -860,21 +860,6 @@
}
}
},
- "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": {
- "version": "4.9.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
- "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=4.2.0"
- }
- },
"node_modules/@creit.tech/xbull-wallet-connect": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz",
@@ -7772,21 +7757,6 @@
"ws": "^7.5.1"
}
},
- "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": {
- "version": "5.0.10",
- "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
- "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "node-gyp-build": "^4.3.0"
- },
- "engines": {
- "node": ">=6.14.2"
- }
- },
"node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
@@ -12904,21 +12874,6 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
- "node_modules/jayson/node_modules/utf-8-validate": {
- "version": "5.0.10",
- "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
- "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "node-gyp-build": "^4.3.0"
- },
- "engines": {
- "node": ">=6.14.2"
- }
- },
"node_modules/jayson/node_modules/ws": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 3232e59..8731feb 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -1,5 +1,33 @@
import { useState } from 'react';
import { EventExplorerPage } from './pages/EventExplorerPage';
+import { ExportHistoryPage } from './pages/ExportHistoryPage';
+
+export function App() {
+ const [activeTab, setActiveTab] = useState<'explorer' | 'exports'>('explorer');
+
+ return (
+
+
+
+ {activeTab === 'explorer' ?
:
}
import { NotificationTimelineView } from './components/NotificationTimelineView';
type Tab = 'explorer' | 'timeline';
diff --git a/dashboard/src/index.css b/dashboard/src/index.css
index e628c6d..a8d69c1 100644
--- a/dashboard/src/index.css
+++ b/dashboard/src/index.css
@@ -870,6 +870,24 @@ body {
}
}
+/* ─── Navigation & Export Center Styles ────────────────────────── */
+
+.nav-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ padding-bottom: 12px;
+}
+
+.nav-brand {
+ font-weight: 700;
+ font-size: 1.25rem;
+ color: #38bdf8;
+}
+
+.nav-tabs {
/* ── Notification Delivery Timeline ─────────────────────────────────────── */
.timeline-view {
@@ -903,6 +921,218 @@ body {
gap: 8px;
}
+.nav-tab-btn {
+ background: transparent;
+ border: none;
+ color: #9aa0a6;
+ padding: 8px 16px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.nav-tab-btn:hover {
+ color: #e8eaed;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.nav-tab-btn--active {
+ color: #0b0d12;
+ background: #38bdf8;
+}
+
+.nav-tab-btn--active:hover {
+ color: #0b0d12;
+ background: #7dd3fc;
+}
+
+.export-history-page {
+ padding: 8px 0;
+}
+
+.export-history__header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 32px;
+ gap: 20px;
+}
+
+.export-history__eyebrow {
+ color: #38bdf8;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ margin: 0 0 8px;
+}
+
+.export-history__lead {
+ color: #9aa0a6;
+ max-width: 600px;
+ margin: 8px 0 0;
+}
+
+.export-table-container {
+ overflow-x: auto;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.01);
+ margin-bottom: 24px;
+}
+
+.export-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+.export-table th {
+ padding: 16px;
+ font-size: 0.85rem;
+ color: #9aa0a6;
+ font-weight: 500;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.export-table td {
+ padding: 16px;
+ font-size: 0.9rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.export-table__row:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.export-table__cell-id {
+ font-family: monospace;
+ color: #9aa0a6;
+}
+
+.export-table__cell-name {
+ font-weight: 500;
+ color: #e8eaed;
+}
+
+.export-table__cell-date {
+ color: #9aa0a6;
+}
+
+.export-table__cell-numeric {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.format-badge {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.format-badge--csv {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+}
+
+.format-badge--json {
+ background: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+}
+
+.format-badge--pdf {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.status-badge__dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+}
+
+.status-badge--completed {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+}
+
+.status-badge--completed .status-badge__dot {
+ background: #10b981;
+ box-shadow: 0 0 8px #10b981;
+}
+
+.status-badge--processing {
+ background: rgba(59, 130, 246, 0.1);
+ color: #3b82f6;
+}
+
+.status-badge--processing .status-badge__dot {
+ background: #3b82f6;
+ animation: pulse-badge-animation 1.5s infinite alternate;
+}
+
+.status-badge--failed {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+}
+
+.status-badge--failed .status-badge__dot {
+ background: #ef4444;
+}
+
+@keyframes pulse-badge-animation {
+ 0% {
+ transform: scale(0.8);
+ opacity: 0.5;
+ }
+ 100% {
+ transform: scale(1.2);
+ opacity: 1;
+ box-shadow: 0 0 8px #3b82f6;
+ }
+}
+
+.export-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: #1e293b;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ padding: 6px 12px;
+ color: #e8eaed;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.export-action-btn:hover:not(:disabled) {
+ background: #334155;
+ border-color: rgba(255, 255, 255, 0.16);
+}
+
+.export-action-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.download-icon {
+ width: 14px;
+ height: 14px;
.timeline-view__input {
flex: 1;
padding: 8px 12px;
diff --git a/dashboard/src/pages/ExportHistoryPage.test.tsx b/dashboard/src/pages/ExportHistoryPage.test.tsx
new file mode 100644
index 0000000..e110257
--- /dev/null
+++ b/dashboard/src/pages/ExportHistoryPage.test.tsx
@@ -0,0 +1,65 @@
+import '@testing-library/jest-dom';
+import { render, fireEvent } from '@testing-library/react';
+import { axe, toHaveNoViolations } from 'jest-axe';
+import { ExportHistoryPage } from './ExportHistoryPage';
+
+expect.extend(toHaveNoViolations);
+
+test('ExportHistoryPage has no accessibility violations', async () => {
+ const { container } = render(
);
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+});
+
+test('ExportHistoryPage renders correctly and lists mock exports', () => {
+ const { getByText, getByRole, getAllByRole } = render(
);
+
+ // Header text
+ expect(getByText('Notification Export History')).toBeInTheDocument();
+ expect(getByText(/Manage, filter, and download/)).toBeInTheDocument();
+
+ // Table
+ expect(getByRole('table')).toBeInTheDocument();
+
+ // First page mock rows (limit is 5)
+ const rows = getAllByRole('row');
+ // 1 header row + 5 data rows = 6 rows total
+ expect(rows).toHaveLength(6);
+});
+
+test('ExportHistoryPage search and filtering works', () => {
+ const { getByLabelText, queryByText, getByText } = render(
);
+
+ // Search for "System Alert" which exists in the mock list
+ const searchInput = getByLabelText('Search Exports');
+ fireEvent.change(searchInput, { target: { value: 'System Alert' } });
+
+ // Should see it
+ expect(getByText('System Alert Notification logs')).toBeInTheDocument();
+
+ // Should NOT see other items
+ expect(queryByText('Monthly billing export')).not.toBeInTheDocument();
+});
+
+test('ExportHistoryPage pagination limit and page switching works', () => {
+ const { getByLabelText, getByText, queryByText } = render(
);
+
+ // Initially we are on page 1 of 3 (15 items total, limit 5)
+ expect(getByText('Page 1 of 3')).toBeInTheDocument();
+ expect(getByText('15 total export records')).toBeInTheDocument();
+ expect(getByText('System Alert Notification logs')).toBeInTheDocument();
+
+ // Click Next
+ const nextBtn = getByText('Next');
+ fireEvent.click(nextBtn);
+
+ expect(getByText('Page 2 of 3')).toBeInTheDocument();
+ expect(queryByText('System Alert Notification logs')).not.toBeInTheDocument();
+
+ // Change limit to 10
+ const selectLimit = getByLabelText('Show');
+ fireEvent.change(selectLimit, { target: { value: '10' } });
+
+ // Pages should recalculate to page 1 of 2
+ expect(getByText('Page 1 of 2')).toBeInTheDocument();
+});
diff --git a/dashboard/src/pages/ExportHistoryPage.tsx b/dashboard/src/pages/ExportHistoryPage.tsx
new file mode 100644
index 0000000..2c722e8
--- /dev/null
+++ b/dashboard/src/pages/ExportHistoryPage.tsx
@@ -0,0 +1,296 @@
+import { useState, useMemo, useEffect } from 'react';
+import { generateMockExports, type NotificationExport } from '../utils/exportData';
+import { WalletConnectButton } from '../components/WalletConnectButton';
+
+export function ExportHistoryPage() {
+ const [exports, setExports] = useState
([]);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [page, setPage] = useState(1);
+ const [limit, setLimit] = useState(5);
+
+ useEffect(() => {
+ setExports(generateMockExports());
+ }, []);
+
+ // Filter exports
+ const filteredExports = useMemo(() => {
+ return exports.filter((item) => {
+ const matchesSearch = item.name.toLowerCase().includes(search.toLowerCase()) ||
+ item.id.toLowerCase().includes(search.toLowerCase()) ||
+ item.format.toLowerCase().includes(search.toLowerCase());
+
+ const matchesStatus = statusFilter === 'all' || item.status.toLowerCase() === statusFilter.toLowerCase();
+
+ return matchesSearch && matchesStatus;
+ });
+ }, [exports, search, statusFilter]);
+
+ // Reset page when filters change
+ useEffect(() => {
+ setPage(1);
+ }, [search, statusFilter]);
+
+ // Calculate pagination
+ const totalCount = filteredExports.length;
+ const pageCount = Math.max(1, Math.ceil(totalCount / limit));
+ const displayedExports = useMemo(() => {
+ const startIndex = (page - 1) * limit;
+ return filteredExports.slice(startIndex, startIndex + limit);
+ }, [filteredExports, page, limit]);
+
+ // Adjust page if it exceeds pageCount
+ useEffect(() => {
+ if (page > pageCount) {
+ setPage(pageCount);
+ }
+ }, [pageCount, page]);
+
+ // Format date helper
+ const formatDate = (timestamp: number) => {
+ return new Date(timestamp).toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: true,
+ });
+ };
+
+ // Trigger download action
+ const handleDownload = (item: NotificationExport) => {
+ if (item.status !== 'Completed') return;
+
+ let fileContent = '';
+ let mimeType = 'text/plain';
+ let fileExtension = 'txt';
+
+ if (item.format === 'JSON') {
+ fileContent = JSON.stringify(
+ {
+ export_id: item.id,
+ export_name: item.name,
+ format: item.format,
+ created_at: new Date(item.createdAt).toISOString(),
+ record_count: item.recordCount,
+ records: Array.from({ length: 5 }, (_, i) => ({
+ id: `notif-${2000 + i}`,
+ contract: 'CCEMX6Q5V5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5',
+ event_name: i % 2 === 0 ? 'NotificationScheduled' : 'NotificationExpired',
+ timestamp: new Date(item.createdAt - i * 15 * 60 * 1000).toISOString(),
+ status: 'Delivered',
+ })),
+ },
+ null,
+ 2
+ );
+ mimeType = 'application/json';
+ fileExtension = 'json';
+ } else if (item.format === 'CSV') {
+ fileContent =
+ 'ID,Contract Address,Event Name,Timestamp,Status\n' +
+ Array.from({ length: 5 }, (_, i) =>
+ `notif-${2000 + i},CCEMX6Q5V5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5F5,${
+ i % 2 === 0 ? 'NotificationScheduled' : 'NotificationExpired'
+ },${new Date(item.createdAt - i * 15 * 60 * 1000).toISOString()},Delivered`
+ ).join('\n');
+ mimeType = 'text/csv';
+ fileExtension = 'csv';
+ } else {
+ fileContent = `Notify-Chain Notification Export Report\n` +
+ `========================================\n` +
+ `Export ID: ${item.id}\n` +
+ `Export Name: ${item.name}\n` +
+ `Date Generated: ${formatDate(item.createdAt)}\n` +
+ `Total Records: ${item.recordCount}\n` +
+ `File Size: ${item.fileSize}\n` +
+ `========================================\n` +
+ `* This is a mock PDF export file representation *`;
+ mimeType = 'text/plain';
+ fileExtension = 'txt';
+ }
+
+ const blob = new Blob([fileContent], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${item.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${item.id}.${fileExtension}`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+ {/* Filters Section */}
+
+
+ {/* Table Section */}
+
+ {displayedExports.length > 0 ? (
+
+
+
+ | ID |
+ Name |
+ Format |
+ Created At |
+ Records |
+ Size |
+ Status |
+ Actions |
+
+
+
+ {displayedExports.map((item) => (
+
+ | {item.id} |
+ {item.name} |
+
+
+ {item.format}
+
+ |
+ {formatDate(item.createdAt)} |
+ {item.recordCount.toLocaleString()} |
+ {item.fileSize} |
+
+
+
+ {item.status}
+
+ |
+
+
+ |
+
+ ))}
+
+
+ ) : (
+
+ No export records found
+ Try modifying your search query or status filter to locate matching exports.
+
+ )}
+
+
+ {/* Custom Pagination */}
+
+
+
+ Page {page} of {pageCount}
+
+ {totalCount.toLocaleString()} total export records
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/utils/exportData.ts b/dashboard/src/utils/exportData.ts
new file mode 100644
index 0000000..00ea045
--- /dev/null
+++ b/dashboard/src/utils/exportData.ts
@@ -0,0 +1,65 @@
+export interface NotificationExport {
+ id: string;
+ name: string;
+ format: 'CSV' | 'JSON' | 'PDF';
+ status: 'Completed' | 'Processing' | 'Failed';
+ createdAt: number;
+ recordCount: number;
+ fileSize: string;
+}
+
+export function generateMockExports(): NotificationExport[] {
+ const baseTime = 1782399000000; // Represents roughly mid 2026
+ const formats: ('CSV' | 'JSON' | 'PDF')[] = ['CSV', 'JSON', 'PDF'];
+
+ const names = [
+ 'System Alert Notification logs',
+ 'Monthly billing export',
+ 'Contract event dispatch history',
+ 'Urgent error broadcast records',
+ 'Stellar network sync logs',
+ 'User activity digest export',
+ 'AutoShare usage tracking audit',
+ 'Revocation history summary',
+ 'Priority dispatch queue telemetry',
+ 'Security auditing report',
+ 'Deduplication database logs',
+ 'Webhook delivery metrics',
+ 'Client preferences dump',
+ 'API access token usage report',
+ 'Failure recovery logs'
+ ];
+
+ return names.map((name, index) => {
+ // Determine status (mostly completed, some processing/failed for realism)
+ let status: 'Completed' | 'Processing' | 'Failed' = 'Completed';
+ if (index === 1) {
+ status = 'Processing';
+ } else if (index === 5) {
+ status = 'Failed';
+ } else if (index === 8) {
+ status = 'Processing';
+ }
+
+ // Determine format
+ const format = formats[index % formats.length];
+
+ // Determine size and count
+ const recordCount = (index + 1) * 384 + (index % 3) * 12;
+ const fileSize = status === 'Failed'
+ ? '0 KB'
+ : status === 'Processing'
+ ? '--'
+ : `${((recordCount * 0.15) + (index % 5)).toFixed(1)} KB`;
+
+ return {
+ id: `exp-${1000 + index}`,
+ name,
+ format,
+ status,
+ createdAt: baseTime - index * 3 * 3600 * 1000 - (index % 5) * 15 * 60 * 1000,
+ recordCount: status === 'Failed' ? 0 : recordCount,
+ fileSize
+ };
+ });
+}