add staking page

This commit is contained in:
2026-06-17 18:10:23 +03:00
parent 7966ef1c52
commit a5bb7abce8
17 changed files with 928 additions and 330 deletions

View File

@@ -1,53 +1,5 @@
import { COIN_ICONS } from '@shared/assets/coins'
/** Курируемый пул Uniswap v3 (данные из GET /wallets/ETH/lp/pools). */
export interface Pool {
poolAddress: string
symbol0: string
symbol1: string
decimals0: number
decimals1: number
/** В сотых долях процента: 3000 = 0.3%, 500 = 0.05%. */
feeTier: number
currentPrice: number
/** Внешняя статистика может быть недоступна — тогда null (прочерк в UI). */
tvlUSD: number | null
volumeUSD24h: number | null
aprPercent: number | null
}
/** LP-позиция юзера (NFT) из GET /wallets/ETH/lp/positions. */
export interface LpPosition {
tokenId: string
symbol0: string
symbol1: string
feeTier: number
priceLower: number
priceUpper: number
currentAmount0Human: string
currentAmount1Human: string
feesOwed0Human: string
feesOwed1Human: string
/** false → цена вышла за диапазон, позиция временно не зарабатывает комиссии. */
inRange: boolean
}
/** Превью депозита (POST /wallets/ETH/lp/quote) — комиссия 0.7% + проверка баланса. */
export interface LpQuote {
amount0DesiredHuman: string
amount1DesiredHuman: string
aprPercent: number | null
appFee0Human: string
appFee1Human: string
appFeeTotalUsd: number | null
balance: {
token0: { symbol: string; have: string; need: string; sufficient: boolean | null }
token1: { symbol: string; have: string; need: string; sufficient: boolean | null }
gas: { symbol: string; have: string; needReserve: string; sufficient: boolean }
sufficient: boolean
}
}
/** Иконка по символу токена: WETH→ETH, WBTC→BTC, иначе по тикеру (может вернуть undefined). */
export function coinIcon(symbol: string): string | undefined {
const map: Record<string, string> = { WETH: 'ETH', WBTC: 'BTC' }
@@ -58,90 +10,3 @@ export function coinIcon(symbol: string): string | undefined {
export function formatFeeTier(feeTier: number): string {
return `${feeTier / 10000}%`
}
// ── Мок-данные (раздел 2 CryptoWallet-DeFi-Guide.md). Заменяются API позже. ──
export const MOCK_POOLS: Pool[] = [
{
poolAddress: '0x4e68ccd3e89f51c3074ca5072bbac773960dfa36',
symbol0: 'WETH',
symbol1: 'USDT',
decimals0: 18,
decimals1: 6,
feeTier: 3000,
currentPrice: 1665.75,
tvlUSD: 33_000_000,
volumeUSD24h: 5_400_000,
aprPercent: 40.33,
},
{
poolAddress: '0xcbcdf9626bc03e24f779434178a73a0b4bad62ed',
symbol0: 'WBTC',
symbol1: 'WETH',
decimals0: 8,
decimals1: 18,
feeTier: 500,
currentPrice: 18.42,
tvlUSD: 21_700_000,
volumeUSD24h: 3_120_000,
aprPercent: 22.5,
},
{
poolAddress: '0x5c128d25a21f681e678cb050e551a895c9309945',
symbol0: 'XAUt',
symbol1: 'USDT',
decimals0: 6,
decimals1: 6,
feeTier: 3000,
currentPrice: 2384.1,
// Внешняя статистика недоступна — null (показываем прочерк, депозит не блокируется).
tvlUSD: null,
volumeUSD24h: null,
aprPercent: null,
},
]
export const MOCK_POSITIONS: LpPosition[] = [
{
tokenId: '123456',
symbol0: 'WETH',
symbol1: 'USDT',
feeTier: 3000,
priceLower: 1568.75,
priceUpper: 1665.75,
currentAmount0Human: '0.000099',
currentAmount1Human: '0.26',
feesOwed0Human: '0.0000000012',
feesOwed1Human: '0.0005',
inRange: true,
},
{
tokenId: '123498',
symbol0: 'WBTC',
symbol1: 'WETH',
feeTier: 500,
priceLower: 19.1,
priceUpper: 21.4,
currentAmount0Human: '0.0021',
currentAmount1Human: '0.0',
feesOwed0Human: '0.0000004',
feesOwed1Human: '0.00012',
inRange: false,
},
]
/** Мок-квота под выбранный пул (для превью депозита). */
export const MOCK_QUOTE: LpQuote = {
amount0DesiredHuman: '0.0001',
amount1DesiredHuman: '0.263359',
aprPercent: 40.33,
appFee0Human: '0.0007',
appFee1Human: '0.001843',
appFeeTotalUsd: 3.04,
balance: {
token0: { symbol: 'WETH', have: '0.5', need: '0.0001', sufficient: true },
token1: { symbol: 'USDT', have: '0', need: '0.263359', sufficient: false },
gas: { symbol: 'ETH', have: '0.02', needReserve: '0.001', sufficient: true },
sufficient: false,
},
}

View File

@@ -1,11 +1,14 @@
import { PrimaryButton, TokenIcon } from '@shared/ui'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import { coinIcon, formatFeeTier, type Pool, type LpQuote } from '../model/meta'
import type { Pool, LpQuote } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta'
import styles from './LpDepositCard.module.css'
interface Props {
pool: Pool | null
quote: LpQuote
quote: LpQuote | undefined
quoteFetching?: boolean
pending?: boolean
amount0: string
amount1: string
priceLower: string
@@ -87,6 +90,8 @@ function PriceInput({
export function LpDepositCard({
pool,
quote,
quoteFetching,
pending,
amount0,
amount1,
priceLower,
@@ -120,9 +125,15 @@ export function LpDepositCard({
)
}
const insufficient = quote.balance.sufficient === false
const apr = quote.aprPercent != null ? `${quote.aprPercent}%` : DASH
const feeUsd = quote.appFeeTotalUsd != null ? `$${quote.appFeeTotalUsd.toFixed(2)}` : DASH
const balance = quote?.balance
// sufficient ноги может быть null (не проверялось / покрыто нативным ETH) — это не «не хватает».
const insufficient = balance?.sufficient === false
const apr = quote?.aprPercent != null ? `${quote.aprPercent}%` : DASH
const feeUsd = quote?.appFeeTotalUsd != null ? `$${quote.appFeeTotalUsd.toFixed(2)}` : DASH
const amount0Out = quote ? truncateDecimals(quote.amount0DesiredHuman, 8) : DASH
const amount1Out = quote ? truncateDecimals(quote.amount1DesiredHuman, 8) : DASH
const shortLeg = balance && balance.token1.sufficient === false ? balance.token1 : balance?.token0
const canSubmit = !!quote && !insufficient && !pending
return (
<div className={styles.card}>
@@ -174,14 +185,14 @@ export function LpDepositCard({
</label>
</div>
<div className={styles.quote}>
<div className={styles.quote} data-loading={quoteFetching ? 'true' : undefined}>
<div className={styles.quoteRow}>
<span className={styles.quoteLabel}>Взнос {pool.symbol0}</span>
<span className={styles.quoteValue}>{truncateDecimals(quote.amount0DesiredHuman, 8)}</span>
<span className={styles.quoteValue}>{amount0Out}</span>
</div>
<div className={styles.quoteRow}>
<span className={styles.quoteLabel}>Взнос {pool.symbol1}</span>
<span className={styles.quoteValue}>{truncateDecimals(quote.amount1DesiredHuman, 8)}</span>
<span className={styles.quoteValue}>{amount1Out}</span>
</div>
<div className={styles.quoteRow}>
<span className={styles.quoteLabel}>Комиссия сервиса (0.7%)</span>
@@ -195,12 +206,17 @@ export function LpDepositCard({
{insufficient && (
<p className={styles.warning}>
Недостаточно средств: не хватает {quote.balance.token1.symbol}. Пополните баланс.
Недостаточно средств{shortLeg ? `: не хватает ${shortLeg.symbol}` : ''}. Пополните баланс.
</p>
)}
<div className={styles.submit}>
<PrimaryButton label="Внести в пул" type="button" onClick={onSubmit} disabled={insufficient} />
<PrimaryButton
label={pending ? 'Отправка…' : 'Внести в пул'}
type="button"
onClick={onSubmit}
disabled={!canSubmit}
/>
</div>
</div>
)

View File

@@ -69,6 +69,13 @@
gap: 10px;
}
.message {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
padding: 24px 0;
}
.pos {
background: var(--bg-deep);
border: 1px solid var(--glass-border);

View File

@@ -1,15 +1,18 @@
import { TokenIcon } from '@shared/ui'
import { Spinner, TokenIcon } from '@shared/ui'
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
import { coinIcon, formatFeeTier, type LpPosition } from '../model/meta'
import type { LpPosition } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta'
import styles from './LpPositionsPanel.module.css'
interface Props {
positions: LpPosition[]
onRemove: (tokenId: string) => void
removePending: boolean
isLoading?: boolean
error?: string | null
}
export function LpPositionsPanel({ positions, onRemove, removePending }: Props) {
export function LpPositionsPanel({ positions, onRemove, removePending, isLoading, error }: Props) {
const empty = positions.length === 0
return (
@@ -23,7 +26,11 @@ export function LpPositionsPanel({ positions, onRemove, removePending }: Props)
</div>
<div className={styles.body}>
{empty && (
{isLoading && <Spinner label="Загрузка позиций" />}
{!isLoading && error && <div className={styles.message}>{error}</div>}
{!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">
@@ -36,7 +43,7 @@ export function LpPositionsPanel({ positions, onRemove, removePending }: Props)
</div>
)}
{!empty && (
{!isLoading && !error && !empty && (
<div className={styles.list}>
{positions.map((p) => (
<div key={p.tokenId} className={styles.pos}>

View File

@@ -0,0 +1,144 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(10, 11, 46, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 16px;
}
.card {
background: var(--bg-mid);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 28px;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.closeBtn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
line-height: 1;
padding: 0;
font-family: var(--font-sans);
}
.closeBtn:hover {
color: var(--text-primary);
}
.token {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tokenLabel {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
font-weight: 700;
}
.tokenAmount {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
}
.tokenMeta {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.modes {
display: flex;
gap: 10px;
}
.mode {
flex: 1;
text-align: center;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 14px 12px;
cursor: pointer;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
transition: border-color 0.2s, background 0.2s;
}
.mode:hover {
border-color: rgba(255, 255, 255, 0.25);
}
.modeActive {
border-color: var(--interactive);
background: rgba(74, 109, 255, 0.1);
}
.note {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.45;
}
.confirmBtn {
width: 100%;
height: 56px;
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
border: none;
border-radius: 14px;
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
cursor: pointer;
font-family: var(--font-sans);
letter-spacing: 0.3px;
transition: filter 0.25s, box-shadow 0.25s;
}
.confirmBtn:hover {
filter: brightness(1.15);
box-shadow: 0 0 24px rgba(91, 61, 184, 0.5);
}
.confirmBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: none;
box-shadow: none;
}

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import type { LpPosition } from '@features/pools'
import { formatFeeTier } from '../model/meta'
import styles from './LpRemoveModal.module.css'
interface Props {
position: LpPosition
pending?: boolean
onConfirm: (percent: number) => void
onClose: () => void
}
const PERCENTS = [25, 50, 100]
export function LpRemoveModal({ position, pending, onConfirm, onClose }: Props) {
const [percent, setPercent] = useState(100)
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.card} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<span className={styles.title}>Вывод ликвидности</span>
<button className={styles.closeBtn} onClick={onClose}>
×
</button>
</div>
<div className={styles.token}>
<span className={styles.tokenLabel}>Позиция</span>
<span className={styles.tokenAmount}>
{position.symbol0}/{position.symbol1}
</span>
<span className={styles.tokenMeta}>
{formatFeeTier(position.feeTier)} · #{position.tokenId}
</span>
</div>
<div className={styles.modes}>
{PERCENTS.map((p) => (
<button
key={p}
className={`${styles.mode} ${percent === p ? styles.modeActive : ''}`}
onClick={() => setPercent(p)}
>
{p}%
</button>
))}
</div>
{percent === 100 && (
<p className={styles.note}>При выводе 100% позиция (NFT) будет сожжена.</p>
)}
<button className={styles.confirmBtn} onClick={() => onConfirm(percent)} disabled={pending}>
{pending ? 'Отправка…' : 'Подтвердить вывод'}
</button>
</div>
</div>
)
}

View File

@@ -21,6 +21,13 @@
overflow-x: auto;
}
.message {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
padding: 24px 0;
}
.table {
width: 100%;
border-collapse: collapse;

View File

@@ -1,11 +1,14 @@
import { TokenIcon } from '@shared/ui'
import { coinIcon, formatFeeTier, type Pool } from '../model/meta'
import { Spinner, TokenIcon } from '@shared/ui'
import type { Pool } from '@features/pools'
import { coinIcon, 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 = '—'
@@ -35,13 +38,20 @@ function PairIcons({ symbol0, symbol1 }: { symbol0: string; symbol1: string }) {
)
}
export function PoolsTable({ pools, selected, onSelect }: Props) {
export function PoolsTable({ pools, selected, onSelect, loading, error }: Props) {
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>
@@ -94,6 +104,7 @@ export function PoolsTable({ pools, selected, onSelect }: Props) {
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -1,42 +1,135 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Notification } from '@shared/ui'
import { useDebounce } from '@shared/lib/hooks/useDebounce'
import {
usePools,
useLpPositions,
useLpQuote,
useAddLp,
useRemoveLp,
getPoolsErrorMessage,
type LpDepositInput,
type LpPosition,
} from '@features/pools'
import { PoolsTable } from './PoolsTable'
import { LpDepositCard } from './LpDepositCard'
import { LpPositionsPanel } from './LpPositionsPanel'
import { MOCK_POOLS, MOCK_POSITIONS, MOCK_QUOTE } from '../model/meta'
import { LpRemoveModal } from './LpRemoveModal'
import styles from './PoolsWidget.module.css'
type Toast = { status: 'success' | 'error' | 'info'; message: string } | null
// Предзаполнение диапазона при выборе пула: текущая цена ±5%.
const RANGE_SPREAD = 0.05
export function PoolsWidget() {
const [selected, setSelected] = useState<string | null>(MOCK_POOLS[0]?.poolAddress ?? null)
const poolsQuery = usePools()
const positionsQuery = useLpPositions()
const addM = useAddLp()
const removeM = useRemoveLp()
const pools = poolsQuery.data ?? []
const positions = positionsQuery.data?.positions ?? []
const [selected, setSelected] = useState<string | null>(null)
const [amount0, setAmount0] = useState('')
const [amount1, setAmount1] = useState('')
const [priceLower, setPriceLower] = useState('')
const [priceUpper, setPriceUpper] = useState('')
const [slippage, setSlippage] = useState('1')
const [useNativeEth, setUseNativeEth] = useState(true)
const [removeTarget, setRemoveTarget] = useState<LpPosition | null>(null)
const [toast, setToast] = useState<Toast>(null)
const pool = MOCK_POOLS.find((p) => p.poolAddress === selected) ?? null
const pool = pools.find((p) => p.poolAddress === selected) ?? null
// Заглушки: логика endpoint появится позже, пока только тосты.
function handleSubmit() {
setToast({ status: 'info', message: 'Депозит в пул будет доступен после подключения API' })
function handleSelect(poolAddress: string) {
setSelected(poolAddress)
const next = pools.find((p) => p.poolAddress === poolAddress)
// Предзаполняем диапазон ±5% от текущей цены (юзер может поправить вручную).
if (next) {
setPriceLower((next.currentPrice * (1 - RANGE_SPREAD)).toFixed(2))
setPriceUpper((next.currentPrice * (1 + RANGE_SPREAD)).toFixed(2))
}
setAmount0('')
setAmount1('')
}
function handleRemove() {
setToast({ status: 'info', message: 'Вывод позиции будет доступен после подключения API' })
// Дебаунсим вход квоты, чтобы не дёргать /lp/quote на каждый символ.
const dAmount0 = useDebounce(amount0, 400)
const dAmount1 = useDebounce(amount1, 400)
const dPriceLower = useDebounce(priceLower, 400)
const dPriceUpper = useDebounce(priceUpper, 400)
const dSlippage = useDebounce(slippage, 400)
const quoteInput = useMemo<LpDepositInput | null>(() => {
if (!pool) return null
const slippageNum = parseFloat(dSlippage)
return {
pool,
amount0Human: dAmount0,
amount1Human: dAmount1,
priceLower: parseFloat(dPriceLower) || 0,
priceUpper: parseFloat(dPriceUpper) || 0,
slippageBps: Number.isFinite(slippageNum) ? Math.round(slippageNum * 100) : undefined,
useNativeEth,
}
}, [pool, dAmount0, dAmount1, dPriceLower, dPriceUpper, dSlippage, useNativeEth])
const quoteQuery = useLpQuote(quoteInput)
function handleSubmit() {
if (!pool) return
const slippageNum = parseFloat(slippage)
const input: LpDepositInput = {
pool,
amount0Human: amount0,
amount1Human: amount1,
priceLower: parseFloat(priceLower) || 0,
priceUpper: parseFloat(priceUpper) || 0,
slippageBps: Number.isFinite(slippageNum) ? Math.round(slippageNum * 100) : undefined,
useNativeEth,
}
addM.mutate(input, {
onSuccess: () => {
setToast({ status: 'success', message: `Депозит в пул ${pool.symbol0}/${pool.symbol1} выполнен` })
setAmount0('')
setAmount1('')
},
onError: (e) => setToast({ status: 'error', message: getPoolsErrorMessage(e) }),
})
}
function handleConfirmRemove(percent: number) {
if (!removeTarget) return
removeM.mutate(
{ tokenId: removeTarget.tokenId, percent },
{
onSuccess: () => {
setRemoveTarget(null)
setToast({ status: 'success', message: 'Ликвидность выведена на кошелёк' })
},
onError: (e) => setToast({ status: 'error', message: getPoolsErrorMessage(e) }),
}
)
}
return (
<div className={styles.widget}>
<PoolsTable pools={MOCK_POOLS} selected={selected} onSelect={setSelected} />
<PoolsTable
pools={pools}
selected={selected}
onSelect={handleSelect}
loading={poolsQuery.isLoading}
error={poolsQuery.isError ? getPoolsErrorMessage(poolsQuery.error) : null}
/>
<div className={styles.grid}>
<LpDepositCard
pool={pool}
quote={MOCK_QUOTE}
quote={quoteQuery.data}
quoteFetching={quoteQuery.isFetching}
pending={addM.isPending}
amount0={amount0}
amount1={amount1}
priceLower={priceLower}
@@ -51,9 +144,24 @@ export function PoolsWidget() {
onUseNativeEthChange={setUseNativeEth}
onSubmit={handleSubmit}
/>
<LpPositionsPanel positions={MOCK_POSITIONS} onRemove={handleRemove} removePending={false} />
<LpPositionsPanel
positions={positions}
onRemove={(tokenId) => setRemoveTarget(positions.find((p) => p.tokenId === tokenId) ?? null)}
removePending={removeM.isPending}
isLoading={positionsQuery.isLoading}
error={positionsQuery.isError ? getPoolsErrorMessage(positionsQuery.error) : null}
/>
</div>
{removeTarget && (
<LpRemoveModal
position={removeTarget}
pending={removeM.isPending}
onConfirm={handleConfirmRemove}
onClose={() => setRemoveTarget(null)}
/>
)}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
</div>
)