add pull page
This commit is contained in:
1
src/widgets/pools/index.ts
Normal file
1
src/widgets/pools/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PoolsWidget } from './ui/PoolsWidget'
|
||||
147
src/widgets/pools/model/meta.ts
Normal file
147
src/widgets/pools/model/meta.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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' }
|
||||
return COIN_ICONS[map[symbol] ?? symbol]
|
||||
}
|
||||
|
||||
/** feeTier (сотые доли процента) → человекочитаемые проценты: 3000 → «0.3%». */
|
||||
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,
|
||||
},
|
||||
}
|
||||
267
src/widgets/pools/ui/LpDepositCard.module.css
Normal file
267
src/widgets/pools/ui/LpDepositCard.module.css
Normal file
@@ -0,0 +1,267 @@
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.fee {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amounts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.amountRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.amountInput {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.amountInput::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.range {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.priceField,
|
||||
.slippage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.priceInput {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.priceInput::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.slippage {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--interactive);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quote {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 4px 18px;
|
||||
}
|
||||
|
||||
.quoteRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.quoteRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.quoteLabel {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quoteValue {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin: 16px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Пустое состояние, пока пул не выбран. */
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
padding: 40px 18px;
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.placeholderTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.options {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.slippage {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
207
src/widgets/pools/ui/LpDepositCard.tsx
Normal file
207
src/widgets/pools/ui/LpDepositCard.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { PrimaryButton, TokenIcon } from '@shared/ui'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import { coinIcon, formatFeeTier, type Pool, type LpQuote } from '../model/meta'
|
||||
import styles from './LpDepositCard.module.css'
|
||||
|
||||
interface Props {
|
||||
pool: Pool | null
|
||||
quote: LpQuote
|
||||
amount0: string
|
||||
amount1: string
|
||||
priceLower: string
|
||||
priceUpper: string
|
||||
slippage: string
|
||||
useNativeEth: boolean
|
||||
onAmount0Change: (v: string) => void
|
||||
onAmount1Change: (v: string) => void
|
||||
onPriceLowerChange: (v: string) => void
|
||||
onPriceUpperChange: (v: string) => void
|
||||
onSlippageChange: (v: string) => void
|
||||
onUseNativeEthChange: (v: boolean) => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const DASH = '—'
|
||||
const decimalRe = /^(\d+\.?\d*|\.?\d*)$/
|
||||
|
||||
function AmountInput({
|
||||
symbol,
|
||||
value,
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
symbol: string
|
||||
value: string
|
||||
color: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.amountRow}>
|
||||
<input
|
||||
className={styles.amountInput}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (decimalRe.test(v) || v === '') onChange(v)
|
||||
}}
|
||||
placeholder="0"
|
||||
aria-label={`Сумма ${symbol}`}
|
||||
/>
|
||||
<div className={styles.token}>
|
||||
<TokenIcon letter={symbol[0]} color={color} logo={coinIcon(symbol)} size={28} />
|
||||
<span>{symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<label className={styles.priceField}>
|
||||
<span className={styles.priceLabel}>{label}</span>
|
||||
<input
|
||||
className={styles.priceInput}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (decimalRe.test(v) || v === '') onChange(v)
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function LpDepositCard({
|
||||
pool,
|
||||
quote,
|
||||
amount0,
|
||||
amount1,
|
||||
priceLower,
|
||||
priceUpper,
|
||||
slippage,
|
||||
useNativeEth,
|
||||
onAmount0Change,
|
||||
onAmount1Change,
|
||||
onPriceLowerChange,
|
||||
onPriceUpperChange,
|
||||
onSlippageChange,
|
||||
onUseNativeEthChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
if (!pool) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.placeholder}>
|
||||
<span className={styles.placeholderIcon} aria-hidden>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="9" r="5" />
|
||||
<circle cx="15" cy="15" r="5" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className={styles.placeholderTitle}>Выберите пул</p>
|
||||
<p className={styles.placeholderText}>
|
||||
Выберите пул в таблице выше, чтобы внести ликвидность.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const insufficient = quote.balance.sufficient === false
|
||||
const apr = quote.aprPercent != null ? `${quote.aprPercent}%` : DASH
|
||||
const feeUsd = quote.appFeeTotalUsd != null ? `≈ $${quote.appFeeTotalUsd.toFixed(2)}` : DASH
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.top}>
|
||||
<span className={styles.pair}>
|
||||
<span className={styles.icons}>
|
||||
<TokenIcon letter={pool.symbol0[0]} color="var(--grad-center)" logo={coinIcon(pool.symbol0)} size={24} />
|
||||
<TokenIcon letter={pool.symbol1[0]} color="var(--grad-edge)" logo={coinIcon(pool.symbol1)} size={24} />
|
||||
</span>
|
||||
{pool.symbol0}/{pool.symbol1}
|
||||
</span>
|
||||
<span className={styles.fee}>{formatFeeTier(pool.feeTier)}</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.tag}>Суммы взноса</span>
|
||||
<div className={styles.amounts}>
|
||||
<AmountInput symbol={pool.symbol0} value={amount0} color="var(--grad-center)" onChange={onAmount0Change} />
|
||||
<AmountInput symbol={pool.symbol1} value={amount1} color="var(--grad-edge)" onChange={onAmount1Change} />
|
||||
</div>
|
||||
|
||||
<span className={styles.tag}>Диапазон цен ({pool.symbol1} за {pool.symbol0})</span>
|
||||
<div className={styles.range}>
|
||||
<PriceInput label="Мин." value={priceLower} onChange={onPriceLowerChange} />
|
||||
<PriceInput label="Макс." value={priceUpper} onChange={onPriceUpperChange} />
|
||||
</div>
|
||||
|
||||
<div className={styles.options}>
|
||||
<label className={styles.slippage}>
|
||||
<span className={styles.priceLabel}>Проскальзывание, %</span>
|
||||
<input
|
||||
className={styles.priceInput}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={slippage}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (decimalRe.test(v) || v === '') onSlippageChange(v)
|
||||
}}
|
||||
placeholder="1"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.toggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useNativeEth}
|
||||
onChange={(e) => onUseNativeEthChange(e.target.checked)}
|
||||
/>
|
||||
<span>Использовать нативный ETH</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.quote}>
|
||||
<div className={styles.quoteRow}>
|
||||
<span className={styles.quoteLabel}>Взнос {pool.symbol0}</span>
|
||||
<span className={styles.quoteValue}>{truncateDecimals(quote.amount0DesiredHuman, 8)}</span>
|
||||
</div>
|
||||
<div className={styles.quoteRow}>
|
||||
<span className={styles.quoteLabel}>Взнос {pool.symbol1}</span>
|
||||
<span className={styles.quoteValue}>{truncateDecimals(quote.amount1DesiredHuman, 8)}</span>
|
||||
</div>
|
||||
<div className={styles.quoteRow}>
|
||||
<span className={styles.quoteLabel}>Комиссия сервиса (0.7%)</span>
|
||||
<span className={styles.quoteValue}>{feeUsd}</span>
|
||||
</div>
|
||||
<div className={styles.quoteRow}>
|
||||
<span className={styles.quoteLabel}>Годовая доходность (APR)</span>
|
||||
<span className={`${styles.quoteValue} ${styles.success}`}>{apr}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{insufficient && (
|
||||
<p className={styles.warning}>
|
||||
Недостаточно средств: не хватает {quote.balance.token1.symbol}. Пополните баланс.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.submit}>
|
||||
<PrimaryButton label="Внести в пул" type="button" onClick={onSubmit} disabled={insufficient} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
223
src/widgets/pools/ui/LpPositionsPanel.module.css
Normal file
223
src/widgets/pools/ui/LpPositionsPanel.module.css
Normal file
@@ -0,0 +1,223 @@
|
||||
.panel {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* На десктопе высота блока позиций не превышает левую колонку — список скроллится внутри. */
|
||||
@media (min-width: 1101px) {
|
||||
.panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.totalUnit {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pos {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.posTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.coin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.fee {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeActive {
|
||||
color: var(--success);
|
||||
background: rgba(38, 161, 123, 0.14);
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
color: var(--error);
|
||||
background: rgba(255, 68, 102, 0.14);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
padding: 28px 18px;
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
max-width: 280px;
|
||||
}
|
||||
95
src/widgets/pools/ui/LpPositionsPanel.tsx
Normal file
95
src/widgets/pools/ui/LpPositionsPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { TokenIcon } from '@shared/ui'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import { coinIcon, formatFeeTier, type LpPosition } from '../model/meta'
|
||||
import styles from './LpPositionsPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
positions: LpPosition[]
|
||||
onRemove: (tokenId: string) => void
|
||||
removePending: boolean
|
||||
}
|
||||
|
||||
export function LpPositionsPanel({ positions, onRemove, removePending }: Props) {
|
||||
const empty = positions.length === 0
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title}>Мои позиции</span>
|
||||
<div className={styles.total}>
|
||||
{positions.length} <span className={styles.totalUnit}>NFT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{empty && (
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.emptyIcon} aria-hidden>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="9" r="5" />
|
||||
<circle cx="15" cy="15" r="5" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className={styles.emptyTitle}>Активных позиций пока нет</p>
|
||||
<p className={styles.emptyText}>На данный момент у Вас нет позиций в пулах ликвидности.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!empty && (
|
||||
<div className={styles.list}>
|
||||
{positions.map((p) => (
|
||||
<div key={p.tokenId} className={styles.pos}>
|
||||
<div className={styles.posTop}>
|
||||
<span className={styles.coin}>
|
||||
<span className={styles.icons}>
|
||||
<TokenIcon letter={p.symbol0[0]} color="var(--grad-center)" logo={coinIcon(p.symbol0)} size={24} />
|
||||
<TokenIcon letter={p.symbol1[0]} color="var(--grad-edge)" logo={coinIcon(p.symbol1)} size={24} />
|
||||
</span>
|
||||
{p.symbol0}/{p.symbol1}
|
||||
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
|
||||
</span>
|
||||
<span className={`${styles.badge} ${p.inRange ? styles.badgeActive : styles.badgeWarn}`}>
|
||||
{p.inRange ? 'В диапазоне' : 'Вне диапазона ⚠️'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.rows}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Диапазон цен</span>
|
||||
<span className={styles.value}>
|
||||
{p.priceLower} – {p.priceUpper}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>{p.symbol0}</span>
|
||||
<span className={styles.value}>{truncateDecimals(p.currentAmount0Human, 8)}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>{p.symbol1}</span>
|
||||
<span className={styles.value}>{truncateDecimals(p.currentAmount1Human, 8)}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>Комиссии к выводу</span>
|
||||
<span className={`${styles.value} ${styles.success}`}>
|
||||
{truncateDecimals(p.feesOwed0Human, 8)} {p.symbol0} · {truncateDecimals(p.feesOwed1Human, 8)} {p.symbol1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.btn}
|
||||
onClick={() => onRemove(p.tokenId)}
|
||||
disabled={removePending}
|
||||
>
|
||||
Вывести
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
src/widgets/pools/ui/PoolsTable.module.css
Normal file
148
src/widgets/pools/ui/PoolsTable.module.css
Normal file
@@ -0,0 +1,148 @@
|
||||
.card {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
padding: 0 16px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thLeft {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.row,
|
||||
.rowSelected {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.rowSelected {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdLeft {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.tdAction {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.apr {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Вторая монета пары слегка наезжает на первую. */
|
||||
.icons > :last-child {
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.pairName {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fee {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.btn,
|
||||
.btnSelected {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.btnSelected {
|
||||
border: none;
|
||||
background: var(--highlight);
|
||||
color: var(--bg-deep);
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
99
src/widgets/pools/ui/PoolsTable.tsx
Normal file
99
src/widgets/pools/ui/PoolsTable.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { TokenIcon } from '@shared/ui'
|
||||
import { coinIcon, formatFeeTier, type Pool } from '../model/meta'
|
||||
import styles from './PoolsTable.module.css'
|
||||
|
||||
interface Props {
|
||||
pools: Pool[]
|
||||
selected: string | null
|
||||
onSelect: (poolAddress: string) => void
|
||||
}
|
||||
|
||||
const DASH = '—'
|
||||
|
||||
function formatUsd(value: number | null): string {
|
||||
if (value == null) return DASH
|
||||
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`
|
||||
if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K`
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function formatPrice(value: number): string {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function PairIcons({ symbol0, symbol1 }: { symbol0: string; symbol1: string }) {
|
||||
return (
|
||||
<span className={styles.pair}>
|
||||
<span className={styles.icons}>
|
||||
<TokenIcon letter={symbol0[0]} color="var(--grad-center)" logo={coinIcon(symbol0)} size={24} />
|
||||
<TokenIcon letter={symbol1[0]} color="var(--grad-edge)" logo={coinIcon(symbol1)} size={24} />
|
||||
</span>
|
||||
<span className={styles.pairName}>
|
||||
{symbol0}/{symbol1}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function PoolsTable({ pools, selected, onSelect }: Props) {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title}>Курируемые пулы</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thLeft}>Пул</th>
|
||||
<th>Комиссия</th>
|
||||
<th>Цена</th>
|
||||
<th>TVL</th>
|
||||
<th>Объём 24ч</th>
|
||||
<th>APR</th>
|
||||
<th aria-label="Действие" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pools.map((p) => {
|
||||
const isSelected = p.poolAddress === selected
|
||||
return (
|
||||
<tr
|
||||
key={p.poolAddress}
|
||||
className={isSelected ? styles.rowSelected : styles.row}
|
||||
onClick={() => onSelect(p.poolAddress)}
|
||||
>
|
||||
<td className={styles.tdLeft}>
|
||||
<PairIcons symbol0={p.symbol0} symbol1={p.symbol1} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.fee}>{formatFeeTier(p.feeTier)}</span>
|
||||
</td>
|
||||
<td className={styles.mono}>{formatPrice(p.currentPrice)}</td>
|
||||
<td className={styles.mono}>{formatUsd(p.tvlUSD)}</td>
|
||||
<td className={styles.mono}>{formatUsd(p.volumeUSD24h)}</td>
|
||||
<td className={`${styles.mono} ${styles.apr}`}>
|
||||
{p.aprPercent != null ? `${p.aprPercent}%` : DASH}
|
||||
</td>
|
||||
<td className={styles.tdAction}>
|
||||
<button
|
||||
type="button"
|
||||
className={isSelected ? styles.btnSelected : styles.btn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(p.poolAddress)
|
||||
}}
|
||||
>
|
||||
{isSelected ? 'Выбран' : 'Выбрать'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/widgets/pools/ui/PoolsWidget.module.css
Normal file
18
src/widgets/pools/ui/PoolsWidget.module.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
60
src/widgets/pools/ui/PoolsWidget.tsx
Normal file
60
src/widgets/pools/ui/PoolsWidget.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { Notification } from '@shared/ui'
|
||||
import { PoolsTable } from './PoolsTable'
|
||||
import { LpDepositCard } from './LpDepositCard'
|
||||
import { LpPositionsPanel } from './LpPositionsPanel'
|
||||
import { MOCK_POOLS, MOCK_POSITIONS, MOCK_QUOTE } from '../model/meta'
|
||||
import styles from './PoolsWidget.module.css'
|
||||
|
||||
type Toast = { status: 'success' | 'error' | 'info'; message: string } | null
|
||||
|
||||
export function PoolsWidget() {
|
||||
const [selected, setSelected] = useState<string | null>(MOCK_POOLS[0]?.poolAddress ?? 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 [toast, setToast] = useState<Toast>(null)
|
||||
|
||||
const pool = MOCK_POOLS.find((p) => p.poolAddress === selected) ?? null
|
||||
|
||||
// Заглушки: логика endpoint появится позже, пока только тосты.
|
||||
function handleSubmit() {
|
||||
setToast({ status: 'info', message: 'Депозит в пул будет доступен после подключения API' })
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
setToast({ status: 'info', message: 'Вывод позиции будет доступен после подключения API' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<PoolsTable pools={MOCK_POOLS} selected={selected} onSelect={setSelected} />
|
||||
|
||||
<div className={styles.grid}>
|
||||
<LpDepositCard
|
||||
pool={pool}
|
||||
quote={MOCK_QUOTE}
|
||||
amount0={amount0}
|
||||
amount1={amount1}
|
||||
priceLower={priceLower}
|
||||
priceUpper={priceUpper}
|
||||
slippage={slippage}
|
||||
useNativeEth={useNativeEth}
|
||||
onAmount0Change={setAmount0}
|
||||
onAmount1Change={setAmount1}
|
||||
onPriceLowerChange={setPriceLower}
|
||||
onPriceUpperChange={setPriceUpper}
|
||||
onSlippageChange={setSlippage}
|
||||
onUseNativeEthChange={setUseNativeEth}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<LpPositionsPanel positions={MOCK_POSITIONS} onRemove={handleRemove} removePending={false} />
|
||||
</div>
|
||||
|
||||
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user