Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions src/app/governance/[id]/ProposalDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Nav } from "@/components/Nav";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
import { ProposalStatusChip } from "@/components/ProposalStatusChip";
import { TimelockBanner } from "@/components/TimelockBanner";
import { useProposalSubscriptions } from "@/hooks/useProposalSubscriptions";
import { VotePanel } from "@/components/VotePanel";
import { VotingPowerPanel } from "@/components/VotingPowerPanel";
import { useWalletStore } from "@/store/wallet";
Expand Down Expand Up @@ -236,6 +239,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
}
};

const { isSubscribed, toggle: toggleSubscription, reminders } = useProposalSubscriptions(proposal, userAddress ?? null);

const { power } = useVotingPower(userAddress, proposalId);
const votingPower = power?.total ?? 0;

Expand Down Expand Up @@ -322,17 +327,7 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
<span className="text-xs px-2 py-0.5 rounded-full bg-vx-surface text-vx-muted border border-vx-border">
{proposal.category}
</span>
<span
className={`text-xs px-2.5 py-0.5 rounded-full font-medium w-fit ${
proposal.status === "active"
? "bg-vx-sage-bg text-vx-sage border border-vx-sage/30"
: proposal.status === "passed"
? "bg-blue-500/10 text-blue-400 border border-blue-500/30"
: "bg-red-500/10 text-red-400 border border-red-500/30"
}`}
>
{proposal.status}
</span>
<ProposalStatusChip status={proposal.status} />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-vx-text mb-3">{proposal.title}</h1>
<p className="text-xs text-vx-muted">
Expand All @@ -342,6 +337,31 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
</p>
</div>

<div className="mb-6 space-y-2">
<button
type="button"
onClick={toggleSubscription}
disabled={!isConnected}
aria-pressed={isSubscribed}
className="px-3 py-1.5 text-xs font-semibold text-vx-sage border border-vx-sage/30 rounded-lg hover:bg-vx-sage/10 transition-colors disabled:opacity-50 focus-visible:outline focus-visible:outline-2"
>
{isSubscribed ? "Notifications on" : "Notify me"}
</button>
<Link
href={`/governance/compare?a=${encodeURIComponent(proposal.id)}`}
className="ml-2 px-3 py-1.5 text-xs font-semibold text-vx-sage border border-vx-sage/30 rounded-lg hover:bg-vx-sage/10 transition-colors inline-block focus-visible:outline focus-visible:outline-2"
>
Compare
</Link>
{reminders.map((r, i) => (
<p key={`${r.kind}-${i}`} role="status" className="text-xs text-vx-sage">
{r.message}
</p>
))}
</div>

{proposal.status === "queued" && <TimelockBanner timelockEndsAt={proposal.timelockEndsAt} />}

<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* ── Main column ──────────────────────────────────────────────── */}
<div className="space-y-6">
Expand Down
2 changes: 1 addition & 1 deletion src/app/governance/[id]/ProposalImpactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function ProposalImpactPreview({ proposal }: { proposal: GovernancePropos
);
}

