diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 974dcf68..5b782327 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import { NotificationPreferencesPage } from './pages/NotificationPreferencesPage import { TemplatesPage } from './pages/TemplatesPage'; import { ChannelDetailsPage } from './pages/ChannelDetailsPage'; import { RpcBenchmarkPage } from './pages/RpcBenchmarkPage'; +import { ContractDetailPage } from './pages/ContractDetailPage'; import { ThemeToggle } from './components/ThemeToggle'; import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer'; @@ -26,11 +27,17 @@ import { useEventStore } from './store/eventStore'; import { SyncStatus } from './components/SyncStatus'; import { ErrorBoundary } from './components/ErrorBoundary'; import { DashboardLayout } from './layouts/DashboardLayout'; +import { EmptyState } from './components/EmptyState'; export function App() { const [tab, setTab] = useState(() => { const hash = window.location.hash.slice(1); + // Handle contract-detail route with address parameter + if (hash.startsWith('contract-detail?')) { + return 'contract-detail'; + } + if (NAV_ITEMS.some((item) => item.id === hash)) { return hash as Tab; } @@ -302,6 +309,36 @@ function renderPanel(tab: Tab, events: any[]) { ); + case 'contract-detail': { + const hash = window.location.hash.slice(1); + const params = new URLSearchParams(hash.split('?')[1] || ''); + const contractAddress = params.get('address'); + + if (!contractAddress) { + return ( + + { + window.location.hash = 'explorer'; + }, + }} + /> + + ); + } + + return ( + + + + ); + } + default: return null; } diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index 30c863ee..90b7bbfb 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -18,6 +18,7 @@ export interface EventExplorerCardProps { isCopied?: boolean; onSelect?: (event: BlockchainEvent) => void; contractStatuses?: ContractStatus[]; + onContractClick?: (contractAddress: string) => void; } export const EventExplorerCard = memo(function EventExplorerCard({ @@ -26,6 +27,7 @@ export const EventExplorerCard = memo(function EventExplorerCard({ isCopied = false, onSelect, contractStatuses = [], + onContractClick, }: EventExplorerCardProps) { const contractStatus = contractStatuses.find( (status) => status.address === event.contractAddress, @@ -66,16 +68,24 @@ export const EventExplorerCard = memo(function EventExplorerCard({ >
-

+

diff --git a/dashboard/src/components/MobileNavDrawer.tsx b/dashboard/src/components/MobileNavDrawer.tsx index ada92e8a..d4b88bc3 100644 --- a/dashboard/src/components/MobileNavDrawer.tsx +++ b/dashboard/src/components/MobileNavDrawer.tsx @@ -23,7 +23,8 @@ export type Tab = | 'preferences' | 'templates' | 'channels' - | 'rpc-benchmark'; + | 'rpc-benchmark' + | 'contract-detail'; export interface NavItem { id: Tab; diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 64a4347e..60986d29 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -8426,3 +8426,378 @@ textarea { border-color: rgba(0, 0, 0, 0.12); color: #4b5563; } + +/* ── Issue #973: Contract Detail Page ───────────────────────────────────────── */ + +.contract-detail-page { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + display: grid; + gap: 28px; +} + +.contract-detail__header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.contract-detail__eyebrow { + margin: 0 0 6px; + font-size: 0.82rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #9aa0a6; +} + +.contract-detail__title { + margin: 0 0 12px; + font-size: 1.75rem; + font-weight: 700; + color: #e8eaed; +} + +.contract-detail__address-row { + display: flex; + align-items: center; + gap: 10px; +} + +.contract-detail__address { + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; + font-size: 0.9rem; + padding: 6px 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + color: #b0b8c2; + word-break: break-all; +} + +.contract-detail__stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + min-width: 280px; +} + +.contract-detail__stat-card { + padding: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + text-align: center; +} + +.contract-detail__stat-label { + margin: 0 0 8px; + font-size: 0.8rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.contract-detail__stat-value { + margin: 0; + font-size: 1.4rem; + font-weight: 700; + color: #e8eaed; +} + +.contract-detail__section-title { + margin: 0 0 16px; + font-size: 1.25rem; + font-weight: 600; + color: #e8eaed; +} + +.contract-detail__event-types { + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.contract-detail__event-type-list { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.contract-detail__event-type-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; +} + +.contract-detail__event-type-name { + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; + font-size: 0.85rem; + color: #b0b8c2; +} + +.contract-detail__event-type-count { + font-size: 0.85rem; + font-weight: 600; + color: #e8eaed; + background: rgba(91, 125, 255, 0.15); + padding: 2px 8px; + border-radius: 4px; +} + +.contract-detail__activity { + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.contract-detail__activity-list { + display: grid; + gap: 12px; +} + +.contract-detail__activity-item { + cursor: pointer; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + transition: background 0.15s, border-color 0.15s; +} + +.contract-detail__activity-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.12); +} + +.contract-detail__activity-item:focus { + outline: 2px solid rgba(91, 125, 255, 0.5); + outline-offset: 2px; +} + +.contract-detail__tx-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: #5b7dff; + text-decoration: none; + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; + font-size: 0.85rem; + transition: color 0.15s; +} + +.contract-detail__tx-link:hover { + color: #7b9dff; + text-decoration: underline; +} + +.contract-detail__tx-link-icon { + font-size: 0.9rem; +} + +/* Skeleton loading states */ +.contract-detail__skeleton { + padding: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.contract-detail__skeleton-header { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; +} + +.contract-detail__skeleton-title { + height: 32px; + width: 60%; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%); + border-radius: 8px; + animation: skeleton-shimmer 1.5s infinite; +} + +.contract-detail__skeleton-address { + height: 24px; + width: 80%; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%); + border-radius: 6px; + animation: skeleton-shimmer 1.5s infinite; +} + +.contract-detail__skeleton-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.contract-detail__skeleton-stat { + height: 80px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%); + border-radius: 12px; + animation: skeleton-shimmer 1.5s infinite; +} + +@keyframes skeleton-shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .contract-detail-page { + padding: 16px; + gap: 20px; + } + + .contract-detail__header { + flex-direction: column; + gap: 20px; + } + + .contract-detail__stats { + grid-template-columns: repeat(2, 1fr); + width: 100%; + } + + .contract-detail__event-type-list { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .contract-detail__stats { + grid-template-columns: 1fr; + } + + .contract-detail__title { + font-size: 1.4rem; + } +} + +/* Light theme overrides */ +[data-theme="light"] .contract-detail__header { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .contract-detail__title { + color: #1a1a2e; +} + +[data-theme="light"] .contract-detail__address { + background: rgba(0, 0, 0, 0.05); + color: #4b5563; +} + +[data-theme="light"] .contract-detail__stat-card { + background: #f9fafb; + border-color: rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .contract-detail__stat-label { + color: #6b7280; +} + +[data-theme="light"] .contract-detail__stat-value { + color: #1a1a2e; +} + +[data-theme="light"] .contract-detail__section-title { + color: #1a1a2e; +} + +[data-theme="light"] .contract-detail__event-types { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .contract-detail__event-type-item { + background: #f9fafb; + border-color: rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .contract-detail__event-type-name { + color: #4b5563; +} + +[data-theme="light"] .contract-detail__event-type-count { + color: #1a1a2e; + background: rgba(91, 125, 255, 0.1); +} + +[data-theme="light"] .contract-detail__activity { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .contract-detail__activity-item { + background: #f9fafb; + border-color: rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .contract-detail__activity-item:hover { + background: #f3f4f6; + border-color: rgba(0, 0, 0, 0.12); +} + +[data-theme="light"] .contract-detail__tx-link { + color: #3b82f6; +} + +[data-theme="light"] .contract-detail__tx-link:hover { + color: #2563eb; +} + +[data-theme="light"] .contract-detail__skeleton { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="light"] .contract-detail__skeleton-title, +[data-theme="light"] .contract-detail__skeleton-address, +[data-theme="light"] .contract-detail__skeleton-stat { + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); +} + +/* Contract link styling in event explorer */ +.event-explorer__contract-link { + background: none; + border: none; + padding: 0; + color: #5b7dff; + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; + font-size: 0.85rem; + cursor: pointer; + text-decoration: none; + transition: color 0.15s; +} + +.event-explorer__contract-link:hover { + color: #7b9dff; + text-decoration: underline; +} + +[data-theme="light"] .event-explorer__contract-link { + color: #3b82f6; +} + +[data-theme="light"] .event-explorer__contract-link:hover { + color: #2563eb; +} diff --git a/dashboard/src/pages/ContractDetailPage.tsx b/dashboard/src/pages/ContractDetailPage.tsx new file mode 100644 index 00000000..04f11d91 --- /dev/null +++ b/dashboard/src/pages/ContractDetailPage.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useEventStore } from '../store/eventStore'; +import type { BlockchainEvent } from '../types/event'; +import { EmptyState } from '../components/EmptyState'; +import { CopyButton } from '../components/CopyButton'; +import { EventCard } from '../components/EventCard'; +import { formatTimestamp as formatTime } from '../utils/formatTime'; + +interface ContractStats { + totalEvents: number; + uniqueEventTypes: number; + firstEventAt: number | null; + lastEventAt: number | null; +} + +function formatAddress(address: string): string { + if (address.length <= 16) return address; + return `${address.slice(0, 8)}...${address.slice(-8)}`; +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function ContractHeader({ address, stats }: { address: string; stats: ContractStats }) { + return ( +
+
+

Contract Details

+

{formatAddress(address)}

+
+ {address} + +
+
+
+ + + + +
+
+ ); +} + +function EventTypeList({ events }: { events: BlockchainEvent[] }) { + const eventTypes = useMemo(() => { + const types = new Map(); + events.forEach((event) => { + if (event.eventName) { + types.set(event.eventName, (types.get(event.eventName) || 0) + 1); + } + }); + return Array.from(types.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ name, count })); + }, [events]); + + if (eventTypes.length === 0) { + return null; + } + + return ( +
+

Available Event Types

+
+ {eventTypes.map(({ name, count }) => ( +
+ {name} + {count.toLocaleString()} +
+ ))} +
+
+ ); +} + +function RecentActivity({ events, onEventClick }: { events: BlockchainEvent[]; onEventClick: (event: BlockchainEvent) => void }) { + const recentEvents = useMemo(() => { + return events + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, 10); + }, [events]); + + if (recentEvents.length === 0) { + return null; + } + + return ( +
+

Recent Activity

+
+ {recentEvents.map((event) => ( +
onEventClick(event)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onEventClick(event); + } + }} + > + +
+ ))} +
+
+ ); +} + +function ContractDetailSkeleton() { + return ( +
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); +} + +export function ContractDetailPage({ contractAddress }: { contractAddress: string }) { + const events = useEventStore((state) => state.events); + const [loading, setLoading] = useState(true); + const [selectedEvent, setSelectedEvent] = useState(null); + + const contractEvents = useMemo(() => { + return events.filter((event) => event.contractAddress === contractAddress); + }, [events, contractAddress]); + + const stats = useMemo(() => { + if (contractEvents.length === 0) { + return { + totalEvents: 0, + uniqueEventTypes: 0, + firstEventAt: null, + lastEventAt: null, + }; + } + + const timestamps = contractEvents.map((e) => e.receivedAt); + const eventTypes = new Set(contractEvents.map((e) => e.eventName).filter(Boolean)); + + return { + totalEvents: contractEvents.length, + uniqueEventTypes: eventTypes.size, + firstEventAt: Math.min(...timestamps), + lastEventAt: Math.max(...timestamps), + }; + }, [contractEvents]); + + useEffect(() => { + // Simulate loading state for smooth UX + const timer = setTimeout(() => { + setLoading(false); + }, 300); + + return () => clearTimeout(timer); + }, []); + + const handleEventClick = useCallback((event: BlockchainEvent) => { + setSelectedEvent(event); + // Navigate to event explorer with the event selected + window.location.hash = 'explorer'; + // Store the selected event ID in URL params for the explorer to pick up + const url = new URL(window.location.href); + url.searchParams.set('eventId', event.eventId); + window.history.replaceState({}, '', `${url.pathname}${url.search}#explorer`); + }, []); + + if (loading) { + return ; + } + + if (contractEvents.length === 0) { + return ( +
+
+
+

Contract Details

+

{formatAddress(contractAddress)}

+
+ {contractAddress} + +
+
+
+ { + window.location.hash = 'explorer'; + }, + }} + /> +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 788444db..3da0d0d7 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -290,6 +290,10 @@ export function EventExplorerPage() { replaceSelectedEventInUrl(event.eventId); }, []); + const handleContractClick = useCallback((contractAddress: string) => { + window.location.hash = `contract-detail?address=${contractAddress}`; + }, []); + const handleCloseDrawer = useCallback(() => { setSelectedNotification(null); replaceSelectedEventInUrl(null); @@ -362,6 +366,7 @@ export function EventExplorerPage() { events={currentPageEvents} onSelectEvent={handleSelectEvent} contractStatuses={contractStatuses} + onContractClick={handleContractClick} /> ) : (