- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import type { GetServerSideProps } from 'next'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
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'
|
|
import EntityBadge from '@/components/common/EntityBadge'
|
|
import PageIntro from '@/components/common/PageIntro'
|
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
|
import { normalizeTransaction } from '@/services/api/blockscout'
|
|
|
|
interface TransactionsPageProps {
|
|
initialTransactions: Transaction[]
|
|
}
|
|
|
|
function serializeTransactionList(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,
|
|
token_transfers: Array.isArray(transaction.token_transfers)
|
|
? transaction.token_transfers.map((transfer) => ({ token_address: transfer.token_address }))
|
|
: [],
|
|
})),
|
|
),
|
|
) as Transaction[]
|
|
}
|
|
|
|
export default function TransactionsPage({ initialTransactions }: TransactionsPageProps) {
|
|
const pageSize = 20
|
|
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
|
const [loading, setLoading] = useState(initialTransactions.length === 0)
|
|
const [page, setPage] = useState(1)
|
|
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
|
|
const loadTransactions = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const { ok, data } = await transactionsApi.listSafe(chainId, page, pageSize)
|
|
setTransactions(ok ? data : [])
|
|
} catch (error) {
|
|
console.error('Failed to load transactions:', error)
|
|
setTransactions([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [chainId, page, pageSize])
|
|
|
|
useEffect(() => {
|
|
if (page === 1 && initialTransactions.length > 0) {
|
|
setTransactions(initialTransactions)
|
|
setLoading(false)
|
|
return
|
|
}
|
|
void loadTransactions()
|
|
}, [initialTransactions, loadTransactions, page])
|
|
|
|
const transactionSummary = useMemo(() => {
|
|
const sampleSize = transactions.length
|
|
if (sampleSize === 0) {
|
|
return {
|
|
sampleSize: 0,
|
|
successRate: 0,
|
|
contractCreations: 0,
|
|
tokenTransferTransactions: 0,
|
|
averageFee: null as string | null,
|
|
}
|
|
}
|
|
|
|
const successes = transactions.filter((transaction) => transaction.status === 1).length
|
|
const contractCreations = transactions.filter((transaction) => Boolean(transaction.contract_address)).length
|
|
const tokenTransferTransactions = transactions.filter(
|
|
(transaction) => (transaction.token_transfers?.length || 0) > 0,
|
|
).length
|
|
const feeValues = transactions
|
|
.map((transaction) => {
|
|
if (!transaction.fee) return null
|
|
const numeric = Number(transaction.fee)
|
|
return Number.isFinite(numeric) ? numeric : null
|
|
})
|
|
.filter((value): value is number => value != null)
|
|
const averageFee =
|
|
feeValues.length > 0
|
|
? formatWeiAsEth(Math.round(feeValues.reduce((sum, value) => sum + value, 0) / feeValues.length).toString(), 6)
|
|
: null
|
|
|
|
return {
|
|
sampleSize,
|
|
successRate: Math.round((successes / sampleSize) * 100),
|
|
contractCreations,
|
|
tokenTransferTransactions,
|
|
averageFee,
|
|
}
|
|
}, [transactions])
|
|
|
|
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 showCopy={false} />
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
header: 'Block',
|
|
accessor: (tx: Transaction) => (
|
|
<Link href={`/blocks/${tx.block_number}`} className="text-primary-600 hover:underline">
|
|
{tx.block_number}
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
header: 'From',
|
|
accessor: (tx: Transaction) => (
|
|
<Link href={`/addresses/${tx.from_address}`} className="text-primary-600 hover:underline">
|
|
<Address address={tx.from_address} truncate showCopy={false} />
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
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 showCopy={false} />
|
|
</Link>
|
|
) : <span className="text-gray-400">Contract Creation</span>,
|
|
},
|
|
{
|
|
header: 'Value',
|
|
accessor: (tx: Transaction) => formatWeiAsEth(tx.value),
|
|
},
|
|
{
|
|
header: 'Status',
|
|
accessor: (tx: Transaction) => (
|
|
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
|
{tx.status === 1 ? 'Success' : 'Failed'}
|
|
</span>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
|
<PageIntro
|
|
eyebrow="Indexed Flow"
|
|
title="Transactions"
|
|
description="Review recent Chain 138 transactions and move directly into the linked block, address, search, and watchlist surfaces from here."
|
|
actions={[
|
|
{ href: '/blocks', label: 'Open blocks' },
|
|
{ href: '/addresses', label: 'Browse addresses' },
|
|
{ href: '/watchlist', label: 'Open watchlist' },
|
|
]}
|
|
/>
|
|
|
|
{!loading && transactions.length > 0 && (
|
|
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<Card>
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Sample Size</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.sampleSize.toLocaleString()}</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Transactions on the current explorer page.</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Success Rate</div>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.successRate}%</span>
|
|
<EntityBadge label={transactionSummary.successRate >= 90 ? 'healthy' : 'mixed'} tone={transactionSummary.successRate >= 90 ? 'success' : 'warning'} />
|
|
</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">Based on the visible recent transaction sample.</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Contract Creations</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.contractCreations.toLocaleString()}</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">New contracts created in the visible sample.</div>
|
|
</Card>
|
|
<Card>
|
|
<div className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Avg Sample Fee</div>
|
|
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{transactionSummary.averageFee || 'Unavailable'}</div>
|
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
Token-transfer txs: {transactionSummary.tokenTransferTransactions.toLocaleString()}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{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}
|
|
/>
|
|
)}
|
|
|
|
{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="Next Steps">
|
|
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
|
Use the linked hashes above to inspect detail pages, or pivot into block production, address activity, and explorer-wide search.
|
|
</p>
|
|
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
|
<Link href="/blocks" className="text-primary-600 hover:underline">
|
|
Blocks →
|
|
</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<TransactionsPageProps> = async () => {
|
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
|
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
|
|
const initialTransactions = Array.isArray(transactionsResult?.items)
|
|
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
|
|
: []
|
|
|
|
return {
|
|
props: {
|
|
initialTransactions: serializeTransactionList(initialTransactions),
|
|
},
|
|
}
|
|
}
|