From ff1726ab0244ee30502438ef00f606f73eefbdd8 Mon Sep 17 00:00:00 2001 From: rassadin11 Date: Tue, 16 Jun 2026 17:00:07 +0300 Subject: [PATCH] add staking page --- src/features/staking/model/useStaking.ts | 17 +++++- src/pages/staking/ui/StakingPage.module.css | 4 +- .../staking/ui/PositionsPanel.module.css | 16 ++--- src/widgets/staking/ui/StakeCard.module.css | 18 +++--- src/widgets/staking/ui/StakeCard.tsx | 59 +++++++++++++++++-- .../staking/ui/StakeQuotePanel.module.css | 4 +- .../staking/ui/StakingWidget.module.css | 6 +- .../staking/ui/UnstakeModal.module.css | 6 +- .../staking/ui/WithdrawNotice.module.css | 2 +- 9 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/features/staking/model/useStaking.ts b/src/features/staking/model/useStaking.ts index 15f10d4..1dd782b 100644 --- a/src/features/staking/model/useStaking.ts +++ b/src/features/staking/model/useStaking.ts @@ -50,7 +50,11 @@ 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] }), + onSuccess: (_data, { chain }) => { + qc.invalidateQueries({ queryKey: ['staking', 'positions', chain] }) + // Монеты ушли в стейк — обновляем баланс (prefix-match покрывает org/self). + qc.invalidateQueries({ queryKey: ['wallet', 'balance', chain] }) + }, }) } @@ -63,7 +67,11 @@ export function useUnstake() { return useMutation({ mutationFn: (vars) => vars.chain === 'ETH' ? unstakeEth(vars.amountHuman, vars.mode) : unstakeSol(vars.stakeAccount), - onSuccess: (_data, vars) => qc.invalidateQueries({ queryKey: ['staking', 'positions', vars.chain] }), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: ['staking', 'positions', vars.chain] }) + // Монеты вернулись на кошелёк — обновляем баланс (prefix-match покрывает org/self). + qc.invalidateQueries({ queryKey: ['wallet', 'balance', vars.chain] }) + }, }) } @@ -71,6 +79,9 @@ export function useClaim() { const qc = useQueryClient() return useMutation({ mutationFn: (requestId: string) => claimUnstake(requestId), - onSuccess: () => qc.invalidateQueries({ queryKey: ['staking', 'positions', 'ETH'] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['staking', 'positions', 'ETH'] }) + qc.invalidateQueries({ queryKey: ['wallet', 'balance', 'ETH'] }) + }, }) } diff --git a/src/pages/staking/ui/StakingPage.module.css b/src/pages/staking/ui/StakingPage.module.css index edf35e8..4369463 100644 --- a/src/pages/staking/ui/StakingPage.module.css +++ b/src/pages/staking/ui/StakingPage.module.css @@ -1,6 +1,6 @@ .page { width: 100%; - max-width: 960px; + max-width: 1200px; margin: 0 auto; padding: 32px 32px 48px; } @@ -17,7 +17,7 @@ } .subtitle { - font-size: 14px; + font-size: 16px; color: var(--text-secondary); } diff --git a/src/widgets/staking/ui/PositionsPanel.module.css b/src/widgets/staking/ui/PositionsPanel.module.css index c0c9beb..7c08528 100644 --- a/src/widgets/staking/ui/PositionsPanel.module.css +++ b/src/widgets/staking/ui/PositionsPanel.module.css @@ -15,7 +15,7 @@ } .title { - font-size: 12px; + font-size: 14px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1.2px; @@ -24,7 +24,7 @@ .total { font-family: var(--font-mono); - font-size: 24px; + font-size: 28px; color: var(--text-primary); } @@ -34,7 +34,7 @@ } .subhead { - font-size: 11px; + font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1px; @@ -78,7 +78,7 @@ .amount { font-family: var(--font-mono); - font-size: 18px; + font-size: 20px; color: var(--text-primary); margin-bottom: 4px; } @@ -91,9 +91,9 @@ } .badge { - font-size: 11px; + font-size: 12px; font-weight: 700; - padding: 3px 10px; + padding: 4px 10px; border-radius: 999px; white-space: nowrap; } @@ -121,7 +121,7 @@ background: transparent; color: var(--text-primary); font-weight: 600; - font-size: 13px; + font-size: 14px; font-family: var(--font-sans); cursor: pointer; transition: background 0.2s; @@ -156,7 +156,7 @@ } .empty { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); text-align: center; padding: 24px 0; diff --git a/src/widgets/staking/ui/StakeCard.module.css b/src/widgets/staking/ui/StakeCard.module.css index 1b00aca..9e61653 100644 --- a/src/widgets/staking/ui/StakeCard.module.css +++ b/src/widgets/staking/ui/StakeCard.module.css @@ -25,7 +25,7 @@ } .tag { - font-size: 12px; + font-size: 14px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 1.2px; @@ -39,7 +39,7 @@ } .apr { - font-size: 12px; + font-size: 14px; color: var(--success); background: rgba(38, 161, 123, 0.12); border: 1px solid rgba(38, 161, 123, 0.35); @@ -59,8 +59,8 @@ color: var(--text-secondary); border: none; border-radius: 999px; - padding: 5px 14px; - font-size: 12px; + padding: 6px 14px; + font-size: 14px; cursor: pointer; font-family: var(--font-sans); font-weight: 600; @@ -85,7 +85,7 @@ border: none; outline: none; font-family: var(--font-mono); - font-size: 40px; + font-size: 48px; font-weight: 700; color: var(--text-primary); width: 100%; @@ -93,7 +93,7 @@ } .input::placeholder { - color: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.3); } .token { @@ -114,7 +114,7 @@ } .balance { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); display: flex; align-items: center; @@ -127,7 +127,7 @@ color: var(--interactive); cursor: pointer; font-weight: 700; - font-size: 13px; + font-size: 14px; font-family: var(--font-sans); padding: 0; } @@ -138,7 +138,7 @@ .hint { margin: 14px 0 0; - font-size: 13px; + font-size: 14px; color: var(--error); font-weight: 600; line-height: 1.4; diff --git a/src/widgets/staking/ui/StakeCard.tsx b/src/widgets/staking/ui/StakeCard.tsx index 51ec3d1..fd6064d 100644 --- a/src/widgets/staking/ui/StakeCard.tsx +++ b/src/widgets/staking/ui/StakeCard.tsx @@ -1,9 +1,11 @@ +import { useEffect, useState } from 'react' import { PrimaryButton, TokenIcon } from '@shared/ui' import { COIN_ICONS } from '@shared/assets/coins' import { truncateDecimals } from '@shared/lib/utils/truncateDecimals' +import { toBaseUnits, fromBaseUnits } from '@shared/lib/utils/baseUnits' 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 { useStakeQuote, useOpCost, STAKE_DECIMALS, 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' @@ -64,6 +66,7 @@ function buildRows( export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: Props) { const meta = CHAIN_META[chain] + const decimals = STAKE_DECIMALS[chain] const { data: balance } = useWalletBalance(chain) const balanceHuman = balance?.native.formatted ?? '0' const balanceNum = parseFloat(balanceHuman) || 0 @@ -72,21 +75,62 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: const { data: quote, isFetching } = useStakeQuote(chain, debounced) const { data: opCost, isError: opError } = useOpCost(chain, debounced) + // Газ за операцию резервируется в том же нативном токене, что и сумма стейка + // (op-cost → balance.gas.needReserve). Значит максимум к стейку = баланс − этот резерв. + // Считаем в смолл-юнитах через BigInt, чтобы не ловить ошибки float-вычитания. + const gasReserveHuman = opCost?.balance?.gas?.needReserve != null ? String(opCost.balance.gas.needReserve) : null + const stakeableHuman = (() => { + if (gasReserveHuman == null) return balanceHuman + const balBase = BigInt(toBaseUnits(balanceHuman, decimals)) + const reserveBase = BigInt(toBaseUnits(gasReserveHuman, decimals)) + const maxBase = balBase > reserveBase ? balBase - reserveBase : 0n + return fromBaseUnits(maxBase.toString(), decimals) + })() + + // Клик по «МАКС/100%» до того, как известен резерв на газ: ставим полный баланс, чтобы + // дёрнуть op-cost, и уменьшаем сумму на резерв, как только он приходит (или сдаёмся при ошибке). + const [maxPending, setMaxPending] = useState(false) + useEffect(() => { + if (!maxPending) return + if (gasReserveHuman != null) { + onAmountChange(stakeableHuman) + setMaxPending(false) + } else if (opError) { + setMaxPending(false) + } + }, [maxPending, gasReserveHuman, stakeableHuman, opError, onAmountChange]) + 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 insufficient = withinBalance && opCost?.sufficient === false && !maxPending const gasShort = insufficient && opCost?.balance?.gas?.sufficient === false const canStake = withinBalance && sufficient && !isStaking - const setPercent = (p: number) => onAmountChange(((balanceNum * p) / 100).toString()) + const setPercent = (p: number) => { + if (p >= 100) { + // Максимум: либо сразу вычитаем известный резерв, либо ставим полный баланс и ждём op-cost. + if (gasReserveHuman != null) onAmountChange(stakeableHuman) + else { + setMaxPending(true) + onAmountChange(balanceHuman) + } + return + } + onAmountChange(((balanceNum * p) / 100).toString()) + } const pills = ( <> {PERCENTS.map((p) => ( - ))} @@ -119,6 +163,7 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }: if (/^(\d+\.?\d*|\.?\d*)$/.test(v) || v === '') onAmountChange(v) }} placeholder="0" + aria-label={`Сумма стейка в ${meta.symbol}`} />
@@ -129,7 +174,11 @@ export function StakeCard({ chain, amount, onAmountChange, onStake, isStaking }:
Баланс: {truncateDecimals(balanceHuman, 6)} {meta.symbol} - diff --git a/src/widgets/staking/ui/StakeQuotePanel.module.css b/src/widgets/staking/ui/StakeQuotePanel.module.css index 3a28dd5..3ec4c6a 100644 --- a/src/widgets/staking/ui/StakeQuotePanel.module.css +++ b/src/widgets/staking/ui/StakeQuotePanel.module.css @@ -15,7 +15,7 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 13px 0; + padding: 14px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } @@ -24,7 +24,7 @@ } .label { - font-size: 13px; + font-size: 14px; color: var(--text-secondary); } diff --git a/src/widgets/staking/ui/StakingWidget.module.css b/src/widgets/staking/ui/StakingWidget.module.css index 18b064b..cc88fb4 100644 --- a/src/widgets/staking/ui/StakingWidget.module.css +++ b/src/widgets/staking/ui/StakingWidget.module.css @@ -6,12 +6,12 @@ .grid { display: grid; - grid-template-columns: 1.2fr 1fr; - gap: 16px; + grid-template-columns: 1.5fr 1fr; + gap: 20px; align-items: start; } -@media (max-width: 1024px) { +@media (max-width: 1100px) { .grid { grid-template-columns: 1fr; } diff --git a/src/widgets/staking/ui/UnstakeModal.module.css b/src/widgets/staking/ui/UnstakeModal.module.css index 89322cc..87af2c5 100644 --- a/src/widgets/staking/ui/UnstakeModal.module.css +++ b/src/widgets/staking/ui/UnstakeModal.module.css @@ -60,7 +60,7 @@ } .tokenLabel { - font-size: 11px; + font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-secondary); @@ -110,14 +110,14 @@ } .modeDesc { - font-size: 11px; + font-size: 12px; color: var(--text-secondary); line-height: 1.3; } .solNote { margin: 0; - font-size: 13px; + font-size: 14px; color: var(--text-secondary); line-height: 1.45; } diff --git a/src/widgets/staking/ui/WithdrawNotice.module.css b/src/widgets/staking/ui/WithdrawNotice.module.css index 322d705..e817beb 100644 --- a/src/widgets/staking/ui/WithdrawNotice.module.css +++ b/src/widgets/staking/ui/WithdrawNotice.module.css @@ -6,7 +6,7 @@ border: 1px solid rgba(0, 212, 255, 0.3); border-radius: 12px; padding: 12px 14px; - font-size: 13px; + font-size: 14px; line-height: 1.4; color: var(--text-secondary); }