Skip to content
2 changes: 1 addition & 1 deletion src/components/common/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function ConnectWalletButton() {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">
Connected Wallet
Wallet address
</span>
<button
type="button"
Expand Down
108 changes: 108 additions & 0 deletions src/components/common/ProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { cn } from '@/lib/utils';
import { Clock, ThumbsUp, ThumbsDown, Minus } from 'lucide-react';
import QuorumIndicator from '@/components/common/QuorumIndicator';
import type { Proposal } from '@/types/governance';
import { formatCompactNumber } from '@/utils/numberFormat.utils';

interface ProposalCardProps {
proposal: Proposal;
className?: string;
}

const statusClasses: Record<Proposal['status'], string> = {
active: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
passed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
rejected: 'border-red-500/30 bg-red-500/10 text-red-400',
executed: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
cancelled: 'border-white/10 bg-white/[0.04] text-white/40',
};

function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

/**
* Proposal card with quorum progress indicator (#826).
*
* Shows the proposal title, description, vote tallies, time remaining,
* and a quorum progress bar so voters can see whether participation is
* on track before voting ends.
*/
const ProposalCard: React.FC<ProposalCardProps> = ({ proposal, className }) => {
const isActive = proposal.status === 'active';
const totalVotes =
proposal.forVotes + proposal.againstVotes + proposal.abstainVotes;

return (
<div
className={cn(
'rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 transition-all duration-200',
isActive && 'hover:border-amber-500/20 hover:bg-white/[0.05]',
className
)}
>
{/* Header row: status + title */}
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="font-jakarta text-base font-bold text-white leading-snug">
{proposal.title}
</h3>
<span
className={cn(
'shrink-0 rounded-full border px-2.5 py-0.5 text-[0.65rem] font-semibold capitalize',
statusClasses[proposal.status]
)}
>
{proposal.status}
</span>
</div>

{/* Description */}
<p className="mb-4 text-sm leading-relaxed text-white/60 line-clamp-2">
{proposal.description}
</p>

{/* Vote tallies */}
<div className="mb-4 flex items-center gap-4 text-xs text-white/50">
<span className="inline-flex items-center gap-1">
<ThumbsUp className="size-3 text-emerald-400" aria-hidden="true" />
{formatCompactNumber(proposal.forVotes)}
</span>
<span className="inline-flex items-center gap-1">
<ThumbsDown className="size-3 text-red-400" aria-hidden="true" />
{formatCompactNumber(proposal.againstVotes)}
</span>
<span className="inline-flex items-center gap-1">
<Minus className="size-3 text-white/40" aria-hidden="true" />
{formatCompactNumber(proposal.abstainVotes)}
</span>
<span className="ml-auto tabular-nums text-white/40">
{formatCompactNumber(totalVotes)} votes
</span>
</div>

{/* Quorum indicator — core of #826 */}
{isActive && (
<QuorumIndicator
quorumBps={proposal.quorumBps}
totalVotingWeight={proposal.totalVotingWeight}
totalCirculatingSupply={proposal.totalCirculatingSupply}
className="mb-4"
/>
)}

{/* Footer: dates */}
<div className="flex items-center gap-1.5 text-xs text-white/35">
<Clock className="size-3" aria-hidden="true" />
<span>
{formatDate(proposal.startDate)} — {formatDate(proposal.endDate)}
</span>
</div>
</div>
);
};

export default ProposalCard;
97 changes: 97 additions & 0 deletions src/components/common/QuorumIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { CheckCircle, AlertCircle } from 'lucide-react';
import type { QuorumIndicatorProps } from '@/types/governance';

/**
* Governance quorum indicator (#826).
*
* Renders a progress bar filled to the current participation percentage,
* a marker at the quorum threshold, and a green / amber status label.
*
* Acceptance criteria:
* - Participation percentage computed and bar filled correctly
* - Quorum threshold marker shown at the correct position
* - 'Quorum reached' shown in green when participation meets the threshold
* - 'Quorum not yet reached' shown in amber when below threshold
* - Bar updates after each vote (caller passes refreshed data)
*/
const QuorumIndicator: React.FC<QuorumIndicatorProps> = ({
quorumBps,
totalVotingWeight,
totalCirculatingSupply,
className,
}) => {
const { participationPct, quorumPct, quorumReached } = useMemo(() => {
if (!totalCirculatingSupply || totalCirculatingSupply <= 0) {
return { participationPct: 0, quorumPct: 0, quorumReached: false };
}

const participation = (totalVotingWeight / totalCirculatingSupply) * 100;
const quorum = quorumBps / 100; // basis points → percentage

return {
participationPct: Math.min(participation, 100),
quorumPct: Math.min(quorum, 100),
quorumReached: participation >= quorum,
};
}, [quorumBps, totalVotingWeight, totalCirculatingSupply]);

return (
<div
className={cn('space-y-1.5', className)}
role="status"
aria-label={
quorumReached
? `Quorum reached: ${participationPct.toFixed(1)}% participation`
: `Quorum not yet reached: ${participationPct.toFixed(1)}% participation, ${quorumPct}% required`
}
>
{/* Progress bar track */}
<div className="relative h-2 w-full overflow-hidden rounded-full bg-white/[0.08]">
{/* Filled portion — participation */}
<div
className={cn(
'absolute inset-y-0 left-0 rounded-full transition-all duration-700 ease-out',
quorumReached
? 'bg-emerald-500'
: 'bg-amber-400'
)}
style={{ width: `${participationPct}%` }}
aria-hidden="true"
/>

{/* Quorum threshold marker */}
<div
className="absolute inset-y-0 w-0.5 bg-white/70"
style={{ left: `${quorumPct}%` }}
aria-hidden="true"
/>
</div>

{/* Labels row */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1">
{quorumReached ? (
<CheckCircle className="size-3.5 text-emerald-400" aria-hidden="true" />
) : (
<AlertCircle className="size-3.5 text-amber-400" aria-hidden="true" />
)}
<span
className={cn(
'font-semibold',
quorumReached ? 'text-emerald-400' : 'text-amber-400'
)}
>
{quorumReached ? 'Quorum reached' : 'Quorum not yet reached'}
</span>
</div>
<span className="tabular-nums text-white/50">
{participationPct.toFixed(1)}% / {quorumPct.toFixed(0)}%
</span>
</div>
</div>
);
};

export default QuorumIndicator;
19 changes: 19 additions & 0 deletions src/hooks/useGovernanceProposals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { governanceService } from '@/services/governance.service';
import { queryKeys } from '@/lib/queryKeys';

/**
* Fetches governance proposals, optionally filtered by creator.
* Data refreshes every 15 seconds to keep quorum indicators current
* without manual page reloads.
*/
export function useGovernanceProposals(creatorId?: string) {
return useQuery({
queryKey: queryKeys.governance.proposals(creatorId),
queryFn: () => governanceService.getProposals(creatorId),
/** 10 s stale time keeps the quorum bar responsive to votes. */
staleTime: 10_000,
/** 15 s refetch interval so the bar updates after a vote. */
refetchInterval: 15_000,
});
}
6 changes: 6 additions & 0 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ export const queryKeys = {
admin: {
oracleCallers: () => ['admin', 'oracle', 'callers'] as const,
},
governance: {
all: () => ['governance'] as const,
proposals: (creatorId?: string) =>
['governance', 'proposals', creatorId ?? null] as const,
proposal: (id: string) => ['governance', 'proposal', id] as const,
},
} as const;
15 changes: 7 additions & 8 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,13 @@ function CreatorDetailPageContent() {
<CreatorProfileInfoGrid items={feeItems} />
</div>

{/* Co-Creator Section */}
<CoCreatorSection
courseId={creator.id}
coCreatorAddress={creator.coCreatorAddress}
coCreatorSplitBps={creator.coCreatorSplitBps}
totalPaidToCoCreator={creator.totalPaidToCoCreator}
totalPaidToCreator={creator.totalPaidToCreator}
/>
{/* Co-Creator Section */} <CoCreatorSection
courseId={creator.id}
coCreatorAddress={creator.coCreatorAddress}
coCreatorSplitBps={creator.coCreatorSplitBps}
totalPaidToCoCreator={creator.totalPaidToCoCreator}
totalPaidToCreator={creator.totalPaidToCreator}
/>

{/* Activity Feed */}
<div className="rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8">
Expand Down
120 changes: 120 additions & 0 deletions src/pages/GovernancePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useGovernanceProposals } from '@/hooks/useGovernanceProposals';
import ProposalCard from '@/components/common/ProposalCard';
import type { ProposalStatus } from '@/types/governance';
import { cn } from '@/lib/utils';
import { ArrowLeft, Filter } from 'lucide-react';

const STATUS_FILTERS: Array<{ label: string; value: ProposalStatus | 'all' }> = [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Passed', value: 'passed' },
{ label: 'Rejected', value: 'rejected' },
];

function GovernancePageContent() {
const { data: proposals, isLoading, error } = useGovernanceProposals();
const [statusFilter, setStatusFilter] = useState<ProposalStatus | 'all'>('all');

const filtered = proposals?.filter(
p => statusFilter === 'all' || p.status === statusFilter
);

const activeCount = proposals?.filter(p => p.status === 'active').length ?? 0;

return (
<main className="min-h-screen bg-[#06111f] px-4 py-8 text-white sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl">
{/* Back nav */}
<Link
to="/"
className="mb-6 inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-white/70"
>
<ArrowLeft className="size-4" aria-hidden="true" />
Back to marketplace
</Link>

{/* Header */}
<div className="mb-8">
<p className="font-mono text-[10px] uppercase tracking-[0.22em] text-amber-400/80">
Governance
</p>
<h1 className="mt-1 font-jakarta text-3xl font-black tracking-tight sm:text-4xl">
Proposals
</h1>
<p className="mt-2 max-w-lg text-sm text-white/50">
{activeCount > 0
? `${activeCount} active proposal${activeCount === 1 ? '' : 's'} require${activeCount === 1 ? 's' : ''} your vote.`
: 'No active proposals at the moment.'}
</p>
</div>

{/* Filters */}
<div className="mb-6 flex items-center gap-2">
<Filter className="size-3.5 text-white/30" aria-hidden="true" />
{STATUS_FILTERS.map(f => (
<button
key={f.value}
type="button"
onClick={() => setStatusFilter(f.value)}
className={cn(
'rounded-full border px-3 py-1 text-xs font-semibold transition-colors',
statusFilter === f.value
? 'border-amber-500/40 bg-amber-500/15 text-amber-400'
: 'border-white/10 bg-white/[0.04] text-white/50 hover:border-white/20 hover:text-white/70'
)}
>
{f.label}
</button>
))}
</div>

{/* Loading skeleton */}
{isLoading && (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-48 animate-pulse rounded-2xl border border-white/[0.06] bg-white/[0.03]"
/>
))}
</div>
)}

{/* Error state */}
{error && (
<div className="rounded-2xl border border-dashed border-red-500/30 p-8 text-center">
<p className="text-sm text-red-400">
Unable to load proposals. Please try again.
</p>
</div>
)}

{/* Empty state */}
{!isLoading && !error && filtered && filtered.length === 0 && (
<div className="rounded-2xl border border-dashed border-white/10 p-8 text-center">
<p className="text-sm text-white/40">
{statusFilter === 'all'
? 'No proposals yet.'
: `No ${statusFilter} proposals.`}
</p>
</div>
)}

{/* Proposal cards */}
{!isLoading && !error && filtered && filtered.length > 0 && (
<div className="space-y-4">
{filtered.map(proposal => (
<ProposalCard key={proposal.id} proposal={proposal} />
))}
</div>
)}
</div>
</main>
);
}

export default function GovernancePage() {
return <GovernancePageContent />;
}
Loading
Loading