Compare commits

..

1 Commits

Author SHA1 Message Date
8c7e1c70de chore(frontend): commit to pages router, drop empty src/app, unify on npm
Fixes the 'unfinished router migration + inconsistent packageManager'
finding from the review:

1. src/app/ only ever contained globals.css; every actual route lives
   under src/pages/. Keeping both routers in the tree made the build
   surface area ambiguous and left a trap where a future contributor
   might add a new route under src/app/ and break Next's routing
   resolution. PR #9 commits to the pages router and removes src/app/.

2. globals.css moved from src/app/globals.css to src/styles/globals.css
   (so it no longer sits under an otherwise-deleted app router folder)
   and _app.tsx's import was updated accordingly. This is a no-op at
   runtime: the CSS payload is byte-identical.

3. tailwind.config.js had './src/app/**/*.{js,ts,jsx,tsx,mdx}' at the
   top of its content glob list. Replaced with './src/styles/**/*.css'
   so Tailwind still sees globals.css; the src/components/** and
   src/pages/** globs are unchanged.

4. Unified the package manager on npm:

   - package.json packageManager: 'pnpm@10.0.0' -> 'npm@10.8.2'.
     The lockfile (package-lock.json) and CI (npm ci / npm run lint /
     npm run type-check / npm run build in .github/workflows/ci.yml)
     have always used npm; the pnpm declaration was aspirational and
     would have forced contributors with corepack enabled into a tool
     the repo doesn't actually support.
   - Added an 'engines' block pinning node >=20 <21 and npm >=10 so
     CI, Docker, and a fresh laptop clone all land on the same runtime.

Verification:
  npm ci            465 packages, no warnings.
  npm run lint      next lint:   No ESLint warnings or errors.
  npm run type-check tsc --noEmit: clean.
  npm run build     Next.js 14.2.35 compiled 19 pages successfully;
                    every route (/, /blocks, /transactions, /tokens,
                    /bridge, /analytics, /operator, /docs, /wallet,
                    etc.) rendered without emitting a warning.

Advances completion criterion 5 (frontend housekeeping): 'one router;
one package manager; build is reproducible from the lockfile.'
2026-04-18 19:23:35 +00:00
9 changed files with 26 additions and 328 deletions

View File

@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

View File

@@ -2,7 +2,11 @@
"name": "explorer-frontend",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.0.0",
"packageManager": "npm@10.8.2",
"engines": {
"node": ">=20.0.0 <21.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "sh -c 'HOST=${HOST:-127.0.0.1}; PORT=${PORT:-3000}; next dev -H \"$HOST\" -p \"$PORT\"'",
"build": "next build",

View File

@@ -1,77 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
const DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE = 25
interface UseBlockTransactionsOptions {
blockNumber: number
chainId: number
enabled: boolean
}
export function useBlockTransactions({ blockNumber, chainId, enabled }: UseBlockTransactionsOptions) {
const [transactions, setTransactions] = useState<Transaction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [hasNextPage, setHasNextPage] = useState(false)
const [page, setPage] = useState(1)
const previousBlockNumberRef = useRef(blockNumber)
useEffect(() => {
if (!enabled) {
previousBlockNumberRef.current = blockNumber
if (page !== 1) {
setPage(1)
}
setTransactions([])
setLoading(false)
setError(false)
setHasNextPage(false)
return
}
if (previousBlockNumberRef.current !== blockNumber) {
previousBlockNumberRef.current = blockNumber
if (page !== 1) {
setPage(1)
return
}
}
let cancelled = false
setLoading(true)
setError(false)
void (async () => {
const result = await transactionsApi.listByBlockSafe(
chainId,
blockNumber,
page,
DEFAULT_BLOCK_TRANSACTION_PAGE_SIZE,
)
if (cancelled) {
return
}
setTransactions(result.items)
setHasNextPage(result.hasNextPage)
setError(!result.ok)
setLoading(false)
})()
return () => {
cancelled = true
}
}, [blockNumber, chainId, enabled, page])
return {
transactions,
loading,
error,
hasNextPage,
page,
setPage,
}
}

View File

@@ -1,5 +1,5 @@
import type { AppProps } from 'next/app'
import '../app/globals.css'
import '../styles/globals.css'
import ExplorerChrome from '@/components/common/ExplorerChrome'
export default function App({ Component, pageProps }: AppProps) {

View File

@@ -1,15 +1,13 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import { DetailRow } from '@/components/common/DetailRow'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
import { type Transaction } from '@/services/api/transactions'
import { useBlockTransactions } from '@/hooks/useBlockTransactions'
import { formatTimestamp } from '@/utils/format'
export default function BlockDetailPage() {
const router = useRouter()
@@ -21,18 +19,17 @@ export default function BlockDetailPage() {
const [block, setBlock] = useState<Block | null>(null)
const [loading, setLoading] = useState(true)
const {
transactions: blockTransactions,
loading: transactionsLoading,
error: transactionsError,
hasNextPage: hasNextTransactionsPage,
page: transactionPage,
setPage: setTransactionPage,
} = useBlockTransactions({
blockNumber,
chainId,
enabled: router.isReady && isValidBlock,
})
const loadBlock = useCallback(async () => {
setLoading(true)
try {
const response = await blocksApi.getByNumber(chainId, blockNumber)
setBlock(response.data)
} catch (error) {
console.error('Failed to load block:', error)
} finally {
setLoading(false)
}
}, [chainId, blockNumber])
useEffect(() => {
if (!router.isReady) {
@@ -43,85 +40,12 @@ export default function BlockDetailPage() {
setBlock(null)
return
}
let cancelled = false
setLoading(true)
void (async () => {
try {
const response = await blocksApi.getByNumber(chainId, blockNumber)
if (cancelled) {
return
}
setBlock(response.data)
} catch (error) {
console.error('Failed to load block:', error)
if (cancelled) {
return
}
setBlock(null)
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [blockNumber, chainId, isValidBlock, router.isReady])
loadBlock()
}, [isValidBlock, loadBlock, router.isReady])
const gasUtilization = block && block.gas_limit > 0
? Math.round((block.gas_used / block.gas_limit) * 100)
: null
const transactionColumns = useMemo(() => [
{
header: 'Hash',
accessor: (transaction: Transaction) => (
<Link href={`/transactions/${transaction.hash}`} className="text-primary-600 hover:underline">
<Address address={transaction.hash} truncate showCopy={false} />
</Link>
),
},
{
header: 'From',
accessor: (transaction: Transaction) => (
<Link href={`/addresses/${transaction.from_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.from_address} truncate showCopy={false} />
</Link>
),
},
{
header: 'To',
accessor: (transaction: Transaction) =>
transaction.to_address ? (
<Link href={`/addresses/${transaction.to_address}`} className="text-primary-600 hover:underline">
<Address address={transaction.to_address} truncate showCopy={false} />
</Link>
) : (
<span className="text-gray-500 dark:text-gray-400">Contract creation</span>
),
},
{
header: 'Value',
accessor: (transaction: Transaction) => formatWeiAsEth(transaction.value),
},
{
header: 'Status',
accessor: (transaction: Transaction) => (
<span className={transaction.status === 1 ? 'text-green-600' : 'text-red-600'}>
{transaction.status === 1 ? 'Success' : 'Failed'}
</span>
),
},
], [])
const transactionsEmptyMessage = transactionsError
? 'Unable to load indexed block transactions right now. Please retry from this page in a moment.'
: (block?.transaction_count ?? 0) > 0
? 'No indexed block transactions were returned for this page yet.'
: 'This block does not contain any indexed transactions.'
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -150,11 +74,6 @@ export default function BlockDetailPage() {
Next block
</Link>
)}
{block?.transaction_count ? (
<a href="#block-transactions" className="text-primary-600 hover:underline">
Open block transactions
</a>
) : null}
</div>
{!router.isReady || loading ? (
@@ -200,9 +119,9 @@ export default function BlockDetailPage() {
</Link>
</DetailRow>
<DetailRow label="Transactions">
<a href="#block-transactions" className="text-primary-600 hover:underline">
<Link href={`/search?q=${block.number}`} className="text-primary-600 hover:underline">
{block.transaction_count}
</a>
</Link>
</DetailRow>
<DetailRow label="Gas Used">
{block.gas_used.toLocaleString()} / {block.gas_limit.toLocaleString()}
@@ -215,53 +134,6 @@ export default function BlockDetailPage() {
</dl>
</Card>
)}
{block && (
<Card title="Block Transactions" className="mt-6">
<div id="block-transactions" className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
This section shows the exact indexed transaction set for block #{block.number.toLocaleString()}, independent of generic explorer search.
</p>
{transactionsLoading ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Loading block transactions...</p>
) : (
<>
<Table
columns={transactionColumns}
data={blockTransactions}
emptyMessage={transactionsEmptyMessage}
keyExtractor={(transaction) => transaction.hash}
/>
{block.transaction_count > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>
Showing page {transactionPage} of the indexed transactions for this block.
</span>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setTransactionPage((current) => Math.max(1, current - 1))}
disabled={transactionsLoading || transactionPage === 1}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Previous tx page
</button>
<button
type="button"
onClick={() => setTransactionPage((current) => current + 1)}
disabled={transactionsLoading || !hasNextTransactionsPage}
className="rounded bg-gray-200 px-4 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-800 dark:text-gray-100"
>
Next tx page
</button>
</div>
</div>
) : null}
</>
)}
</div>
</Card>
)}
</div>
)
}

View File

@@ -2,62 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { transactionsApi } from './transactions'
describe('transactionsApi.listByBlockSafe', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns normalized transactions for a specific block', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [
{
hash: '0xabc',
block_number: 123,
block_hash: '0xdef',
transaction_index: 0,
from: { hash: '0x0000000000000000000000000000000000000001' },
to: { hash: '0x0000000000000000000000000000000000000002' },
value: '0',
gas_price: '1',
gas: '21000',
gas_used: '21000',
status: 'ok',
timestamp: '2026-04-16T09:40:12.000000Z',
},
],
next_page_params: { page: 2, page_size: 10 },
}),
})
vi.stubGlobal('fetch', fetchMock)
const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10)
expect(result.ok).toBe(true)
expect(result.items).toHaveLength(1)
expect(result.hasNextPage).toBe(true)
expect(result.items[0]?.hash).toBe('0xabc')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]?.[0]).toEqual(
expect.stringContaining('/api/v2/blocks/123/transactions?page=1&page_size=10'),
)
})
it('returns a non-throwing failure result when the block transaction request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const result = await transactionsApi.listByBlockSafe(138, 123, 1, 10)
expect(result).toEqual({
ok: false,
items: [],
hasNextPage: false,
})
})
})
describe('transactionsApi.diagnoseMissing', () => {
beforeEach(() => {
vi.restoreAllMocks()

View File

@@ -76,17 +76,6 @@ export interface TransactionLookupDiagnostic {
rpc_url?: string
}
export interface BlockTransactionListPage {
items: Transaction[]
hasNextPage: boolean
}
export interface SafeTransactionPage<T> {
ok: boolean
items: T[]
hasNextPage: boolean
}
const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org'
function resolvePublicRpcUrl(chainId: number): string | null {
@@ -238,37 +227,4 @@ export const transactionsApi = {
return { ok: false, data: [] }
}
},
listByBlock: async (chainId: number, blockNumber: number, page = 1, pageSize = 25): Promise<ApiResponse<BlockTransactionListPage>> => {
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = await fetchBlockscoutJson<{ items?: unknown[]; next_page_params?: Record<string, unknown> | null }>(
`/api/v2/blocks/${blockNumber}/transactions?${params.toString()}`
)
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransaction(item as never, chainId)) : []
return {
data: {
items: data,
hasNextPage: raw?.next_page_params != null,
},
}
},
listByBlockSafe: async (
chainId: number,
blockNumber: number,
page = 1,
pageSize = 25,
): Promise<SafeTransactionPage<Transaction>> => {
try {
const { data } = await transactionsApi.listByBlock(chainId, blockNumber, page, pageSize)
return {
ok: true,
items: data.items,
hasNextPage: data.hasNextPage,
}
} catch {
return { ok: false, items: [], hasNextPage: false }
}
},
}

View File

@@ -1,9 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/styles/**/*.css',
],
theme: {
extend: {