mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-24 11:57:56 +00:00
Reorganize repository into monorepo structure
Restructured the project to support multiple frontend applications: - Move web app to web/ directory - Create pezkuwi-sdk-ui/ for Polkadot SDK clone (planned) - Create mobile/ directory for mobile app development - Add shared/ directory with common utilities, types, and blockchain code - Update README.md with comprehensive documentation - Remove obsolete DKSweb/ directory This monorepo structure enables better code sharing and organized development across web, mobile, and SDK UI projects.
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, Coins, Lock, Clock, Award, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { useWallet } from '@/contexts/WalletContext';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { web3FromAddress } from '@polkadot/extension-dapp';
|
||||
import {
|
||||
getStakingInfo,
|
||||
getActiveValidators,
|
||||
getMinNominatorBond,
|
||||
getBondingDuration,
|
||||
getCurrentEra,
|
||||
parseAmount,
|
||||
type StakingInfo
|
||||
} from '@/lib/staking';
|
||||
|
||||
export const StakingDashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { api, selectedAccount, isApiReady } = usePolkadot();
|
||||
const { balances, refreshBalances } = useWallet();
|
||||
|
||||
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
|
||||
const [validators, setValidators] = useState<string[]>([]);
|
||||
const [minNominatorBond, setMinNominatorBond] = useState('0');
|
||||
const [bondingDuration, setBondingDuration] = useState(28);
|
||||
const [currentEra, setCurrentEra] = useState(0);
|
||||
|
||||
const [bondAmount, setBondAmount] = useState('');
|
||||
const [unbondAmount, setUnbondAmount] = useState('');
|
||||
const [selectedValidators, setSelectedValidators] = useState<string[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
|
||||
// Fetch staking data
|
||||
useEffect(() => {
|
||||
const fetchStakingData = async () => {
|
||||
if (!api || !isApiReady || !selectedAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
const [info, activeVals, minBond, duration, era] = await Promise.all([
|
||||
getStakingInfo(api, selectedAccount.address),
|
||||
getActiveValidators(api),
|
||||
getMinNominatorBond(api),
|
||||
getBondingDuration(api),
|
||||
getCurrentEra(api)
|
||||
]);
|
||||
|
||||
setStakingInfo(info);
|
||||
setValidators(activeVals);
|
||||
setMinNominatorBond(minBond);
|
||||
setBondingDuration(duration);
|
||||
setCurrentEra(era);
|
||||
|
||||
// Pre-select current nominations if any
|
||||
if (info.nominations.length > 0) {
|
||||
setSelectedValidators(info.nominations);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch staking data:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch staking information',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStakingData();
|
||||
const interval = setInterval(fetchStakingData, 30000); // Refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
const handleBond = async () => {
|
||||
if (!api || !selectedAccount || !bondAmount) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const amount = parseAmount(bondAmount);
|
||||
|
||||
// Validate
|
||||
if (parseFloat(bondAmount) < parseFloat(minNominatorBond)) {
|
||||
throw new Error(`Minimum bond is ${minNominatorBond} HEZ`);
|
||||
}
|
||||
|
||||
if (parseFloat(bondAmount) > parseFloat(balances.HEZ)) {
|
||||
throw new Error('Insufficient HEZ balance');
|
||||
}
|
||||
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// If already bonded, use bondExtra, otherwise use bond
|
||||
let tx;
|
||||
if (stakingInfo && parseFloat(stakingInfo.bonded) > 0) {
|
||||
tx = api.tx.staking.bondExtra(amount);
|
||||
} else {
|
||||
// For new bond, also need to specify reward destination
|
||||
tx = api.tx.staking.bond(amount, 'Staked'); // Auto-compound rewards
|
||||
}
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, events, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
console.log('Transaction in block:', status.asInBlock.toHex());
|
||||
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Transaction failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Bonded ${bondAmount} HEZ successfully`,
|
||||
});
|
||||
setBondAmount('');
|
||||
refreshBalances();
|
||||
// Refresh staking data after a delay
|
||||
setTimeout(() => {
|
||||
if (api && selectedAccount) {
|
||||
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
|
||||
}
|
||||
}, 3000);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Bond failed:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to bond tokens',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNominate = async () => {
|
||||
if (!api || !selectedAccount || selectedValidators.length === 0) return;
|
||||
|
||||
if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'You must bond tokens before nominating validators',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
const tx = api.tx.staking.nominate(selectedValidators);
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Nomination failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Nominated ${selectedValidators.length} validator(s)`,
|
||||
});
|
||||
// Refresh staking data
|
||||
setTimeout(() => {
|
||||
if (api && selectedAccount) {
|
||||
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
|
||||
}
|
||||
}, 3000);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Nomination failed:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to nominate validators',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbond = async () => {
|
||||
if (!api || !selectedAccount || !unbondAmount) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const amount = parseAmount(unbondAmount);
|
||||
|
||||
if (!stakingInfo || parseFloat(unbondAmount) > parseFloat(stakingInfo.active)) {
|
||||
throw new Error('Insufficient staked amount');
|
||||
}
|
||||
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
const tx = api.tx.staking.unbond(amount);
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Unbond failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Unbonded ${unbondAmount} HEZ. Withdrawal available in ${bondingDuration} eras`,
|
||||
});
|
||||
setUnbondAmount('');
|
||||
setTimeout(() => {
|
||||
if (api && selectedAccount) {
|
||||
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
|
||||
}
|
||||
}, 3000);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Unbond failed:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to unbond tokens',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdrawUnbonded = async () => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
if (!stakingInfo || parseFloat(stakingInfo.redeemable) === 0) {
|
||||
toast({
|
||||
title: 'Info',
|
||||
description: 'No tokens available to withdraw',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
|
||||
// Number of slashing spans (usually 0)
|
||||
const tx = api.tx.staking.withdrawUnbonded(0);
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Withdrawal failed';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Withdrew ${stakingInfo.redeemable} HEZ`,
|
||||
});
|
||||
refreshBalances();
|
||||
setTimeout(() => {
|
||||
if (api && selectedAccount) {
|
||||
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
|
||||
}
|
||||
}, 3000);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Withdrawal failed:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to withdraw tokens',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartScoreTracking = async () => {
|
||||
if (!api || !selectedAccount) return;
|
||||
|
||||
if (!stakingInfo || parseFloat(stakingInfo.bonded) === 0) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'You must bond tokens before starting score tracking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const injector = await web3FromAddress(selectedAccount.address);
|
||||
const tx = api.tx.stakingScore.startScoreTracking();
|
||||
|
||||
await tx.signAndSend(
|
||||
selectedAccount.address,
|
||||
{ signer: injector.signer },
|
||||
({ status, dispatchError }) => {
|
||||
if (status.isInBlock) {
|
||||
if (dispatchError) {
|
||||
let errorMessage = 'Failed to start score tracking';
|
||||
if (dispatchError.isModule) {
|
||||
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||
}
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Score tracking started successfully! Your staking score will now accumulate over time.',
|
||||
});
|
||||
// Refresh staking data after a delay
|
||||
setTimeout(() => {
|
||||
if (api && selectedAccount) {
|
||||
getStakingInfo(api, selectedAccount.address).then(setStakingInfo);
|
||||
}
|
||||
}, 3000);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Start score tracking failed:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'Failed to start score tracking',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleValidator = (validator: string) => {
|
||||
setSelectedValidators(prev => {
|
||||
if (prev.includes(validator)) {
|
||||
return prev.filter(v => v !== validator);
|
||||
} else {
|
||||
// Max 16 nominations
|
||||
if (prev.length >= 16) {
|
||||
toast({
|
||||
title: 'Limit Reached',
|
||||
description: 'Maximum 16 validators can be nominated',
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
return [...prev, validator];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-400">Loading staking data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-400">Total Bonded</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{stakingInfo?.bonded || '0'} HEZ
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Active: {stakingInfo?.active || '0'} HEZ
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-400">Unlocking</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-500">
|
||||
{stakingInfo?.unlocking.reduce((sum, u) => sum + parseFloat(u.amount), 0).toFixed(2) || '0'} HEZ
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{stakingInfo?.unlocking.length || 0} chunk(s)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-400">Redeemable</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{stakingInfo?.redeemable || '0'} HEZ
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleWithdrawUnbonded}
|
||||
disabled={!stakingInfo || parseFloat(stakingInfo.redeemable) === 0 || isLoading}
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-400">Staking Score</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stakingInfo?.hasStartedScoreTracking ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-purple-500">
|
||||
{stakingInfo.stakingScore}/100
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Duration: {stakingInfo.stakingDuration
|
||||
? `${Math.floor(stakingInfo.stakingDuration / (24 * 60 * 10))} days`
|
||||
: '0 days'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-gray-500">Not Started</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStartScoreTracking}
|
||||
disabled={!stakingInfo || parseFloat(stakingInfo.bonded) === 0 || isLoading}
|
||||
className="mt-2 w-full bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
Start Score Tracking
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-gray-400">PEZ Rewards</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stakingInfo?.pezRewards && stakingInfo.pezRewards.hasPendingClaim ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-orange-500">
|
||||
{parseFloat(stakingInfo.pezRewards.totalClaimable).toFixed(2)} PEZ
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{stakingInfo.pezRewards.claimableRewards.length} epoch(s) to claim
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
toast({
|
||||
title: 'Coming Soon',
|
||||
description: 'Claim PEZ rewards functionality will be available soon',
|
||||
});
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="mt-2 w-full bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Claim Rewards
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-gray-500">0 PEZ</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{stakingInfo?.pezRewards
|
||||
? `Epoch ${stakingInfo.pezRewards.currentEpoch}`
|
||||
: 'No rewards available'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Staking Interface */}
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-white">Validator Nomination Staking</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Bond HEZ and nominate validators to earn staking rewards
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="stake">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="stake">Stake</TabsTrigger>
|
||||
<TabsTrigger value="nominate">Nominate</TabsTrigger>
|
||||
<TabsTrigger value="unstake">Unstake</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* STAKE TAB */}
|
||||
<TabsContent value="stake" className="space-y-4">
|
||||
<Alert className="bg-blue-900/20 border-blue-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Minimum bond: {minNominatorBond} HEZ. Bonded tokens are locked and earn rewards when nominated validators produce blocks.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label>Amount to Bond (HEZ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`Min: ${minNominatorBond}`}
|
||||
value={bondAmount}
|
||||
onChange={(e) => setBondAmount(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Available: {balances.HEZ} HEZ</span>
|
||||
<button
|
||||
onClick={() => setBondAmount(balances.HEZ)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleBond}
|
||||
disabled={isLoading || !bondAmount || parseFloat(bondAmount) < parseFloat(minNominatorBond)}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{stakingInfo && parseFloat(stakingInfo.bonded) > 0 ? 'Bond Additional' : 'Bond Tokens'}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* NOMINATE TAB */}
|
||||
<TabsContent value="nominate" className="space-y-4">
|
||||
<Alert className="bg-purple-900/20 border-purple-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Select up to 16 validators to nominate. Your stake will be distributed to active validators.
|
||||
{stakingInfo && parseFloat(stakingInfo.bonded) === 0 && ' You must bond tokens first.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Active Validators ({validators.length})</Label>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2 border border-gray-700 rounded-lg p-3 bg-gray-800">
|
||||
{validators.map((validator) => (
|
||||
<div
|
||||
key={validator}
|
||||
className={`flex items-center justify-between p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedValidators.includes(validator)
|
||||
? 'bg-purple-900/30 border border-purple-500'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => toggleValidator(validator)}
|
||||
>
|
||||
<span className="text-sm font-mono truncate flex-1">
|
||||
{validator.slice(0, 8)}...{validator.slice(-8)}
|
||||
</span>
|
||||
{selectedValidators.includes(validator) && (
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-400 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Selected: {selectedValidators.length}/16
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNominate}
|
||||
disabled={isLoading || selectedValidators.length === 0 || !stakingInfo || parseFloat(stakingInfo.bonded) === 0}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
Nominate Validators
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
{/* UNSTAKE TAB */}
|
||||
<TabsContent value="unstake" className="space-y-4">
|
||||
<Alert className="bg-yellow-900/20 border-yellow-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Unbonded tokens will be locked for {bondingDuration} eras (~{Math.floor(bondingDuration / 4)} days) before withdrawal.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label>Amount to Unbond (HEZ)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`Max: ${stakingInfo?.active || '0'}`}
|
||||
value={unbondAmount}
|
||||
onChange={(e) => setUnbondAmount(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-400">
|
||||
<span>Staked: {stakingInfo?.active || '0'} HEZ</span>
|
||||
<button
|
||||
onClick={() => setUnbondAmount(stakingInfo?.active || '0')}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Max
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stakingInfo && stakingInfo.unlocking.length > 0 && (
|
||||
<div className="bg-gray-800 rounded-lg p-3 space-y-2">
|
||||
<Label className="text-sm">Unlocking Chunks</Label>
|
||||
{stakingInfo.unlocking.map((chunk, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className="text-gray-400">{chunk.amount} HEZ</span>
|
||||
<span className="text-gray-500">
|
||||
Era {chunk.era} ({chunk.blocksRemaining > 0 ? `~${Math.floor(chunk.blocksRemaining / 600)} blocks` : 'Ready'})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleUnbond}
|
||||
disabled={isLoading || !unbondAmount || !stakingInfo || parseFloat(stakingInfo.active) === 0}
|
||||
className="w-full bg-red-600 hover:bg-red-700"
|
||||
variant="destructive"
|
||||
>
|
||||
Unbond Tokens
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user