add staking page
This commit is contained in:
@@ -18,6 +18,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
|
||||
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
|
||||
import { ReestryPage } from '@pages/reestr-pd-rkn'
|
||||
import { TransactionsPage } from '@pages/transactions'
|
||||
import { StakingPage } from '@pages/staking'
|
||||
import { WalletLayout } from '@widgets/wallet-layout'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { ScrollToTop } from './ScrollToTop'
|
||||
@@ -53,6 +54,7 @@ export function RouterProvider() {
|
||||
<Route path={ROUTES.SWAP} element={<SwapPage />} />
|
||||
<Route path={ROUTES.BRIDGE} element={<BridgePage />} />
|
||||
<Route path={ROUTES.TRANSACTIONS} element={<TransactionsPage />} />
|
||||
<Route path={ROUTES.STAKING} element={<StakingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={ROUTES.WALLET} element={<WalletPage />} />
|
||||
|
||||
271
src/features/staking/api/stakingApi.ts
Normal file
271
src/features/staking/api/stakingApi.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { getCsrfToken } from '@shared/api/csrf'
|
||||
import { tokenStore, refreshAccessToken } from '@shared/api/tokenStore'
|
||||
import { toBaseUnits } from '@shared/lib/utils/baseUnits'
|
||||
|
||||
// Стейкинг живёт на том же custodial-хосте, что и кошелёк (см. features/wallet/api/walletApi.ts).
|
||||
const WALLET_API_URL = 'https://app.cryptowallet.elcsa.ru'
|
||||
|
||||
export type StakeChain = 'ETH' | 'SOL'
|
||||
|
||||
/** Десятичность смолл-юнитов: ETH = wei (10^18), SOL = lamports (10^9). */
|
||||
export const STAKE_DECIMALS: Record<StakeChain, number> = { ETH: 18, SOL: 9 }
|
||||
|
||||
// ── Низкоуровневые запросы (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() }
|
||||
}
|
||||
|
||||
// ── 1.1 Quote ──
|
||||
|
||||
interface EthQuoteRaw {
|
||||
amountWeiHuman: string
|
||||
appFeeWeiHuman: string
|
||||
stakeAmountWeiHuman: string
|
||||
expectedStEthWeiHuman: string
|
||||
aprPercent: number | null
|
||||
}
|
||||
|
||||
interface SolQuoteRaw {
|
||||
amountLamportsHuman: string
|
||||
appFeeLamportsHuman: string
|
||||
stakeLamportsHuman: string
|
||||
validator: string
|
||||
estApyPercent: number | null
|
||||
}
|
||||
|
||||
/** Нормализованное превью стейка для UI (human-числа). */
|
||||
export interface StakeQuote {
|
||||
chain: StakeChain
|
||||
amountHuman: string
|
||||
feeHuman: string
|
||||
afterFeeHuman: string
|
||||
/** Ожидаемый stETH (ETH) либо делегируемая сумма (SOL). */
|
||||
resultHuman: string
|
||||
aprPercent: number | null
|
||||
validator?: string
|
||||
}
|
||||
|
||||
export async function getStakeQuote(chain: StakeChain, amountHuman: string): Promise<StakeQuote> {
|
||||
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain])
|
||||
|
||||
if (chain === 'ETH') {
|
||||
const res = await walletPost<{ success: boolean; data: EthQuoteRaw }>('/api/wallets/ETH/stake/quote', { amount })
|
||||
const d = res.data
|
||||
return {
|
||||
chain,
|
||||
amountHuman: d.amountWeiHuman,
|
||||
feeHuman: d.appFeeWeiHuman,
|
||||
afterFeeHuman: d.stakeAmountWeiHuman,
|
||||
resultHuman: d.expectedStEthWeiHuman,
|
||||
aprPercent: d.aprPercent,
|
||||
}
|
||||
}
|
||||
|
||||
const res = await walletPost<{ success: boolean; data: SolQuoteRaw }>('/api/wallets/SOL/stake/quote', { amount })
|
||||
const d = res.data
|
||||
return {
|
||||
chain,
|
||||
amountHuman: d.amountLamportsHuman,
|
||||
feeHuman: d.appFeeLamportsHuman,
|
||||
afterFeeHuman: d.stakeLamportsHuman,
|
||||
resultHuman: d.stakeLamportsHuman,
|
||||
aprPercent: d.estApyPercent,
|
||||
validator: d.validator,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1.2 Stake ──
|
||||
|
||||
export async function stake(chain: StakeChain, amountHuman: string): Promise<void> {
|
||||
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS[chain])
|
||||
await walletPost(`/api/wallets/${chain}/stake`, { amount }, true, idempotency())
|
||||
}
|
||||
|
||||
// ── 1.3 Positions ──
|
||||
|
||||
export interface EthWithdrawalRequest {
|
||||
requestId: string
|
||||
amountStEthWeiHuman: string
|
||||
isFinalized: boolean
|
||||
isClaimed: boolean
|
||||
}
|
||||
|
||||
export interface EthStakePositions {
|
||||
chain: 'ETH'
|
||||
protocol: string
|
||||
stEthBalanceWeiHuman: string
|
||||
aprPercent: number | null
|
||||
withdrawalRequests: EthWithdrawalRequest[]
|
||||
}
|
||||
|
||||
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
|
||||
|
||||
export interface SolPosition {
|
||||
stakeAccount: string
|
||||
lamportsHuman: string
|
||||
state: SolState
|
||||
validator: string
|
||||
}
|
||||
|
||||
export interface SolStakePositions {
|
||||
chain: 'SOL'
|
||||
protocol: string
|
||||
validator: string
|
||||
positions: SolPosition[]
|
||||
}
|
||||
|
||||
export async function getEthPositions(): Promise<EthStakePositions> {
|
||||
const res = await walletGet<{ success: boolean; data: EthStakePositions }>('/api/wallets/ETH/stake/positions')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getSolPositions(): Promise<SolStakePositions> {
|
||||
const res = await walletGet<{ success: boolean; data: SolStakePositions }>('/api/wallets/SOL/stake/positions')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── 1.4 Unstake ──
|
||||
|
||||
export interface EthUnstakeResult {
|
||||
mode: 'swap' | 'queue'
|
||||
note?: string
|
||||
}
|
||||
|
||||
export async function unstakeEth(amountHuman: string, mode: 'swap' | 'queue'): Promise<EthUnstakeResult> {
|
||||
const amount = toBaseUnits(amountHuman, STAKE_DECIMALS.ETH)
|
||||
const res = await walletPost<{ success: boolean; data: EthUnstakeResult }>(
|
||||
'/api/wallets/ETH/unstake',
|
||||
{ amount, mode },
|
||||
true,
|
||||
idempotency()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface SolUnstakeResult {
|
||||
action: 'deactivate' | 'withdraw'
|
||||
note?: string
|
||||
}
|
||||
|
||||
export async function unstakeSol(stakeAccount: string): Promise<SolUnstakeResult> {
|
||||
const res = await walletPost<{ success: boolean; data: SolUnstakeResult }>(
|
||||
'/api/wallets/SOL/unstake',
|
||||
{ stakeAccount },
|
||||
true,
|
||||
idempotency()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── 1.5 Claim (ETH, mode=queue) ──
|
||||
|
||||
export async function claimUnstake(requestId: string): Promise<void> {
|
||||
await walletPost('/api/wallets/ETH/unstake/claim', { requestId }, true, idempotency())
|
||||
}
|
||||
|
||||
// ── Предварительная проверка стоимости/баланса (op-cost) ──
|
||||
// Read-only: считает комиссию 0.7% (в токене + нативе + USD) и проверяет, хватает ли
|
||||
// средств на сумму И нативки на газ. Для нативного стейкинга token = символ сети.
|
||||
|
||||
export interface OpCostLeg {
|
||||
symbol?: string
|
||||
have?: string | number
|
||||
need?: string | number
|
||||
needReserve?: string | number
|
||||
shortfall?: string | number
|
||||
sufficient?: boolean | null
|
||||
}
|
||||
|
||||
export interface OpCost {
|
||||
appCommission?: { inToken?: number; inNative?: number; usd?: number | null }
|
||||
balance?: { token?: OpCostLeg; gas?: OpCostLeg }
|
||||
/** Хватает ли средств на сумму + газ. */
|
||||
sufficient: boolean
|
||||
}
|
||||
|
||||
export async function getOpCost(chain: StakeChain, amountHuman: string): Promise<OpCost> {
|
||||
const res = await walletPost<{ success: boolean; data: OpCost }>(`/api/wallets/${chain}/op-cost`, {
|
||||
token: chain,
|
||||
amountHuman,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ── Ошибки ──
|
||||
|
||||
/** Достаёт человекочитаемый текст из конверта ошибки {success:false,error,code} или Error. */
|
||||
export function getStakingErrorMessage(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 'Произошла ошибка. Попробуйте ещё раз.'
|
||||
}
|
||||
12
src/features/staking/index.ts
Normal file
12
src/features/staking/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { useStakeQuote, useOpCost, useStakePositions, useStake, useUnstake, useClaim } from './model/useStaking'
|
||||
export { getStakingErrorMessage, STAKE_DECIMALS } from './api/stakingApi'
|
||||
export type {
|
||||
StakeChain,
|
||||
StakeQuote,
|
||||
OpCost,
|
||||
EthStakePositions,
|
||||
EthWithdrawalRequest,
|
||||
SolStakePositions,
|
||||
SolPosition,
|
||||
SolState,
|
||||
} from './api/stakingApi'
|
||||
76
src/features/staking/model/useStaking.ts
Normal file
76
src/features/staking/model/useStaking.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
getStakeQuote,
|
||||
getOpCost,
|
||||
getEthPositions,
|
||||
getSolPositions,
|
||||
stake,
|
||||
unstakeEth,
|
||||
unstakeSol,
|
||||
claimUnstake,
|
||||
type StakeChain,
|
||||
type EthStakePositions,
|
||||
type SolStakePositions,
|
||||
type EthUnstakeResult,
|
||||
type SolUnstakeResult,
|
||||
} from '../api/stakingApi'
|
||||
|
||||
export function useStakeQuote(chain: StakeChain, amountHuman: string) {
|
||||
const amount = parseFloat(amountHuman)
|
||||
return useQuery({
|
||||
queryKey: ['staking', 'quote', chain, amountHuman],
|
||||
queryFn: () => getStakeQuote(chain, amountHuman),
|
||||
enabled: Number.isFinite(amount) && amount > 0,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpCost(chain: StakeChain, amountHuman: string) {
|
||||
const amount = parseFloat(amountHuman)
|
||||
return useQuery({
|
||||
queryKey: ['staking', 'op-cost', chain, amountHuman],
|
||||
queryFn: () => getOpCost(chain, amountHuman),
|
||||
enabled: Number.isFinite(amount) && amount > 0,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStakePositions(chain: StakeChain) {
|
||||
return useQuery<EthStakePositions | SolStakePositions>({
|
||||
queryKey: ['staking', 'positions', chain],
|
||||
queryFn: () => (chain === 'ETH' ? getEthPositions() : getSolPositions()),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useStake() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ chain, amountHuman }: { chain: StakeChain; amountHuman: string }) => stake(chain, amountHuman),
|
||||
onSuccess: (_data, { chain }) => qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] }),
|
||||
})
|
||||
}
|
||||
|
||||
export type UnstakeVars =
|
||||
| { chain: 'ETH'; amountHuman: string; mode: 'swap' | 'queue' }
|
||||
| { chain: 'SOL'; stakeAccount: string }
|
||||
|
||||
export function useUnstake() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation<EthUnstakeResult | SolUnstakeResult, Error, UnstakeVars>({
|
||||
mutationFn: (vars) =>
|
||||
vars.chain === 'ETH' ? unstakeEth(vars.amountHuman, vars.mode) : unstakeSol(vars.stakeAccount),
|
||||
onSuccess: (_data, vars) => qc.invalidateQueries({ queryKey: ['staking', 'positions', vars.chain] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useClaim() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (requestId: string) => claimUnstake(requestId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['staking', 'positions', 'ETH'] }),
|
||||
})
|
||||
}
|
||||
1
src/pages/staking/index.ts
Normal file
1
src/pages/staking/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StakingPage } from './ui/StakingPage'
|
||||
34
src/pages/staking/ui/StakingPage.module.css
Normal file
34
src/pages/staking/ui/StakingPage.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.page {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 32px 48px;
|
||||
}
|
||||
|
||||
.head {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page {
|
||||
padding: 24px 16px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.title {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
14
src/pages/staking/ui/StakingPage.tsx
Normal file
14
src/pages/staking/ui/StakingPage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StakingWidget } from '@widgets/staking'
|
||||
import styles from './StakingPage.module.css'
|
||||
|
||||
export function StakingPage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<h1 className={styles.title}>Стейкинг</h1>
|
||||
<p className={styles.subtitle}>Зарабатывайте на хранении ETH и SOL. Комиссия сервиса — 0.7%.</p>
|
||||
</header>
|
||||
<StakingWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const ROUTES = {
|
||||
SEED_PHRASE: '/seed-phrase',
|
||||
CONVERTER: '/converter',
|
||||
KYC: '/kyc',
|
||||
STAKING: '/staking',
|
||||
RESTORE_PASSWORD: '/restore-password',
|
||||
PUBLICHNAYA_OFERTA: '/publichnaya-oferta',
|
||||
POLITIKA_PERSONALNYH_DANNYH: '/politika-personalnyh-dannyh',
|
||||
|
||||
1
src/widgets/staking/index.ts
Normal file
1
src/widgets/staking/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StakingWidget } from './ui/StakingWidget'
|
||||
19
src/widgets/staking/model/meta.ts
Normal file
19
src/widgets/staking/model/meta.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StakeChain, SolState } from '@features/staking'
|
||||
|
||||
/** Статические UI-метаданные по сети (всё динамическое приходит из API). */
|
||||
export const CHAIN_META: Record<StakeChain, { symbol: string; aprLabel: 'APR' | 'APY'; protocol: string }> = {
|
||||
ETH: { symbol: 'ETH', aprLabel: 'APR', protocol: 'Lido' },
|
||||
SOL: { symbol: 'SOL', aprLabel: 'APY', protocol: 'Нативный' },
|
||||
}
|
||||
|
||||
export const CHAIN_TABS: { chain: StakeChain; label: string }[] = [
|
||||
{ chain: 'ETH', label: 'ETH · Lido' },
|
||||
{ chain: 'SOL', label: 'SOL · нативный' },
|
||||
]
|
||||
|
||||
export const SOL_STATE_META: Record<SolState, { label: string; variant: 'active' | 'ready' | 'muted' }> = {
|
||||
active: { label: 'Активна', variant: 'active' },
|
||||
activating: { label: 'Активируется', variant: 'muted' },
|
||||
deactivating: { label: 'Деактивация', variant: 'muted' },
|
||||
inactive: { label: 'Завершена', variant: 'ready' },
|
||||
}
|
||||
42
src/widgets/staking/ui/ChainToggle.module.css
Normal file
42
src/widgets/staking/ui/ChainToggle.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: var(--font-sans);
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.inactive {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inactive:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
24
src/widgets/staking/ui/ChainToggle.tsx
Normal file
24
src/widgets/staking/ui/ChainToggle.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { StakeChain } from '@features/staking'
|
||||
import { CHAIN_TABS } from '../model/meta'
|
||||
import styles from './ChainToggle.module.css'
|
||||
|
||||
interface Props {
|
||||
value: StakeChain
|
||||
onChange: (chain: StakeChain) => void
|
||||
}
|
||||
|
||||
export function ChainToggle({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className={styles.toggle}>
|
||||
{CHAIN_TABS.map(({ chain, label }) => (
|
||||
<button
|
||||
key={chain}
|
||||
className={`${styles.tab} ${value === chain ? styles.active : styles.inactive}`}
|
||||
onClick={() => onChange(chain)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
src/widgets/staking/ui/PositionsPanel.module.css
Normal file
168
src/widgets/staking/ui/PositionsPanel.module.css
Normal file
@@ -0,0 +1,168 @@
|
||||
.panel {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.totalUnit {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pos {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.reqCard {
|
||||
border-color: rgba(0, 212, 255, 0.25);
|
||||
}
|
||||
|
||||
.posTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.coin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeActive {
|
||||
color: var(--success);
|
||||
background: rgba(38, 161, 123, 0.14);
|
||||
}
|
||||
|
||||
.badgeReady {
|
||||
color: var(--highlight);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.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: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
border: none;
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: var(--success);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 8px 0 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.amount + .btn,
|
||||
.meta + .btn {
|
||||
margin-top: 2px;
|
||||
}
|
||||
164
src/widgets/staking/ui/PositionsPanel.tsx
Normal file
164
src/widgets/staking/ui/PositionsPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Spinner, TokenIcon } from '@shared/ui'
|
||||
import { COIN_ICONS } from '@shared/assets/coins'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import { WithdrawNotice } from './WithdrawNotice'
|
||||
import { useStakePositions, getStakingErrorMessage, type StakeChain, type SolPosition } from '@features/staking'
|
||||
import { SOL_STATE_META } from '../model/meta'
|
||||
import styles from './PositionsPanel.module.css'
|
||||
|
||||
interface Props {
|
||||
chain: StakeChain
|
||||
onUnstakeEth: (amountHuman: string) => void
|
||||
onUnstakeSol: (position: SolPosition) => void
|
||||
onClaim: (requestId: string) => void
|
||||
unstakePending: boolean
|
||||
claimPending: boolean
|
||||
}
|
||||
|
||||
export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, unstakePending, claimPending }: Props) {
|
||||
const { data, isLoading, isError, error } = useStakePositions(chain)
|
||||
|
||||
const badgeClass: Record<'active' | 'ready' | 'muted', string> = {
|
||||
active: styles.badgeActive,
|
||||
ready: styles.badgeReady,
|
||||
muted: styles.badgeMuted,
|
||||
}
|
||||
|
||||
const total =
|
||||
data?.chain === 'ETH'
|
||||
? data.stEthBalanceWeiHuman
|
||||
: data?.chain === 'SOL'
|
||||
? data.positions.reduce((sum, p) => sum + (parseFloat(p.lamportsHuman) || 0), 0).toString()
|
||||
: '0'
|
||||
const unit = chain === 'ETH' ? 'stETH' : 'SOL'
|
||||
|
||||
const ethEmpty =
|
||||
data?.chain === 'ETH' && (parseFloat(data.stEthBalanceWeiHuman) || 0) === 0 && data.withdrawalRequests.length === 0
|
||||
const solEmpty = data?.chain === 'SOL' && data.positions.length === 0
|
||||
const empty = ethEmpty || solEmpty
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.title}>Мои позиции</span>
|
||||
<div className={styles.total}>
|
||||
{truncateDecimals(total, 6)} <span className={styles.totalUnit}>{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WithdrawNotice compact />
|
||||
|
||||
{isLoading && <Spinner label="Загрузка позиций" />}
|
||||
|
||||
{isError && <div className={styles.empty}>{getStakingErrorMessage(error)}</div>}
|
||||
|
||||
{!isLoading && !isError && empty && (
|
||||
<div className={styles.empty}>У вас пока нет активных позиций стейкинга.</div>
|
||||
)}
|
||||
|
||||
{data?.chain === 'ETH' && !empty && (
|
||||
<>
|
||||
{(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.pos}>
|
||||
<div className={styles.posTop}>
|
||||
<span className={styles.coin}>
|
||||
<TokenIcon letter="E" color="var(--grad-center)" logo={COIN_ICONS.ETH} size={24} />
|
||||
stETH
|
||||
</span>
|
||||
<span className={`${styles.badge} ${styles.badgeActive}`}>
|
||||
Активна{data.aprPercent != null ? ` · APR ${data.aprPercent}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.amount}>{truncateDecimals(data.stEthBalanceWeiHuman, 8)}</div>
|
||||
<button
|
||||
className={styles.btn}
|
||||
onClick={() => onUnstakeEth(data.stEthBalanceWeiHuman)}
|
||||
disabled={unstakePending}
|
||||
>
|
||||
Вывести
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.withdrawalRequests.length > 0 && (
|
||||
<>
|
||||
<div className={styles.subhead}>Заявки на вывод</div>
|
||||
<div className={styles.list}>
|
||||
{data.withdrawalRequests.map((r) => {
|
||||
const ready = r.isFinalized && !r.isClaimed
|
||||
return (
|
||||
<div key={r.requestId} className={`${styles.pos} ${ready ? styles.reqCard : ''}`}>
|
||||
<div className={styles.posTop}>
|
||||
<span className={styles.coin}>Заявка #{r.requestId}</span>
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
r.isClaimed ? styles.badgeMuted : ready ? styles.badgeReady : styles.badgeMuted
|
||||
}`}
|
||||
>
|
||||
{r.isClaimed ? 'Выведено' : ready ? 'Готово к выводу' : 'В очереди'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.amount}>{truncateDecimals(r.amountStEthWeiHuman, 8)} ETH</div>
|
||||
{ready ? (
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => onClaim(r.requestId)}
|
||||
disabled={claimPending}
|
||||
>
|
||||
Забрать ETH
|
||||
</button>
|
||||
) : r.isClaimed ? null : (
|
||||
<div className={styles.note}>Ожидает финализации в Lido</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data?.chain === 'SOL' && !empty && (
|
||||
<div className={styles.list}>
|
||||
{data.positions.map((p) => {
|
||||
const st = SOL_STATE_META[p.state]
|
||||
return (
|
||||
<div key={p.stakeAccount} className={styles.pos}>
|
||||
<div className={styles.posTop}>
|
||||
<span className={styles.coin}>
|
||||
<TokenIcon letter="S" color="var(--grad-center)" logo={COIN_ICONS.SOL} size={24} />
|
||||
SOL
|
||||
</span>
|
||||
<span className={`${styles.badge} ${badgeClass[st.variant]}`}>{st.label}</span>
|
||||
</div>
|
||||
<div className={styles.amount}>{truncateDecimals(p.lamportsHuman, 8)}</div>
|
||||
<div className={styles.meta}>
|
||||
Валидатор: {p.validator} · {p.stakeAccount}
|
||||
</div>
|
||||
{p.state === 'active' && (
|
||||
<button className={styles.btn} onClick={() => onUnstakeSol(p)} disabled={unstakePending}>
|
||||
Вывести
|
||||
</button>
|
||||
)}
|
||||
{p.state === 'inactive' && (
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => onUnstakeSol(p)}
|
||||
disabled={unstakePending}
|
||||
>
|
||||
Вывести средства
|
||||
</button>
|
||||
)}
|
||||
{p.state === 'deactivating' && <div className={styles.note}>Разблокируется через 1–2 эпохи</div>}
|
||||
{p.state === 'activating' && <div className={styles.note}>Активируется…</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/widgets/staking/ui/StakeCard.module.css
Normal file
171
src/widgets/staking/ui/StakeCard.module.css
Normal file
@@ -0,0 +1,171 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pillsOuter {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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: 18px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.apr {
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
background: rgba(38, 161, 123, 0.12);
|
||||
border: 1px solid rgba(38, 161, 123, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pillsInner {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
.mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.balance {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.max {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--interactive);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.max:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stakeBtn {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.stakeBtnDisabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.pillsOuter {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pillsInner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
162
src/widgets/staking/ui/StakeCard.tsx
Normal file
162
src/widgets/staking/ui/StakeCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { PrimaryButton, TokenIcon } from '@shared/ui'
|
||||
import { COIN_ICONS } from '@shared/assets/coins'
|
||||
import { truncateDecimals } from '@shared/lib/utils/truncateDecimals'
|
||||
import { useDebounce } from '@shared/lib/hooks/useDebounce'
|
||||
import { useWalletBalance } from '@features/wallet'
|
||||
import { useStakeQuote, useOpCost, type StakeChain, type StakeQuote, type OpCost } from '@features/staking'
|
||||
import { StakeQuotePanel, type QuoteRow } from './StakeQuotePanel'
|
||||
import { CHAIN_META } from '../model/meta'
|
||||
import styles from './StakeCard.module.css'
|
||||
|
||||
interface Props {
|
||||
chain: StakeChain
|
||||
amount: string
|
||||
onAmountChange: (value: string) => void
|
||||
onStake: () => void
|
||||
isStaking: boolean
|
||||
}
|
||||
|
||||
const PERCENTS = [25, 50, 100]
|
||||
|
||||
type Meta = (typeof CHAIN_META)[StakeChain]
|
||||
|
||||
const DASH = '—'
|
||||
|
||||
function usd(value: number): string {
|
||||
return `≈ $${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function buildRows(
|
||||
meta: Meta,
|
||||
quote: StakeQuote | undefined,
|
||||
opCost: OpCost | undefined,
|
||||
amountNum: number,
|
||||
nativeUsdPrice: number
|
||||
): QuoteRow[] {
|
||||
const resultLabel = meta.symbol === 'ETH' ? 'Получите stETH' : 'Делегируется валидатору'
|
||||
const aprLabel = `Годовая доходность (${meta.aprLabel})`
|
||||
|
||||
const fee = quote ? `${truncateDecimals(quote.feeHuman, 8)} ${meta.symbol}` : DASH
|
||||
const afterFee = quote ? `${truncateDecimals(quote.afterFeeHuman, 8)} ${meta.symbol}` : DASH
|
||||
const result = quote
|
||||
? meta.symbol === 'ETH'
|
||||
? `≈ ${truncateDecimals(quote.resultHuman, 8)}`
|
||||
: `${truncateDecimals(quote.resultHuman, 8)} SOL`
|
||||
: DASH
|
||||
const apr = quote?.aprPercent != null ? `${quote.aprPercent}%` : DASH
|
||||
|
||||
// Сетевой сбор (газ) и итог к списанию в USD — из op-cost.
|
||||
const gasRaw = opCost?.balance?.gas?.needReserve
|
||||
const gasNum = gasRaw != null ? parseFloat(String(gasRaw)) || 0 : 0
|
||||
const gas = gasRaw != null ? `≈ ${truncateDecimals(String(gasRaw), 8)} ${meta.symbol}` : DASH
|
||||
// Из кошелька списывается сумма (комиссия 0.7% внутри неё) + газ нативкой.
|
||||
const totalUsd = opCost && nativeUsdPrice > 0 && amountNum > 0 ? usd((amountNum + gasNum) * nativeUsdPrice) : DASH
|
||||
|
||||
return [
|
||||
{ label: 'Комиссия сервиса (0.7%)', value: fee },
|
||||
{ label: 'Сетевой сбор (газ)', value: gas },
|
||||
{ label: 'Сумма после комиссии', value: afterFee },
|
||||
{ label: resultLabel, value: result, accent: 'highlight' },
|
||||
{ label: 'Итого к списанию', value: totalUsd },
|
||||
{ label: aprLabel, value: apr, accent: 'success' },
|
||||
]
|
||||
}
|
||||
|
||||
export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: Props) {
|
||||
const meta = CHAIN_META[chain]
|
||||
const { data: balance } = useWalletBalance(chain)
|
||||
const balanceHuman = balance?.native.formatted ?? '0'
|
||||
const balanceNum = parseFloat(balanceHuman) || 0
|
||||
|
||||
const debounced = useDebounce(amount, 400)
|
||||
const { data: quote, isFetching } = useStakeQuote(chain, debounced)
|
||||
const { data: opCost, isError: opError } = useOpCost(chain, debounced)
|
||||
|
||||
const amountNum = parseFloat(amount) || 0
|
||||
const withinBalance = amountNum > 0 && amountNum <= balanceNum
|
||||
// Достаточность с учётом газа проверяет сервер (op-cost). Если эндпоинт недоступен —
|
||||
// не блокируем жёстко, откатываемся к проверке «не больше баланса».
|
||||
const sufficient = opError ? withinBalance : opCost?.sufficient === true
|
||||
const insufficient = withinBalance && opCost?.sufficient === false
|
||||
const gasShort = insufficient && opCost?.balance?.gas?.sufficient === false
|
||||
const canStake = withinBalance && sufficient && !isStaking
|
||||
|
||||
const setPercent = (p: number) => onAmountChange(((balanceNum * p) / 100).toString())
|
||||
|
||||
const pills = (
|
||||
<>
|
||||
{PERCENTS.map((p) => (
|
||||
<button key={p} className={styles.pill} onClick={() => setPercent(p)}>
|
||||
{p}%
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.pillsOuter}>{pills}</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.top}>
|
||||
<span className={styles.tag}>Сумма стейка</span>
|
||||
<div className={styles.right}>
|
||||
<span className={styles.apr}>
|
||||
{meta.aprLabel} {quote?.aprPercent != null ? `${quote.aprPercent}%` : '—'}
|
||||
</span>
|
||||
<div className={styles.pillsInner}>{pills}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.mid}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (/^(\d+\.?\d*|\.?\d*)$/.test(v) || v === '') onAmountChange(v)
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
<div className={styles.token}>
|
||||
<TokenIcon letter={meta.symbol[0]} color="var(--grad-center)" logo={COIN_ICONS[meta.symbol]} size={28} />
|
||||
<span>{meta.symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<span className={styles.balance}>
|
||||
Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol}
|
||||
<button className={styles.max} onClick={() => setPercent(100)}>
|
||||
МАКС
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StakeQuotePanel
|
||||
rows={buildRows(meta, quote, opCost, amountNum, balance?.native.usdPrice ?? 0)}
|
||||
loading={isFetching}
|
||||
/>
|
||||
|
||||
{insufficient && (
|
||||
<p className={styles.hint}>
|
||||
{gasShort
|
||||
? `Недостаточно ${meta.symbol} на сетевую комиссию (газ).`
|
||||
: 'Недостаточно средств для стейкинга с учётом комиссии и газа.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`${styles.stakeBtn} ${!canStake ? styles.stakeBtnDisabled : ''}`}>
|
||||
<PrimaryButton
|
||||
label={isStaking ? 'Отправка…' : 'Застейкать'}
|
||||
type="button"
|
||||
onClick={onStake}
|
||||
disabled={!canStake}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/widgets/staking/ui/StakeQuotePanel.module.css
Normal file
45
src/widgets/staking/ui/StakeQuotePanel.module.css
Normal file
@@ -0,0 +1,45 @@
|
||||
.panel {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 4px 18px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.panel[data-loading='true'] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
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);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--highlight);
|
||||
}
|
||||
25
src/widgets/staking/ui/StakeQuotePanel.tsx
Normal file
25
src/widgets/staking/ui/StakeQuotePanel.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import styles from './StakeQuotePanel.module.css'
|
||||
|
||||
export interface QuoteRow {
|
||||
label: string
|
||||
value: string
|
||||
accent?: 'success' | 'highlight'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: QuoteRow[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StakeQuotePanel({ rows, loading }: Props) {
|
||||
return (
|
||||
<div className={styles.panel} data-loading={loading ? 'true' : undefined}>
|
||||
{rows.map(({ label, value, accent }) => (
|
||||
<div key={label} className={styles.row}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={`${styles.value} ${accent ? styles[accent] : ''}`}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/widgets/staking/ui/StakingWidget.module.css
Normal file
18
src/widgets/staking/ui/StakingWidget.module.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
139
src/widgets/staking/ui/StakingWidget.tsx
Normal file
139
src/widgets/staking/ui/StakingWidget.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { Notification } from '@shared/ui'
|
||||
import { ChainToggle } from './ChainToggle'
|
||||
import { StakeCard } from './StakeCard'
|
||||
import { PositionsPanel } from './PositionsPanel'
|
||||
import { UnstakeModal } from './UnstakeModal'
|
||||
import {
|
||||
useStake,
|
||||
useUnstake,
|
||||
useClaim,
|
||||
getStakingErrorMessage,
|
||||
type StakeChain,
|
||||
type SolPosition,
|
||||
} from '@features/staking'
|
||||
import { CHAIN_META } from '../model/meta'
|
||||
import styles from './StakingWidget.module.css'
|
||||
|
||||
type ModalState = { kind: 'eth'; amount: string } | { kind: 'sol'; position: SolPosition } | null
|
||||
|
||||
type Toast = { status: 'success' | 'error' | 'info'; message: string } | null
|
||||
|
||||
export function StakingWidget() {
|
||||
const [chain, setChain] = useState<StakeChain>('ETH')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [modal, setModal] = useState<ModalState>(null)
|
||||
const [toast, setToast] = useState<Toast>(null)
|
||||
|
||||
const stakeM = useStake()
|
||||
const unstakeM = useUnstake()
|
||||
const claimM = useClaim()
|
||||
|
||||
function handleChain(next: StakeChain) {
|
||||
setChain(next)
|
||||
setAmount('')
|
||||
}
|
||||
|
||||
function handleStake() {
|
||||
stakeM.mutate(
|
||||
{ chain, amountHuman: amount },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setToast({ status: 'success', message: `Стейкинг ${amount} ${CHAIN_META[chain].symbol} выполнен` })
|
||||
setAmount('')
|
||||
},
|
||||
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function handleConfirm(mode?: 'swap' | 'queue') {
|
||||
if (modal?.kind === 'eth') {
|
||||
const selected = mode ?? 'swap'
|
||||
unstakeM.mutate(
|
||||
{ chain: 'ETH', amountHuman: modal.amount, mode: selected },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setModal(null)
|
||||
setToast({
|
||||
status: 'success',
|
||||
message:
|
||||
selected === 'queue' ? 'Заявка на вывод добавлена в очередь Lido' : 'Вывод stETH → ETH выполнен',
|
||||
})
|
||||
},
|
||||
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
|
||||
}
|
||||
)
|
||||
} else if (modal?.kind === 'sol') {
|
||||
unstakeM.mutate(
|
||||
{ chain: 'SOL', stakeAccount: modal.position.stakeAccount },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setModal(null)
|
||||
const withdrawn = 'action' in res && res.action === 'withdraw'
|
||||
setToast({
|
||||
status: 'success',
|
||||
message: withdrawn ? 'Средства выведены на кошелёк' : 'Деактивация запущена — вывод через 1–2 эпохи',
|
||||
})
|
||||
},
|
||||
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClaim(requestId: string) {
|
||||
claimM.mutate(requestId, {
|
||||
onSuccess: () => setToast({ status: 'success', message: 'ETH забран на кошелёк' }),
|
||||
onError: (e) => setToast({ status: 'error', message: getStakingErrorMessage(e) }),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<ChainToggle value={chain} onChange={handleChain} />
|
||||
|
||||
<div className={styles.grid}>
|
||||
<StakeCard
|
||||
chain={chain}
|
||||
amount={amount}
|
||||
onAmountChange={setAmount}
|
||||
onStake={handleStake}
|
||||
isStaking={stakeM.isPending}
|
||||
/>
|
||||
<PositionsPanel
|
||||
chain={chain}
|
||||
onUnstakeEth={(amountHuman) => setModal({ kind: 'eth', amount: amountHuman })}
|
||||
onUnstakeSol={(position) => setModal({ kind: 'sol', position })}
|
||||
onClaim={handleClaim}
|
||||
unstakePending={unstakeM.isPending}
|
||||
claimPending={claimM.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modal?.kind === 'eth' && (
|
||||
<UnstakeModal
|
||||
chain="ETH"
|
||||
symbol="ETH"
|
||||
amount={modal.amount}
|
||||
pending={unstakeM.isPending}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
{modal?.kind === 'sol' && (
|
||||
<UnstakeModal
|
||||
chain="SOL"
|
||||
symbol="SOL"
|
||||
amount={modal.position.lamportsHuman}
|
||||
solState={modal.position.state}
|
||||
pending={unstakeM.isPending}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && <Notification status={toast.status} message={toast.message} onClose={() => setToast(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
src/widgets/staking/ui/UnstakeModal.module.css
Normal file
156
src/widgets/staking/ui/UnstakeModal.module.css
Normal file
@@ -0,0 +1,156 @@
|
||||
.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: 11px;
|
||||
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);
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mode {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
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);
|
||||
}
|
||||
|
||||
.modeTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modeDesc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.solNote {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modes {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
96
src/widgets/staking/ui/UnstakeModal.tsx
Normal file
96
src/widgets/staking/ui/UnstakeModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { WithdrawNotice } from './WithdrawNotice'
|
||||
import type { StakeChain, SolState } from '@features/staking'
|
||||
import styles from './UnstakeModal.module.css'
|
||||
|
||||
interface Props {
|
||||
chain: StakeChain
|
||||
/** Тикер сети (ETH / SOL). */
|
||||
symbol: string
|
||||
/** Сумма к выводу (human). */
|
||||
amount: string
|
||||
/** Состояние SOL-позиции (для двухфазного вывода). */
|
||||
solState?: SolState
|
||||
/** Идёт ли запрос вывода. */
|
||||
pending?: boolean
|
||||
onConfirm: (mode?: 'swap' | 'queue') => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function UnstakeModal({ chain, symbol, amount, solState, pending, onConfirm, onClose }: Props) {
|
||||
const [mode, setMode] = useState<'swap' | 'queue'>('swap')
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const isSolWithdraw = chain === 'SOL' && solState === 'inactive'
|
||||
const confirmLabel = pending
|
||||
? 'Отправка…'
|
||||
: chain === 'ETH'
|
||||
? 'Подтвердить вывод'
|
||||
: isSolWithdraw
|
||||
? 'Вывести средства'
|
||||
: 'Запустить деактивацию'
|
||||
|
||||
const showNotice = chain === 'SOL' || (chain === 'ETH' && mode === 'queue')
|
||||
|
||||
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}>
|
||||
{amount} {chain === 'ETH' ? 'stETH' : symbol}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{chain === 'ETH' && (
|
||||
<div className={styles.modes}>
|
||||
<button
|
||||
className={`${styles.mode} ${mode === 'swap' ? styles.modeActive : ''}`}
|
||||
onClick={() => setMode('swap')}
|
||||
>
|
||||
<span className={styles.modeTitle}>Мгновенно</span>
|
||||
<span className={styles.modeDesc}>Своп stETH → ETH через Curve</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.mode} ${mode === 'queue' ? styles.modeActive : ''}`}
|
||||
onClick={() => setMode('queue')}
|
||||
>
|
||||
<span className={styles.modeTitle}>Очередь</span>
|
||||
<span className={styles.modeDesc}>Заявка в Lido Withdrawal Queue</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chain === 'SOL' && !isSolWithdraw && (
|
||||
<p className={styles.solNote}>
|
||||
Средства разблокируются через 1–2 эпохи. После завершения вернитесь и подтвердите вывод.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showNotice && <WithdrawNotice />}
|
||||
|
||||
<button
|
||||
className={styles.confirmBtn}
|
||||
onClick={() => onConfirm(chain === 'ETH' ? mode : undefined)}
|
||||
disabled={pending}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/widgets/staking/ui/WithdrawNotice.module.css
Normal file
32
src/widgets/staking/ui/WithdrawNotice.module.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--highlight);
|
||||
color: var(--bg-deep);
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
14
src/widgets/staking/ui/WithdrawNotice.tsx
Normal file
14
src/widgets/staking/ui/WithdrawNotice.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import styles from './WithdrawNotice.module.css'
|
||||
|
||||
interface Props {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function WithdrawNotice({ compact }: Props) {
|
||||
return (
|
||||
<div className={`${styles.notice} ${compact ? styles.compact : ''}`}>
|
||||
<span className={styles.icon} aria-hidden="true">!</span>
|
||||
<span>Вывод средств из стейкинга может занимать до 2 недель.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -92,6 +92,9 @@ export function WalletHeader() {
|
||||
<Link to={ROUTES.WALLET} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Кошелёк
|
||||
</Link>
|
||||
<Link to={ROUTES.STAKING} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Стейкинг
|
||||
</Link>
|
||||
<Link to={ROUTES.TRANSACTIONS} className={styles.dropdownItem} onClick={() => setOpen(false)}>
|
||||
Транзакции
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user