From 90d0e29e8b938e8045ae02bde29b369d294cec12 Mon Sep 17 00:00:00 2001 From: rassadin11 Date: Tue, 16 Jun 2026 17:37:58 +0300 Subject: [PATCH] add staking page --- src/features/staking/api/stakingApi.ts | 4 ++ src/features/staking/model/useStaking.ts | 50 +++++++++++++++++-- .../staking/ui/PositionsPanel.module.css | 24 +++++++-- src/widgets/staking/ui/PositionsPanel.tsx | 10 ++-- .../staking/ui/StakeQuotePanel.module.css | 5 +- 5 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/features/staking/api/stakingApi.ts b/src/features/staking/api/stakingApi.ts index 678a142..d08f634 100644 --- a/src/features/staking/api/stakingApi.ts +++ b/src/features/staking/api/stakingApi.ts @@ -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 { diff --git a/src/features/staking/model/useStaking.ts b/src/features/staking/model/useStaking.ts index 1dd782b..8883b54 100644 --- a/src/features/staking/model/useStaking.ts +++ b/src/features/staking/model/useStaking.ts @@ -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(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) }, }) } diff --git a/src/widgets/staking/ui/PositionsPanel.module.css b/src/widgets/staking/ui/PositionsPanel.module.css index 7c08528..fff28f0 100644 --- a/src/widgets/staking/ui/PositionsPanel.module.css +++ b/src/widgets/staking/ui/PositionsPanel.module.css @@ -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; -} +} \ No newline at end of file diff --git a/src/widgets/staking/ui/PositionsPanel.tsx b/src/widgets/staking/ui/PositionsPanel.tsx index 013135d..709b6a1 100644 --- a/src/widgets/staking/ui/PositionsPanel.tsx +++ b/src/widgets/staking/ui/PositionsPanel.tsx @@ -42,7 +42,7 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
Мои позиции
- {truncateDecimals(total, 6)} {unit} + {truncateDecimals(isError ? '0' : total, 6)} {unit}
@@ -56,11 +56,11 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns
У вас пока нет активных позиций стейкинга.
)} - {data?.chain === 'ETH' && !empty && ( + {!isError && data?.chain === 'ETH' && !empty && ( <> {(parseFloat(data.stEthBalanceWeiHuman) || 0) > 0 && (
-
+
@@ -121,12 +121,12 @@ export function PositionsPanel({ chain, onUnstakeEth, onUnstakeSol, onClaim, uns )} - {data?.chain === 'SOL' && !empty && ( + {!isError && data?.chain === 'SOL' && !empty && (
{data.positions.map((p) => { const st = SOL_STATE_META[p.state] return ( -
+
diff --git a/src/widgets/staking/ui/StakeQuotePanel.module.css b/src/widgets/staking/ui/StakeQuotePanel.module.css index 5f4522e..d27055d 100644 --- a/src/widgets/staking/ui/StakeQuotePanel.module.css +++ b/src/widgets/staking/ui/StakeQuotePanel.module.css @@ -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; -} +} \ No newline at end of file