add staking page

This commit is contained in:
2026-06-16 17:37:58 +03:00
parent 5a6440de21
commit 90d0e29e8b
5 changed files with 78 additions and 15 deletions

View File

@@ -161,6 +161,8 @@ export interface EthStakePositions {
stEthBalanceWeiHuman: string
aprPercent: number | null
withdrawalRequests: EthWithdrawalRequest[]
/** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */
optimistic?: boolean
}
export type SolState = 'active' | 'activating' | 'deactivating' | 'inactive'
@@ -170,6 +172,8 @@ export interface SolPosition {
lamportsHuman: string
state: SolState
validator: string
/** Клиентский флаг: позиция показана оптимистично и ещё не подтверждена сервером. */
optimistic?: boolean
}
export interface SolStakePositions {

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toBaseUnits, fromBaseUnits } from '@shared/lib/utils/baseUnits'
import {
getStakeQuote,
getOpCost,
@@ -8,13 +9,38 @@ import {
unstakeEth,
unstakeSol,
claimUnstake,
STAKE_DECIMALS,
type StakeChain,
type EthStakePositions,
type SolStakePositions,
type SolPosition,
type EthUnstakeResult,
type SolUnstakeResult,
} 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) {
const amount = parseFloat(amountHuman)
return useQuery({
@@ -50,10 +76,28 @@ 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] })
// Монеты ушли в стейк — обновляем баланс (prefix-match покрывает org/self).
// Рисуем позицию сразу, оптимистично — не ждём подтверждения.
onMutate: async ({ chain, amountHuman }) => {
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] })
setTimeout(() => {
qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] })
qc.invalidateQueries({ queryKey: ['wallet', 'balance', chain] })
}, 6000)
},
})
}

View File

@@ -55,6 +55,22 @@
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 {
border-color: rgba(0, 212, 255, 0.25);
}
@@ -149,7 +165,7 @@
}
.note {
font-size: 12px;
font-size: 14px;
color: var(--text-secondary);
text-align: center;
padding: 8px 0 2px;
@@ -162,7 +178,7 @@
padding: 24px 0;
}
.amount + .btn,
.meta + .btn {
.amount+.btn,
.meta+.btn {
margin-top: 2px;
}
}

View File

@@ -42,7 +42,7 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
<div className={styles.head}>
<span className={styles.title}>Мои позиции</span>
<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>
@@ -56,11 +56,11 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
<div className={styles.empty}>У вас пока нет активных позиций стейкинга.</div>
)}
{data?.chain === 'ETH' && !empty && (
{!isError && data?.chain === 'ETH' && !empty && (
<>
{(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
<div className={styles.list}>
<div className={styles.pos}>
<div className={`${styles.pos} ${data.optimistic ? styles.pending : ''}`}>
<div className={styles.posTop}>
<span className={styles.coin}>
<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}>
{data.positions.map((p) => {
const st = SOL_STATE_META[p.state]
return (
<div key={p.stakeAccount} className={styles.pos}>
<div key={p.stakeAccount} className={`${styles.pos} ${p.optimistic ? styles.pending : ''}`}>
<div className={styles.posTop}>
<span className={styles.coin}>
<TokenIcon letter="S" color="var(--grad-center)" logo={COIN_ICONS.SOL} size={24} />

View File

@@ -47,8 +47,7 @@
/* Финальный акцентный итог «Вы получите …» — отделён разделителем и крупнее. */
.rowTotal {
margin-top: 4px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding-top: 8px;
border-bottom: none;
align-items: baseline;
}
@@ -56,4 +55,4 @@
.valueLg {
font-size: 24px;
font-weight: 700;
}
}