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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-CvjPBUMw.js"></script> <script type="module" crossorigin src="/assets/index-DO4w311N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CyLzS1Ao.css"> <link rel="stylesheet" crossorigin href="/assets/index-BqMPDWqk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -22,15 +22,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icons {
display: flex;
align-items: center;
}
.icons > :last-child {
margin-left: -8px;
}
.fee { .fee {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -156,10 +147,44 @@
} }
.toggle input { .toggle input {
appearance: none;
-webkit-appearance: none;
position: relative;
flex-shrink: 0;
width: 18px; width: 18px;
height: 18px; height: 18px;
accent-color: var(--interactive); margin: 0;
border: 1px solid var(--glass-border);
border-radius: 5px;
background: var(--bg-deep);
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.toggle input:hover {
border-color: var(--interactive);
}
.toggle input:checked {
background: var(--interactive);
border-color: var(--interactive);
}
.toggle input:checked::after {
content: '';
position: absolute;
top: 2px;
left: 6px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.toggle input:focus-visible {
outline: 2px solid var(--highlight);
outline-offset: 2px;
} }
.quote { .quote {
@@ -224,18 +249,6 @@
background: var(--bg-deep); background: var(--bg-deep);
} }
.placeholderIcon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin-bottom: 4px;
border-radius: 50%;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
}
.placeholderTitle { .placeholderTitle {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;

View File

@@ -1,7 +1,7 @@
import { PrimaryButton, TokenIcon } from '@shared/ui' import { PrimaryButton } from '@shared/ui'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import type { Pool, LpQuote } from '@features/pools' import type { Pool, LpQuote } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta' import { formatFeeTier } from '../model/meta'
import styles from './LpDepositCard.module.css' import styles from './LpDepositCard.module.css'
interface Props { interface Props {
@@ -30,12 +30,10 @@ const decimalRe = /^(\d+\.?\d*|\.?\d*)$/
function AmountInput({ function AmountInput({
symbol, symbol,
value, value,
color,
onChange, onChange,
}: { }: {
symbol: string symbol: string
value: string value: string
color: string
onChange: (v: string) => void onChange: (v: string) => void
}) { }) {
return ( return (
@@ -53,7 +51,6 @@ function AmountInput({
aria-label={`Сумма ${symbol}`} aria-label={`Сумма ${symbol}`}
/> />
<div className={styles.token}> <div className={styles.token}>
<TokenIcon letter={symbol[0]} color={color} logo={coinIcon(symbol)} size={28} />
<span>{symbol}</span> <span>{symbol}</span>
</div> </div>
</div> </div>
@@ -110,12 +107,6 @@ export function LpDepositCard({
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.placeholder}> <div className={styles.placeholder}>
<span className={styles.placeholderIcon} aria-hidden>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="9" r="5" />
<circle cx="15" cy="15" r="5" />
</svg>
</span>
<p className={styles.placeholderTitle}>Выберите пул</p> <p className={styles.placeholderTitle}>Выберите пул</p>
<p className={styles.placeholderText}> <p className={styles.placeholderText}>
Выберите пул в таблице выше, чтобы внести ликвидность. Выберите пул в таблице выше, чтобы внести ликвидность.
@@ -139,10 +130,6 @@ export function LpDepositCard({
<div className={styles.card}> <div className={styles.card}>
<div className={styles.top}> <div className={styles.top}>
<span className={styles.pair}> <span className={styles.pair}>
<span className={styles.icons}>
<TokenIcon letter={pool.symbol0[0]} color="var(--grad-center)" logo={coinIcon(pool.symbol0)} size={24} />
<TokenIcon letter={pool.symbol1[0]} color="var(--grad-edge)" logo={coinIcon(pool.symbol1)} size={24} />
</span>
{pool.symbol0}/{pool.symbol1} {pool.symbol0}/{pool.symbol1}
</span> </span>
<span className={styles.fee}>{formatFeeTier(pool.feeTier)}</span> <span className={styles.fee}>{formatFeeTier(pool.feeTier)}</span>
@@ -150,8 +137,8 @@ export function LpDepositCard({
<span className={styles.tag}>Суммы взноса</span> <span className={styles.tag}>Суммы взноса</span>
<div className={styles.amounts}> <div className={styles.amounts}>
<AmountInput symbol={pool.symbol0} value={amount0} color="var(--grad-center)" onChange={onAmount0Change} /> <AmountInput symbol={pool.symbol0} value={amount0} onChange={onAmount0Change} />
<AmountInput symbol={pool.symbol1} value={amount1} color="var(--grad-edge)" onChange={onAmount1Change} /> <AmountInput symbol={pool.symbol1} value={amount1} onChange={onAmount1Change} />
</div> </div>
<span className={styles.tag}>Диапазон цен ({pool.symbol1} за {pool.symbol0})</span> <span className={styles.tag}>Диапазон цен ({pool.symbol1} за {pool.symbol0})</span>

View File

@@ -33,8 +33,27 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding-right: 6px;
margin-right: -4px; margin-right: -6px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.body::-webkit-scrollbar {
width: 6px;
}
.body::-webkit-scrollbar-track {
background: transparent;
}
.body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
} }
} }
@@ -100,15 +119,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icons {
display: flex;
align-items: center;
}
.icons > :last-child {
margin-left: -8px;
}
.fee { .fee {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -202,18 +212,6 @@
background: var(--bg-deep); background: var(--bg-deep);
} }
.emptyIcon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
margin-bottom: 4px;
border-radius: 50%;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
}
.emptyTitle { .emptyTitle {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;

View File

@@ -1,7 +1,7 @@
import { Spinner, TokenIcon } from '@shared/ui' import { Spinner } from '@shared/ui'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import type { LpPosition } from '@features/pools' import type { LpPosition } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta' import { formatFeeTier } from '../model/meta'
import styles from './LpPositionsPanel.module.css' import styles from './LpPositionsPanel.module.css'
interface Props { interface Props {
@@ -32,12 +32,6 @@ export function LpPositionsPanel({ positions, onRemove, removePending, isLoading
{!isLoading && !error && empty && ( {!isLoading && !error && empty && (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<span className={styles.emptyIcon} aria-hidden>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="9" r="5" />
<circle cx="15" cy="15" r="5" />
</svg>
</span>
<p className={styles.emptyTitle}>Активных позиций пока нет</p> <p className={styles.emptyTitle}>Активных позиций пока нет</p>
<p className={styles.emptyText}>На данный момент у Вас нет позиций в пулах ликвидности.</p> <p className={styles.emptyText}>На данный момент у Вас нет позиций в пулах ликвидности.</p>
</div> </div>
@@ -49,15 +43,11 @@ export function LpPositionsPanel({ positions, onRemove, removePending, isLoading
<div key={p.tokenId} className={styles.pos}> <div key={p.tokenId} className={styles.pos}>
<div className={styles.posTop}> <div className={styles.posTop}>
<span className={styles.coin}> <span className={styles.coin}>
<span className={styles.icons}>
<TokenIcon letter={p.symbol0[0]} color="var(--grad-center)" logo={coinIcon(p.symbol0)} size={24} />
<TokenIcon letter={p.symbol1[0]} color="var(--grad-edge)" logo={coinIcon(p.symbol1)} size={24} />
</span>
{p.symbol0}/{p.symbol1} {p.symbol0}/{p.symbol1}
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span> <span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
</span> </span>
<span className={`${styles.badge} ${p.inRange ? styles.badgeActive : styles.badgeWarn}`}> <span className={`${styles.badge} ${p.inRange ? styles.badgeActive : styles.badgeWarn}`}>
{p.inRange ? 'В диапазоне' : 'Вне диапазона ⚠️'} {p.inRange ? 'В диапазоне' : 'Вне диапазона'}
</span> </span>
</div> </div>

View File

@@ -49,6 +49,20 @@
text-align: left !important; text-align: left !important;
} }
.sortable {
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
.sortable:hover {
color: var(--text-primary);
}
.arrow {
font-size: 10px;
}
.row, .row,
.rowSelected { .rowSelected {
cursor: pointer; cursor: pointer;
@@ -89,22 +103,6 @@
font-weight: 700; font-weight: 700;
} }
.pair {
display: flex;
align-items: center;
gap: 10px;
}
.icons {
display: flex;
align-items: center;
}
/* Вторая монета пары слегка наезжает на первую. */
.icons > :last-child {
margin-left: -8px;
}
.pairName { .pairName {
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
@@ -123,11 +121,13 @@
.btn, .btn,
.btnSelected { .btnSelected {
height: 32px; height: 32px;
min-width: 110px;
padding: 0 16px; padding: 0 16px;
border-radius: 10px; border-radius: 10px;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }

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 type { Pool } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta' import { formatFeeTier } from '../model/meta'
import styles from './PoolsTable.module.css' import styles from './PoolsTable.module.css'
interface Props { interface Props {
@@ -13,6 +14,9 @@ interface Props {
const DASH = '—' const DASH = '—'
type SortKey = 'pair' | 'feeTier' | 'currentPrice' | 'tvlUSD' | 'volumeUSD24h' | 'aprPercent'
type SortDir = 'desc' | 'asc'
function formatUsd(value: number | null): string { function formatUsd(value: number | null): string {
if (value == null) return DASH if (value == null) return DASH
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M` 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 }) return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
function PairIcons({ symbol0, symbol1 }: { symbol0: string; symbol1: string }) { // APR обрезаем до 3 знаков после запятой и убираем лишние нули.
return ( function formatApr(value: number | null): string {
<span className={styles.pair}> if (value == null) return DASH
<span className={styles.icons}> return `${parseFloat(value.toFixed(3))}%`
<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> function pairName(p: Pool): string {
<span className={styles.pairName}> return `${p.symbol0}/${p.symbol1}`
{symbol0}/{symbol1} }
</span>
</span> // Числовые поля могут быть 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) { 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 ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.head}> <div className={styles.head}>
@@ -56,17 +111,22 @@ export function PoolsTable({ pools, selected, onSelect, loading, error }: Props)
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th className={styles.thLeft}>Пул</th> {columns.map((col) => (
<th>Комиссия</th> <th
<th>Цена</th> key={col.key}
<th>TVL</th> className={`${styles.sortable} ${col.align === 'left' ? styles.thLeft : ''}`}
<th>Объём 24ч</th> onClick={() => handleSort(col.key)}
<th>APR</th> aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
>
{col.label}
<span className={styles.arrow}>{sortArrow(col.key)}</span>
</th>
))}
<th aria-label="Действие" /> <th aria-label="Действие" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pools.map((p) => { {sortedPools.map((p) => {
const isSelected = p.poolAddress === selected const isSelected = p.poolAddress === selected
return ( return (
<tr <tr
@@ -75,7 +135,9 @@ export function PoolsTable({ pools, selected, onSelect, loading, error }: Props)
onClick={() => onSelect(p.poolAddress)} onClick={() => onSelect(p.poolAddress)}
> >
<td className={styles.tdLeft}> <td className={styles.tdLeft}>
<PairIcons symbol0={p.symbol0} symbol1={p.symbol1} /> <span className={styles.pairName}>
{p.symbol0}/{p.symbol1}
</span>
</td> </td>
<td> <td>
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span> <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}>{formatPrice(p.currentPrice)}</td>
<td className={styles.mono}>{formatUsd(p.tvlUSD)}</td> <td className={styles.mono}>{formatUsd(p.tvlUSD)}</td>
<td className={styles.mono}>{formatUsd(p.volumeUSD24h)}</td> <td className={styles.mono}>{formatUsd(p.volumeUSD24h)}</td>
<td className={`${styles.mono} ${styles.apr}`}> <td className={`${styles.mono} ${styles.apr}`}>{formatApr(p.aprPercent)}</td>
{p.aprPercent != null ? `${p.aprPercent}%` : DASH}
</td>
<td className={styles.tdAction}> <td className={styles.tdAction}>
<button <button
type="button" type="button"

View File

@@ -11,6 +11,14 @@
align-items: stretch; align-items: stretch;
} }
/* Базовая высота колонок, чтобы при невыбранном пуле панель «Мои позиции»
не сжималась до плейсхолдера и не уходила в скролл. */
@media (min-width: 1101px) {
.grid > * {
min-height: 480px;
}
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;