From b593d6cf1f6faab0847a108db11c8592b0fbdce6 Mon Sep 17 00:00:00 2001 From: Muhsin Date: Tue, 28 Jul 2026 02:27:38 +0100 Subject: [PATCH] Add Governance module: proposals, weighted voting, feature-gated UI - Add Proposal/Vote types and 15-endpoint API contract (docs/governance-contract.md) - Implement mock governance engine with weighted voting (tier x role) and localStorage persistence - Add live backend client with graceful 503 degradation - Add proposal list, detail, and create routes, feature-gated via NEXT_PUBLIC_FEATURE_GOVERNANCE - Add governance query keys for list/detail/memberVote/votes --- .../governance/[proposalId]/page.tsx | 314 +++++++++++ .../governance/create/page.tsx | 195 +++++++ app/[communitySlug]/governance/page.tsx | 166 ++++++ .../governance/proposal-status-badge.tsx | 18 + docs/governance-contract.md | 508 ++++++++++++++++++ lib/api/live.ts | 220 ++++++++ lib/api/mock.ts | 374 +++++++++++++ lib/api/types.ts | 150 ++++++ lib/query/query-keys.ts | 11 + 9 files changed, 1956 insertions(+) create mode 100644 app/[communitySlug]/governance/[proposalId]/page.tsx create mode 100644 app/[communitySlug]/governance/create/page.tsx create mode 100644 app/[communitySlug]/governance/page.tsx create mode 100644 app/[communitySlug]/governance/proposal-status-badge.tsx create mode 100644 docs/governance-contract.md diff --git a/app/[communitySlug]/governance/[proposalId]/page.tsx b/app/[communitySlug]/governance/[proposalId]/page.tsx new file mode 100644 index 0000000..43e7712 --- /dev/null +++ b/app/[communitySlug]/governance/[proposalId]/page.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useAccount } from 'wagmi'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getApi } from '@/lib/api'; +import { queryKeys } from '@/lib/query'; +import { FeatureGate } from '@/components/feature-gate'; +import { features } from '@/lib/features'; +import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { ProposalStatusBadge } from '../proposal-status-badge'; +import { useSiweAuth } from '@/lib/wallet/providers'; +import { VoteChoice } from '@/lib/api/types'; +import { useState } from 'react'; + +export default function ProposalDetailPage() { + const params = useParams() as { communitySlug?: string; proposalId: string }; + const communitySlug = params.communitySlug || 'guildpass-demo'; + const proposalId = params.proposalId; + const router = useRouter(); + const { address } = useAccount(); + const { session } = useSiweAuth(); + const queryClient = useQueryClient(); + const [selectedVote, setSelectedVote] = useState(null); + + const isAdmin = session?.roles?.includes('admin') ?? false; + + const { + data: proposal, + isLoading: proposalLoading, + error: proposalError, + refetch: refetchProposal, + } = useQuery({ + queryKey: queryKeys.governance.detail(proposalId), + queryFn: () => getApi(address, session?.token, communitySlug).getProposal(proposalId), + enabled: !!address && !!proposalId, + }); + + const { data: memberVote } = useQuery({ + queryKey: queryKeys.governance.memberVote(proposalId), + queryFn: () => getApi(address, session?.token, communitySlug).getMemberVote(proposalId), + enabled: !!address && !!proposalId && proposal?.status === 'active', + }); + + const { data: allVotes = [] } = useQuery({ + queryKey: queryKeys.governance.votes(proposalId), + queryFn: () => getApi(address, session?.token, communitySlug).listProposalVotes(proposalId), + enabled: !!address && !!proposalId, + }); + + const castVoteMutation = useMutation({ + mutationFn: (choice: VoteChoice) => + getApi(address, session?.token, communitySlug).castVote(proposalId, choice), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.governance.memberVote(proposalId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.governance.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.governance.votes(proposalId) }); + }, + }); + + const closeVotingMutation = useMutation({ + mutationFn: () => + getApi(address, session?.token, communitySlug).closeProposalVoting(proposalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.governance.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.governance.list(communitySlug) }); + }, + }); + + if (!address) { + return ( + +
+

Please connect your wallet to view proposals.

+
+
+ ); + } + + if (proposalLoading) { + return ( + + + + ); + } + + if (proposalError || !proposal) { + return ( + + refetchProposal()} + /> + + ); + } + + const canVote = proposal.status === 'active' && !memberVote; + const hasVoted = !!memberVote; + + return ( + +
+ + + {/* Proposal Header */} + + +
+
+
+

{proposal.title}

+ +
+

{proposal.description}

+
+

Type: {proposal.type}

+

Proposer: {proposal.proposer.slice(0, 10)}...

+
+
+
+
+
+ + {/* Proposal Details */} +
+ + + Voting Period + + +
+

Starts

+

{new Date(proposal.votingStartsAt).toLocaleString()}

+
+
+

Ends

+

{new Date(proposal.votingEndsAt).toLocaleString()}

+
+
+
+ + + + Voting Results + + +
+
+
+ For + {proposal.votesSummary.percentFor ?? 0}% +
+
+
+
+
+
+
+ Against + {proposal.votesSummary.percentAgainst ?? 0}% +
+
+
+
+
+
+
+ Abstain + {proposal.votesSummary.totalVotes > 0 ? Math.round((proposal.votesSummary.weightsAbstain / proposal.totalWeight) * 100) : 0}% +
+
+
0 ? Math.round((proposal.votesSummary.weightsAbstain / proposal.totalWeight) * 100) : 0}%` }} + /> +
+
+

+ Total votes: {proposal.votesSummary.totalVotes} +

+
+ + +
+ + {/* Voting Section */} + {proposal.status === 'active' && ( + + + Cast Your Vote + {hasVoted && ( + You voted {memberVote?.choice} + )} + + +
+ {(['for', 'against', 'abstain'] as const).map((choice) => ( + + ))} +
+ {castVoteMutation.error && ( +

+ {safeErrorMessage(castVoteMutation.error)} +

+ )} +
+
+ )} + + {/* Admin Actions */} + {isAdmin && proposal.status === 'active' && ( + + + Admin Actions + + + + {closeVotingMutation.error && ( +

+ {safeErrorMessage(closeVotingMutation.error)} +

+ )} +
+
+ )} + + {/* Proposal Payload */} + {proposal.payload && Object.keys(proposal.payload).length > 0 && ( + + + Proposal Details + + +
+                {JSON.stringify(proposal.payload, null, 2)}
+              
+
+
+ )} + + {/* Recent Votes */} + {allVotes.length > 0 && ( + + + Recent Votes + + +
+ {allVotes.slice(0, 10).map((vote) => ( +
+
+

{vote.voter.slice(0, 10)}...

+

+ {vote.voterContext?.tier} member, {vote.voterContext?.role} +

+
+
+

+ {vote.choice.charAt(0).toUpperCase() + vote.choice.slice(1)} +

+

Weight: {vote.weight}

+
+
+ ))} +
+
+
+ )} +
+ + ); +} diff --git a/app/[communitySlug]/governance/create/page.tsx b/app/[communitySlug]/governance/create/page.tsx new file mode 100644 index 0000000..cea5302 --- /dev/null +++ b/app/[communitySlug]/governance/create/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useAccount } from 'wagmi'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getApi } from '@/lib/api'; +import { FeatureGate } from '@/components/feature-gate'; +import { features } from '@/lib/features'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useSiweAuth } from '@/lib/wallet/providers'; +import { ProposalType } from '@/lib/api/types'; +import { useState } from 'react'; +import { isApiError, safeErrorMessage } from '@/lib/api/errors'; + +export default function CreateProposalPage() { + const params = useParams() as { communitySlug?: string }; + const communitySlug = params.communitySlug || 'guildpass-demo'; + const router = useRouter(); + const { address } = useAccount(); + const { session } = useSiweAuth(); + const queryClient = useQueryClient(); + + const [formData, setFormData] = useState({ + type: 'policy_change' as ProposalType, + title: '', + description: '', + votingStartsAt: new Date().toISOString().split('T')[0], + votingEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }); + + const [error, setError] = useState(null); + + const createProposalMutation = useMutation({ + mutationFn: () => + getApi(address, session?.token, communitySlug).createProposal({ + type: formData.type as ProposalType, + title: formData.title, + description: formData.description, + proposer: address!, + votingStartsAt: new Date(formData.votingStartsAt).toISOString(), + votingEndsAt: new Date(formData.votingEndsAt).toISOString(), + payload: {}, + }), + onSuccess: (proposal) => { + queryClient.invalidateQueries({ queryKey: ['governance'] }); + router.push(`/${communitySlug}/governance/${proposal.id}`); + }, + onError: (err) => { + setError(safeErrorMessage(err)); + }, + }); + + const isAdmin = session?.roles?.includes('admin') ?? false; + + if (!address) { + return ( + +
+

Please connect your wallet.

+
+
+ ); + } + + if (!isAdmin) { + return ( + +
+

Only admins can create proposals.

+
+
+ ); + } + + return ( + +
+ + + + + Create Proposal + + +
{ + e.preventDefault(); + createProposalMutation.mutate(); + }} + className="space-y-6" + > + {/* Type */} +
+ + +
+ + {/* Title */} +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="e.g., Lower Pro tier pricing" + className="w-full px-3 py-2 border rounded-lg" + required + /> +
+ + {/* Description */} +
+ +