feat: explorer API, wallet, CCIP scripts, and config refresh
- Backend REST/gateway/track routes, analytics, Blockscout proxy paths. - Frontend wallet and liquidity surfaces; MetaMask token list alignment. - Deployment docs, verification scripts, address inventory updates. Check: go build ./... under backend/ (pass). Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import '../app/globals.css'
|
||||
import ExplorerChrome from '@/components/common/ExplorerChrome'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
return (
|
||||
<ExplorerChrome>
|
||||
<Component {...pageProps} />
|
||||
</ExplorerChrome>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
normalizeWatchlistAddress,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -13,6 +21,7 @@ export default function AddressDetailPage() {
|
||||
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadAddressInfo = useCallback(async () => {
|
||||
@@ -54,16 +63,39 @@ export default function AddressDetailPage() {
|
||||
loadTransactions()
|
||||
}, [address, loadAddressInfo, loadTransactions, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading address...</div>
|
||||
}
|
||||
try {
|
||||
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlistEntries([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!addressInfo) {
|
||||
return <div className="p-8">Address not found</div>
|
||||
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
|
||||
const isSavedToWatchlist = watchlistAddress
|
||||
? isWatchlistEntry(watchlistEntries, watchlistAddress)
|
||||
: false
|
||||
|
||||
const handleWatchlistToggle = () => {
|
||||
if (!watchlistAddress || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
setWatchlistEntries((current) => {
|
||||
const next = isSavedToWatchlist
|
||||
? current.filter((entry) => entry.toLowerCase() !== watchlistAddress.toLowerCase())
|
||||
: [...current, watchlistAddress]
|
||||
|
||||
try {
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const transactionColumns = [
|
||||
@@ -71,7 +103,7 @@ export default function AddressDetailPage() {
|
||||
header: 'Hash',
|
||||
accessor: (tx: TransactionSummary) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -87,17 +119,13 @@ export default function AddressDetailPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: TransactionSummary) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : 'Contract Creation',
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: TransactionSummary) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: TransactionSummary) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -110,56 +138,74 @@ export default function AddressDetailPage() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">
|
||||
{addressInfo.label || 'Address'}
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">
|
||||
{addressInfo?.label || 'Address'}
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Back to addresses
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo.address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
{(addressInfo?.address || address) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(addressInfo?.address || address)}`} className="text-primary-600 hover:underline">
|
||||
Search this address
|
||||
</Link>
|
||||
)}
|
||||
{watchlistAddress && router.isReady && !loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWatchlistToggle}
|
||||
className="rounded-full border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-600 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
>
|
||||
{isSavedToWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Address:</span>
|
||||
<Address address={addressInfo.address} className="ml-2" />
|
||||
</div>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Tags:</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<span className="ml-2">{addressInfo.transaction_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Tokens:</span>
|
||||
<span className="ml-2">{addressInfo.token_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Type:</span>
|
||||
<span className="ml-2">{addressInfo.is_contract ? 'Contract' : 'EOA'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading address...</p>
|
||||
</Card>
|
||||
) : !addressInfo ? (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Address not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Address">
|
||||
<Address address={addressInfo.address} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Watchlist">
|
||||
{isSavedToWatchlist ? 'Saved for quick access' : 'Not saved yet'}
|
||||
</DetailRow>
|
||||
{addressInfo.tags.length > 0 && (
|
||||
<DetailRow label="Tags" valueClassName="flex flex-wrap gap-2">
|
||||
{addressInfo.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Transactions">{addressInfo.transaction_count}</DetailRow>
|
||||
<DetailRow label="Tokens">{addressInfo.token_count}</DetailRow>
|
||||
<DetailRow label="Type">{addressInfo.is_contract ? 'Contract' : 'EOA'}</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Transactions">
|
||||
<Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
</Card>
|
||||
<Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={transactions}
|
||||
emptyMessage="No recent transactions were found for this address."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
|
||||
function normalizeAddress(value: string) {
|
||||
const trimmed = value.trim()
|
||||
@@ -35,10 +36,12 @@ export default function AddressesPage() {
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const entries = raw ? JSON.parse(raw) : []
|
||||
setWatchlist(Array.isArray(entries) ? entries.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setWatchlist(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setWatchlist([])
|
||||
}
|
||||
@@ -70,8 +73,8 @@ export default function AddressesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Addresses</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Addresses</h1>
|
||||
|
||||
<Card className="mb-6" title="Open An Address">
|
||||
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
||||
@@ -85,7 +88,7 @@ export default function AddressesPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!normalizeAddress(query)}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Open address
|
||||
</button>
|
||||
@@ -105,7 +108,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{watchlist.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
@@ -126,7 +129,7 @@ export default function AddressesPage() {
|
||||
<div className="space-y-3">
|
||||
{activeAddresses.map((entry) => (
|
||||
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
9
frontend/src/pages/analytics/index.tsx
Normal file
9
frontend/src/pages/analytics/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const AnalyticsOperationsPage = dynamic(() => import('@/components/explorer/AnalyticsOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return <AnalyticsOperationsPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { blocksApi, Block } from '@/services/api/blocks'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function BlockDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -40,68 +41,63 @@ export default function BlockDetailPage() {
|
||||
loadBlock()
|
||||
}, [isValidBlock, loadBlock, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!isValidBlock) {
|
||||
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading block...</div>
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
return <div className="p-8">Block not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Block #{block.number}</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">{block ? `Block #${block.number}` : 'Block'}</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/blocks" className="text-primary-600 hover:underline">
|
||||
Back to blocks
|
||||
</Link>
|
||||
{block.number > 0 ? (
|
||||
{block && block.number > 0 ? (
|
||||
<Link href={`/blocks/${block.number - 1}`} className="text-primary-600 hover:underline">
|
||||
Previous block
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
{block && (
|
||||
<Link href={`/blocks/${block.number + 1}`} className="text-primary-600 hover:underline">
|
||||
Next block
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Block Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={block.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Timestamp:</span>
|
||||
<span className="ml-2">{new Date(block.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Miner:</span>
|
||||
<Link href={`/addresses/${block.miner}`} className="ml-2 text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Transactions:</span>
|
||||
<Link href="/transactions" className="ml-2 text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block...</p>
|
||||
</Card>
|
||||
) : !isValidBlock ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Invalid block number. Please use a valid block number from the URL.</p>
|
||||
</Card>
|
||||
) : !block ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Block not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Block Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={block.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Timestamp">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</DetailRow>
|
||||
<DetailRow label="Miner">
|
||||
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
||||
<Address address={block.miner} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Transactions">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
{block.transaction_count}
|
||||
</Link>
|
||||
</DetailRow>
|
||||
<DetailRow label="Gas Used">
|
||||
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function BlocksPage() {
|
||||
const pageSize = 20
|
||||
const [blocks, setBlocks] = useState<Block[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -17,75 +18,89 @@ export default function BlocksPage() {
|
||||
const response = await blocksApi.list({
|
||||
chain_id: chainId,
|
||||
page,
|
||||
page_size: 20,
|
||||
page_size: pageSize,
|
||||
sort: 'number',
|
||||
order: 'desc',
|
||||
})
|
||||
setBlocks(response.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load blocks:', error)
|
||||
setBlocks([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chainId, page])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadBlocks()
|
||||
}, [loadBlocks])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading blocks...</div>
|
||||
}
|
||||
const showPagination = page > 1 || blocks.length > 0
|
||||
const canGoNext = blocks.length === pageSize
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Blocks</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Blocks</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
{blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href={`/blocks/${block.number}`}
|
||||
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
<Address address={block.hash} truncate />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{blocks.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
||||
</Card>
|
||||
) : (
|
||||
blocks.map((block) => (
|
||||
<Card key={block.number}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Link
|
||||
href={`/blocks/${block.number}`}
|
||||
className="text-lg font-semibold text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Block #{block.number}
|
||||
</Link>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Address address={block.hash} truncate showCopy={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm">
|
||||
{new Date(block.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{block.transaction_count} transactions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
9
frontend/src/pages/bridge/index.tsx
Normal file
9
frontend/src/pages/bridge/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const BridgeMonitoringPage = dynamic(() => import('@/components/explorer/BridgeMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function BridgePage() {
|
||||
return <BridgeMonitoringPage />
|
||||
}
|
||||
28
frontend/src/pages/home/index.tsx
Normal file
28
frontend/src/pages/home/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function HomeAliasPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
void router.replace('/')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-gray-200 bg-white p-6 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Redirecting to SolaceScanScout</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-400">
|
||||
The legacy <code className="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-900">/home</code> route now redirects to the main explorer landing page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-5 inline-flex rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Continue to the explorer
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/more/index.tsx
Normal file
9
frontend/src/pages/more/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MoreOperationsPage = dynamic(() => import('@/components/explorer/MoreOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function MorePage() {
|
||||
return <MoreOperationsPage />
|
||||
}
|
||||
9
frontend/src/pages/operator/index.tsx
Normal file
9
frontend/src/pages/operator/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const OperatorOperationsPage = dynamic(() => import('@/components/explorer/OperatorOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function OperatorPage() {
|
||||
return <OperatorOperationsPage />
|
||||
}
|
||||
@@ -1,88 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
|
||||
const poolCards = [
|
||||
{
|
||||
title: 'Canonical PMM routes',
|
||||
description: 'Review the public Chain 138 DODO PMM route matrix, live pool freshness, and payload examples.',
|
||||
href: '/liquidity',
|
||||
label: 'Open liquidity access',
|
||||
},
|
||||
{
|
||||
title: 'Wallet Funding Path',
|
||||
description: 'Open wallet tools first if you need Chain 138 setup, token import links, or a quick route into supported assets.',
|
||||
href: '/wallet',
|
||||
label: 'Open wallet tools',
|
||||
},
|
||||
{
|
||||
title: 'Explorer Docs',
|
||||
description: 'Static documentation covers the live pool map, expected web content, and route access details.',
|
||||
href: '/docs.html',
|
||||
label: 'Open docs landing page',
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const shortcutCards = [
|
||||
{
|
||||
title: 'cUSDT / USDT',
|
||||
description: 'Open the canonical direct stable route coverage and compare the live pool snapshot.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDC / USDC',
|
||||
description: 'Check the public stable bridge route and inspect the live reserves block.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
{
|
||||
title: 'cUSDT / cXAUC',
|
||||
description: 'Review one of the live gold-backed route families from the liquidity access page.',
|
||||
href: '/liquidity',
|
||||
},
|
||||
]
|
||||
import PoolsOperationsPage from '@/components/explorer/PoolsOperationsPage'
|
||||
|
||||
export default function PoolsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Pools</h1>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{poolCards.map((card) => (
|
||||
<Card key={card.title} title={card.title}>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
<div className="mt-4">
|
||||
{card.external ? (
|
||||
<a href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</a>
|
||||
) : (
|
||||
<Link href={card.href} className="text-primary-600 hover:underline">
|
||||
{card.label} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Card title="Pool operation shortcuts">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{shortcutCards.map((card) => (
|
||||
<Link
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
className="rounded-lg border border-gray-200 p-4 transition hover:border-primary-400 hover:shadow-sm dark:border-gray-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{card.title}</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{card.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <PoolsOperationsPage />
|
||||
}
|
||||
|
||||
9
frontend/src/pages/routes/index.tsx
Normal file
9
frontend/src/pages/routes/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const RoutesMonitoringPage = dynamic(() => import('@/components/explorer/RoutesMonitoringPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function RoutesPage() {
|
||||
return <RoutesMonitoringPage />
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { getExplorerApiBase } from '@/services/api/blockscout'
|
||||
import { inferDirectSearchTarget } from '@/utils/search'
|
||||
|
||||
interface SearchResult {
|
||||
type: string
|
||||
@@ -24,17 +25,29 @@ export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
if (!rawQuery.trim()) return
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
if (!trimmedQuery) {
|
||||
setHasSearched(false)
|
||||
setResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setHasSearched(true)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(rawQuery)}`
|
||||
`${getExplorerApiBase()}/api/v2/search?q=${encodeURIComponent(trimmedQuery)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
return
|
||||
}
|
||||
const normalizedResults = Array.isArray(data?.items)
|
||||
@@ -59,6 +72,7 @@ export default function SearchPage() {
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setResults([])
|
||||
setError('Search is temporarily unavailable right now.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -73,15 +87,37 @@ export default function SearchPage() {
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await runSearch(query)
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
if (directTarget) {
|
||||
void router.push(directTarget.href)
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: { q: trimmedQuery },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
)
|
||||
await runSearch(trimmedQuery)
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Search</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Search</h1>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
@@ -91,42 +127,75 @@ export default function SearchPage() {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
disabled={loading || !trimmedQuery}
|
||||
className="w-full rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{!loading && error && (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && directTarget && (
|
||||
<Card className="mb-6" title="Direct Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href={directTarget.href} className="text-primary-600 hover:underline">
|
||||
{directTarget.label} →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Card title="Search Results">
|
||||
<div className="space-y-4">
|
||||
{results.map((result, index) => (
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
|
||||
<div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 pb-4 last:border-0 dark:border-gray-700">
|
||||
{result.type === 'block' && result.data.number && (
|
||||
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
|
||||
<Link href={`/blocks/${result.data.number}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block</span>
|
||||
Block #{result.data.number}
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'transaction' && result.data.hash && (
|
||||
<Link href={`/transactions/${result.data.hash}`} className="text-primary-600 hover:underline">
|
||||
Transaction <Address address={result.data.hash} truncate />
|
||||
<Link href={`/transactions/${result.data.hash}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transaction</span>
|
||||
<Address address={result.data.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
{result.type === 'address' && result.data.address && (
|
||||
<Link href={`/addresses/${result.data.address}`} className="text-primary-600 hover:underline">
|
||||
Address <Address address={result.data.address} truncate />
|
||||
<Link href={`/addresses/${result.data.address}`} className="inline-flex flex-col gap-1 text-primary-600 hover:underline">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address</span>
|
||||
<Address address={result.data.address} truncate showCopy={false} />
|
||||
</Link>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
Type: {result.type} | Chain: {result.chain_id ?? 138} | Score: {(result.score ?? 0).toFixed(2)}
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500">
|
||||
<span>Type: {result.type}</span>
|
||||
<span>Chain: {result.chain_id ?? 138}</span>
|
||||
<span>Score: {(result.score ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && !error && results.length === 0 && (
|
||||
<Card title="No Results Found">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>.
|
||||
Try a full address, transaction hash, token symbol, or block number.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
9
frontend/src/pages/system/index.tsx
Normal file
9
frontend/src/pages/system/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const SystemOperationsPage = dynamic(() => import('@/components/explorer/SystemOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function SystemPage() {
|
||||
return <SystemOperationsPage />
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function TokensPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const router = useRouter()
|
||||
@@ -42,92 +44,76 @@ export default function TransactionDetailPage() {
|
||||
loadTransaction()
|
||||
}, [hash, loadTransaction, router.isReady])
|
||||
|
||||
if (!router.isReady) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transaction...</div>
|
||||
}
|
||||
|
||||
if (!transaction) {
|
||||
return <div className="p-8">Transaction not found</div>
|
||||
}
|
||||
|
||||
const value = BigInt(transaction.value)
|
||||
const ethValue = Number(value) / 1e18
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transaction</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transaction</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/transactions" className="text-primary-600 hover:underline">
|
||||
Back to transactions
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction.hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
{(transaction?.hash || hash) && (
|
||||
<Link href={`/search?q=${encodeURIComponent(transaction?.hash || hash)}`} className="text-primary-600 hover:underline">
|
||||
Search this hash
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card title="Transaction Information">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold">Hash:</span>
|
||||
<Address address={transaction.hash} className="ml-2" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Block:</span>
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="ml-2 text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">From:</span>
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="ml-2">
|
||||
<Address address={transaction.from_address} truncate />
|
||||
</Link>
|
||||
</div>
|
||||
{transaction.to_address && (
|
||||
<div>
|
||||
<span className="font-semibold">To:</span>
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="ml-2">
|
||||
<Address address={transaction.to_address} truncate />
|
||||
{!router.isReady || loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transaction...</p>
|
||||
</Card>
|
||||
) : !transaction ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Transaction not found.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Block">
|
||||
<Link href={`/blocks/${transaction.block_number}`} className="text-primary-600 hover:underline">
|
||||
#{transaction.block_number}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Value:</span>
|
||||
<span className="ml-2">{ethValue.toFixed(4)} ETH</span>
|
||||
</div>
|
||||
{transaction.gas_price && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Price:</span>
|
||||
<span className="ml-2">{transaction.gas_price / 1e9} Gwei</span>
|
||||
</div>
|
||||
)}
|
||||
{transaction.gas_used && (
|
||||
<div>
|
||||
<span className="font-semibold">Gas Used:</span>
|
||||
<span className="ml-2">{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Status:</span>
|
||||
<span className={`ml-2 ${transaction.status === 1 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
{transaction.contract_address && (
|
||||
<div>
|
||||
<span className="font-semibold">Contract Created:</span>
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="ml-2">
|
||||
<Address address={transaction.contract_address} truncate />
|
||||
</DetailRow>
|
||||
<DetailRow label="From">
|
||||
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</DetailRow>
|
||||
{transaction.to_address && (
|
||||
<DetailRow label="To">
|
||||
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Value">{formatWeiAsEth(transaction.value)}</DetailRow>
|
||||
<DetailRow label="Gas Price">
|
||||
{transaction.gas_price != null ? `${transaction.gas_price / 1e9} Gwei` : 'N/A'}
|
||||
</DetailRow>
|
||||
{transaction.gas_used != null && (
|
||||
<DetailRow label="Gas Used">
|
||||
{transaction.gas_used.toLocaleString()} / {transaction.gas_limit.toLocaleString()}
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="Status">
|
||||
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{transaction.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
{transaction.contract_address && (
|
||||
<DetailRow label="Contract Created">
|
||||
<Link href={`/addresses/${transaction.contract_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={transaction.contract_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
import { formatWeiAsEth } from '@/utils/format'
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const pageSize = 20
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -14,7 +16,7 @@ export default function TransactionsPage() {
|
||||
const loadTransactions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, 20)
|
||||
const { ok, data } = await transactionsApi.listSafe(chainId, page, pageSize)
|
||||
setTransactions(ok ? data : [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
@@ -22,18 +24,21 @@ export default function TransactionsPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chainId, page])
|
||||
}, [chainId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
loadTransactions()
|
||||
}, [loadTransactions])
|
||||
|
||||
const showPagination = page > 1 || transactions.length > 0
|
||||
const canGoNext = transactions.length === pageSize
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
<Address address={tx.hash} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -49,7 +54,7 @@ export default function TransactionsPage() {
|
||||
header: 'From',
|
||||
accessor: (tx: Transaction) => (
|
||||
<Link href={`/addresses/${tx.from_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.from_address} truncate />
|
||||
<Address address={tx.from_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
@@ -57,17 +62,13 @@ export default function TransactionsPage() {
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? (
|
||||
<Link href={`/addresses/${tx.to_address}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.to_address} truncate />
|
||||
<Address address={tx.to_address} truncate showCopy={false} />
|
||||
</Link>
|
||||
) : <span className="text-gray-400">Contract Creation</span>,
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
},
|
||||
accessor: (tx: Transaction) => formatWeiAsEth(tx.value),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
@@ -79,32 +80,42 @@ export default function TransactionsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading transactions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Transactions</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Transactions</h1>
|
||||
|
||||
<Table columns={columns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading transactions...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={transactions}
|
||||
emptyMessage="Recent transactions are unavailable right now."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-4 py-2">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-4 py-2 bg-gray-200 rounded"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import {
|
||||
readWatchlistFromStorage,
|
||||
writeWatchlistToStorage,
|
||||
sanitizeWatchlistEntries,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
export default function WatchlistPage() {
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem('explorerWatchlist')
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
setEntries(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [])
|
||||
setEntries(readWatchlistFromStorage(window.localStorage))
|
||||
} catch {
|
||||
setEntries([])
|
||||
}
|
||||
@@ -21,13 +28,17 @@ export default function WatchlistPage() {
|
||||
setEntries((current) => {
|
||||
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
|
||||
try {
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const exportWatchlist = () => {
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -45,12 +56,9 @@ export default function WatchlistPage() {
|
||||
|
||||
file.text().then((text) => {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const next = Array.isArray(parsed)
|
||||
? parsed.filter((entry): entry is string => typeof entry === 'string')
|
||||
: []
|
||||
const next = sanitizeWatchlistEntries(JSON.parse(text))
|
||||
setEntries(next)
|
||||
window.localStorage.setItem('explorerWatchlist', JSON.stringify(next))
|
||||
writeWatchlistToStorage(window.localStorage, next)
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -58,8 +66,8 @@ export default function WatchlistPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Watchlist</h1>
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-6 text-3xl font-bold">Watchlist</h1>
|
||||
|
||||
<Card title="Saved Addresses">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
@@ -75,7 +83,7 @@ export default function WatchlistPage() {
|
||||
type="button"
|
||||
onClick={exportWatchlist}
|
||||
disabled={entries.length === 0}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 disabled:opacity-100 dark:disabled:bg-gray-700 dark:disabled:text-gray-400"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
@@ -98,7 +106,7 @@ export default function WatchlistPage() {
|
||||
{entries.map((entry) => (
|
||||
<div key={entry} className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700 md:flex-row md:items-center md:justify-between">
|
||||
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
||||
<Address address={entry} />
|
||||
<Address address={entry} showCopy={false} />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
9
frontend/src/pages/weth/index.tsx
Normal file
9
frontend/src/pages/weth/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const WethOperationsPage = dynamic(() => import('@/components/explorer/WethOperationsPage'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export default function WethPage() {
|
||||
return <WethOperationsPage />
|
||||
}
|
||||
Reference in New Issue
Block a user