Frontend: complete task list (C1–L4), security, a11y, L1 block card helper
- React: response.ok checks (address, transaction, search); block number validation; stable Table keys; API modules (addresses, transactions, blocks normalizer) - SPA: escapeHtml/safe URLs/onclick; getRpcUrl in rpcCall; cancel blocks rAF on view change; named constants; hash route decode - SPA: createBlockCardHtml + normalizeBlockDisplay (L1); DEBUG console gating; aria-live for errors; token/block/tx detail escaping - Docs: FRONTEND_REVIEW.md, FRONTEND_TASKS_AND_REVIEW.md; favicons; .gitignore *.tsbuildinfo Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,9 +11,11 @@ interface TableProps<T> {
|
||||
columns: Column<T>[]
|
||||
data: T[]
|
||||
className?: string
|
||||
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
|
||||
keyExtractor?: (row: T) => string | number
|
||||
}
|
||||
|
||||
export function Table<T>({ columns, data, className }: TableProps<T>) {
|
||||
export function Table<T>({ columns, data, className, keyExtractor }: TableProps<T>) {
|
||||
return (
|
||||
<div className={clsx('overflow-x-auto', className)}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -34,7 +36,7 @@ export function Table<T>({ columns, data, className }: TableProps<T>) {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<tr key={keyExtractor ? keyExtractor(row) : rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
|
||||
@@ -5,25 +5,7 @@ import { useParams } from 'next/navigation'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import { Table } from '@/components/common/Table'
|
||||
|
||||
interface AddressInfo {
|
||||
address: string
|
||||
chain_id: number
|
||||
transaction_count: number
|
||||
token_count: number
|
||||
is_contract: boolean
|
||||
label?: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
hash: string
|
||||
block_number: number
|
||||
from_address: string
|
||||
to_address?: string
|
||||
value: string
|
||||
status?: number
|
||||
}
|
||||
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
|
||||
|
||||
export default function AddressDetailPage() {
|
||||
const params = useParams()
|
||||
@@ -31,7 +13,7 @@ export default function AddressDetailPage() {
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([])
|
||||
const [transactions, setTransactions] = useState<TransactionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,25 +23,21 @@ export default function AddressDetailPage() {
|
||||
|
||||
const loadAddressInfo = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/addresses/${chainId}/${address}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setAddressInfo(data.data)
|
||||
const response = await addressesApi.get(chainId, address)
|
||||
setAddressInfo(response.data ?? null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load address info:', error)
|
||||
setAddressInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTransactions = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions?chain_id=${chainId}&from_address=${address}&page=1&page_size=20`
|
||||
)
|
||||
const data = await response.json()
|
||||
setTransactions(data.data || [])
|
||||
const response = await addressesApi.getTransactions(chainId, address, 1, 20)
|
||||
setTransactions(response.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error)
|
||||
setTransactions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -76,7 +54,7 @@ export default function AddressDetailPage() {
|
||||
const transactionColumns = [
|
||||
{
|
||||
header: 'Hash',
|
||||
accessor: (tx: Transaction) => (
|
||||
accessor: (tx: TransactionSummary) => (
|
||||
<a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
|
||||
<Address address={tx.hash} truncate />
|
||||
</a>
|
||||
@@ -84,15 +62,15 @@ export default function AddressDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Block',
|
||||
accessor: (tx: Transaction) => tx.block_number,
|
||||
accessor: (tx: TransactionSummary) => tx.block_number,
|
||||
},
|
||||
{
|
||||
header: 'To',
|
||||
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
|
||||
accessor: (tx: TransactionSummary) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
|
||||
},
|
||||
{
|
||||
header: 'Value',
|
||||
accessor: (tx: Transaction) => {
|
||||
accessor: (tx: TransactionSummary) => {
|
||||
const value = BigInt(tx.value)
|
||||
const eth = Number(value) / 1e18
|
||||
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
|
||||
@@ -100,7 +78,7 @@ export default function AddressDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessor: (tx: Transaction) => (
|
||||
accessor: (tx: TransactionSummary) => (
|
||||
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
|
||||
{tx.status === 1 ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
@@ -148,7 +126,7 @@ export default function AddressDetailPage() {
|
||||
</Card>
|
||||
|
||||
<Card title="Transactions">
|
||||
<Table columns={transactionColumns} data={transactions} />
|
||||
<Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,15 +9,22 @@ import Link from 'next/link'
|
||||
|
||||
export default function BlockDetailPage() {
|
||||
const params = useParams()
|
||||
const blockNumber = parseInt((params?.number as string) ?? '0')
|
||||
const rawNumber = (params?.number as string) ?? ''
|
||||
const blockNumber = parseInt(rawNumber, 10)
|
||||
const isValidBlock = rawNumber !== '' && !Number.isNaN(blockNumber) && blockNumber >= 0
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
|
||||
const [block, setBlock] = useState<Block | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidBlock) {
|
||||
setLoading(false)
|
||||
setBlock(null)
|
||||
return
|
||||
}
|
||||
loadBlock()
|
||||
}, [blockNumber])
|
||||
}, [blockNumber, isValidBlock])
|
||||
|
||||
const loadBlock = async () => {
|
||||
setLoading(true)
|
||||
@@ -31,6 +38,10 @@ export default function BlockDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -32,9 +32,14 @@ export default function SearchPage() {
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
setResults(data.results || [])
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -67,7 +72,7 @@ export default function SearchPage() {
|
||||
<Card title="Search Results">
|
||||
<div className="space-y-4">
|
||||
{results.map((result, index) => (
|
||||
<div key={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 dark:border-gray-700 pb-4 last:border-0">
|
||||
{result.type === 'block' && result.data.number && (
|
||||
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
|
||||
Block #{result.data.number}
|
||||
|
||||
@@ -5,26 +5,7 @@ import { useParams } from 'next/navigation'
|
||||
import { Card } from '@/components/common/Card'
|
||||
import { Address } from '@/components/blockchain/Address'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Transaction {
|
||||
chain_id: number
|
||||
hash: string
|
||||
block_number: number
|
||||
block_hash: string
|
||||
transaction_index: number
|
||||
from_address: string
|
||||
to_address?: string
|
||||
value: string
|
||||
gas_price?: number
|
||||
max_fee_per_gas?: number
|
||||
max_priority_fee_per_gas?: number
|
||||
gas_limit: number
|
||||
gas_used?: number
|
||||
status?: number
|
||||
input_data?: string
|
||||
contract_address?: string
|
||||
created_at: string
|
||||
}
|
||||
import { transactionsApi, Transaction } from '@/services/api/transactions'
|
||||
|
||||
export default function TransactionDetailPage() {
|
||||
const params = useParams()
|
||||
@@ -41,13 +22,11 @@ export default function TransactionDetailPage() {
|
||||
const loadTransaction = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions/${chainId}/${hash}`
|
||||
)
|
||||
const data = await response.json()
|
||||
setTransaction(data.data)
|
||||
const response = await transactionsApi.get(chainId, hash)
|
||||
setTransaction(response.data ?? null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load transaction:', error)
|
||||
setTransaction(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
53
frontend/src/services/api/addresses.ts
Normal file
53
frontend/src/services/api/addresses.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { apiClient, ApiResponse } from './client'
|
||||
|
||||
export interface AddressInfo {
|
||||
address: string
|
||||
chain_id: number
|
||||
transaction_count: number
|
||||
token_count: number
|
||||
is_contract: boolean
|
||||
label?: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface AddressTransactionsParams {
|
||||
chain_id: number
|
||||
from_address: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface TransactionSummary {
|
||||
hash: string
|
||||
block_number: number
|
||||
from_address: string
|
||||
to_address?: string
|
||||
value: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export const addressesApi = {
|
||||
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
|
||||
return apiClient.get<AddressInfo>(`/api/v1/addresses/${chainId}/${address}`)
|
||||
},
|
||||
|
||||
getTransactions: async (
|
||||
chainId: number,
|
||||
address: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<ApiResponse<TransactionSummary[]>> => {
|
||||
const params = new URLSearchParams({
|
||||
chain_id: chainId.toString(),
|
||||
from_address: address,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
})
|
||||
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as {
|
||||
data?: TransactionSummary[]
|
||||
items?: TransactionSummary[]
|
||||
}
|
||||
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
|
||||
return { data }
|
||||
},
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export interface BlockListParams {
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** Normalize list response: backend may return { data: T[] } or { items: T[] }. */
|
||||
function normalizeListResponse<T>(raw: { data?: T[]; items?: T[] }): ApiResponse<T[]> {
|
||||
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
|
||||
return { data }
|
||||
}
|
||||
|
||||
export const blocksApi = {
|
||||
list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => {
|
||||
const queryParams = new URLSearchParams()
|
||||
@@ -34,7 +40,8 @@ export const blocksApi = {
|
||||
if (params.sort) queryParams.append('sort', params.sort)
|
||||
if (params.order) queryParams.append('order', params.order)
|
||||
|
||||
return apiClient.get<Block[]>(`/api/v1/blocks?${queryParams.toString()}`)
|
||||
const raw = (await apiClient.get(`/api/v1/blocks?${queryParams.toString()}`)) as unknown as { data?: Block[]; items?: Block[] }
|
||||
return normalizeListResponse(raw)
|
||||
},
|
||||
|
||||
getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => {
|
||||
|
||||
27
frontend/src/services/api/transactions.ts
Normal file
27
frontend/src/services/api/transactions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { apiClient, ApiResponse } from './client'
|
||||
|
||||
export interface Transaction {
|
||||
chain_id: number
|
||||
hash: string
|
||||
block_number: number
|
||||
block_hash: string
|
||||
transaction_index: number
|
||||
from_address: string
|
||||
to_address?: string
|
||||
value: string
|
||||
gas_price?: number
|
||||
max_fee_per_gas?: number
|
||||
max_priority_fee_per_gas?: number
|
||||
gas_limit: number
|
||||
gas_used?: number
|
||||
status?: number
|
||||
input_data?: string
|
||||
contract_address?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const transactionsApi = {
|
||||
get: async (chainId: number, hash: string): Promise<ApiResponse<Transaction>> => {
|
||||
return apiClient.get<Transaction>(`/api/v1/transactions/${chainId}/${hash}`)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user