add staking page
This commit is contained in:
@@ -22,15 +22,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.fee {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -156,10 +147,44 @@
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--interactive);
|
||||
margin: 0;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-deep);
|
||||
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 {
|
||||
@@ -224,18 +249,6 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrimaryButton, TokenIcon } from '@shared/ui'
|
||||
import { PrimaryButton } from '@shared/ui'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import type { Pool, LpQuote } from '@features/pools'
|
||||
import { coinIcon, formatFeeTier } from '../model/meta'
|
||||
import { formatFeeTier } from '../model/meta'
|
||||
import styles from './LpDepositCard.module.css'
|
||||
|
||||
interface Props {
|
||||
@@ -30,12 +30,10 @@ const decimalRe = /^(\d+\.?\d*|\.?\d*)$/
|
||||
function AmountInput({
|
||||
symbol,
|
||||
value,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
symbol: string
|
||||
value: string
|
||||
color: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -53,7 +51,6 @@ function AmountInput({
|
||||
aria-label={`Сумма ${symbol}`}
|
||||
/>
|
||||
<div className={styles.token}>
|
||||
<TokenIcon letter={symbol[0]} color={color} logo={coinIcon(symbol)} size={28} />
|
||||
<span>{symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,12 +107,6 @@ export function LpDepositCard({
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<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.placeholderText}>
|
||||
Выберите пул в таблице выше, чтобы внести ликвидность.
|
||||
@@ -139,10 +130,6 @@ export function LpDepositCard({
|
||||
<div className={styles.card}>
|
||||
<div className={styles.top}>
|
||||
<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}
|
||||
</span>
|
||||
<span className={styles.fee}>{formatFeeTier(pool.feeTier)}</span>
|
||||
@@ -150,8 +137,8 @@ export function LpDepositCard({
|
||||
|
||||
<span className={styles.tag}>Суммы взноса</span>
|
||||
<div className={styles.amounts}>
|
||||
<AmountInput symbol={pool.symbol0} value={amount0} color="var(--grad-center)" onChange={onAmount0Change} />
|
||||
<AmountInput symbol={pool.symbol1} value={amount1} color="var(--grad-edge)" onChange={onAmount1Change} />
|
||||
<AmountInput symbol={pool.symbol0} value={amount0} onChange={onAmount0Change} />
|
||||
<AmountInput symbol={pool.symbol1} value={amount1} onChange={onAmount1Change} />
|
||||
</div>
|
||||
|
||||
<span className={styles.tag}>Диапазон цен ({pool.symbol1} за {pool.symbol0})</span>
|
||||
|
||||
@@ -33,8 +33,27 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin-right: -4px;
|
||||
padding-right: 6px;
|
||||
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);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.fee {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -202,18 +212,6 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Spinner, TokenIcon } from '@shared/ui'
|
||||
import { Spinner } from '@shared/ui'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import type { LpPosition } from '@features/pools'
|
||||
import { coinIcon, formatFeeTier } from '../model/meta'
|
||||
import { formatFeeTier } from '../model/meta'
|
||||
import styles from './LpPositionsPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
@@ -32,12 +32,6 @@ export function LpPositionsPanel({ positions, onRemove, removePending, isLoading
|
||||
|
||||
{!isLoading && !error && empty && (
|
||||
<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.emptyText}>На данный момент у Вас нет позиций в пулах ликвидности.</p>
|
||||
</div>
|
||||
@@ -49,15 +43,11 @@ export function LpPositionsPanel({ positions, onRemove, removePending, isLoading
|
||||
<div key={p.tokenId} className={styles.pos}>
|
||||
<div className={styles.posTop}>
|
||||
<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}
|
||||
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
|
||||
</span>
|
||||
<span className={`${styles.badge} ${p.inRange ? styles.badgeActive : styles.badgeWarn}`}>
|
||||
{p.inRange ? 'В диапазоне' : 'Вне диапазона ⚠️'}
|
||||
{p.inRange ? 'В диапазоне' : 'Вне диапазона'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,20 @@
|
||||
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,
|
||||
.rowSelected {
|
||||
cursor: pointer;
|
||||
@@ -89,22 +103,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Вторая монета пары слегка наезжает на первую. */
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.pairName {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
@@ -123,11 +121,13 @@
|
||||
.btn,
|
||||
.btnSelected {
|
||||
height: 32px;
|
||||
min-width: 110px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Базовая высота колонок, чтобы при невыбранном пуле панель «Мои позиции»
|
||||
не сжималась до плейсхолдера и не уходила в скролл. */
|
||||
@media (min-width: 1101px) {
|
||||
.grid > * {
|
||||
min-height: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user