171 lines
6.0 KiB
TypeScript
171 lines
6.0 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { Spinner } from '@shared/ui'
|
||
import type { Pool } from '@features/pools'
|
||
import { formatFeeTier } from '../model/meta'
|
||
import styles from './PoolsTable.module.css'
|
||
|
||
interface Props {
|
||
pools: Pool[]
|
||
selected: string | null
|
||
onSelect: (poolAddress: string) => void
|
||
loading?: boolean
|
||
error?: string | null
|
||
}
|
||
|
||
const DASH = '—'
|
||
|
||
type SortKey = 'pair' | 'feeTier' | 'currentPrice' | 'tvlUSD' | 'volumeUSD24h' | 'aprPercent'
|
||
type SortDir = 'desc' | 'asc'
|
||
|
||
function formatUsd(value: number | null): string {
|
||
if (value == null) return DASH
|
||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`
|
||
if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K`
|
||
return `$${value.toFixed(2)}`
|
||
}
|
||
|
||
function formatPrice(value: number): string {
|
||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
// APR обрезаем до 3 знаков после запятой и убираем лишние нули.
|
||
function formatApr(value: number | null): string {
|
||
if (value == null) return DASH
|
||
return `${parseFloat(value.toFixed(3))}%`
|
||
}
|
||
|
||
function pairName(p: Pool): string {
|
||
return `${p.symbol0}/${p.symbol1}`
|
||
}
|
||
|
||
// Числовые поля могут быть null — null уходит вниз независимо от направления.
|
||
function compare(a: Pool, b: Pool, key: SortKey, dir: SortDir): number {
|
||
if (key === 'pair') {
|
||
const cmp = pairName(a).localeCompare(pairName(b))
|
||
return dir === 'asc' ? cmp : -cmp
|
||
}
|
||
const av = a[key]
|
||
const bv = b[key]
|
||
if (av == null && bv == null) return 0
|
||
if (av == null) return 1
|
||
if (bv == null) return -1
|
||
const cmp = av - bv
|
||
return dir === 'asc' ? cmp : -cmp
|
||
}
|
||
|
||
export function PoolsTable({ pools, selected, onSelect, loading, error }: Props) {
|
||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||
const [sortDir, setSortDir] = useState<SortDir | null>(null)
|
||
|
||
function handleSort(key: SortKey) {
|
||
if (sortKey !== key) {
|
||
// Новая колонка — начинаем с убывания.
|
||
setSortKey(key)
|
||
setSortDir('desc')
|
||
return
|
||
}
|
||
// Цикл по той же колонке: убывание → возрастание → по умолчанию.
|
||
if (sortDir === 'desc') {
|
||
setSortDir('asc')
|
||
} else if (sortDir === 'asc') {
|
||
setSortKey(null)
|
||
setSortDir(null)
|
||
} else {
|
||
setSortDir('desc')
|
||
}
|
||
}
|
||
|
||
const sortedPools = useMemo(() => {
|
||
if (!sortKey || !sortDir) return pools
|
||
return [...pools].sort((a, b) => compare(a, b, sortKey, sortDir))
|
||
}, [pools, sortKey, sortDir])
|
||
|
||
function sortArrow(key: SortKey): string {
|
||
if (sortKey !== key || !sortDir) return ''
|
||
return sortDir === 'desc' ? ' ▼' : ' ▲'
|
||
}
|
||
|
||
const columns: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
|
||
{ key: 'pair', label: 'Пул', align: 'left' },
|
||
{ key: 'feeTier', label: 'Комиссия', align: 'right' },
|
||
{ key: 'currentPrice', label: 'Цена', align: 'right' },
|
||
{ key: 'tvlUSD', label: 'TVL', align: 'right' },
|
||
{ key: 'volumeUSD24h', label: 'Объём 24ч', align: 'right' },
|
||
{ key: 'aprPercent', label: 'APR', align: 'right' },
|
||
]
|
||
|
||
return (
|
||
<div className={styles.card}>
|
||
<div className={styles.head}>
|
||
<span className={styles.title}>Курируемые пулы</span>
|
||
</div>
|
||
|
||
{loading && <Spinner label="Загрузка пулов" />}
|
||
{!loading && error && <div className={styles.message}>{error}</div>}
|
||
{!loading && !error && pools.length === 0 && (
|
||
<div className={styles.message}>Курируемых пулов пока нет.</div>
|
||
)}
|
||
|
||
{!loading && !error && pools.length > 0 && (
|
||
<div className={styles.scroll}>
|
||
<table className={styles.table}>
|
||
<thead>
|
||
<tr>
|
||
{columns.map((col) => (
|
||
<th
|
||
key={col.key}
|
||
className={`${styles.sortable} ${col.align === 'left' ? styles.thLeft : ''}`}
|
||
onClick={() => handleSort(col.key)}
|
||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||
>
|
||
{col.label}
|
||
<span className={styles.arrow}>{sortArrow(col.key)}</span>
|
||
</th>
|
||
))}
|
||
<th aria-label="Действие" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sortedPools.map((p) => {
|
||
const isSelected = p.poolAddress === selected
|
||
return (
|
||
<tr
|
||
key={p.poolAddress}
|
||
className={isSelected ? styles.rowSelected : styles.row}
|
||
onClick={() => onSelect(p.poolAddress)}
|
||
>
|
||
<td className={styles.tdLeft}>
|
||
<span className={styles.pairName}>
|
||
{p.symbol0}/{p.symbol1}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
|
||
</td>
|
||
<td className={styles.mono}>{formatPrice(p.currentPrice)}</td>
|
||
<td className={styles.mono}>{formatUsd(p.tvlUSD)}</td>
|
||
<td className={styles.mono}>{formatUsd(p.volumeUSD24h)}</td>
|
||
<td className={`${styles.mono} ${styles.apr}`}>{formatApr(p.aprPercent)}</td>
|
||
<td className={styles.tdAction}>
|
||
<button
|
||
type="button"
|
||
className={isSelected ? styles.btnSelected : styles.btn}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onSelect(p.poolAddress)
|
||
}}
|
||
>
|
||
{isSelected ? 'Выбран' : 'Выбрать'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|