add staking page
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user