add staking page
This commit is contained in:
@@ -161,6 +161,8 @@ export interface EthStakePositions {
|
|||||||
stEthBalanceWeiHuman: string
|
stEthBalanceWeiHuman: string
|
||||||
aprPercent: number | null
|
aprPercent: number | null
|
||||||
withdrawalRequests: EthWithdrawalRequest[]
|
withdrawalRequests: EthWithdrawalRequest[]
|
||||||
|
/** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */
|
||||||
|
optimistic?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
|
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
|
||||||
@@ -170,6 +172,8 @@ export interface SolPosition {
|
|||||||
lamportsHuman: string
|
lamportsHuman: string
|
||||||
state: SolState
|
state: SolState
|
||||||
validator: string
|
validator: string
|
||||||
|
/** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */
|
||||||
|
optimistic?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SolStakePositions {
|
export interface SolStakePositions {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toBaseUnits, fromBaseUnits } from '@shared/lib/utils/baseUnits'
|
||||||
import {
|
import {
|
||||||
getStakeQuote,
|
getStakeQuote,
|
||||||
getOpCost,
|
getOpCost,
|
||||||
@@ -8,13 +9,38 @@ import {
|
|||||||
unstakeEth,
|
unstakeEth,
|
||||||
unstakeSol,
|
unstakeSol,
|
||||||
claimUnstake,
|
claimUnstake,
|
||||||
|
STAKE_DECIMALS,
|
||||||
type StakeChain,
|
type StakeChain,
|
||||||
type EthStakePositions,
|
type EthStakePositions,
|
||||||
type SolStakePositions,
|
type SolStakePositions,
|
||||||
|
type SolPosition,
|
||||||
type EthUnstakeResult,
|
type EthUnstakeResult,
|
||||||
type SolUnstakeResult,
|
type SolUnstakeResult,
|
||||||
} from '../api/stakingApi'
|
} from '../api/stakingApi'
|
||||||
|
|
||||||
|
type Positions = EthStakePositions | SolStakePositions
|
||||||
|
|
||||||
|
/** Оптимистично добавляет только что застейканную сумму в текущие позиции. */
|
||||||
|
function addOptimisticPosition(prev: Positions, amountHuman: string): Positions {
|
||||||
|
if (prev.chain === 'ETH') {
|
||||||
|
const base = BigInt(toBaseUnits(prev.stEthBalanceWeiHuman, STAKE_DECIMALS.ETH))
|
||||||
|
const add = BigInt(toBaseUnits(amountHuman, STAKE_DECIMALS.ETH))
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
stEthBalanceWeiHuman: fromBaseUnits((base + add).toString(), STAKE_DECIMALS.ETH),
|
||||||
|
optimistic: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const optimistic: SolPosition = {
|
||||||
|
stakeAccount: 'pending',
|
||||||
|
lamportsHuman: amountHuman,
|
||||||
|
state: 'activating',
|
||||||
|
validator: prev.validator,
|
||||||
|
optimistic: true,
|
||||||
|
}
|
||||||
|
return { ...prev, positions: [optimistic, ...prev.positions] }
|
||||||
|
}
|
||||||
|
|
||||||
export function useStakeQuote(chain: StakeChain, amountHuman: string) {
|
export function useStakeQuote(chain: StakeChain, amountHuman: string) {
|
||||||
const amount = parseFloat(amountHuman)
|
const amount = parseFloat(amountHuman)
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -50,10 +76,28 @@ export function useStake() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ chain, amountHuman }: { chain: StakeChain; amountHuman: string }) => stake(chain, amountHuman),
|
mutationFn: ({ chain, amountHuman }: { chain: StakeChain; amountHuman: string }) => stake(chain, amountHuman),
|
||||||
onSuccess: (_data, { chain }) => {
|
// Рисуем позицию сразу, оптимистично — не ждём подтверждения.
|
||||||
qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] })
|
onMutate: async ({ chain, amountHuman }) => {
|
||||||
// Монеты ушли в стейк — обновляем баланс (prefix-match покрывает org/self).
|
const key = ['staking', 'positions', chain] as const
|
||||||
|
// Отменяем летящий рефетч, чтобы он не затёр оптимистичную позицию.
|
||||||
|
await qc.cancelQueries({ queryKey: key })
|
||||||
|
const previous = qc.getQueryData<Positions>(key)
|
||||||
|
if (previous) qc.setQueryData(key, addOptimisticPosition(previous, amountHuman))
|
||||||
|
return { key, previous }
|
||||||
|
},
|
||||||
|
// Стейк не прошёл — откатываем оптимистичную позицию (ошибку покажет тост в виджете).
|
||||||
|
onError: (_err, _vars, ctx) => {
|
||||||
|
if (ctx?.previous) qc.setQueryData(ctx.key, ctx.previous)
|
||||||
|
},
|
||||||
|
onSettled: (_data, _err, { chain }) => {
|
||||||
|
// Баланс обновляем сразу; позиции сверяем с сервером с задержкой — стейк отражается в
|
||||||
|
// сети не мгновенно, а ранний рефетч мигнул бы (позиции ещё нет → исчезла бы → вернулась).
|
||||||
|
// Если этот рефетч упадёт, PositionsPanel скроет данные и покажет ошибку.
|
||||||
qc.invalidateQueries({ queryKey: ['wallet', 'balance', chain] })
|
qc.invalidateQueries({ queryKey: ['wallet', 'balance', chain] })
|
||||||
|
setTimeout(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['wallet', 'balance', chain] })
|
||||||
|
}, 6000)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,22 @@
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Оптимистичная позиция — пока не подтверждена сервером: полупрозрачная + мягкая пульсация. */
|
||||||
|
.pending {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pendingPulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pendingPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.reqCard {
|
.reqCard {
|
||||||
border-color: rgba(0, 212, 255, 0.25);
|
border-color: rgba(0, 212, 255, 0.25);
|
||||||
}
|
}
|
||||||
@@ -149,7 +165,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 0 2px;
|
padding: 8px 0 2px;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
|
|||||||
<div className={styles.head}>
|
<div className={styles.head}>
|
||||||
<span className={styles.title}>Мои позиции</span>
|
<span className={styles.title}>Мои позиции</span>
|
||||||
<div className={styles.total}>
|
<div className={styles.total}>
|
||||||
{truncateDecimals(total, 6)} <span className={styles.totalUnit}>{unit}</span>
|
{truncateDecimals(isError ? '0' : total, 6)} <span className={styles.totalUnit}>{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,11 +56,11 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
|
|||||||
<div className={styles.empty}>У вас пока нет активных позиций стейкинга.</div>
|
<div className={styles.empty}>У вас пока нет активных позиций стейкинга.</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.chain === 'ETH' && !empty && (
|
{!isError && data?.chain === 'ETH' && !empty && (
|
||||||
<>
|
<>
|
||||||
{(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
|
{(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
<div className={styles.pos}>
|
<div className={`${styles.pos} ${data.optimistic ? styles.pending : ''}`}>
|
||||||
<div className={styles.posTop}>
|
<div className={styles.posTop}>
|
||||||
<span className={styles.coin}>
|
<span className={styles.coin}>
|
||||||
<TokenIcon letter="E" color="var(--grad-center)" logo={COIN_ICONS.ETH} size={24} />
|
<TokenIcon letter="E" color="var(--grad-center)" logo={COIN_ICONS.ETH} size={24} />
|
||||||
@@ -121,12 +121,12 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.chain === 'SOL' && !empty && (
|
{!isError && data?.chain === 'SOL' && !empty && (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{data.positions.map((p) => {
|
{data.positions.map((p) => {
|
||||||
const st = SOL_STATE_META[p.state]
|
const st = SOL_STATE_META[p.state]
|
||||||
return (
|
return (
|
||||||
<div key={p.stakeAccount} className={styles.pos}>
|
<div key={p.stakeAccount} className={`${styles.pos} ${p.optimistic ? styles.pending : ''}`}>
|
||||||
<div className={styles.posTop}>
|
<div className={styles.posTop}>
|
||||||
<span className={styles.coin}>
|
<span className={styles.coin}>
|
||||||
<TokenIcon letter="S" color="var(--grad-center)" logo={COIN_ICONS.SOL} size={24} />
|
<TokenIcon letter="S" color="var(--grad-center)" logo={COIN_ICONS.SOL} size={24} />
|
||||||
|
|||||||
@@ -47,8 +47,7 @@
|
|||||||
/* Финальный акцентный итог «Вы получите …» — отделён разделителем и крупнее. */
|
/* Финальный акцентный итог «Вы получите …» — отделён разделителем и крупнее. */
|
||||||
.rowTotal {
|
.rowTotal {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-top: 16px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user