272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import type { GetServerSideProps } from 'next'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { blocksApi, Block } from '@/services/api/blocks'
|
|
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
|
import Link from 'next/link'
|
|
import PageIntro from '@/components/common/PageIntro'
|
|
import { formatTimestamp } from '@/utils/format'
|
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
|
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
|
import type { Transaction } from '@/services/api/transactions'
|
|
import { transactionsApi } from '@/services/api/transactions'
|
|
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, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
|
|
|
interface BlocksPageProps {
|
|
initialBlocks: Block[]
|
|
initialRecentTransactions: Transaction[]
|
|
initialStats: ExplorerStats | null
|
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
|
}
|
|
|
|
function serializeTransactions(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,
|
|
value: transaction.value,
|
|
status: transaction.status ?? null,
|
|
contract_address: transaction.contract_address ?? null,
|
|
fee: transaction.fee ?? null,
|
|
created_at: transaction.created_at,
|
|
})),
|
|
),
|
|
) as Transaction[]
|
|
}
|
|
|
|
export default function BlocksPage({
|
|
initialBlocks,
|
|
initialRecentTransactions,
|
|
initialStats,
|
|
initialBridgeStatus,
|
|
}: BlocksPageProps) {
|
|
const pageSize = 20
|
|
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
|
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
|
const [loading, setLoading] = useState(initialBlocks.length === 0)
|
|
const [page, setPage] = useState(1)
|
|
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
|
|
const loadBlocks = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const response = await blocksApi.list({
|
|
chain_id: chainId,
|
|
page,
|
|
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, pageSize])
|
|
|
|
useEffect(() => {
|
|
if (page === 1 && initialBlocks.length > 0) {
|
|
setBlocks(initialBlocks)
|
|
setLoading(false)
|
|
return
|
|
}
|
|
void loadBlocks()
|
|
}, [initialBlocks, loadBlocks, page])
|
|
|
|
useEffect(() => {
|
|
if (initialRecentTransactions.length > 0) {
|
|
setRecentTransactions(initialRecentTransactions)
|
|
return
|
|
}
|
|
|
|
let active = true
|
|
transactionsApi.listSafe(chainId, 1, 5)
|
|
.then(({ ok, data }) => {
|
|
if (active && ok && data.length > 0) {
|
|
setRecentTransactions(data)
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (active) {
|
|
setRecentTransactions([])
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [chainId, initialRecentTransactions])
|
|
|
|
const showPagination = page > 1 || blocks.length > 0
|
|
const canGoNext = blocks.length === pageSize
|
|
const activityContext = useMemo(
|
|
() =>
|
|
summarizeChainActivity({
|
|
blocks,
|
|
transactions: recentTransactions,
|
|
latestBlockNumber: blocks[0]?.number ?? null,
|
|
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
|
}),
|
|
[blocks, initialBridgeStatus, initialStats, recentTransactions],
|
|
)
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
|
<PageIntro
|
|
eyebrow="Chain Activity"
|
|
title="Blocks"
|
|
description="Browse recent Chain 138 blocks, then pivot into transactions, addresses, and indexed search without falling into a dead end."
|
|
actions={[
|
|
{ href: '/transactions', label: 'Open transactions' },
|
|
{ href: '/addresses', label: 'Browse addresses' },
|
|
{ href: '/search', label: 'Search explorer' },
|
|
]}
|
|
/>
|
|
|
|
<div className="mb-6">
|
|
<ActivityContextPanel context={activityContext} title="Block Production Context" />
|
|
<FreshnessTrustNote
|
|
className="mt-3"
|
|
context={activityContext}
|
|
stats={initialStats}
|
|
bridgeStatus={initialBridgeStatus}
|
|
scopeLabel="This page focuses on recent visible head blocks."
|
|
/>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<Card>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
|
|
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
|
|
<p className="text-sm text-amber-900 dark:text-amber-100">
|
|
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
|
|
</p>
|
|
</Card>
|
|
) : null}
|
|
{blocks.length === 0 ? (
|
|
<Card>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
|
|
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
|
<Link href="/transactions" className="text-primary-600 hover:underline">
|
|
Open recent transactions →
|
|
</Link>
|
|
<Link href="/search" className="text-primary-600 hover:underline">
|
|
Search by block number →
|
|
</Link>
|
|
</div>
|
|
</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 className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Miner:{' '}
|
|
<Link href={`/addresses/${block.miner}`} className="text-primary-600 hover:underline">
|
|
<Address address={block.miner} truncate showCopy={false} />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
<div className="text-left sm:text-right">
|
|
<div className="text-sm">
|
|
{formatTimestamp(block.timestamp)}
|
|
</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
{block.transaction_count} transactions
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))
|
|
)}
|
|
</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 className="mt-8 grid gap-4 lg:grid-cols-2">
|
|
<Card title="Keep Exploring">
|
|
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
|
Need a different entry point? Open transaction flow, search directly by block number, or jump into recently active addresses.
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
|
<Link href="/transactions" className="text-primary-600 hover:underline">
|
|
Transactions →
|
|
</Link>
|
|
<Link href="/addresses" className="text-primary-600 hover:underline">
|
|
Addresses →
|
|
</Link>
|
|
<Link href="/search" className="text-primary-600 hover:underline">
|
|
Search →
|
|
</Link>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
|
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([
|
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null),
|
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null),
|
|
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
|
|
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
|
])
|
|
|
|
return {
|
|
props: {
|
|
initialBlocks: Array.isArray(blocksResult?.items)
|
|
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
|
|
: [],
|
|
initialRecentTransactions: Array.isArray(transactionsResult?.items)
|
|
? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)))
|
|
: [],
|
|
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
|
initialBridgeStatus: bridgeResult,
|
|
},
|
|
}
|
|
}
|