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
94 changes: 91 additions & 3 deletions src/components/pages/homepage/governance/drep/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import SectionTitle from "@/components/ui/section-title";
import Pagination from "@/components/common/overall-layout/pagination";
import { getProvider } from "@/utils/get-provider";
Expand All @@ -10,6 +10,7 @@ import RowLabelInfo from "@/components/common/row-label-info";
import { TooltipProvider } from "@/components/ui/tooltip";
import ActiveIndicator from "./activeIndicator";
import ScriptIndicator from "./scriptIndicator";
import { Button } from "@/components/ui/button";

export default function DrepOverviewPage() {
const [drepList, setDrepList] = useState<
Expand All @@ -22,6 +23,7 @@ export default function DrepOverviewPage() {
const [isLastPage, setIsLastPage] = useState<boolean>(false);
// Mainnet for anonymous visitors, the wallet's network once connected.
const network = usePublicNetwork();
const [filter, setFilter] = useState<"all" | "active" | "inactive">("all");

useEffect(() => {
async function loadDrepList() {
Expand Down Expand Up @@ -92,11 +94,79 @@ export default function DrepOverviewPage() {
}
};

const aggregate = useMemo(() => {
let active = 0;
let totalLovelace = 0;
for (const { details } of drepList) {
if (details?.active) active += 1;
const amt = details?.amount ? parseInt(details.amount, 10) : 0;
if (Number.isFinite(amt)) totalLovelace += amt;
}
return {
total: drepList.length,
active,
inactive: drepList.length - active,
totalAda: totalLovelace / 1_000_000,
};
}, [drepList]);

const visibleDreps = useMemo(() => {
if (filter === "all") return drepList;
if (filter === "active") return drepList.filter((d) => d.details?.active);
return drepList.filter((d) => !d.details?.active);
}, [drepList, filter]);

return (
<TooltipProvider>
<main className="flex flex-col gap-8 p-4 text-foreground md:p-8">
<SectionTitle>DREP Overview</SectionTitle>

{/* Aggregate stats for current page */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">On this page</div>
<div className="mt-1 text-xl font-semibold text-foreground">
{aggregate.total}
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">Active</div>
<div className="mt-1 text-xl font-semibold text-foreground">
{aggregate.active}
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">Inactive</div>
<div className="mt-1 text-xl font-semibold text-foreground">
{aggregate.inactive}
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">ADA delegated</div>
<div className="mt-1 text-xl font-semibold text-foreground">
{aggregate.totalAda >= 1_000_000
? `${(aggregate.totalAda / 1_000_000).toFixed(2)}M ₳`
: aggregate.totalAda >= 1_000
? `${(aggregate.totalAda / 1_000).toFixed(1)}k ₳`
: `${aggregate.totalAda.toFixed(0)} ₳`}
</div>
</div>
</div>

{/* Filter controls */}
<div className="flex flex-wrap gap-2">
{(["all", "active", "inactive"] as const).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? "default" : "secondary"}
onClick={() => setFilter(f)}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</Button>
))}
</div>

{/* Pagination Component */}
<Pagination
currentPage={currentPage}
Expand All @@ -113,7 +183,7 @@ export default function DrepOverviewPage() {
{loading ? (
<p>Loading DREP information...</p>
) : (
drepList.map(({ details, metadata }) => {
visibleDreps.map(({ details, metadata }) => {
const drepId = details.drep_id;
const givenName =
typeof metadata?.json_metadata?.body?.givenName === "object"
Expand Down Expand Up @@ -172,7 +242,20 @@ export default function DrepOverviewPage() {
</div>

{/* DRep ID directly under name */}
<RowLabelInfo label="DRep ID:" value={drepId} copyString={drepId} />
<RowLabelInfo
label="DRep ID:"
value={drepId}
copyString={drepId}
className="text-sm text-gray-400"
/>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
{details?.active_epoch != null && (
<span>Active since epoch {details.active_epoch}</span>
)}
{details?.hex && (
<span className="font-mono">hex: {details.hex.slice(0, 16)}…</span>
)}
</div>
</div>

{/* ADA Amount (Larger, Aligned Right) */}
Expand All @@ -190,6 +273,11 @@ export default function DrepOverviewPage() {
{!loading && drepList.length === 0 && (
<p className="text-muted-foreground">No DREP information available.</p>
)}
{!loading && drepList.length > 0 && visibleDreps.length === 0 && (
<p className="text-gray-500">
No DReps match the {filter} filter on this page.
</p>
)}
</div>
</main>
</TooltipProvider>
Expand Down
3 changes: 3 additions & 0 deletions src/components/pages/homepage/governance/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SectionTitle from "@/components/ui/section-title";
import CardUI from "@/components/ui/card-content";
import Button from "@/components/common/button";
import Link from "next/link";
import GovernanceNetworkStats from "./network-stats";

export default function PageGovernance() {
const governanceFeatures = [
Expand Down Expand Up @@ -81,6 +82,8 @@ export default function PageGovernance() {
wallet experience.
</p>

<GovernanceNetworkStats />

{governanceFeatures.map((feature, index) => (
<React.Fragment key={index}>
<CardUI title={feature.title} cardClassName="w-full">
Expand Down
158 changes: 158 additions & 0 deletions src/components/pages/homepage/governance/network-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import CardUI from "@/components/ui/card-content";
import { Users, FileText, Coins } from "lucide-react";
import { getProvider } from "@/utils/get-provider";
import { useWallet } from "@meshsdk/react";
import type { BlockfrostDrepInfo } from "@/types/governance";

type Stats = {
drepCount: number | null;
activeDrepCount: number | null;
totalDelegatedAda: number | null;
activeProposals: number | null;
};

const INITIAL: Stats = {
drepCount: null,
activeDrepCount: null,
totalDelegatedAda: null,
activeProposals: null,
};

function formatNumber(n: number | null): string {
if (n == null) return "…";
return n.toLocaleString();
}

function formatAda(n: number | null): string {
if (n == null) return "…";
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B ₳`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M ₳`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k ₳`;
return `${n.toFixed(0)} ₳`;
}

export default function GovernanceNetworkStats() {
const { wallet, connected } = useWallet();
const [network, setNetwork] = useState<number>(1);
const [stats, setStats] = useState<Stats>(INITIAL);

useEffect(() => {
let cancelled = false;
const fetchNet = async () => {
if (connected && wallet) {
try {
const n = await wallet.getNetworkId();
if (!cancelled) setNetwork(n);
} catch {
/* default to mainnet */
}
}
};
void fetchNet();
return () => {
cancelled = true;
};
}, [connected, wallet]);

useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const provider = getProvider(network);
const [drepsPage, proposalsPage] = await Promise.all([
provider
.get(`/governance/dreps/?count=100&page=1&order=desc`)
.catch(() => [] as BlockfrostDrepInfo[]),
provider
.get(`/governance/proposals?count=100&page=1&order=desc`)
.catch(() => [] as Array<{ tx_hash: string; cert_index: number }>),
]);
const dreps = Array.isArray(drepsPage) ? (drepsPage as BlockfrostDrepInfo[]) : [];
const totalLovelace = dreps.reduce((acc, d) => {
const amt = d?.amount ? parseInt(String(d.amount), 10) : 0;
return acc + (Number.isFinite(amt) ? amt : 0);
}, 0);
const activeCount = dreps.filter((d) => Boolean(d?.active)).length;
const proposals = Array.isArray(proposalsPage) ? proposalsPage : [];

if (!cancelled) {
setStats({
drepCount: dreps.length,
activeDrepCount: activeCount,
totalDelegatedAda: totalLovelace / 1_000_000,
activeProposals: proposals.length,
});
}
} catch {
if (!cancelled) setStats(INITIAL);
}
};
void load();
return () => {
cancelled = true;
};
}, [network]);

return (
<CardUI
title="Live Cardano governance"
description={`Snapshot from ${network === 0 ? "preprod" : "mainnet"} (first 100 DReps and proposals).`}
cardClassName="w-full"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<Tile
icon={<Users className="h-4 w-4" />}
label="DReps tracked"
value={formatNumber(stats.drepCount)}
hint={
stats.activeDrepCount != null
? `${stats.activeDrepCount} active`
: "…"
}
/>
<Tile
icon={<Coins className="h-4 w-4" />}
label="ADA delegated"
value={formatAda(stats.totalDelegatedAda)}
hint="To these DReps"
/>
<Tile
icon={<FileText className="h-4 w-4" />}
label="Recent proposals"
value={formatNumber(stats.activeProposals)}
hint="Latest 100"
/>
<Tile
icon={<Users className="h-4 w-4" />}
label="Network"
value={network === 0 ? "Preprod" : "Mainnet"}
hint="From your wallet, if connected"
/>
</div>
</CardUI>
);
}

function Tile({
icon,
label,
value,
hint,
}: {
icon: React.ReactNode;
label: string;
value: string;
hint?: string;
}) {
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<div className="mt-1 text-xl font-semibold">{value}</div>
{hint && <div className="text-xs text-muted-foreground">{hint}</div>}
</div>
);
}
Loading
Loading