feat(ui): a market route — /:chain/market and /:chain/market/near-intents
The market index lists the venues a chain's token is followed on, asked of a
new /v1/chains/{chain}/markets so a chain followed nowhere says so and a
second venue needs no frontend change. The near-intents page carries the
price figures and listing evidence, swaps as they settle (from the
IntentsSwap push), and the settled history, paged newest first through
/intents/swaps?order=newest&before=… — the existing `after` stays for readers
going forward, and mixing the two directions is a 400.
`market` is a subject in lib/routes.ts like `node`: in parse, href and
isStandings together. `markets` parses too and is rewritten to the singular
every other kind uses.
Rendered against 964 real wNEAR trades before it shipped, which is what
showed the panel did not scale: seventy counter tokens became seventy tiles,
NFT swaps sat in the grid as prices with no number, and "wNEAR in USDC"
appeared six times for six different bridged tokens. Now only counters with
decimals are prices, most traded first — four tiles on the network page,
eight and a table of the rest on the market page — NFT and multi-token swaps
are a count, and a bridged token is labelled by its origin (`USDC · base`),
the contract it is, not the symbol it chose.
Closes #32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
@@ -18,27 +18,34 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { IntentsPrice } from '../api/generated/IntentsPrice'
|
||||
import { RequestFailed, fetchIntentsPrice } from '../api/rest'
|
||||
import { ago, height as fmtHeight, price as fmtPrice, tokens } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
import { href } from '../lib/routes'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
import { Field } from './Field'
|
||||
|
||||
/** A multi-token id short enough for a label: `nep141:zec.omft.near` → `zec.omft.near`. */
|
||||
function shortToken(id: string): string {
|
||||
const contract = id.replace(/^nep\d+:/, '')
|
||||
return contract.length > 24 ? `${contract.slice(0, 10)}…${contract.slice(-10)}` : contract
|
||||
}
|
||||
|
||||
export function IntentsPanel({
|
||||
chain,
|
||||
decimals,
|
||||
symbol,
|
||||
linkMarket = false,
|
||||
tiles = 4,
|
||||
table = false,
|
||||
}: {
|
||||
chain: string
|
||||
decimals: number
|
||||
symbol: string
|
||||
/** Point at the full market page. Set where this panel is a summary of it. */
|
||||
linkMarket?: boolean
|
||||
/** Counter tokens given a tile, most traded first. The rest are a table
|
||||
* (`table`) or a count. A busy token trades against seventy. */
|
||||
tiles?: number
|
||||
/** List every priced counter below the tiles. The market page's job. */
|
||||
table?: boolean
|
||||
}) {
|
||||
const [data, setData] = useState<IntentsPrice | null>(null)
|
||||
const [absent, setAbsent] = useState(false)
|
||||
@@ -73,7 +80,9 @@ export function IntentsPanel({
|
||||
|
||||
const head = (eyebrow: string) => (
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Market</h2>
|
||||
<h2 className="panel-title">
|
||||
{linkMarket ? <Link to={href({ chain, market: 'near-intents' })}>Market</Link> : 'Market'}
|
||||
</h2>
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -105,7 +114,17 @@ export function IntentsPanel({
|
||||
? `NEAR blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}`
|
||||
: 'nothing read yet'
|
||||
const nothingBridged = listing?.total_supply === '0'
|
||||
const thin = data.counters.some((c) => c.trades < 5)
|
||||
// Only a counter with known decimals has a price. NFTs and multi-tokens have
|
||||
// none, and a "—" in a grid of prices reads as a price nobody could read
|
||||
// rather than as what it is: a swap for something that is not a currency.
|
||||
const priced = data.counters
|
||||
.filter((c) => c.decimals !== null && c.vwap !== null)
|
||||
.sort((a, b) => b.trades - a.trades)
|
||||
const unpriced = data.counters.filter((c) => !priced.includes(c))
|
||||
const unpricedTrades = unpriced.reduce((n, c) => n + c.trades, 0)
|
||||
const shown = priced.slice(0, tiles)
|
||||
const rest = priced.slice(tiles)
|
||||
const thin = shown.some((c) => c.trades < 5)
|
||||
|
||||
const listingFields = (
|
||||
<>
|
||||
@@ -157,22 +176,73 @@ export function IntentsPanel({
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
{head(`NEAR Intents · ${range}`)}
|
||||
<div className="node-grid">
|
||||
{data.counters.map((c) => {
|
||||
const unit = c.symbol ?? shortToken(c.counter_token)
|
||||
{shown.map((c) => {
|
||||
const unit = tokenLabel(c.counter_token, c.symbol)
|
||||
return (
|
||||
<Field
|
||||
key={c.counter_token}
|
||||
label={`${asset} in ${unit}`}
|
||||
value={`${fmtPrice(c.vwap)} ${unit}`}
|
||||
value={`${fmtPrice(c.vwap)} ${c.symbol ?? ''}`.trim()}
|
||||
note={`${fmtHeight(c.trades)} trade${c.trades === 1 ? '' : 's'}${
|
||||
c.taker_trades === c.trades ? '' : `, ${fmtHeight(c.taker_trades)} by users`
|
||||
} · last ${fmtPrice(c.last_price)} ${unit}, ${ago(c.last_at)}`}
|
||||
} · last ${fmtPrice(c.last_price)}, ${ago(c.last_at)}`}
|
||||
title={c.counter_token}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{listingFields}
|
||||
</div>
|
||||
{table && rest.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Every other priced counter token</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
{asset} in
|
||||
</th>
|
||||
<th scope="col">Volume-weighted</th>
|
||||
<th scope="col">Last</th>
|
||||
<th scope="col">Trades</th>
|
||||
<th scope="col" className="left">
|
||||
Last traded
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rest.map((c) => (
|
||||
<tr key={c.counter_token}>
|
||||
<td className="left" title={c.counter_token}>
|
||||
{tokenLabel(c.counter_token, c.symbol)}
|
||||
</td>
|
||||
<td className="numeral">{fmtPrice(c.vwap)}</td>
|
||||
<td className="numeral">{fmtPrice(c.last_price)}</td>
|
||||
<td className="numeral">{fmtHeight(c.trades)}</td>
|
||||
<td className="left">{ago(c.last_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(!table && rest.length > 0) || unpriced.length > 0 ? (
|
||||
<p className="panel-note">
|
||||
{!table && rest.length > 0 && (
|
||||
<>
|
||||
{fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'}
|
||||
{linkMarket && (
|
||||
<>
|
||||
{' '}
|
||||
— <Link to={href({ chain, market: 'near-intents' })}>on the market page</Link>
|
||||
</>
|
||||
)}
|
||||
.{' '}
|
||||
</>
|
||||
)}
|
||||
{unpriced.length > 0 &&
|
||||
`${fmtHeight(unpricedTrades)} trade${unpricedTrades === 1 ? '' : 's'} against ${fmtHeight(unpriced.length)} token${unpriced.length === 1 ? '' : 's'} with no price — NFTs and multi-tokens, which have no decimals to divide by — are counted but not listed.`}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="panel-note">
|
||||
Each figure is volume-weighted over the latest {fmtHeight(data.window_trades)} trades
|
||||
against that token at most — the ratio of the summed amounts, so a dust trade at an odd
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* One market: what NEAR Intents has settled for this chain's token.
|
||||
*
|
||||
* Three readings, top to bottom, in order of how settled they are. The price
|
||||
* figures and the listing evidence first — the same panel the network page
|
||||
* carries — then swaps as they settle, then the history they join.
|
||||
*
|
||||
* Empty is a state here, not an error. Until the token is bridged, the honest
|
||||
* page is the evidence that nothing can have traded, and that is what the
|
||||
* header shows; the feed and the history then say plainly that there is
|
||||
* nothing in them yet. blackbeard/observer#32.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { IntentsSwap } from '../api/generated/IntentsSwap'
|
||||
import type { IntentsSwaps } from '../api/generated/IntentsSwaps'
|
||||
import { RequestFailed, fetchSwapsNewest } from '../api/rest'
|
||||
import { height as fmtHeight } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
import { href } from '../lib/routes'
|
||||
import { IntentsPanel } from './IntentsPanel'
|
||||
import { SwapHead, SwapRow } from './SwapRow'
|
||||
|
||||
/** The venues this page knows how to show. */
|
||||
const KNOWN = new Set(['near-intents'])
|
||||
|
||||
/** Lines the live feed keeps. Older ones are in the history below it. */
|
||||
const LIVE_LINES = 12
|
||||
|
||||
export function MarketPage({
|
||||
chain,
|
||||
market,
|
||||
decimals,
|
||||
symbol,
|
||||
}: {
|
||||
chain: string
|
||||
market: string
|
||||
decimals: number
|
||||
symbol: string
|
||||
}) {
|
||||
if (!KNOWN.has(market)) {
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">No such market</h2>
|
||||
</div>
|
||||
<p className="empty">
|
||||
This chain's token is not followed on a market called “{market}”.{' '}
|
||||
<Link to={href({ chain, index: 'market' })}>The markets it is followed on</Link>.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return <NearIntents chain={chain} decimals={decimals} symbol={symbol} />
|
||||
}
|
||||
|
||||
function NearIntents({
|
||||
chain,
|
||||
decimals,
|
||||
symbol,
|
||||
}: {
|
||||
chain: string
|
||||
decimals: number
|
||||
symbol: string
|
||||
}) {
|
||||
const [history, setHistory] = useState<IntentsSwaps | null>(null)
|
||||
const [older, setOlder] = useState<IntentsSwap[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const [live, setLive] = useState<{ key: number; swap: IntentsSwap }[]>([])
|
||||
// A monotonic key rather than height-and-index: the wormhole console's keys
|
||||
// looked unique and were not, and React given duplicate keys renders
|
||||
// duplicates and stops honouring the list's own length cap.
|
||||
const nextKey = useRef(0)
|
||||
const observer = useObserverInstance()
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setHistory(null)
|
||||
setOlder([])
|
||||
setError(null)
|
||||
setLive([])
|
||||
fetchSwapsNewest(chain, null, controller.signal)
|
||||
.then(setHistory)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
observer.onIntentsSwap((swap) => {
|
||||
const key = nextKey.current++
|
||||
setLive((rows) => [{ key, swap }, ...rows].slice(0, LIVE_LINES))
|
||||
}),
|
||||
[observer],
|
||||
)
|
||||
|
||||
const asset = history?.asset_symbol ?? symbol
|
||||
const assetDecimals = history?.asset_decimals ?? decimals
|
||||
// The previous page's `next`, carried forward as each older page is read.
|
||||
const cursor = history?.next ?? null
|
||||
|
||||
const loadOlder = () => {
|
||||
if (!cursor) return
|
||||
setLoadingOlder(true)
|
||||
fetchSwapsNewest(chain, cursor)
|
||||
.then((page) => {
|
||||
setOlder((rows) => [...rows, ...page.swaps])
|
||||
setHistory((h) => (h ? { ...h, next: page.next } : page))
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.'),
|
||||
)
|
||||
.finally(() => setLoadingOlder(false))
|
||||
}
|
||||
|
||||
const rows = history ? [...history.swaps, ...older] : []
|
||||
const range =
|
||||
history && history.indexed_from !== null && history.indexed_to !== null
|
||||
? `NEAR blocks ${fmtHeight(history.indexed_from)}–${fmtHeight(history.indexed_to)}`
|
||||
: 'nothing read yet'
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="panel-note" style={{ marginBottom: 12 }}>
|
||||
<Link to={href({ chain, index: 'market' })}>Markets</Link> › NEAR Intents
|
||||
</p>
|
||||
|
||||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Settling now</h2>
|
||||
<span className="eyebrow">pushed as each block is read</span>
|
||||
</div>
|
||||
{live.length === 0 ? (
|
||||
<p className="empty">
|
||||
Waiting for the next {asset} swap to settle. Nothing appears here that has not.
|
||||
</p>
|
||||
) : (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Swaps as they settle, newest first</caption>
|
||||
<SwapHead asset={asset} />
|
||||
<tbody>
|
||||
{live.map(({ key, swap }) => (
|
||||
<SwapRow
|
||||
key={key}
|
||||
swap={swap}
|
||||
asset={asset}
|
||||
decimals={assetDecimals}
|
||||
fresh
|
||||
relative
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Settled</h2>
|
||||
<span className="eyebrow">{range}</span>
|
||||
</div>
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !history && <p className="empty">Reading…</p>}
|
||||
{history && rows.length === 0 && (
|
||||
<p className="empty">
|
||||
No {asset} swap has settled in the blocks read. That is a count over {range}, not a
|
||||
guess.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Settled swaps, newest first</caption>
|
||||
<SwapHead asset={asset} />
|
||||
<tbody>
|
||||
{rows.map((swap) => (
|
||||
<SwapRow
|
||||
key={`${swap.near_height}:${swap.trade_index}`}
|
||||
swap={swap}
|
||||
asset={asset}
|
||||
decimals={assetDecimals}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{cursor && (
|
||||
<div className="pager">
|
||||
<button className="segmented panel-button" disabled={loadingOlder} onClick={loadOlder}>
|
||||
{loadingOlder ? 'Reading…' : 'Older →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="panel-note">
|
||||
A row is one user's side of a swap, priced from what they gave and what they got.
|
||||
“Between solvers” is market makers trading with each other: a real trade at a real price,
|
||||
with no taker and so no side. “Via” is the front end the taker came through, as it named
|
||||
itself. A solver's routing through other tokens is how a swap was filled, never a
|
||||
trade of its own, and is not listed.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The venues this chain's token is followed on.
|
||||
*
|
||||
* One today, NEAR Intents. Asked of the API rather than written here, so a
|
||||
* chain followed nowhere says so instead of offering a link to an empty page,
|
||||
* and a second venue appears without the frontend learning about it first.
|
||||
* blackbeard/observer#32.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { Markets } from '../api/generated/Markets'
|
||||
import { RequestFailed, fetchMarkets } from '../api/rest'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
export function MarketsIndex({ chain }: { chain: string }) {
|
||||
const [markets, setMarkets] = useState<Markets | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setMarkets(null)
|
||||
setError(null)
|
||||
fetchMarkets(chain, controller.signal)
|
||||
.then(setMarkets)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Markets</h2>
|
||||
<span className="eyebrow">where this chain's token is followed</span>
|
||||
</div>
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !markets && <p className="empty">Reading…</p>}
|
||||
{markets && markets.markets.length === 0 && (
|
||||
<p className="empty">This chain's token is not followed on any market.</p>
|
||||
)}
|
||||
{markets && markets.markets.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
Markets this chain's token is followed on
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Market
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Asset there
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
How a price is read
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{markets.markets.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="left">
|
||||
<Link to={href({ chain, market: m.id })}>{m.name}</Link>
|
||||
</td>
|
||||
<td className="left" title={m.asset}>
|
||||
{m.asset_symbol ?? m.asset}
|
||||
</td>
|
||||
<td className="left">{m.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="panel-note">
|
||||
A market here is a place where trades of this token settle and can be read back. A quote
|
||||
from a front end, or a rate on a swap screen, is not one: it is an offer, and nothing
|
||||
settled at it.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function NetworkPanel({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} />
|
||||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} linkMarket />
|
||||
|
||||
{conc !== null && conc.complete && (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* One settled swap, as a table row: shared by the live feed and the history on
|
||||
* the market page, so the two cannot come to describe the same trade
|
||||
* differently.
|
||||
*/
|
||||
|
||||
import type { IntentsSwap } from '../api/generated/IntentsSwap'
|
||||
import { ago, price as fmtPrice, tokens, when } from '../lib/format'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
|
||||
/** NearBlocks, for the transaction. A NEAR tx hash is all it needs. */
|
||||
const EXPLORER = 'https://nearblocks.io/txns/'
|
||||
|
||||
export function SwapRow({
|
||||
swap,
|
||||
asset,
|
||||
decimals,
|
||||
fresh = false,
|
||||
relative = false,
|
||||
}: {
|
||||
swap: IntentsSwap
|
||||
/** The tracked asset's own symbol. */
|
||||
asset: string
|
||||
/** What `asset_amount` is scaled by. */
|
||||
decimals: number
|
||||
/** Flash once on arrival, as the block ticker does. */
|
||||
fresh?: boolean
|
||||
/** "3m ago" rather than a date — right for a live feed, wrong for history. */
|
||||
relative?: boolean
|
||||
}) {
|
||||
const counter = tokenLabel(swap.counter_token, swap.counter_symbol)
|
||||
// A side only for a user's trade. Solvers trading with each other have no
|
||||
// taker, so there is no side to name — said in words rather than left blank,
|
||||
// because a blank reads as "unknown" and this is known.
|
||||
const side =
|
||||
swap.kind === 'unattributed' ? 'between solvers' : swap.side === 'buy' ? 'bought' : 'sold'
|
||||
return (
|
||||
<tr className={fresh ? 'fresh' : undefined}>
|
||||
<td className="left numeral" title={swap.at}>
|
||||
{relative ? ago(swap.at) : when(swap.at)}
|
||||
</td>
|
||||
<td className="left">{side}</td>
|
||||
<td className="numeral" title={`${swap.asset_amount} base units`}>
|
||||
{tokens(swap.asset_amount, decimals)} {asset}
|
||||
</td>
|
||||
<td className="numeral" title={swap.counter_token}>
|
||||
{fmtPrice(swap.price)} {counter}
|
||||
</td>
|
||||
<td className="left" title={swap.taker ?? swap.accounts.join(', ')}>
|
||||
{swap.kind === 'unattributed' ? '—' : (swap.referral ?? '—')}
|
||||
</td>
|
||||
<td className="left">
|
||||
<a href={`${EXPLORER}${swap.tx_hash}`} target="_blank" rel="noreferrer">
|
||||
{swap.tx_hash.slice(0, 8)}…
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** The header row the swap tables share. */
|
||||
export function SwapHead({ asset }: { asset: string }) {
|
||||
return (
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Settled
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Taker
|
||||
</th>
|
||||
<th scope="col">{asset}</th>
|
||||
<th scope="col">Price per {asset}</th>
|
||||
<th scope="col" className="left">
|
||||
Via
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Tx
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user