diff --git a/src/pages/stakingPage/components/StakeUnstakeModal.tsx b/src/pages/stakingPage/components/StakeUnstakeModal.tsx new file mode 100644 index 0000000..918cda0 --- /dev/null +++ b/src/pages/stakingPage/components/StakeUnstakeModal.tsx @@ -0,0 +1,220 @@ +import { + Box, + Button, + Flex, + HStack, + Input, + Modal, + ModalCloseButton, + ModalContent, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import React, { useMemo } from 'react'; + +import { GrixLogo } from '@/components/commons/Logo'; + +import { formatBalance } from '../utils/formatters'; + +type StakeUnstakeModalProps = { + isOpen: boolean; + onClose: () => void; + mode: 'stake' | 'unstake'; + title: string; + amount: string; + handleInputChange: (e: React.ChangeEvent) => void; + needsApproval: boolean; + isApproving: boolean; + handleApprove: () => void; + isAmountValid: () => boolean; + isLoading: boolean; + onSubmit: () => void; + availableAmount: string; + tokenType: 'gx' | 'esgx'; +}; + +export const StakeUnstakeModal: React.FC = ({ + isOpen, + onClose, + mode, + title, + amount, + handleInputChange, + needsApproval, + isApproving, + handleApprove, + isAmountValid, + isLoading, + onSubmit, + availableAmount, + tokenType, +}) => { + const isStakeMode = mode === 'stake'; + const isEsGrix = tokenType === 'esgx'; + + const hasBalance = useMemo(() => { + try { + const balance = Number(availableAmount); + return balance > 0.000001; // Consider very small amounts as no balance + } catch { + return false; + } + }, [availableAmount]); + + const handlePercentageClick = (percentage: number) => { + if (!hasBalance) return; + + if (percentage === 100) { + // Use exact available amount without any calculations + const event = { + target: { value: availableAmount }, + } as React.ChangeEvent; + handleInputChange(event); + return; + } + + // For other percentages, use string manipulation to maintain precision + + const baseAmount = availableAmount.replace('.', ''); + const decimals = availableAmount.split('.')[1]?.length || 0; + const multiplier = BigInt(percentage); + const result = (BigInt(baseAmount) * multiplier) / BigInt(100); + + let valueStr = result.toString(); + if (decimals > 0) { + const padded = valueStr.padStart(decimals + 1, '0'); + valueStr = `${padded.slice(0, -decimals)}.${padded.slice(-decimals)}`; + } + + const event = { + target: { value: valueStr }, + } as React.ChangeEvent; + handleInputChange(event); + }; + + return ( + + + + + + + {title} + + + + + + + {isStakeMode ? 'Stake Amount' : 'Unstake Amount'} + + + + + + + + + + {isEsGrix ? 'esGRIX' : 'GRIX'} + + + + + + + + + {isStakeMode ? 'Available balance' : 'Staked balance'} + + + {formatBalance(availableAmount)} {isEsGrix ? 'esGRIX' : 'GRIX'} + + + + + {[25, 50, 75, 100].map((percentage) => ( + + ))} + + + {!isStakeMode && ( + + + + ⓘ + + + You might not be able to unstake your desired {isEsGrix ? 'esGRIX' : 'GRIX'} if there is an active + vesting position. + + + + )} + + + {amount && needsApproval && isStakeMode ? ( + + ) : ( + + )} + + + + + + ); +}; diff --git a/src/pages/stakingPage/components/StakingCard.tsx b/src/pages/stakingPage/components/StakingCard.tsx index 1747298..7f32ae3 100644 --- a/src/pages/stakingPage/components/StakingCard.tsx +++ b/src/pages/stakingPage/components/StakingCard.tsx @@ -11,13 +11,13 @@ import { getEsGrixStakedAmount, getStakedAmount, getTokenBalance, - stakeEsGs, - stakeGs, - unstakeEsGs, + stakeEsGRIX, + stakeGRIX, + unstakeEsGRIX, unstakeGs, } from '@/web3Config/staking/hooks'; -import { StakingCardContent } from './StakingCardContent.js'; +import { StakingCardContent } from './StakingCardContent'; type StakingCardProps = { title: string; @@ -43,7 +43,6 @@ export const StakingCard: React.FC = ({ const [availableBalance, setAvailableBalance] = useState('0'); const [stakedAmount, setStakedAmount] = useState('0'); const [apr, setApr] = useState(0); - const [_showError, setShowError] = useState(false); const toast = useToast(); const tokenAddress = type === 'gx' ? stakingContracts.grixToken.address : stakingContracts.esGRIXToken.address; @@ -105,14 +104,6 @@ export const StakingCard: React.FC = ({ return () => clearInterval(interval); }, [fetchBalance, fetchStakedAmount, fetchAPR, refreshTrigger]); - const handleMaxClick = () => { - if (type === 'esgx') { - setAmount(stakedAmount); - } else { - setAmount(availableBalance); - } - }; - const isAmountValid = useCallback(() => { if (!amount) return false; try { @@ -143,7 +134,7 @@ export const StakingCard: React.FC = ({ const amountBigInt = amount ? parseEther(amount) : 0n; setNeedsApproval(allowance < amountBigInt); } catch (error) { - setNeedsApproval(true); // Default to needing approval on error + setNeedsApproval(true); } }, [address, amount, tokenAddress]); @@ -158,6 +149,10 @@ export const StakingCard: React.FC = ({ } }; + const clearAmount = () => { + setAmount(''); + }; + const refreshAllData = useCallback( async (triggerParentRefresh = true) => { await Promise.all([fetchBalance(), fetchStakedAmount(), fetchAPR()]); @@ -181,9 +176,7 @@ export const StakingCard: React.FC = ({ setIsApproving(true); const amountBigInt = parseEther(amount); await approveStaking(tokenAddress, amountBigInt); - showToast('Approval Successful', 'You can now stake your tokens', 'success'); - setNeedsApproval(false); await refreshAllData(); } catch (error) { @@ -196,26 +189,18 @@ export const StakingCard: React.FC = ({ const handleStake = async () => { if (!amount || !address) return; - if (!isAmountValid()) { - showToast('Invalid Amount', 'Amount exceeds available balance', 'error'); - setShowError(true); - return; - } - try { setIsStaking(true); const amountBigInt = parseEther(amount); if (type === 'gx') { - await stakeGs(amountBigInt); + await stakeGRIX(amountBigInt); } else { - await stakeEsGs(amountBigInt); + await stakeEsGRIX(amountBigInt); } showToast('Staking Successful', 'Your tokens have been staked', 'success'); - setAmount(''); - setShowError(false); await refreshAllData(); } catch (error) { showToast('Staking Failed', 'There was an error staking your tokens', 'error'); @@ -228,27 +213,17 @@ export const StakingCard: React.FC = ({ if (!amount || !address) return; try { - const currentStaked = type === 'gx' ? await getStakedAmount(address) : await getEsGrixStakedAmount(address); - - if (Number(amount) > Number(currentStaked)) { - showToast('Invalid Amount', 'Amount exceeds staked balance', 'error'); - setShowError(true); - return; - } - setIsUnstaking(true); const amountBigInt = parseEther(amount); if (type === 'gx') { await unstakeGs(amountBigInt); } else { - await unstakeEsGs(amountBigInt); + await unstakeEsGRIX(amountBigInt); } showToast('Unstaking Successful', 'Your tokens have been unstaked', 'success'); - setAmount(''); - setShowError(false); await refreshAllData(); } catch (error) { showToast('Unstaking Failed', 'There was an error unstaking your tokens', 'error'); @@ -266,7 +241,6 @@ export const StakingCard: React.FC = ({ apr={apr} amount={amount} handleInputChange={handleInputChange} - handleMaxClick={handleMaxClick} needsApproval={needsApproval} isApproving={isApproving} handleApprove={() => void handleApprove()} @@ -276,6 +250,8 @@ export const StakingCard: React.FC = ({ handleStake={() => void handleStake()} isUnstaking={isUnstaking} handleUnstake={() => void handleUnstake()} + type={type} + clearAmount={clearAmount} /> ); }; diff --git a/src/pages/stakingPage/components/StakingCardContent.tsx b/src/pages/stakingPage/components/StakingCardContent.tsx index 90ca4e0..a1e2c47 100644 --- a/src/pages/stakingPage/components/StakingCardContent.tsx +++ b/src/pages/stakingPage/components/StakingCardContent.tsx @@ -1,22 +1,11 @@ -import { - Box, - Button, - Flex, - Heading, - HStack, - Input, - InputGroup, - InputRightElement, - SimpleGrid, - Text, - VStack, -} from '@chakra-ui/react'; +import { Box, Button, Flex, Heading, HStack, SimpleGrid, Text, useDisclosure, VStack } from '@chakra-ui/react'; import React from 'react'; import { GrixLogo } from '@/components/commons/Logo'; import { formatBalance } from '../utils/formatters'; import { BoldGrix } from './BoldGrix'; +import { StakeUnstakeModal } from './StakeUnstakeModal'; type StakingCardContentProps = { title: string; @@ -26,7 +15,6 @@ type StakingCardContentProps = { apr: number; amount: string; handleInputChange: (e: React.ChangeEvent) => void; - handleMaxClick: () => void; needsApproval: boolean; isApproving: boolean; handleApprove: () => void; @@ -36,6 +24,8 @@ type StakingCardContentProps = { handleStake: () => void; isUnstaking: boolean; handleUnstake: () => void; + type: 'gx' | 'esgx'; + clearAmount: () => void; }; export const StakingCardContent: React.FC = ({ @@ -46,7 +36,6 @@ export const StakingCardContent: React.FC = ({ apr, amount, handleInputChange, - handleMaxClick, needsApproval, isApproving, handleApprove, @@ -56,124 +45,87 @@ export const StakingCardContent: React.FC = ({ handleStake, isUnstaking, handleUnstake, -}) => ( - - - -
- - - - - - -
-
+ type, + clearAmount, +}) => { + const { isOpen: isStakeModalOpen, onOpen: onStakeModalOpen, onClose: onStakeModalClose } = useDisclosure(); + const { isOpen: isUnstakeModalOpen, onOpen: onUnstakeModalOpen, onClose: onUnstakeModalClose } = useDisclosure(); - - - - Staked - - - {formatBalance(stakedAmount)} - - + const tokenSymbol = type === 'esgx' ? 'esGRIX' : 'GRIX'; - - - Available to stake - - - {formatBalance(availableBalance)} - - + const handleStakeModalClose = () => { + clearAmount(); + onStakeModalClose(); + }; - - - APR - - - - ↗ + const handleUnstakeModalClose = () => { + clearAmount(); + onUnstakeModalClose(); + }; + + return ( + + + +
+ + + + + - {apr.toFixed(2)}% - - - +
+
- - - - - - - + + + + Staked + + + {formatBalance(stakedAmount)} + + + + + + Available to stake + + + {formatBalance(availableBalance)} + + + + + + APR + + + + ↗ + + {apr.toFixed(2)}% + + + - {amount && needsApproval ? ( - - ) : ( - - )} + - -
-); + + + + {}} + isAmountValid={isUnstakeAmountValid} + isLoading={isUnstaking} + onSubmit={handleUnstake} + availableAmount={stakedAmount} + tokenType={type} + /> +
+ ); +}; diff --git a/src/pages/stakingPage/components/VestingCard.tsx b/src/pages/stakingPage/components/VestingCard.tsx index 309b328..754f43c 100644 --- a/src/pages/stakingPage/components/VestingCard.tsx +++ b/src/pages/stakingPage/components/VestingCard.tsx @@ -11,7 +11,7 @@ import { getTokenBalance, getVestingData, getVestingDuration, - vestEsGs as vestEsGrix, + vestEsGRIX as vestEsGrix, withdrawEsGs as withdrawEsGrix, } from '@/web3Config/staking/hooks'; @@ -38,7 +38,6 @@ export const VestingCard: React.FC = ({ onActionComplete, user const toast = useToast(); const [esGrixBalance, setEsGrixBalance] = useState('0'); const [grixBalance, setGrixBalance] = useState('0'); - const [isApproving, setIsApproving] = useState(false); const [isVesting, setIsVesting] = useState(false); const [needsApproval, setNeedsApproval] = useState(true); const [lastVestingTime, setLastVestingTime] = useState(null); @@ -82,7 +81,7 @@ export const VestingCard: React.FC = ({ onActionComplete, user useEffect(() => { void fetchVestingData(); - const interval = setInterval(() => void fetchVestingData(), 30000); + const interval = setInterval(() => void fetchVestingData(), 15000); return () => clearInterval(interval); }, [fetchVestingData]); @@ -90,12 +89,6 @@ export const VestingCard: React.FC = ({ onActionComplete, user void checkAllowance(); }, [checkAllowance]); - useEffect(() => { - if (!isApproving) { - void checkAllowance(); - } - }, [isApproving, checkAllowance]); - const fetchBalance = useCallback(async () => { if (!address) return; const balance = await getTokenBalance(stakingContracts.esGRIXToken.address, address); @@ -122,44 +115,59 @@ export const VestingCard: React.FC = ({ onActionComplete, user const handleVest = useCallback( async (vestAmount: string) => { - if (!address) return; + if (!address || !vestAmount || parseFloat(vestAmount) <= 0) return; + + const amountToVest = parseEther(vestAmount); + setIsVesting(true); // Start loading try { + // Step 1: Handle Approval if needed if (needsApproval) { - setIsApproving(true); - await approveVesting(stakingContracts.esGRIXToken.address, parseEther(vestAmount)); - await checkAllowance(); - } else { - setIsVesting(true); - await vestEsGrix(parseEther(vestAmount)); + try { + await approveVesting(stakingContracts.esGRIXToken.address, amountToVest); + // Approval succeeded, proceed to vesting. Do NOT close modal here. + } catch (approvalError) { + toast({ + title: 'Approval Failed', + description: 'Could not approve esGRIX spending. Please try again.', + status: 'error', + duration: 5000, + isClosable: true, + }); + setIsVesting(false); // Stop loading on approval failure + return; // Exit the function, modal stays open + } } - await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance()]); + // Step 2: Perform vesting + await vestEsGrix(amountToVest); + + // Step 3: Refresh all data with a small delay to ensure blockchain state is updated + await new Promise((resolve) => setTimeout(resolve, 2000)); // Add small delay + await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance(), checkAllowance()]); toast({ - title: needsApproval ? 'Approval Successful' : 'Vesting Successful', + title: 'Success', + description: 'Successfully vested esGRIX', status: 'success', duration: 5000, isClosable: true, }); - if (!needsApproval) { - onVestingClose(); - } + onVestingClose(); } catch (error) { toast({ - title: needsApproval ? 'Approval Failed' : 'Vesting Failed', - description: `There was an error during the ${needsApproval ? 'approval' : 'vesting'} process`, + title: 'Vesting Failed', + description: 'Could not vest esGRIX. Please try again.', status: 'error', duration: 5000, isClosable: true, }); } finally { - setIsApproving(false); setIsVesting(false); } }, - [address, needsApproval, fetchBalance, fetchVestingData, fetchGrixBalance, toast, onVestingClose, checkAllowance] + [address, needsApproval, fetchBalance, fetchVestingData, fetchGrixBalance, checkAllowance, toast, onVestingClose] ); // Calculate remaining days and progress @@ -191,10 +199,15 @@ export const VestingCard: React.FC = ({ onActionComplete, user try { setIsWithdrawing(true); await withdrawEsGrix(); - await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance()]); + + // Add delay to ensure blockchain state is updated + + // Refresh all relevant data + await Promise.all([fetchBalance(), fetchVestingData(true), fetchGrixBalance(), checkAllowance()]); toast({ title: 'Withdrawal Successful', + description: 'Successfully withdrew GRIX tokens', status: 'success', duration: 5000, isClosable: true, @@ -211,7 +224,7 @@ export const VestingCard: React.FC = ({ onActionComplete, user } finally { setIsWithdrawing(false); } - }, [address, fetchBalance, fetchVestingData, fetchGrixBalance, toast, onWithdrawClose]); + }, [address, fetchBalance, fetchVestingData, fetchGrixBalance, checkAllowance, toast, onWithdrawClose]); return ( = ({ onActionComplete, user } } onVestClick={onVestingOpen} - isVesting={isVesting || isApproving} - needsApproval={needsApproval} + isVesting={isVesting} onWithdraw={onWithdrawOpen} isWithdrawing={isWithdrawing} /> @@ -246,7 +258,7 @@ export const VestingCard: React.FC = ({ onActionComplete, user onClose={onVestingClose} esGrixBalance={esGrixBalance} grixBalance={grixBalance} - isLoading={isVesting || isApproving} + isLoading={isVesting} onVest={handleVest} claimableRewards={userRewardData?.claimable || '0'} /> diff --git a/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx b/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx index ba9fe06..7dcca07 100644 --- a/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx +++ b/src/pages/stakingPage/components/VestingComponents/VestingStats.tsx @@ -21,7 +21,6 @@ type VestingStatsProps = { } | null; onVestClick: () => void; isVesting: boolean; - needsApproval: boolean; onWithdraw: () => void; isWithdrawing: boolean; }; @@ -30,7 +29,6 @@ export const VestingStats: React.FC = ({ vestingData, onVestClick, isVesting, - needsApproval, onWithdraw, isWithdrawing, }) => ( @@ -53,29 +51,7 @@ export const VestingStats: React.FC = ({ - {vestingData ? formatBalance(vestingData.totalVested) : '0.0000'} - - - - - - - - - - - {vestingData?.esGrixBalance ? formatBalance(vestingData.esGrixBalance) : '0.0000'} - - - - - - - - - - - {vestingData ? formatBalance(vestingData.maxVestableAmount) : '0.0000'} + {vestingData ? Number(formatBalance(vestingData.totalVested)).toFixed(2) : '0.0000'} @@ -111,7 +87,7 @@ export const VestingStats: React.FC = ({