feat(ui): a price chart on the market page, and its placeholder
The asset's price in one counter token over its latest trades, read against the dataviz skill before it was drawn. One series in the data hue and no legend: a token trades against dozens of counters, in units no axis could share, so the counter is a control above the chart rather than a hue per line. Buckets come from blackbeard_core::intents::bucket_prices over /intents/series — volume-weighted, with low and high, empty buckets left out so the line breaks where nothing traded instead of drawing a price nobody paid. The y-axis is fitted to the prices; the window is a count of trades, so the time axis covers only what they span. A table view sits under it. Until anything has traded the frame keeps the chart's height — nothing moves when the first trade lands — with a baseline and the reason, and no axes, since a scale over no data is a range nobody measured. For QTC today that reads "no QTC is bridged to NEAR yet, so none can have traded there". Rendering it caught three things the build did not. The line never drew: the API reported the bucket width truncated to whole seconds, so no two buckets looked adjacent — widths are now whole seconds by construction. The chart painted at its 720px fallback until a ResizeObserver reported, centred in a full-width panel; it is measured before first paint now. And on the market page the seventy-row counter table pushed the chart below the fold; it folds. Checked against real wNEAR trades and the QTC placeholder, light and dark. Closes #33 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
@@ -193,37 +193,44 @@ export function IntentsPanel({
|
||||
{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>
|
||||
// Folded, so the chart below the figures is not pushed under a
|
||||
// seventy-row table; the summary says how much is inside.
|
||||
<details className="panel-note">
|
||||
<summary>
|
||||
{fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'}
|
||||
</summary>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
</details>
|
||||
)}
|
||||
{(!table && rest.length > 0) || unpriced.length > 0 ? (
|
||||
<p className="panel-note">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { height as fmtHeight } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
import { href } from '../lib/routes'
|
||||
import { IntentsPanel } from './IntentsPanel'
|
||||
import { PriceChart } from './PriceChart'
|
||||
import { SwapHead, SwapRow } from './SwapRow'
|
||||
|
||||
/** The venues this page knows how to show. */
|
||||
@@ -133,6 +134,8 @@ function NearIntents({
|
||||
|
||||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
|
||||
|
||||
<PriceChart chain={chain} symbol={symbol} />
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Settling now</h2>
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* The asset's price over its latest trades, in one counter token at a time.
|
||||
*
|
||||
* Read against the `dataviz` skill before it was written (blackbeard/observer#33):
|
||||
*
|
||||
* - **One series, one hue, no legend.** The job is trend-over-time for one
|
||||
* quantity. A token trades against dozens of counters, and one line each
|
||||
* would be a hue per counter — the categorical blow-up both the skill and
|
||||
* CLAUDE.md rule out, on an axis the lines would not even share, since each
|
||||
* price is in a different unit. So the counter is a control above the chart.
|
||||
* - **Gaps are gaps.** The API leaves out buckets nothing traded in, and the
|
||||
* line is only drawn between adjacent buckets. Joining two trades hours apart
|
||||
* would draw a price path nobody traded at; on a thin market that would be
|
||||
* most of the chart. Every bucket with trades carries a dot, so a lone trade
|
||||
* is still visible.
|
||||
* - **The y-axis floats.** A price has no meaningful zero here, and a zero
|
||||
* baseline would flatten every move into the top pixel. Exact values are in
|
||||
* the tooltip and the table.
|
||||
* - **The window is a count of trades.** The time axis spans whatever those
|
||||
* trades cover, so it cannot claim more than the data holds.
|
||||
*
|
||||
* Until anything has traded, the frame keeps its height and draws no axes — a
|
||||
* scale over no data would invent a range — and says why there is nothing yet.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { CounterPrice } from '../api/generated/CounterPrice'
|
||||
import type { IntentsPrice } from '../api/generated/IntentsPrice'
|
||||
import type { IntentsSeries } from '../api/generated/IntentsSeries'
|
||||
import type { IntentsSeriesPoint } from '../api/generated/IntentsSeriesPoint'
|
||||
import { RequestFailed, fetchIntentsPrice, fetchIntentsSeries } from '../api/rest'
|
||||
import { height as fmtHeight, price as fmtPrice, tokens, when } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
|
||||
const HEIGHT = 220
|
||||
const PAD = { top: 16, right: 18, bottom: 26, left: 64 }
|
||||
const FALLBACK_WIDTH = 720
|
||||
|
||||
interface Placed {
|
||||
point: IntentsSeriesPoint
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The container's width, measured before the first paint.
|
||||
*
|
||||
* Read synchronously in a layout effect, then kept current by a
|
||||
* ResizeObserver. Waiting for the observer alone paints the chart once at the
|
||||
* fallback width — 720px, centred in a full-width panel — and then jumps; and
|
||||
* where the observer is slow to report, that first paint is all there is.
|
||||
*/
|
||||
function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(FALLBACK_WIDTH)
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current
|
||||
if (node) setWidth(Math.max(320, node.getBoundingClientRect().width))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry) setWidth(Math.max(320, entry.contentRect.width))
|
||||
})
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return [ref, width]
|
||||
}
|
||||
|
||||
function timeLabel(ms: number, spanMs: number): string {
|
||||
return new Date(ms).toLocaleString(undefined, {
|
||||
...(spanMs > 86_400_000 ? { month: 'short', day: 'numeric' } : {}),
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** A bucket width as words: "5 min", "2 h". */
|
||||
function span(seconds: number): string {
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)} min`
|
||||
if (seconds < 86_400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0)} h`
|
||||
return `${(seconds / 86_400).toFixed(1)} d`
|
||||
}
|
||||
|
||||
/** Counters with a price, most traded first — the ones a chart can be drawn for. */
|
||||
function chartable(price: IntentsPrice | null): CounterPrice[] {
|
||||
return (price?.counters ?? [])
|
||||
.filter((c) => c.decimals !== null && c.vwap !== null)
|
||||
.sort((a, b) => b.trades - a.trades)
|
||||
}
|
||||
|
||||
export function PriceChart({ chain, symbol }: { chain: string; symbol: string }) {
|
||||
const [price, setPrice] = useState<IntentsPrice | null>(null)
|
||||
const [counter, setCounter] = useState<string | null>(null)
|
||||
const [series, setSeries] = useState<IntentsSeries | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [settled, setSettled] = useState(0)
|
||||
const observer = useObserverInstance()
|
||||
|
||||
// A settled swap can be one this chart should show, and can be the first.
|
||||
useEffect(() => observer.onIntentsSwap(() => setSettled((n) => n + 1)), [observer])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchIntentsPrice(chain, controller.signal)
|
||||
.then(setPrice)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain, settled])
|
||||
|
||||
const options = chartable(price)
|
||||
// Default to the most traded counter; keep the reader's choice once made.
|
||||
const chosen = counter ?? options[0]?.counter_token ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!chosen) return
|
||||
const controller = new AbortController()
|
||||
fetchIntentsSeries(chain, chosen, controller.signal)
|
||||
.then(setSeries)
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain, chosen, settled])
|
||||
|
||||
const asset = series?.asset_symbol ?? price?.asset_symbol ?? symbol
|
||||
const unit = chosen
|
||||
? tokenLabel(chosen, options.find((c) => c.counter_token === chosen)?.symbol)
|
||||
: ''
|
||||
const points = series && series.counter_token === chosen ? series.points : []
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Price</h2>
|
||||
<span className="eyebrow">
|
||||
{series && points.length > 0
|
||||
? `${asset} in ${unit} · latest ${fmtHeight(series.trades)} trades · ${span(series.bucket_seconds)} buckets`
|
||||
: `${asset} on NEAR Intents`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{options.length > 1 && (
|
||||
<div className="pager" style={{ justifyContent: 'flex-start' }}>
|
||||
<label className="eyebrow" htmlFor="price-counter">
|
||||
priced in · most traded first
|
||||
</label>{' '}
|
||||
<select
|
||||
id="price-counter"
|
||||
value={chosen ?? ''}
|
||||
onChange={(e) => setCounter(e.target.value)}
|
||||
>
|
||||
{options.map((c) => (
|
||||
<option key={c.counter_token} value={c.counter_token}>
|
||||
{tokenLabel(c.counter_token, c.symbol)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p className="empty">{error}</p>
|
||||
) : points.length === 0 ? (
|
||||
<Placeholder price={price} asset={asset} loading={!price || (!!chosen && !series)} />
|
||||
) : (
|
||||
<Plot points={points} bucketSeconds={series!.bucket_seconds} unit={unit} asset={asset} />
|
||||
)}
|
||||
|
||||
{points.length > 0 && (
|
||||
<details className="panel-note">
|
||||
<summary>The figures behind the chart</summary>
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
{asset} price in {unit} per bucket, oldest first
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
From
|
||||
</th>
|
||||
<th scope="col">Volume-weighted</th>
|
||||
<th scope="col">Low</th>
|
||||
<th scope="col">High</th>
|
||||
<th scope="col">Trades</th>
|
||||
<th scope="col">{asset}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((p) => (
|
||||
<tr key={p.at}>
|
||||
<td className="left numeral">{when(p.at)}</td>
|
||||
<td className="numeral">{fmtPrice(p.vwap)}</td>
|
||||
<td className="numeral">{fmtPrice(p.low)}</td>
|
||||
<td className="numeral">{fmtPrice(p.high)}</td>
|
||||
<td className="numeral">{fmtHeight(p.trades)}</td>
|
||||
<td className="numeral">
|
||||
{tokens(p.asset_volume, series?.asset_decimals ?? 0)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="panel-note">
|
||||
Each point is one time bucket: the volume-weighted price of every trade that settled in it,
|
||||
in {unit || 'the counter token'}. The line breaks where nothing traded — it is never drawn
|
||||
across a gap — and the vertical axis is fitted to the prices, not anchored at zero. The
|
||||
window is the latest trades against this one token, so the time axis covers only what they
|
||||
span.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame, without a chart in it. Same height as the plot, so nothing below
|
||||
* moves when the first trade arrives; a baseline and the reason, and no axes,
|
||||
* because a scale over no data is a range nobody measured.
|
||||
*/
|
||||
function Placeholder({
|
||||
price,
|
||||
asset,
|
||||
loading,
|
||||
}: {
|
||||
price: IntentsPrice | null
|
||||
asset: string
|
||||
loading: boolean
|
||||
}) {
|
||||
const bridged = price?.listing?.total_supply
|
||||
const reason = loading
|
||||
? 'Reading…'
|
||||
: bridged === '0'
|
||||
? `The chart starts with the first settled ${asset} trade. None has: no ${asset} is bridged to NEAR yet, so none can have traded there.`
|
||||
: `The chart starts with the first settled ${asset} trade with a price. None has settled in the blocks read.`
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: HEIGHT,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
padding: '0 18px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: PAD.left,
|
||||
right: PAD.right,
|
||||
bottom: PAD.bottom,
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
/>
|
||||
<p className="empty" style={{ padding: 0, maxWidth: 560 }}>
|
||||
{reason}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Plot({
|
||||
points,
|
||||
bucketSeconds,
|
||||
unit,
|
||||
asset,
|
||||
}: {
|
||||
points: IntentsSeriesPoint[]
|
||||
bucketSeconds: number
|
||||
unit: string
|
||||
asset: string
|
||||
}) {
|
||||
const [wrap, width] = useMeasuredWidth()
|
||||
const [hover, setHover] = useState<Placed | null>(null)
|
||||
const bucketMs = bucketSeconds * 1000
|
||||
|
||||
const chart = useMemo(() => {
|
||||
const times = points.map((p) => new Date(p.at).getTime())
|
||||
const t0 = Math.min(...times)
|
||||
// The last bucket's end, so its point does not sit on the right edge.
|
||||
const t1 = Math.max(...times) + bucketMs
|
||||
const spanMs = t1 - t0 || 1
|
||||
const lo = Math.min(...points.map((p) => p.vwap))
|
||||
const hi = Math.max(...points.map((p) => p.vwap))
|
||||
// Headroom either side; a flat series gets a band around its one value
|
||||
// rather than a zero-height axis.
|
||||
const pad = (hi - lo) * 0.15 || Math.abs(hi) * 0.02 || 1
|
||||
const yMin = lo - pad
|
||||
const yMax = hi + pad
|
||||
|
||||
const plotW = width - PAD.left - PAD.right
|
||||
const plotH = HEIGHT - PAD.top - PAD.bottom
|
||||
const baseline = PAD.top + plotH
|
||||
const x = (t: number) => PAD.left + ((t - t0 + bucketMs / 2) / spanMs) * plotW
|
||||
const y = (v: number) => baseline - ((v - yMin) / (yMax - yMin)) * plotH
|
||||
|
||||
const placed: Placed[] = points.map((point, i) => ({
|
||||
point,
|
||||
x: x(times[i] ?? t0),
|
||||
y: y(point.vwap),
|
||||
}))
|
||||
|
||||
// A segment only between buckets that are next to each other in time.
|
||||
let path = ''
|
||||
placed.forEach((p, i) => {
|
||||
const prev = placed[i - 1]
|
||||
const adjacent =
|
||||
prev && new Date(p.point.at).getTime() - new Date(prev.point.at).getTime() <= bucketMs + 1
|
||||
path += `${adjacent ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)} `
|
||||
})
|
||||
|
||||
const yTicks = [yMin + pad, (yMin + yMax) / 2, yMax - pad].map((v) => ({ v, y: y(v) }))
|
||||
const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + spanMs * f, x: PAD.left + f * plotW }))
|
||||
return { placed, path, yTicks, xTicks, baseline, spanMs, last: placed[placed.length - 1] }
|
||||
}, [points, width, bucketMs])
|
||||
|
||||
const onMove = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const local = event.clientX - event.currentTarget.getBoundingClientRect().left
|
||||
let nearest: Placed | null = null
|
||||
let best = Infinity
|
||||
for (const p of chart.placed) {
|
||||
const d = Math.abs(p.x - local)
|
||||
if (d < best) {
|
||||
best = d
|
||||
nearest = p
|
||||
}
|
||||
}
|
||||
setHover(nearest)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrap} style={{ position: 'relative' }}>
|
||||
<svg
|
||||
className="chart"
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||
role="img"
|
||||
aria-label={`${asset} price in ${unit} over its latest trades, from ${fmtPrice(points[0]?.vwap)} to ${fmtPrice(chart.last?.point.vwap)}`}
|
||||
onPointerMove={onMove}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
>
|
||||
{chart.yTicks.map((t) => (
|
||||
<g key={t.v}>
|
||||
<line className="grid" x1={PAD.left} x2={width - PAD.right} y1={t.y} y2={t.y} />
|
||||
<text className="chart-axis" x={PAD.left - 8} y={t.y + 3.5} textAnchor="end">
|
||||
{fmtPrice(t.v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{chart.xTicks.map((t, i) => (
|
||||
<text
|
||||
key={i}
|
||||
className="chart-axis"
|
||||
x={t.x}
|
||||
y={HEIGHT - 8}
|
||||
textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
|
||||
>
|
||||
{timeLabel(t.t, chart.spanMs)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<path className="line" d={chart.path} />
|
||||
{chart.placed.map((p) => (
|
||||
<circle key={p.point.at} className="marker" cx={p.x} cy={p.y} r={4} />
|
||||
))}
|
||||
|
||||
{hover && (
|
||||
<>
|
||||
<line
|
||||
className="crosshair"
|
||||
x1={hover.x}
|
||||
x2={hover.x}
|
||||
y1={PAD.top}
|
||||
y2={chart.baseline}
|
||||
/>
|
||||
<circle className="marker" cx={hover.x} cy={hover.y} r={5.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* One direct label, on the newest point: the price a reader came for. */}
|
||||
{!hover && chart.last && (
|
||||
<text
|
||||
className="chart-axis"
|
||||
x={chart.last.x - 8}
|
||||
y={Math.min(Math.max(chart.last.y - 10, PAD.top + 10), chart.baseline - 8)}
|
||||
textAnchor="end"
|
||||
style={{ fill: 'var(--text-primary)', fontSize: 11 }}
|
||||
>
|
||||
{fmtPrice(chart.last.point.vwap)}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div
|
||||
className="tooltip"
|
||||
style={{
|
||||
left: hover.x,
|
||||
top: 4,
|
||||
transform: hover.x > width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)',
|
||||
}}
|
||||
>
|
||||
<div className="tooltip-label">
|
||||
{timeLabel(new Date(hover.point.at).getTime(), chart.spanMs)} · {span(bucketSeconds)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{fmtPrice(hover.point.vwap)}</strong> {unit}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
{hover.point.trades === 1
|
||||
? 'one trade'
|
||||
: `${fmtPrice(hover.point.low)}–${fmtPrice(hover.point.high)} over ${fmtHeight(hover.point.trades)} trades`}
|
||||
{hover.point.taker_trades !== hover.point.trades &&
|
||||
`, ${fmtHeight(hover.point.taker_trades)} by users`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user