add staking page
This commit is contained in:
161
dist/assets/index-CvjPBUMw.js
vendored
Normal file
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
161
dist/assets/index-LxsThgvi.js
vendored
161
dist/assets/index-LxsThgvi.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
267
src/features/pools/api/poolsApi.ts
Normal file
267
src/features/pools/api/poolsApi.ts
Normal 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 'Произошла ошибка. Попробуйте ещё раз.'
|
||||||
|
}
|
||||||
13
src/features/pools/index.ts
Normal file
13
src/features/pools/index.ts
Normal 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'
|
||||||
85
src/features/pools/model/usePools.ts
Normal file
85
src/features/pools/model/usePools.ts
Normal 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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
144
src/widgets/pools/ui/LpRemoveModal.module.css
Normal file
144
src/widgets/pools/ui/LpRemoveModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
68
src/widgets/pools/ui/LpRemoveModal.tsx
Normal file
68
src/widgets/pools/ui/LpRemoveModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user