function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) {
export function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) {
const { t } = useTranslation();
const { solvers, isLoading, error } = useSolvers();

Expand Down
25 changes: 23 additions & 2 deletions src/app/governance/archive/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { governanceStore } from '@/lib/governanceStore';
import { isValidStellarAddress } from '@/lib/stellarAddress';
import { ProposalStatusChip } from '@/components/ProposalStatusChip';
import { toCsv } from '@/lib/csv';

const STATUSES = ['all', 'passed', 'rejected', 'expired', 'executed'] as const;
const STATUSES = ['all', 'active', 'queued', 'passed', 'rejected', 'expired', 'executed'] as const;
type Status = (typeof STATUSES)[number];

const PAGE_SIZE = 10;
Expand Down Expand Up @@ -167,6 +168,26 @@ export default async function GovernanceArchivePage({
</button>
</form>

{rows.length >= 2 && (
<form action="/governance/compare" method="get" className="mb-4 flex flex-wrap items-end gap-3 text-sm">
{(['a', 'b'] as const).map((name, i) => (
<label key={name} className="flex flex-col gap-1">
Compare {i === 0 ? 'first' : 'second'} proposal
<select name={name} defaultValue={rows[i]?.id} className="rounded border px-2 py-1">
{rows.map((p) => (
<option key={p.id} value={p.id}>
{p.id}: {p.title}
</option>
))}
</select>
</label>
))}
<button type="submit" className="rounded border px-3 py-2 text-sm">
Compare
</button>
</form>
)}

{rows.length === 0 ? (
<p className="rounded border border-dashed p-6 text-center text-sm text-muted-foreground">
No proposals match the selected filters.
Expand All @@ -179,7 +200,7 @@ export default async function GovernanceArchivePage({
<Link href={`/governance/${p.id}`} className="font-medium underline-offset-2 hover:underline">
{p.title}
</Link>
<span className="rounded bg-muted px-2 py-0.5 text-xs uppercase">{p.status}</span>
<ProposalStatusChip status={p.status} />
</div>
<dl className="mt-2 grid grid-cols-2 gap-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
Expand Down
113 changes: 113 additions & 0 deletions src/app/governance/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getGovernanceProposalById, getGovernanceProposals } from "@/lib/governanceStore";
import type { GovernanceProposal } from "@/lib/governanceStore";
import { ProposalStatusChip } from "@/components/ProposalStatusChip";
import { BondImpact } from "../[id]/ProposalImpactPreview";
import { sanitizeText } from "@/lib/textSafety";

interface ComparePageProps {
searchParams?: { a?: string; b?: string };
}

function ProposalColumn({ proposal, showImpact }: { proposal: GovernanceProposal; showImpact: boolean }) {
const change = proposal.parameterChange;
return (
<section className="card p-5 space-y-3" aria-label={proposal.id}>
<div className="flex flex-wrap items-center gap-2">
<Link href={`/governance/${proposal.id}`} className="font-mono text-xs font-bold text-vx-sage hover:underline">
{proposal.id}
</Link>
<ProposalStatusChip status={proposal.status} />
</div>
<h2 className="text-base font-semibold text-vx-text">{sanitizeText(proposal.title)}</h2>
<dl className="grid grid-cols-2 gap-2 text-xs text-vx-muted">
<dt>Category</dt>
<dd className="text-vx-text">{proposal.category}</dd>
<dt>Votes for</dt>
<dd className="text-vx-text">{proposal.votesFor.toLocaleString()}</dd>
<dt>Votes against</dt>
<dd className="text-vx-text">{proposal.votesAgainst.toLocaleString()}</dd>
<dt>Votes abstain</dt>
<dd className="text-vx-text">{(proposal.votesAbstain ?? 0).toLocaleString()}</dd>
<dt>Parameter change</dt>
<dd className="text-vx-text">
{change ? `${change.parameter}: ${change.currentValue} → ${change.proposedValue}` : "None"}
</dd>
</dl>
{showImpact && change && (
<div className="pt-3 border-t border-vx-line space-y-1.5">
<h3 className="text-xs font-semibold text-vx-text">Projected solver impact</h3>
<BondImpact currentValue={change.currentValue} proposedValue={change.proposedValue} />
</div>
)}
</section>
);
}

export default function GovernanceComparePage({ searchParams }: ComparePageProps) {
const aId = searchParams?.a;
const bId = searchParams?.b;

const a = aId ? getGovernanceProposalById(aId) : undefined;
if (aId && !a) notFound();
const b = bId ? getGovernanceProposalById(bId) : undefined;
if (bId && !b) notFound();

if (!a || !b) {
const proposals = getGovernanceProposals();
return (
<main className="max-w-5xl mx-auto px-3 sm:px-5 py-8 sm:py-12 space-y-4">
<h1 className="text-2xl font-bold text-vx-text">Compare proposals</h1>
<form action="/governance/compare" method="get" className="flex flex-wrap items-end gap-3 text-sm">
{(["a", "b"] as const).map((name) => (
<label key={name} className="flex flex-col gap-1">
{name === "a" ? "First proposal" : "Second proposal"}
<select name={name} defaultValue={name === "a" ? a?.id : b?.id} className="rounded border px-2 py-1">
{proposals.map((p) => (
<option key={p.id} value={p.id}>
{p.id}: {p.title}
</option>
))}
</select>
</label>
))}
<button type="submit" className="rounded border px-3 py-2 text-sm">
Compare
</button>
</form>
</main>
);
}

const sameParam =
a.id !== b.id &&
a.parameterChange?.parameter === "minSolverBondUsd" &&
b.parameterChange?.parameter === "minSolverBondUsd";

return (
<main className="max-w-5xl mx-auto px-3 sm:px-5 py-8 sm:py-12 space-y-6">
<Link href="/governance" className="text-xs text-vx-sage hover:underline font-semibold">
← Back to Proposals
</Link>
<h1 className="text-2xl font-bold text-vx-text">
Compare {a.id} and {b.id}
</h1>
{a.id === b.id && (
<p role="status" className="text-xs text-vx-muted">
Both selections are the same proposal.
</p>
)}
<div className="grid gap-6 md:grid-cols-2">
<ProposalColumn proposal={a} showImpact={sameParam} />
<ProposalColumn proposal={b} showImpact={sameParam} />
</div>
{!sameParam && (
<p className="text-xs text-vx-muted">
An impact comparison is not available: the proposals do not share a comparable parameter change, so only
tallies and metadata are shown.
</p>
)}
</main>
);
}
53 changes: 17 additions & 36 deletions src/app/governance/delegates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,17 @@
'use client';

import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVotingPower } from '@/hooks/useVotingPower';
import { useDelegation } from '@/hooks/useDelegation';
import { sanitizeText } from '@/lib/textSafety';
import { isSameStellarAddress, isValidStellarAddress } from '@/lib/stellarAddress';
import { MOCK_DELEGATES, type DelegateProfile } from '@/lib/delegateDirectory';
import { secureLogger } from '@/lib/secureLogging';

interface DelegateProfile {
address: string;
name: string;
bio: string;
votingPower: number;
participationRate: number;
proposalsVoted: number;
}

type SortKey = 'votingPower' | 'participationRate' | 'proposalsVoted';
type FlowStage = 'idle' | 'review' | 'simulate' | 'sign' | 'pending' | 'done';

const MOCK_DELEGATES: DelegateProfile[] = [
{
address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
name: 'Aurora Governance',
bio: 'Active voter focused on protocol upgrades and treasury stewardship.',
votingPower: 1250000,
participationRate: 0.94,
proposalsVoted: 87,
},
{
address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF',
name: 'Nova Collective',
bio: 'Community-run delegate prioritising developer tooling and grants.',
votingPower: 860000,
participationRate: 0.81,
proposalsVoted: 64,
},
{
address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF',
name: 'Meridian Labs',
bio: 'Security-first delegate reviewing every proposal before voting.',
votingPower: 540000,
participationRate: 0.72,
proposalsVoted: 51,
},
];

function formatPower(value: number): string {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
}
Expand Down Expand Up @@ -96,6 +62,13 @@ export default function DelegatesPage() {
setConfirmedAddress('');
}, []);

// Deep link from the voter-history page: /governance/delegates?delegate=G...
useEffect(() => {
const addr = new URLSearchParams(window.location.search).get('delegate');
const match = MOCK_DELEGATES.find((d) => isSameStellarAddress(d.address, addr ?? ''));
if (match) setSelected(match);
}, []);

useEffect(() => {
if (stage === 'done') {
void refreshVotingPower();
Expand Down Expand Up @@ -234,6 +207,13 @@ export default function DelegatesPage() {
<p className="font-medium">{sanitizeText(d.name)}</p>
<p className="font-mono text-xs break-all text-muted-foreground">{d.address}</p>
</div>
<div className="flex items-center gap-2">
<Link
href={`/governance/voter/${d.address}`}
className="rounded-md border px-3 py-1.5 text-sm focus-visible:outline focus-visible:outline-2"
>
View voting history
</Link>
<button
type="button"
onClick={() => {
Expand All @@ -245,6 +225,7 @@ export default function DelegatesPage() {
>
Delegate
</button>
</div>
</div>
<p className="mt-2 text-sm">{sanitizeText(d.bio)}</p>
<dl className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
Expand Down
9 changes: 9 additions & 0 deletions src/app/governance/voter/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { StrKey } from '@stellar/stellar-sdk';
import { governanceStore } from '@/lib/governanceStore';
import { MOCK_DELEGATES } from '@/lib/delegateDirectory';
import { sanitizeText } from '@/lib/textSafety';

const WINDOWS = [30, 90, 365] as const;
Expand Down Expand Up @@ -84,6 +85,14 @@ export default async function VoterHistoryPage({ params, searchParams }: VoterPa
<header className="mb-6">
<h1 className="text-2xl font-semibold">Voter history</h1>
<p className="mt-1 break-all font-mono text-sm text-muted-foreground">{address}</p>
{MOCK_DELEGATES.some((d) => d.address === address) && (
<Link
href={`/governance/delegates?delegate=${address}`}
className="mt-3 inline-block rounded-md border px-3 py-1.5 text-sm focus-visible:outline focus-visible:outline-2"
>
Delegate to this address
</Link>
)}
</header>

<section aria-label="Participation stats" className="mb-6 grid gap-4 sm:grid-cols-3">
Expand Down
23 changes: 23 additions & 0 deletions src/components/ProposalStatusChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { GovernanceProposalStatus } from "@/lib/governanceStore";

/** Single source of truth for the label/color of every proposal status. */
export const PROPOSAL_STATUS_CHIP: Record<GovernanceProposalStatus, { label: string; className: string }> = {
active: { label: "Active", className: "bg-vx-sage-bg text-vx-sage border border-vx-sage/30" },
passed: { label: "Passed", className: "bg-blue-500/10 text-blue-400 border border-blue-500/30" },
rejected: { label: "Rejected", className: "bg-red-500/10 text-red-400 border border-red-500/30" },
queued: { label: "Queued", className: "bg-amber-500/10 text-amber-400 border border-amber-500/30" },
executed: { label: "Executed", className: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30" },
expired: { label: "Expired", className: "bg-vx-surface text-vx-muted border border-vx-border" },
};

export function ProposalStatusChip({ status }: { status: GovernanceProposalStatus }) {
const chip = PROPOSAL_STATUS_CHIP[status];
return (
<span
data-status={status}
className={`text-xs px-2.5 py-0.5 rounded-full font-medium w-fit ${chip.className}`}
>
{chip.label}
</span>
);
}
Loading
Loading