253 lines
9.3 KiB
TypeScript
253 lines
9.3 KiB
TypeScript
import type { GetServerSideProps } from 'next'
|
|
import Link from 'next/link'
|
|
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'
|
|
import PageIntro from '@/components/common/PageIntro'
|
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
|
import { normalizeTransaction } from '@/services/api/blockscout'
|
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
|
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
|
|
|
function normalizeAddress(value: string) {
|
|
const trimmed = value.trim()
|
|
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
|
|
}
|
|
|
|
interface AddressesPageProps {
|
|
initialRecentTransactions: Transaction[]
|
|
initialLatestBlocks: Array<{ number: number; timestamp: string }>
|
|
initialStats: ExplorerStats | null
|
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
|
}
|
|
|
|
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
|
|
return JSON.parse(
|
|
JSON.stringify(
|
|
transactions.map((transaction) => ({
|
|
hash: transaction.hash,
|
|
block_number: transaction.block_number,
|
|
from_address: transaction.from_address,
|
|
to_address: transaction.to_address ?? null,
|
|
created_at: transaction.created_at,
|
|
})),
|
|
),
|
|
) as Transaction[]
|
|
}
|
|
|
|
export default function AddressesPage({
|
|
initialRecentTransactions,
|
|
initialLatestBlocks,
|
|
initialStats,
|
|
initialBridgeStatus,
|
|
}: AddressesPageProps) {
|
|
const router = useRouter()
|
|
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const [query, setQuery] = useState('')
|
|
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
|
const [watchlist, setWatchlist] = useState<string[]>([])
|
|
const activityContext = useMemo(
|
|
() =>
|
|
summarizeChainActivity({
|
|
blocks: initialLatestBlocks.map((block) => ({
|
|
chain_id: chainId,
|
|
number: block.number,
|
|
hash: '',
|
|
timestamp: block.timestamp,
|
|
miner: '',
|
|
transaction_count: 0,
|
|
gas_used: 0,
|
|
gas_limit: 0,
|
|
})),
|
|
transactions: recentTransactions,
|
|
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
|
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
|
}),
|
|
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (initialRecentTransactions.length > 0) {
|
|
setRecentTransactions(initialRecentTransactions)
|
|
return
|
|
}
|
|
|
|
let active = true
|
|
transactionsApi.listSafe(chainId, 1, 20)
|
|
.then(({ ok, data }) => {
|
|
if (active && ok) {
|
|
setRecentTransactions(data)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (active) {
|
|
setRecentTransactions([])
|
|
}
|
|
})
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [chainId, initialRecentTransactions])
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setWatchlist(readWatchlistFromStorage(window.localStorage))
|
|
} catch {
|
|
setWatchlist([])
|
|
}
|
|
}, [])
|
|
|
|
const activeAddresses = useMemo(() => {
|
|
const seen = new Set<string>()
|
|
const addresses: string[] = []
|
|
|
|
for (const tx of recentTransactions) {
|
|
for (const candidate of [tx.from_address, tx.to_address]) {
|
|
if (!candidate) continue
|
|
const normalized = candidate.toLowerCase()
|
|
if (seen.has(normalized)) continue
|
|
seen.add(normalized)
|
|
addresses.push(candidate)
|
|
if (addresses.length >= 12) return addresses
|
|
}
|
|
}
|
|
|
|
return addresses
|
|
}, [recentTransactions])
|
|
|
|
const handleOpenAddress = (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
const normalized = normalizeAddress(query)
|
|
if (!normalized) return
|
|
router.push(`/addresses/${normalized}`)
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
|
<PageIntro
|
|
eyebrow="Address Discovery"
|
|
title="Addresses"
|
|
description="Open any Chain 138 address directly, revisit saved watchlist entries, or branch into recent activity discovered from indexed transactions."
|
|
actions={[
|
|
{ href: '/watchlist', label: 'Open watchlist' },
|
|
{ href: '/transactions', label: 'Recent transactions' },
|
|
{ href: '/search', label: 'Search explorer' },
|
|
]}
|
|
/>
|
|
|
|
<div className="mb-6">
|
|
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
|
|
<FreshnessTrustNote
|
|
className="mt-3"
|
|
context={activityContext}
|
|
stats={initialStats}
|
|
bridgeStatus={initialBridgeStatus}
|
|
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
|
|
/>
|
|
</div>
|
|
|
|
<Card className="mb-6" title="Open An Address">
|
|
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="0x..."
|
|
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!normalizeAddress(query)}
|
|
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>
|
|
</form>
|
|
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
|
Open any Chain 138 address directly, or jump into your saved watchlist below.
|
|
</p>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card title="Saved Watchlist">
|
|
{watchlist.length === 0 ? (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
No saved addresses yet. Address detail pages let you add entries to the shared explorer watchlist.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{watchlist.map((entry) => (
|
|
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
|
<Address address={entry} showCopy={false} />
|
|
</Link>
|
|
))}
|
|
<div className="pt-2">
|
|
<Link href="/watchlist" className="text-sm text-primary-600 hover:underline">
|
|
Open the full watchlist →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<Card title="Recently Active Addresses">
|
|
{activeAddresses.length === 0 ? (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{activeAddresses.map((entry) => (
|
|
<Link key={entry} href={`/addresses/${entry}`} className="block text-primary-600 hover:underline">
|
|
<Address address={entry} showCopy={false} />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
|
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
|
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
|
|
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
|
|
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
|
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
|
])
|
|
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
|
|
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
|
: []
|
|
const initialLatestBlocks = Array.isArray(blocksResult?.items)
|
|
? blocksResult.items
|
|
.map((item) => ({
|
|
number: Number(item.height || 0),
|
|
timestamp: item.timestamp || '',
|
|
}))
|
|
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
|
|
: []
|
|
|
|
return {
|
|
props: {
|
|
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
|
|
initialLatestBlocks,
|
|
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
|
initialBridgeStatus: bridgeResult,
|
|
},
|
|
}
|
|
}
|