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

161
dist/assets/index-CvjPBUMw.js vendored Normal file

File diff suppressed because one or more lines are too long

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-LxsThgvi.js"></script> <script type="module" crossorigin src="/assets/index-CvjPBUMw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_ylxO9S.css"> <link rel="stylesheet" crossorigin href="/assets/index-CyLzS1Ao.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,267 @@
import { getCsrfToken } from '@shared/api/csrf'
import { tokenStore, refreshAccessToken } from '@shared/api/tokenStore'
import { toBaseUnits } from '@shared/lib/utils/baseUnits'
// Пулы живут на том же custodial-хосте, что и кошелёк/стейкинг (см. features/staking/api/stakingApi.ts).
const WALLET_API_URL = 'https://app.cryptowallet.elcsa.ru'
// ── Низкоуровневые запросы (CSRF + Bearer + один silent refresh на 401) ──
async function walletGet<T>(path: string, allowRetry = true): Promise<T> {
const csrf = await getCsrfToken()
const bearer = tokenStore.get()
const res = await fetch(`${WALLET_API_URL}${path}`, {
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
},
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return walletGet<T>(path, false)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
async function walletPost<T>(
path: string,
body: unknown,
allowRetry = true,
extraHeaders: Record<string, string> = {}
): Promise<T> {
const csrf = await getCsrfToken()
const bearer = tokenStore.get()
const res = await fetch(`${WALLET_API_URL}${path}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...extraHeaders,
},
body: JSON.stringify(body),
})
if (res.status === 401 && allowRetry) {
try {
await refreshAccessToken()
return walletPost<T>(path, body, false, extraHeaders)
} catch {
tokenStore.clear()
throw new Error('Unauthorized')
}
}
const data = await res.json()
if (!res.ok) throw data
return data as T
}
/** Idempotency-Key для мутирующих операций (анти-дабл-спенд). */
function idempotency(): Record<string, string> {
return { 'Idempotency-Key': crypto.randomUUID() }
}
// ── 2.1 Список курируемых пулов ──
export interface Pool {
poolAddress: string
token0: string
token1: string
symbol0: string
symbol1: string
decimals0: number
decimals1: number
/** В сотых долях процента: 3000 = 0.3%, 500 = 0.05%. */
feeTier: number
currentPrice: number
currentTick: number
/** Внешняя статистика может быть null, если недоступна (не блокирует депозит). */
tvlUSD: number | null
volumeUSD24h: number | null
aprPercent: number | null
}
export async function getPools(): Promise<Pool[]> {
const res = await walletGet<{ success: boolean; data: Pool[] }>('/api/wallets/ETH/lp/pools')
return res.data
}
// ── 2.2 Превью депозита (комиссия 0.7% + проверка баланса) ──
/** Нога баланса: sufficient может быть null (не проверялось / покрыто нативным ETH). */
export interface LpBalanceLeg {
symbol: string
have: string
need: string
sufficient: boolean | null
shortfall?: string
}
export interface LpGasLeg {
symbol: string
have: string
needReserve: string
sufficient: boolean
}
export interface LpQuote {
poolAddress: string
tickLower: number
tickUpper: number
priceLower: number
priceUpper: number
amount0DesiredHuman: string
amount1DesiredHuman: string
amount0MinHuman: string
amount1MinHuman: string
liquidity: string
aprPercent: number | null
appFee0Human: string
appFee1Human: string
appFee0Usd: number | null
appFee1Usd: number | null
appFeeTotalUsd: number | null
appFeeTotalEth: string | null
/** Присутствует, если у юзера есть ETH-кошелёк (всегда у custodial-юзера). */
balance?: {
token0: LpBalanceLeg
token1: LpBalanceLeg
gas: LpGasLeg
sufficient: boolean
}
}
/** UI-facing вход для quote/add: human-суммы + границы диапазона. */
export interface LpDepositInput {
pool: Pool
amount0Human: string
amount1Human: string
priceLower: number
priceUpper: number
/** 0..5000, дефолт 100 (= 1%). */
slippageBps?: number
/** Завернуть ETH→WETH, если нога пула — WETH, а на балансе нативный ETH. */
useNativeEth?: boolean
}
/** Собирает тело запроса (доку 2.2): human-суммы → smallest units по decimals каждого токена. */
function buildLpBody(input: LpDepositInput): Record<string, unknown> {
const { pool, amount0Human, amount1Human, priceLower, priceUpper, slippageBps, useNativeEth } = input
return {
pool: pool.poolAddress,
priceLower,
priceUpper,
amount0Desired: toBaseUnits(amount0Human || '0', pool.decimals0),
amount1Desired: toBaseUnits(amount1Human || '0', pool.decimals1),
...(slippageBps != null ? { slippageBps } : {}),
...(useNativeEth != null ? { useNativeEth } : {}),
}
}
export async function getLpQuote(input: LpDepositInput): Promise<LpQuote> {
const res = await walletPost<{ success: boolean; data: LpQuote }>('/api/wallets/ETH/lp/quote', buildLpBody(input))
return res.data
}
// ── 2.3 Депозит ──
export interface AddLpResult {
feeTxids: string[]
approveTxids: string[]
mintTxid: string
wrapTxid?: string
tickLower: number
tickUpper: number
appFee0Human: string
appFee1Human: string
}
export async function addLp(input: LpDepositInput): Promise<AddLpResult> {
const res = await walletPost<{ success: boolean; data: AddLpResult }>(
'/api/wallets/ETH/lp/add',
buildLpBody(input),
true,
idempotency()
)
return res.data
}
// ── 2.4 Вывод ──
export interface RemoveLpResult {
removeTxid: string
removedLiquidity: string
burned: boolean
}
export async function removeLp(tokenId: string, percent = 100): Promise<RemoveLpResult> {
const res = await walletPost<{ success: boolean; data: RemoveLpResult }>(
'/api/wallets/ETH/lp/remove',
{ tokenId, percent },
true,
idempotency()
)
return res.data
}
// ── 2.5 Позиции юзера ──
export interface LpPosition {
tokenId: string
poolAddress: string
symbol0: string
symbol1: string
feeTier: number
tickLower: number
tickUpper: number
priceLower: number
priceUpper: number
liquidity: string
currentAmount0Human: string
currentAmount1Human: string
feesOwed0Human: string
feesOwed1Human: string
/** false → цена вышла за диапазон, позиция временно не зарабатывает комиссии. */
inRange: boolean
}
export interface LpPositions {
chain: 'ETH'
positions: LpPosition[]
totalNfts: number
scanned: number
truncated: boolean
}
export async function getLpPositions(): Promise<LpPositions> {
const res = await walletGet<{ success: boolean; data: LpPositions }>('/api/wallets/ETH/lp/positions')
return res.data
}
// ── Ошибки ──
/** Достаёт человекочитаемый текст из конверта ошибки {success:false,error,code} или Error. */
export function getPoolsErrorMessage(e: unknown): string {
if (e && typeof e === 'object' && 'error' in e) {
const msg = (e as { error?: unknown }).error
if (typeof msg === 'string') return msg
}
if (e instanceof Error) {
return e.message === 'Unauthorized' ? 'Сессия истекла — войдите снова.' : e.message
}
return 'Произошла ошибка. Попробуйте ещё раз.'
}

View File

@@ -0,0 +1,13 @@
export { usePools, useLpPositions, useLpQuote, useAddLp, useRemoveLp } from './model/usePools'
export { getPoolsErrorMessage } from './api/poolsApi'
export type {
Pool,
LpQuote,
LpBalanceLeg,
LpGasLeg,
LpDepositInput,
LpPosition,
LpPositions,
AddLpResult,
RemoveLpResult,
} from './api/poolsApi'

View File

@@ -0,0 +1,85 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getPools,
getLpQuote,
getLpPositions,
addLp,
removeLp,
type LpDepositInput,
} from '../api/poolsApi'
export function usePools() {
return useQuery({
queryKey: ['pools', 'list'],
queryFn: getPools,
staleTime: 30_000,
retry: false,
})
}
export function useLpPositions() {
return useQuery({
queryKey: ['pools', 'positions'],
queryFn: getLpPositions,
staleTime: 30_000,
retry: false,
})
}
/**
* Квота депозита. Запрашивается, только когда пул выбран, обе границы диапазона > 0
* и задана хотя бы одна сумма. Вход дебаунсится на стороне виджета.
*/
export function useLpQuote(input: LpDepositInput | null) {
const ready =
input != null &&
input.priceLower > 0 &&
input.priceUpper > 0 &&
(parseFloat(input.amount0Human) > 0 || parseFloat(input.amount1Human) > 0)
return useQuery({
queryKey: [
'pools',
'quote',
input?.pool.poolAddress,
input?.priceLower,
input?.priceUpper,
input?.amount0Human,
input?.amount1Human,
input?.slippageBps,
input?.useNativeEth,
],
queryFn: () => getLpQuote(input as LpDepositInput),
enabled: ready,
staleTime: 15_000,
retry: false,
})
}
export function useAddLp() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: LpDepositInput) => addLp(input),
// Депозит отражается в сети не мгновенно: баланс обновляем сразу, позиции — с задержкой,
// иначе ранний рефетч «мигнул» бы (позиции ещё нет). Паттерн из useStake.
onSettled: () => {
qc.invalidateQueries({ queryKey: ['wallet', 'balance', 'ETH'] })
setTimeout(() => {
qc.invalidateQueries({ queryKey: ['pools', 'positions'] })
qc.invalidateQueries({ queryKey: ['wallet', 'balance', 'ETH'] })
}, 6000)
},
})
}
export function useRemoveLp() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ tokenId, percent }: { tokenId: string; percent: number }) => removeLp(tokenId, percent),
onSuccess: () => {
// Ликвидность + накопленные комиссии вернулись на кошелёк.
qc.invalidateQueries({ queryKey: ['pools', 'positions'] })
qc.invalidateQueries({ queryKey: ['wallet', 'balance', 'ETH'] })
},
})
}

View File

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

View File

@@ -69,6 +69,13 @@
gap: 10px; gap: 10px;
} }
.message {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
padding: 24px 0;
}
.pos { .pos {
background: var(--bg-deep); background: var(--bg-deep);
border: 1px solid var(--glass-border); 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 { 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' import styles from './LpPositionsPanel.module.css'
interface Props { interface Props {
positions: LpPosition[] positions: LpPosition[]
onRemove: (tokenId: string) => void onRemove: (tokenId: string) => void
removePending: boolean 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 const empty = positions.length === 0
return ( return (
@@ -23,7 +26,11 @@ export function LpPositionsPanel({ positions, onRemove, removePending }: Props)
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{empty && ( {isLoading && <Spinner label="Загрузка позиций" />}
{!isLoading && error && <div className={styles.message}>{error}</div>}
{!isLoading && !error && empty && (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<span className={styles.emptyIcon} aria-hidden> <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"> <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> </div>
)} )}
{!empty && ( {!isLoading && !error && !empty && (
<div className={styles.list}> <div className={styles.list}>
{positions.map((p) => ( {positions.map((p) => (
<div key={p.tokenId} className={styles.pos}> <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; overflow-x: auto;
} }
.message {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
padding: 24px 0;
}
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;

View File

@@ -1,11 +1,14 @@
import { TokenIcon } from '@shared/ui' import { Spinner, TokenIcon } from '@shared/ui'
import { coinIcon, formatFeeTier, type Pool } from '../model/meta' import type { Pool } from '@features/pools'
import { coinIcon, formatFeeTier } from '../model/meta'
import styles from './PoolsTable.module.css' import styles from './PoolsTable.module.css'
interface Props { interface Props {
pools: Pool[] pools: Pool[]
selected: string | null selected: string | null
onSelect: (poolAddress: string) => void onSelect: (poolAddress: string) => void
loading?: boolean
error?: string | null
} }
const DASH = '—' 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 ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.head}> <div className={styles.head}>
<span className={styles.title}>Курируемые пулы</span> <span className={styles.title}>Курируемые пулы</span>
</div> </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}> <div className={styles.scroll}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
@@ -94,6 +104,7 @@ export function PoolsTable({ pools, selected, onSelect }: Props) {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div> </div>
) )
} }

View File

@@ -1,42 +1,135 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { Notification } from '@shared/ui' 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 { PoolsTable } from './PoolsTable'
import { LpDepositCard } from './LpDepositCard' import { LpDepositCard } from './LpDepositCard'
import { LpPositionsPanel } from './LpPositionsPanel' import { LpPositionsPanel } from './LpPositionsPanel'
import { MOCK_POOLS, MOCK_POSITIONS, MOCK_QUOTE } from '../model/meta' import { LpRemoveModal } from './LpRemoveModal'
import styles from './PoolsWidget.module.css' import styles from './PoolsWidget.module.css'
type Toast = { status: 'success' | 'error' | 'info'; message: string } | null type Toast = { status: 'success' | 'error' | 'info'; message: string } | null
// Предзаполнение диапазона при выборе пула: текущая цена ±5%.
const RANGE_SPREAD = 0.05
export function PoolsWidget() { 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 [amount0, setAmount0] = useState('')
const [amount1, setAmount1] = useState('') const [amount1, setAmount1] = useState('')
const [priceLower, setPriceLower] = useState('') const [priceLower, setPriceLower] = useState('')
const [priceUpper, setPriceUpper] = useState('') const [priceUpper, setPriceUpper] = useState('')
const [slippage, setSlippage] = useState('1') const [slippage, setSlippage] = useState('1')
const [useNativeEth, setUseNativeEth] = useState(true) const [useNativeEth, setUseNativeEth] = useState(true)
const [removeTarget, setRemoveTarget] = useState<LpPosition | null>(null)
const [toast, setToast] = useState<Toast>(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 handleSelect(poolAddress: string) {
function handleSubmit() { setSelected(poolAddress)
setToast({ status: 'info', message: 'Депозит в пул будет доступен после подключения API' }) 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() { // Дебаунсим вход квоты, чтобы не дёргать /lp/quote на каждый символ.
setToast({ status: 'info', message: 'Вывод позиции будет доступен после подключения API' }) 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 ( return (
<div className={styles.widget}> <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}> <div className={styles.grid}>
<LpDepositCard <LpDepositCard
pool={pool} pool={pool}
quote={MOCK_QUOTE} quote={quoteQuery.data}
quoteFetching={quoteQuery.isFetching}
pending={addM.isPending}
amount0={amount0} amount0={amount0}
amount1={amount1} amount1={amount1}
priceLower={priceLower} priceLower={priceLower}
@@ -51,9 +144,24 @@ export function PoolsWidget() {
onUseNativeEthChange={setUseNativeEth} onUseNativeEthChange={setUseNativeEth}
onSubmit={handleSubmit} 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> </div>
{removeTarget && (
<LpRemoveModal
position={removeTarget}
pending={removeM.isPending}
onConfirm={handleConfirmRemove}
onClose={() => setRemoveTarget(null)}
/>
)}
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />} {toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
</div> </div>
) )

File diff suppressed because one or more lines are too long