mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +00:00
4f683538d3
Add full internationalization across 127+ components and pages. 790+ translation keys in en, tr, kmr, ckb, ar, fa locales. Remove duplicate keys and delete unused .json locale files.
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Vote, FileText, Users, CheckCircle, XCircle, Clock, Activity, Loader2, Wallet } from 'lucide-react';
|
|
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
|
import { useWallet } from '@/contexts/WalletContext';
|
|
import { formatNumber } from '@/lib/utils';
|
|
|
|
interface ProposalVote {
|
|
proposalId: number;
|
|
proposalTitle: string;
|
|
vote: 'Aye' | 'Nay' | 'Abstain';
|
|
conviction: number;
|
|
amount: string;
|
|
votedAt: number;
|
|
status: 'Active' | 'Approved' | 'Rejected' | 'Expired';
|
|
}
|
|
|
|
interface ElectionVote {
|
|
electionId: number;
|
|
electionType: string;
|
|
candidates: string[];
|
|
votedAt: number;
|
|
status: 'Active' | 'Completed';
|
|
}
|
|
|
|
interface DelegationInfo {
|
|
delegateAddress: string;
|
|
amount: string;
|
|
conviction: number;
|
|
tracks: string[];
|
|
status: 'active' | 'expired';
|
|
}
|
|
|
|
const MyVotes: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { api, isApiReady } = usePezkuwi();
|
|
const { account, isConnected } = useWallet();
|
|
const [proposalVotes, setProposalVotes] = useState<ProposalVote[]>([]);
|
|
const [electionVotes, setElectionVotes] = useState<ElectionVote[]>([]);
|
|
const [delegations, setDelegations] = useState<DelegationInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const formatTokenAmount = (amount: string | number) => {
|
|
const value = typeof amount === 'string' ? BigInt(amount) : BigInt(amount);
|
|
return formatNumber(Number(value) / 1e12, 2);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!api || !isApiReady || !account) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const fetchUserVotes = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const votes: ProposalVote[] = [];
|
|
const elections: ElectionVote[] = [];
|
|
const userDelegations: DelegationInfo[] = [];
|
|
|
|
// Fetch democracy votes
|
|
if (api.query.democracy?.votingOf) {
|
|
const votingInfo = await api.query.democracy.votingOf(account);
|
|
const data = votingInfo.toJSON() as Record<string, unknown>;
|
|
|
|
if (data?.direct?.votes) {
|
|
const directVotes = data.direct.votes as Array<[number, unknown]>;
|
|
for (const [refIndex, voteData] of directVotes) {
|
|
const voteInfo = voteData as Record<string, unknown>;
|
|
votes.push({
|
|
proposalId: refIndex,
|
|
proposalTitle: `Referendum #${refIndex}`,
|
|
vote: voteInfo.aye ? 'Aye' : 'Nay',
|
|
conviction: (voteInfo.conviction as number) || 0,
|
|
amount: (voteInfo.balance as string) || '0',
|
|
votedAt: Date.now(),
|
|
status: 'Active'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for delegations
|
|
if (data?.delegating) {
|
|
const delegation = data.delegating as Record<string, unknown>;
|
|
userDelegations.push({
|
|
delegateAddress: delegation.target as string,
|
|
amount: (delegation.balance as string) || '0',
|
|
conviction: (delegation.conviction as number) || 0,
|
|
tracks: ['All'],
|
|
status: 'active'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fetch welati election votes
|
|
if (api.query.welati?.nextElectionId) {
|
|
const nextId = await api.query.welati.nextElectionId();
|
|
const currentId = (nextId.toJSON() as number) || 0;
|
|
|
|
for (let i = 0; i < currentId; i++) {
|
|
const vote = await api.query.welati.electionVotes(i, account);
|
|
if (vote.isSome) {
|
|
const voteData = vote.unwrap().toJSON() as Record<string, unknown>;
|
|
const election = await api.query.welati.activeElections(i);
|
|
const electionData = election.isSome ? election.unwrap().toJSON() as Record<string, unknown> : null;
|
|
|
|
elections.push({
|
|
electionId: i,
|
|
electionType: (electionData?.electionType as string) || 'Unknown',
|
|
candidates: (voteData.candidates as string[]) || [],
|
|
votedAt: (voteData.votedAt as number) || 0,
|
|
status: electionData?.status === 'Completed' ? 'Completed' : 'Active'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch welati proposal votes
|
|
if (api.query.welati?.nextProposalId) {
|
|
const nextId = await api.query.welati.nextProposalId();
|
|
const currentId = (nextId.toJSON() as number) || 0;
|
|
|
|
for (let i = Math.max(0, currentId - 50); i < currentId; i++) {
|
|
const vote = await api.query.welati.collectiveVotes(i, account);
|
|
if (vote.isSome) {
|
|
const voteData = vote.unwrap().toJSON() as Record<string, unknown>;
|
|
const proposal = await api.query.welati.activeProposals(i);
|
|
const proposalData = proposal.isSome ? proposal.unwrap().toJSON() as Record<string, unknown> : null;
|
|
|
|
votes.push({
|
|
proposalId: i,
|
|
proposalTitle: (proposalData?.title as string) || `Proposal #${i}`,
|
|
vote: voteData.vote as 'Aye' | 'Nay' | 'Abstain',
|
|
conviction: 1,
|
|
amount: '0',
|
|
votedAt: (voteData.votedAt as number) || 0,
|
|
status: (proposalData?.status as 'Active' | 'Approved' | 'Rejected' | 'Expired') || 'Active'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setProposalVotes(votes);
|
|
setElectionVotes(elections);
|
|
setDelegations(userDelegations);
|
|
|
|
} catch (err) {
|
|
console.error('Error fetching user votes:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to fetch voting history');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchUserVotes();
|
|
|
|
const interval = setInterval(fetchUserVotes, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [api, isApiReady, account]);
|
|
|
|
const getVoteIcon = (vote: string) => {
|
|
switch (vote) {
|
|
case 'Aye': return <CheckCircle className="w-4 h-4 text-green-400" />;
|
|
case 'Nay': return <XCircle className="w-4 h-4 text-red-400" />;
|
|
default: return <Clock className="w-4 h-4 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
const getVoteColor = (vote: string) => {
|
|
switch (vote) {
|
|
case 'Aye': return 'bg-green-500/20 text-green-400 border-green-500/30';
|
|
case 'Nay': return 'bg-red-500/20 text-red-400 border-red-500/30';
|
|
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'Active': return 'bg-blue-500/20 text-blue-400';
|
|
case 'Approved': return 'bg-green-500/20 text-green-400';
|
|
case 'Rejected': return 'bg-red-500/20 text-red-400';
|
|
case 'Completed': return 'bg-purple-500/20 text-purple-400';
|
|
default: return 'bg-gray-500/20 text-gray-400';
|
|
}
|
|
};
|
|
|
|
if (!isConnected) {
|
|
return (
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="pt-6">
|
|
<div className="text-center py-12">
|
|
<Wallet className="w-16 h-16 mx-auto mb-4 text-gray-600" />
|
|
<h3 className="text-xl font-semibold text-white mb-2">{t('myVotes.connectTitle')}</h3>
|
|
<p className="text-gray-400 mb-6">
|
|
{t('myVotes.connectDescription')}
|
|
</p>
|
|
<Button className="bg-green-600 hover:bg-green-700">
|
|
{t('myVotes.connectButton')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
|
<span className="ml-3 text-gray-400">{t('myVotes.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card className="border-red-500/30 bg-red-500/10">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center text-red-400">
|
|
<XCircle className="w-5 h-5 mr-2" />
|
|
{t('myVotes.error', { error })}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const totalVotes = proposalVotes.length + electionVotes.length;
|
|
const activeVotes = proposalVotes.filter(v => v.status === 'Active').length +
|
|
electionVotes.filter(v => v.status === 'Active').length;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats Overview */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Vote className="w-8 h-8 text-green-500" />
|
|
<div>
|
|
<div className="text-2xl font-bold text-white">{totalVotes}</div>
|
|
<div className="text-sm text-gray-400">{t('myVotes.totalVotes')}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Activity className="w-8 h-8 text-blue-500" />
|
|
<div>
|
|
<div className="text-2xl font-bold text-white">{activeVotes}</div>
|
|
<div className="text-sm text-gray-400">{t('myVotes.activeVotes')}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<FileText className="w-8 h-8 text-purple-500" />
|
|
<div>
|
|
<div className="text-2xl font-bold text-white">{proposalVotes.length}</div>
|
|
<div className="text-sm text-gray-400">{t('myVotes.proposalVotes')}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Users className="w-8 h-8 text-cyan-500" />
|
|
<div>
|
|
<div className="text-2xl font-bold text-white">{electionVotes.length}</div>
|
|
<div className="text-sm text-gray-400">{t('myVotes.electionVotes')}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Live Data Indicator */}
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="bg-green-500/10 border-green-500 text-green-400">
|
|
<Activity className="h-3 w-3 mr-1" />
|
|
{t('myVotes.liveData')}
|
|
</Badge>
|
|
<span className="text-sm text-gray-500">
|
|
{t('myVotes.connectedAs', { address: `${account?.substring(0, 8)}...${account?.slice(-6)}` })}
|
|
</span>
|
|
</div>
|
|
|
|
<Tabs defaultValue="proposals" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-3 bg-gray-800/50">
|
|
<TabsTrigger value="proposals">{t('myVotes.proposalVotesTab')}</TabsTrigger>
|
|
<TabsTrigger value="elections">{t('myVotes.electionVotesTab')}</TabsTrigger>
|
|
<TabsTrigger value="delegations">{t('myVotes.delegationsTab')}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="proposals" className="space-y-4">
|
|
{proposalVotes.length === 0 ? (
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="pt-6 text-center text-gray-400">
|
|
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>{t('myVotes.noProposalVotes')}</p>
|
|
<p className="text-sm mt-2">{t('myVotes.checkProposals')}</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
proposalVotes.map((vote) => (
|
|
<Card key={vote.proposalId} className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3">
|
|
{getVoteIcon(vote.vote)}
|
|
<div>
|
|
<h4 className="font-medium text-white">{vote.proposalTitle}</h4>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{t('myVotes.conviction', { conviction: vote.conviction, amount: formatTokenAmount(vote.amount) })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Badge className={getVoteColor(vote.vote)}>
|
|
{vote.vote}
|
|
</Badge>
|
|
<Badge className={getStatusColor(vote.status)}>
|
|
{vote.status}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="elections" className="space-y-4">
|
|
{electionVotes.length === 0 ? (
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="pt-6 text-center text-gray-400">
|
|
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>{t('myVotes.noElectionVotes')}</p>
|
|
<p className="text-sm mt-2">{t('myVotes.checkElections')}</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
electionVotes.map((vote) => (
|
|
<Card key={vote.electionId} className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-white">{t('myVotes.electionName', { type: vote.electionType })}</h4>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{t('myVotes.electionDetails', { id: vote.electionId, count: vote.candidates.length })}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{vote.candidates.map((candidate, idx) => (
|
|
<Badge key={idx} variant="outline" className="text-xs">
|
|
{candidate.substring(0, 8)}...
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<Badge className={getStatusColor(vote.status)}>
|
|
{vote.status}
|
|
</Badge>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="delegations" className="space-y-4">
|
|
{delegations.length === 0 ? (
|
|
<Card className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="pt-6 text-center text-gray-400">
|
|
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>{t('myVotes.noDelegations')}</p>
|
|
<p className="text-sm mt-2">{t('myVotes.checkDelegation')}</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
delegations.map((delegation, idx) => (
|
|
<Card key={idx} className="bg-gray-900/50 border-gray-800">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h4 className="font-medium text-white">
|
|
{t('myVotes.delegatedTo', { address: `${delegation.delegateAddress.substring(0, 8)}...${delegation.delegateAddress.slice(-6)}` })}
|
|
</h4>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{t('myVotes.delegationDetails', { amount: formatTokenAmount(delegation.amount), conviction: delegation.conviction })}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{delegation.tracks.map((track, tidx) => (
|
|
<Badge key={tidx} variant="secondary" className="text-xs">
|
|
{track}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Badge className={delegation.status === 'active'
|
|
? 'bg-green-500/20 text-green-400'
|
|
: 'bg-gray-500/20 text-gray-400'}>
|
|
{delegation.status}
|
|
</Badge>
|
|
<Button size="sm" variant="outline" className="text-xs">
|
|
{t('myVotes.revoke')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MyVotes;
|