add staking page

This commit is contained in:
2026-06-17 18:24:14 +03:00
parent a5bb7abce8
commit e135fc1a9d
10 changed files with 191 additions and 135 deletions

View File

@@ -1,6 +1,7 @@
import { Spinner, TokenIcon } from '@shared/ui'
import { useMemo, useState } from 'react'
import { Spinner } from '@shared/ui'
import type { Pool } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta'
import { formatFeeTier } from '../model/meta'
import styles from './PoolsTable.module.css'
interface Props {
@@ -13,6 +14,9 @@ interface Props {
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`
@@ -24,21 +28,72 @@ function formatPrice(value: number): string {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function PairIcons({ symbol0, symbol1 }: { symbol0: string; symbol1: string }) {
return (
<span className={styles.pair}>
<span className={styles.icons}>
<TokenIcon letter={symbol0[0]} color="var(--grad-center)" logo={coinIcon(symbol0)} size={24} />
<TokenIcon letter={symbol1[0]} color="var(--grad-edge)" logo={coinIcon(symbol1)} size={24} />
</span>
<span className={styles.pairName}>
{symbol0}/{symbol1}
</span>
</span>
)
// 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}>
@@ -56,17 +111,22 @@ export function PoolsTable({ pools, selected, onSelect, loading, error }: Props)
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thLeft}>Пул</th>
<th>Комиссия</th>
<th>Цена</th>
<th>TVL</th>
<th>Объём 24ч</th>
<th>APR</th>
{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>
{pools.map((p) => {
{sortedPools.map((p) => {
const isSelected = p.poolAddress === selected
return (
<tr
@@ -75,7 +135,9 @@ export function PoolsTable({ pools, selected, onSelect, loading, error }: Props)
onClick={() => onSelect(p.poolAddress)}
>
<td className={styles.tdLeft}>
<PairIcons symbol0={p.symbol0} symbol1={p.symbol1} />
<span className={styles.pairName}>
{p.symbol0}/{p.symbol1}
</span>
</td>
<td>
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
@@ -83,9 +145,7 @@ export function PoolsTable({ pools, selected, onSelect, loading, error }: Props)
<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}`}>
{p.aprPercent != null ? `${p.aprPercent}%` : DASH}
</td>
<td className={`${styles.mono} ${styles.apr}`}>{formatApr(p.aprPercent)}</td>
<td className={styles.tdAction}>
<button
type="button"