Add all-in-one desktop wallet with extensive feature set: Infrastructure: - Storage: IPFS-based decentralized file storage with upload/download - Hosting: Domain registration and static site hosting - Compute: GPU/CPU job marketplace for distributed computing - Database: Multi-model database services (KV, Document, Vector, etc.) Financial Features: - Privacy: Confidential transactions with Pedersen commitments - Bridge: Cross-chain transfers (Ethereum, Bitcoin, IBC/Cosmos) - Governance: DAO proposals, voting, and delegation - ZK-Rollup: L2 scaling with deposits, withdrawals, and transfers UI/UX Improvements: - Add ErrorBoundary component for graceful error handling - Add LoadingStates components (spinners, skeletons, overlays) - Add Animation components (FadeIn, SlideIn, CountUp, etc.) - Update navigation with new feature sections Testing: - Add Playwright E2E smoke tests - Test route accessibility and page rendering - Verify build process and asset loading Build: - Fix TypeScript compilation errors - Update Tauri plugin dependencies - Successfully build macOS app bundle and DMG
501 lines
20 KiB
TypeScript
501 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import {
|
||
Vote,
|
||
Plus,
|
||
RefreshCw,
|
||
AlertCircle,
|
||
Clock,
|
||
CheckCircle,
|
||
XCircle,
|
||
Users,
|
||
TrendingUp,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Zap,
|
||
} from 'lucide-react';
|
||
import {
|
||
useGovernanceStore,
|
||
getStatusLabel,
|
||
getStatusColor,
|
||
calculateVotePercentage,
|
||
} from '../../store/governance';
|
||
|
||
export default function GovernanceDashboard() {
|
||
const {
|
||
proposals,
|
||
votingPower,
|
||
isLoading,
|
||
isVoting,
|
||
error,
|
||
clearError,
|
||
fetchProposals,
|
||
fetchVotingPower,
|
||
createProposal,
|
||
vote,
|
||
delegate,
|
||
} = useGovernanceStore();
|
||
|
||
const [activeTab, setActiveTab] = useState<'proposals' | 'voting-power'>('proposals');
|
||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||
const [showDelegateForm, setShowDelegateForm] = useState(false);
|
||
const [expandedProposal, setExpandedProposal] = useState<string | null>(null);
|
||
const [isCreating, setIsCreating] = useState(false);
|
||
|
||
// Create form state
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [newDescription, setNewDescription] = useState('');
|
||
const [newActions, setNewActions] = useState('');
|
||
|
||
// Delegate form state
|
||
const [delegateAddress, setDelegateAddress] = useState('');
|
||
|
||
useEffect(() => {
|
||
fetchProposals();
|
||
fetchVotingPower();
|
||
}, [fetchProposals, fetchVotingPower]);
|
||
|
||
const handleCreateProposal = async () => {
|
||
if (!newTitle || !newDescription) return;
|
||
setIsCreating(true);
|
||
try {
|
||
// Parse actions as string array
|
||
let actions: string[] = [];
|
||
if (newActions) {
|
||
try {
|
||
actions = JSON.parse(newActions);
|
||
} catch {
|
||
// If not valid JSON, treat as single action
|
||
actions = [newActions];
|
||
}
|
||
}
|
||
await createProposal(newTitle, newDescription, actions);
|
||
setShowCreateForm(false);
|
||
setNewTitle('');
|
||
setNewDescription('');
|
||
setNewActions('');
|
||
fetchProposals();
|
||
} catch {
|
||
// Error handled by store
|
||
} finally {
|
||
setIsCreating(false);
|
||
}
|
||
};
|
||
|
||
const handleVote = async (proposalId: string, support: 'for' | 'against' | 'abstain') => {
|
||
await vote(proposalId, support);
|
||
fetchProposals();
|
||
};
|
||
|
||
const handleDelegate = async () => {
|
||
if (!delegateAddress) return;
|
||
await delegate(delegateAddress);
|
||
setShowDelegateForm(false);
|
||
setDelegateAddress('');
|
||
fetchVotingPower();
|
||
};
|
||
|
||
const getStatusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return <Clock size={16} className="text-yellow-400" />;
|
||
case 'active':
|
||
return <Vote size={16} className="text-blue-400" />;
|
||
case 'passed':
|
||
return <CheckCircle size={16} className="text-green-400" />;
|
||
case 'rejected':
|
||
return <XCircle size={16} className="text-red-400" />;
|
||
case 'executed':
|
||
return <Zap size={16} className="text-purple-400" />;
|
||
default:
|
||
return <Clock size={16} className="text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
const formatVotes = (votes: string) => {
|
||
const num = parseFloat(votes);
|
||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||
return votes;
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">Governance</h1>
|
||
<p className="text-gray-400 mt-1">Participate in Synor DAO decisions</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => {
|
||
fetchProposals();
|
||
fetchVotingPower();
|
||
}}
|
||
disabled={isLoading}
|
||
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-300 transition-colors disabled:opacity-50"
|
||
>
|
||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||
Refresh
|
||
</button>
|
||
<button
|
||
onClick={() => setShowCreateForm(true)}
|
||
className="flex items-center gap-2 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
<Plus size={16} />
|
||
Create Proposal
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Alert */}
|
||
{error && (
|
||
<div className="flex items-center gap-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||
<AlertCircle className="text-red-400" size={20} />
|
||
<p className="text-red-400 flex-1">{error}</p>
|
||
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Proposal Modal */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||
<h2 className="text-xl font-bold text-white mb-4">Create Proposal</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||
<input
|
||
type="text"
|
||
value={newTitle}
|
||
onChange={(e) => setNewTitle(e.target.value)}
|
||
placeholder="Proposal title"
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Description</label>
|
||
<textarea
|
||
value={newDescription}
|
||
onChange={(e) => setNewDescription(e.target.value)}
|
||
placeholder="Describe your proposal in detail..."
|
||
rows={4}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">
|
||
Actions (optional JSON array)
|
||
</label>
|
||
<textarea
|
||
value={newActions}
|
||
onChange={(e) => setNewActions(e.target.value)}
|
||
placeholder='["action1", "action2"]'
|
||
rows={3}
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono text-sm placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowCreateForm(false)}
|
||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleCreateProposal}
|
||
disabled={!newTitle || !newDescription || isCreating}
|
||
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
{isCreating ? 'Creating...' : 'Create'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delegate Modal */}
|
||
{showDelegateForm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800 w-full max-w-md">
|
||
<h2 className="text-xl font-bold text-white mb-4">Delegate Voting Power</h2>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-gray-400">
|
||
Delegate your voting power to another address. They will be able to vote on your
|
||
behalf, but you retain ownership of your tokens.
|
||
</p>
|
||
<div>
|
||
<label className="block text-sm text-gray-400 mb-1">Delegate Address</label>
|
||
<input
|
||
type="text"
|
||
value={delegateAddress}
|
||
onChange={(e) => setDelegateAddress(e.target.value)}
|
||
placeholder="synor1..."
|
||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white font-mono placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowDelegateForm(false)}
|
||
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
onClick={handleDelegate}
|
||
disabled={!delegateAddress}
|
||
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
Delegate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Voting Power Card */}
|
||
{votingPower && (
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-synor-600/20 rounded-lg">
|
||
<TrendingUp size={24} className="text-synor-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-400">Your Voting Power</p>
|
||
<p className="text-2xl font-bold text-white">{formatVotes(votingPower.votingPower)} SYN</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<div className="text-right">
|
||
<p className="text-xs text-gray-500">Delegated Out</p>
|
||
<p className="text-white">{formatVotes(votingPower.delegatedOut)}</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-xs text-gray-500">Delegated In</p>
|
||
<p className="text-white">{formatVotes(votingPower.delegatedIn)}</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowDelegateForm(true)}
|
||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white font-medium transition-colors"
|
||
>
|
||
<Users size={16} className="inline mr-2" />
|
||
Delegate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 border-b border-gray-800">
|
||
<button
|
||
onClick={() => setActiveTab('proposals')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'proposals'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
Proposals ({proposals.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('voting-power')}
|
||
className={`px-4 py-2 font-medium transition-colors ${
|
||
activeTab === 'voting-power'
|
||
? 'text-synor-400 border-b-2 border-synor-400'
|
||
: 'text-gray-400 hover:text-white'
|
||
}`}
|
||
>
|
||
Voting Power
|
||
</button>
|
||
</div>
|
||
|
||
{/* Proposals Tab */}
|
||
{activeTab === 'proposals' && (
|
||
<div className="space-y-4">
|
||
{proposals.map((proposal) => {
|
||
const votePercentages = calculateVotePercentage(
|
||
proposal.forVotes,
|
||
proposal.againstVotes,
|
||
proposal.abstainVotes
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={proposal.id}
|
||
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
|
||
>
|
||
<div
|
||
className="flex items-start justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||
onClick={() => setExpandedProposal(expandedProposal === proposal.id ? null : proposal.id)}
|
||
>
|
||
<div className="flex items-start gap-4">
|
||
{getStatusIcon(proposal.status)}
|
||
<div>
|
||
<h3 className="text-white font-medium">{proposal.title}</h3>
|
||
<p className="text-sm text-gray-400">
|
||
#{proposal.id.slice(0, 8)} • by {proposal.proposer.slice(0, 8)}...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className={`px-2 py-1 text-xs rounded ${getStatusColor(proposal.status)}`}>
|
||
{getStatusLabel(proposal.status)}
|
||
</span>
|
||
{expandedProposal === proposal.id ? (
|
||
<ChevronUp size={20} className="text-gray-400" />
|
||
) : (
|
||
<ChevronDown size={20} className="text-gray-400" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{expandedProposal === proposal.id && (
|
||
<div className="p-4 border-t border-gray-800 bg-gray-800/30">
|
||
<p className="text-gray-300 mb-4">{proposal.description}</p>
|
||
|
||
{/* Vote Progress */}
|
||
<div className="mb-4 space-y-2">
|
||
<div>
|
||
<div className="flex justify-between text-sm mb-1">
|
||
<span className="text-green-400">For</span>
|
||
<span className="text-white">{formatVotes(proposal.forVotes)} ({votePercentages.for.toFixed(1)}%)</span>
|
||
</div>
|
||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||
<div className="h-full bg-green-500" style={{ width: `${votePercentages.for}%` }} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between text-sm mb-1">
|
||
<span className="text-red-400">Against</span>
|
||
<span className="text-white">{formatVotes(proposal.againstVotes)} ({votePercentages.against.toFixed(1)}%)</span>
|
||
</div>
|
||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||
<div className="h-full bg-red-500" style={{ width: `${votePercentages.against}%` }} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between text-sm mb-1">
|
||
<span className="text-gray-400">Abstain</span>
|
||
<span className="text-white">{formatVotes(proposal.abstainVotes)} ({votePercentages.abstain.toFixed(1)}%)</span>
|
||
</div>
|
||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||
<div className="h-full bg-gray-500" style={{ width: `${votePercentages.abstain}%` }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timeline */}
|
||
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||
<div>
|
||
<p className="text-gray-500">Start Block</p>
|
||
<p className="text-white">#{proposal.startBlock.toLocaleString()}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">End Block</p>
|
||
<p className="text-white">#{proposal.endBlock.toLocaleString()}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Voting Buttons */}
|
||
{proposal.status === 'active' && !proposal.userVoted && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleVote(proposal.id, 'for');
|
||
}}
|
||
disabled={isVoting}
|
||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
Vote For
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleVote(proposal.id, 'against');
|
||
}}
|
||
disabled={isVoting}
|
||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
Vote Against
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleVote(proposal.id, 'abstain');
|
||
}}
|
||
disabled={isVoting}
|
||
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-500 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
Abstain
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{proposal.userVoted && (
|
||
<div className="text-center py-2 text-gray-400">
|
||
You have already voted on this proposal {proposal.userVote && `(${proposal.userVote})`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{proposals.length === 0 && (
|
||
<div className="text-center py-12 text-gray-500">
|
||
No governance proposals yet
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Voting Power Tab */}
|
||
{activeTab === 'voting-power' && votingPower && (
|
||
<div className="space-y-6">
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h3 className="text-lg font-semibold text-white mb-4">Voting Power Breakdown</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||
<span className="text-gray-400">Your Voting Power</span>
|
||
<span className="text-white font-medium">{formatVotes(votingPower.votingPower)} SYN</span>
|
||
</div>
|
||
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||
<span className="text-gray-400">Delegated Out</span>
|
||
<span className="text-white font-medium">{formatVotes(votingPower.delegatedOut)} SYN</span>
|
||
</div>
|
||
<div className="flex justify-between items-center p-4 bg-gray-800 rounded-lg">
|
||
<span className="text-gray-400">Delegated In</span>
|
||
<span className="text-white font-medium">{formatVotes(votingPower.delegatedIn)} SYN</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{votingPower.delegate && (
|
||
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||
<h3 className="text-lg font-semibold text-white mb-4">Delegation</h3>
|
||
<div className="flex items-center justify-between p-4 bg-gray-800 rounded-lg">
|
||
<div>
|
||
<p className="text-gray-400 text-sm">Your votes are delegated to</p>
|
||
<code className="text-white font-mono">{votingPower.delegate}</code>
|
||
</div>
|
||
<button
|
||
onClick={() => delegate('')}
|
||
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm font-medium transition-colors"
|
||
>
|
||
Revoke
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|