Files
web/src/components/stake-form.tsx

579 lines
24 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ChevronDown, CheckCircle, XCircle, Loader2, ArrowDownUp } from 'lucide-react';
import { useAccount } from 'wagmi';
import { useGetAllPools, useTokenDetails, useSwapMintAmounts, useBurnSwapAmounts, useLPTokenBalance, type PoolDetails, type TokenDetails } from '@/hooks/usePartyPlanner';
import { useSwapMint, useBurnSwap } from '@/hooks/usePartyPool';
import { formatUnits, parseUnits } from 'viem';
import IPartyPoolABI from '@/contracts/IPartyPoolABI';
type TransactionStatus = 'idle' | 'pending' | 'success' | 'error';
type Mode = 'stake' | 'unstake';
interface StakeFormProps {
defaultMode?: Mode;
}
export function StakeForm({ defaultMode = 'stake' }: StakeFormProps) {
const { t } = useTranslation();
const { isConnected, address } = useAccount();
const [mode, setMode] = useState<Mode>(defaultMode);
const [stakeAmount, setStakeAmount] = useState('');
const [selectedPool, setSelectedPool] = useState<PoolDetails | null>(null);
const [selectedToken, setSelectedToken] = useState<TokenDetails | null>(null);
const [isPoolDropdownOpen, setIsPoolDropdownOpen] = useState(false);
const [isTokenDropdownOpen, setIsTokenDropdownOpen] = useState(false);
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus>('idle');
const [transactionError, setTransactionError] = useState<string | null>(null);
const poolDropdownRef = useRef<HTMLDivElement>(null);
const tokenDropdownRef = useRef<HTMLDivElement>(null);
// Fetch all pools using the new hook
const { poolDetails, loading: poolsLoading } = useGetAllPools();
// Get token details for the user
const { tokenDetails, loading: tokensLoading } = useTokenDetails(address);
// Initialize swap mint and burn swap hooks
const { executeSwapMint, isSwapMinting } = useSwapMint();
const { executeBurnSwap, isBurnSwapping } = useBurnSwap();
// Fetch LP token balance (for unstake mode) - must be before isAmountExceedingBalance
const { lpBalance } = useLPTokenBalance(
mode === 'unstake' ? selectedPool?.address : undefined,
address
);
// Get available tokens for staking based on selected pool (for unstake mode)
const availableTokensForPool = selectedPool && tokenDetails
? tokenDetails.filter(token =>
selectedPool.tokens.some(poolToken =>
poolToken.toLowerCase() === token.address.toLowerCase()
)
)
: [];
// Get available pools for staking based on selected token (for stake mode)
const availablePoolsForToken = selectedToken && poolDetails
? poolDetails.filter(pool =>
pool.tokens.some(poolToken =>
poolToken.toLowerCase() === selectedToken.address.toLowerCase()
)
)
: [];
// Check if amount exceeds balance
const isAmountExceedingBalance = useMemo(() => {
if (!stakeAmount) return false;
try {
if (mode === 'stake') {
if (!selectedToken) return false;
const amountInWei = parseUnits(stakeAmount, selectedToken.decimals);
return amountInWei > selectedToken.balance;
} else {
// Unstake mode - check against LP balance
if (!lpBalance) return false;
const amountInWei = parseUnits(stakeAmount, 18); // LP tokens have 18 decimals
return amountInWei > lpBalance;
}
} catch {
// If parseUnits fails (invalid input), don't show error
return false;
}
}, [stakeAmount, selectedToken, mode, lpBalance]);
// Get the input token index in the selected pool
const inputTokenIndex = useMemo(() => {
if (!selectedPool || !selectedToken) return undefined;
const index = selectedPool.tokens.findIndex(
token => token.toLowerCase() === selectedToken.address.toLowerCase()
);
return index !== -1 ? index : undefined;
}, [selectedPool, selectedToken]);
// Parse the stake amount to Wei
const maxAmountIn = useMemo(() => {
if (!stakeAmount || !selectedToken) return undefined;
try {
// For unstake mode, LP tokens always have 18 decimals
const decimals = mode === 'unstake' ? 18 : selectedToken.decimals;
return parseUnits(stakeAmount, decimals);
} catch {
return undefined;
}
}, [stakeAmount, selectedToken, mode]);
// Fetch swap mint amounts (for stake mode)
const { swapMintAmounts, loading: swapMintLoading } = useSwapMintAmounts(
mode === 'stake' ? selectedPool?.address : undefined,
mode === 'stake' ? inputTokenIndex : undefined,
mode === 'stake' ? maxAmountIn : undefined
);
// Fetch burn swap amounts (for unstake mode)
const { burnSwapAmounts, loading: burnSwapLoading } = useBurnSwapAmounts(
mode === 'unstake' ? selectedPool?.address : undefined,
mode === 'unstake' ? maxAmountIn : undefined,
mode === 'unstake' ? inputTokenIndex : undefined
);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (poolDropdownRef.current && !poolDropdownRef.current.contains(event.target as Node)) {
setIsPoolDropdownOpen(false);
}
if (tokenDropdownRef.current && !tokenDropdownRef.current.contains(event.target as Node)) {
setIsTokenDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleStake = async () => {
if (!selectedPool || !selectedToken || !stakeAmount || inputTokenIndex === undefined || !maxAmountIn) {
console.error('Missing required stake parameters');
return;
}
setTransactionStatus('pending');
setTransactionError(null);
try {
if (mode === 'stake') {
// Execute the swap mint transaction
await executeSwapMint(
selectedPool.address,
selectedToken.address,
inputTokenIndex,
maxAmountIn
);
} else {
// Execute the burn swap transaction
await executeBurnSwap(
selectedPool.address,
maxAmountIn,
inputTokenIndex,
false // unwrap = false by default
);
}
setTransactionStatus('success');
} catch (err) {
console.error(`${mode === 'stake' ? 'Stake' : 'Unstake'} failed:`, err);
setTransactionError(err instanceof Error ? err.message : 'Transaction failed');
setTransactionStatus('error');
}
};
const handleCloseModal = () => {
if (transactionStatus === 'success') {
// Clear the form after successful stake
setStakeAmount('');
setSelectedPool(null);
setSelectedToken(null);
}
setTransactionStatus('idle');
setTransactionError(null);
};
const toggleMode = () => {
setMode(mode === 'stake' ? 'unstake' : 'stake');
// Clear selections when switching modes
setStakeAmount('');
setSelectedPool(null);
setSelectedToken(null);
};
return (
<Card className="w-full max-w-md mx-auto relative">
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>{mode === 'stake' ? 'Stake' : 'Unstake'}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* First Selection - depends on mode */}
{mode === 'stake' ? (
/* Pool Selection (Stake Mode) */
<div className="space-y-2">
<div className="text-sm">
<label className="text-muted-foreground">{t('stake.selectPool')}</label>
</div>
<div className="relative" ref={poolDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsPoolDropdownOpen(!isPoolDropdownOpen)}
>
{selectedPool ? (
<div className="flex flex-col items-start">
<span className="font-medium">{selectedPool.symbol}</span>
<span className="text-xs text-muted-foreground">{selectedPool.name}</span>
</div>
) : (
t('stake.selectPool')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
{isPoolDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{poolDetails && poolDetails.length > 0 ? (
poolDetails.map((pool) => (
<button
key={pool.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedPool(pool);
setIsPoolDropdownOpen(false);
setSelectedToken(null);
}}
>
<div className="flex flex-col">
<span className="font-medium">{pool.symbol}</span>
<span className="text-xs text-muted-foreground">{pool.name}</span>
</div>
</button>
))
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
{poolsLoading ? 'Loading pools...' : 'No pools available'}
</div>
)}
</div>
)}
</div>
</div>
) : (
/* Pool Selection (Unstake Mode) */
<div className="space-y-2">
<div className="text-sm">
<label className="text-muted-foreground">{t('stake.selectPool')}</label>
</div>
<div className="relative" ref={poolDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsPoolDropdownOpen(!isPoolDropdownOpen)}
>
{selectedPool ? (
<div className="flex flex-col items-start">
<span className="font-medium">{selectedPool.symbol}</span>
<span className="text-xs text-muted-foreground">{selectedPool.name}</span>
</div>
) : (
t('stake.selectPool')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
{isPoolDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{poolDetails && poolDetails.length > 0 ? (
poolDetails.map((pool) => (
<button
key={pool.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedPool(pool);
setIsPoolDropdownOpen(false);
setSelectedToken(null);
}}
>
<div className="flex flex-col">
<span className="font-medium">{pool.symbol}</span>
<span className="text-xs text-muted-foreground">{pool.name}</span>
</div>
</button>
))
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
{poolsLoading ? 'Loading pools...' : 'No pools available'}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Toggle Button */}
<div className="flex justify-center">
<Button
variant="ghost"
size="icon"
onClick={toggleMode}
className="rounded-full"
title={mode === 'stake' ? 'Switch to Unstake' : 'Switch to Stake'}
>
<ArrowDownUp className="h-4 w-4" />
</Button>
</div>
{/* Second Selection - depends on mode */}
{mode === 'stake' ? (
/* Token Selection (Stake Mode) */
<div className="space-y-2">
<div className="text-sm">
<label className="text-muted-foreground">{t('stake.selectToken')}</label>
</div>
<div className="relative" ref={tokenDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsTokenDropdownOpen(!isTokenDropdownOpen)}
disabled={!selectedPool}
>
{selectedToken ? (
<span className="font-medium">{selectedToken.symbol}</span>
) : (
t('stake.selectToken')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
<div className="text-xs text-muted-foreground text-right mt-1">
{t('swap.balance')}: {selectedToken ? formatUnits(selectedToken.balance, selectedToken.decimals) : '0.00'}
</div>
{isTokenDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{availableTokensForPool.length > 0 ? (
availableTokensForPool.map((token) => (
<button
key={token.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedToken(token);
setIsTokenDropdownOpen(false);
}}
>
<span className="font-medium">{token.symbol}</span>
</button>
))
) : selectedPool ? (
<div className="px-4 py-3 text-sm text-muted-foreground">
{tokensLoading ? 'Loading tokens...' : 'No tokens available for this pool'}
</div>
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
Select a pool first
</div>
)}
</div>
)}
</div>
</div>
) : (
/* Token Selection (Unstake Mode) */
<div className="space-y-2">
<div className="text-sm">
<label className="text-muted-foreground">{t('stake.selectToken')}</label>
</div>
<div className="relative" ref={tokenDropdownRef}>
<Button
variant="secondary"
className="w-full h-16 justify-between"
onClick={() => setIsTokenDropdownOpen(!isTokenDropdownOpen)}
disabled={!selectedPool}
>
{selectedToken ? (
<span className="font-medium">{selectedToken.symbol}</span>
) : (
t('stake.selectToken')
)}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
<div className="text-xs text-muted-foreground text-right mt-1">
{t('swap.balance')}: {lpBalance !== null && selectedPool ? formatUnits(lpBalance, 18) : '0.00'} {selectedPool?.symbol || 'LP'}
</div>
{isTokenDropdownOpen && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
{availableTokensForPool.length > 0 ? (
availableTokensForPool.map((token) => (
<button
key={token.address}
className="w-full px-4 py-3 text-left hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => {
setSelectedToken(token);
setIsTokenDropdownOpen(false);
}}
>
<span className="font-medium">{token.symbol}</span>
</button>
))
) : selectedPool ? (
<div className="px-4 py-3 text-sm text-muted-foreground">
{tokensLoading ? 'Loading tokens...' : 'No tokens available for this pool'}
</div>
) : (
<div className="px-4 py-3 text-sm text-muted-foreground">
Select a pool first
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Amount Input */}
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<label className="text-muted-foreground">{t('stake.amount')}</label>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
if (mode === 'stake' && selectedToken) {
setStakeAmount(formatUnits(selectedToken.balance, selectedToken.decimals));
} else if (mode === 'unstake' && lpBalance !== null) {
setStakeAmount(formatUnits(lpBalance, 18));
}
}}
disabled={!selectedToken || !selectedPool || (mode === 'stake' ? !selectedToken : lpBalance === null)}
>
MAX
</Button>
</div>
<Input
type="number"
placeholder="0.0"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
className="text-2xl h-16"
disabled={!selectedToken || !selectedPool}
/>
{isAmountExceedingBalance && (
<p className="text-sm text-destructive">
{t('stake.insufficientBalance')}
</p>
)}
</div>
{/* Swap Mint Amounts Display (Stake Mode) */}
{mode === 'stake' && swapMintAmounts && selectedToken && !isAmountExceedingBalance && (
<div className="px-4 py-3 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('stake.amountUsed')}:</span>
<span className="font-medium">
{swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.amountInUsed, selectedToken.decimals)} ${selectedToken.symbol}`}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('stake.fee')}:</span>
<span className="font-medium">
{swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.fee, selectedToken.decimals)} ${selectedToken.symbol}`}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('stake.lpMinted')}:</span>
<span className="font-medium">
{swapMintLoading ? 'Calculating...' : `${formatUnits(swapMintAmounts.lpMinted, 18)} ${selectedPool?.symbol || 'LP'}`}
</span>
</div>
</div>
)}
{/* Burn Swap Amounts Display (Unstake Mode) */}
{mode === 'unstake' && burnSwapAmounts && selectedToken && !isAmountExceedingBalance && (
<div className="px-4 py-3 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('stake.amountOut')}:</span>
<span className="font-medium">
{burnSwapLoading ? 'Calculating...' : `${formatUnits(burnSwapAmounts, selectedToken.decimals)} ${selectedToken.symbol}`}
</span>
</div>
</div>
)}
{/* Stake/Unstake Button */}
<Button
className="w-full h-14 text-lg"
onClick={handleStake}
disabled={!isConnected || !stakeAmount || !selectedPool || !selectedToken || isAmountExceedingBalance || isSwapMinting || isBurnSwapping}
>
{!isConnected
? t('swap.connectWalletToSwap')
: (isSwapMinting || isBurnSwapping)
? mode === 'stake' ? 'Staking...' : 'Unstaking...'
: mode === 'stake' ? t('stake.stakeButton') : 'Unstake'}
</Button>
</CardContent>
{/* Transaction Modal Overlay */}
{transactionStatus !== 'idle' && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50 rounded-lg">
<div className="bg-card border rounded-lg p-8 max-w-sm w-full mx-4 shadow-lg">
{transactionStatus === 'pending' && (
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-16 w-16 animate-spin text-primary" />
<h3 className="text-xl font-semibold text-center">
{mode === 'stake' ? 'Approving Stake' : 'Approving Unstake'}
</h3>
<p className="text-sm text-muted-foreground text-center">
{mode === 'stake'
? `Staking ${stakeAmount} ${selectedToken?.symbol} to ${selectedPool?.symbol}`
: `Unstaking ${stakeAmount} ${selectedPool?.symbol} LP for ${selectedToken?.symbol}`
}
</p>
<p className="text-xs text-muted-foreground text-center">
Please confirm the transactions in your wallet
</p>
</div>
)}
{transactionStatus === 'success' && (
<div className="flex flex-col items-center space-y-4">
<CheckCircle className="h-16 w-16 text-green-500" />
<h3 className="text-xl font-semibold text-center">
{mode === 'stake' ? 'Stake Confirmed!' : 'Unstake Confirmed!'}
</h3>
<p className="text-sm text-muted-foreground text-center">
{mode === 'stake'
? `Successfully staked ${stakeAmount} ${selectedToken?.symbol} to ${selectedPool?.symbol}`
: `Successfully unstaked ${stakeAmount} ${selectedPool?.symbol} LP for ${selectedToken?.symbol}`
}
</p>
<Button
onClick={handleCloseModal}
className="w-full mt-4"
>
Close
</Button>
</div>
)}
{transactionStatus === 'error' && (
<div className="flex flex-col items-center space-y-4">
<XCircle className="h-16 w-16 text-destructive" />
<h3 className="text-xl font-semibold text-center">
{mode === 'stake' ? 'Stake Failed' : 'Unstake Failed'}
</h3>
<p className="text-sm text-muted-foreground text-center break-words">
{transactionError || 'Transaction failed'}
</p>
<Button
onClick={handleCloseModal}
variant="outline"
className="w-full mt-4"
>
Close
</Button>
</div>
)}
</div>
</div>
)}
</Card>
);
}