Skip to content
Open
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
27 changes: 23 additions & 4 deletions app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"use client";

import { useEffect, useState, useCallback, useRef } from "react";
import type { Metadata } from "next";
import { NETWORK_NAME } from "@/constants";
import { getAllInvoices, Invoice } from "@/utils/soroban";
import DynamicAmountHistogram from "@/components/charts/DynamicAmountHistogram";
Expand Down Expand Up @@ -82,6 +81,8 @@ export interface AnalyticsPayload {
daily: DailyBucket[];
/** Full list of invoices for distribution analysis */
invoices: Invoice[];
/** Raw get_contract_stats() result for contract-native protocol metrics */
contractStats: ProtocolContractStats | null;
/** ISO-8601 timestamp of when this data was last indexed */
indexed_at: string;
}
Expand Down Expand Up @@ -122,6 +123,7 @@ function generateMockPayload(): AnalyticsPayload {
},
daily,
invoices: [], // Real invoices will be fetched in the hook
contractStats: null,
indexed_at: new Date().toISOString(),
};
}
Expand Down Expand Up @@ -167,11 +169,13 @@ function useAnalyticsPolling(): UseAnalyticsReturn {

const fetch_ = useCallback(async () => {
try {
const [payload, invoices] = await Promise.all([
const [payload, invoices, contractStats] = await Promise.all([
fetchAnalytics(),
getAllInvoices(),
getContractStats(),
]);
payload.invoices = invoices;
payload.contractStats = contractStats;
setData(payload);
setLoadState("success");
setLastUpdated(new Date());
Expand All @@ -187,7 +191,7 @@ function useAnalyticsPolling(): UseAnalyticsReturn {

// Initial fetch
useEffect(() => {
fetch_();
void Promise.resolve().then(fetch_);
}, [fetch_]);

// Polling every 5 minutes
Expand Down Expand Up @@ -375,7 +379,7 @@ export default function AnalyticsPage() {
);
}

const { summary, daily, indexed_at } = data!;
const { summary, indexed_at } = data!;

const defaultRate = formatPercent(
summary.total_defaulted,
Expand Down Expand Up @@ -525,6 +529,21 @@ export default function AnalyticsPage() {
</div>
</section>

{/* ── Per-token protocol volume ────────────────────────────────── */}
<section
aria-labelledby="token-volume-heading"
className="mt-14"
>
<SectionHeading>
<span id="token-volume-heading">Token Volume</span>
</SectionHeading>

<PerTokenVolumeChart
stats={data?.contractStats}
invoices={data?.invoices || []}
/>
</section>

{/* ── Time-series charts ────────────────────────────────────────── */}
<section
aria-labelledby="charts-heading"
Expand Down
18 changes: 12 additions & 6 deletions app/governance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) {
Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" },
Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" },
Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" },
Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "gavel" },
};
const { color, icon } = config[status];
return (
Expand Down Expand Up @@ -196,24 +197,29 @@ export default function GovernancePage() {
const [votingPower, setVotingPower] = useState(0);

const load = useCallback(async () => {
const data = await fetchProposals();
setProposals(data);
setLoading(false);
fetchProposals().then((data) => {
setProposals(data);
setLoading(false);
});
}, []);

useEffect(() => {
load();
void load();
// Refresh every 30 s for real-time vote counts
const interval = setInterval(load, 30_000);
return () => clearInterval(interval);
}, [load]);

useEffect(() => {
if (!isConnected || !address) {
setVotingPower(0);
Promise.resolve().then(() => {
setVotingPower(0);
});
return;
}
getVotingPower(address).then(setVotingPower);
getVotingPower(address).then((power) => {
setVotingPower(power);
});
}, [address, isConnected]);

const sorted = useMemo(
Expand Down
4 changes: 2 additions & 2 deletions src/components/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ function getTokenName(token: TokenLike): string {
return token.name ?? token.symbol;
}

function getTokenLogo(token: TokenLike): string {
function getTokenLogo(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "logo">>): string {
return token.logo ?? `/tokens/${token.symbol.toLowerCase()}.svg`;
}

function getTokenIconLabel(token: TokenLike): string {
function getTokenIconLabel(token: Pick<TokenLike, "symbol"> & Partial<Pick<TokenLike, "iconLabel">>): string {
return token.iconLabel ?? (token.symbol.replace(/[^A-Z0-9]/gi, "").slice(0, 2).toUpperCase() || "TK");
}

Expand Down
193 changes: 193 additions & 0 deletions src/components/charts/PerTokenVolumeChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"use client";

import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type TooltipProps,
} from "recharts";
import type { Invoice, ProtocolContractStats } from "@/utils/soroban";
import {
buildPerTokenVolumeData,
type TokenSymbol,
type TokenVolumeRange,
} from "@/utils/per-token-volume";

const TOKEN_COLORS: Record<TokenSymbol, string> = {
USDC: "#2563eb",
EURC: "#f59e0b",
XLM: "#111827",
};

const RANGES: TokenVolumeRange[] = [30, 90];

const CHART_TICK_STYLE = {
fill: "var(--color-on-surface-variant, #64748b)",
fontSize: 11,
fontFamily: "inherit",
};

function formatUsd(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
return `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
}

function TokenTooltip({ active, payload, label }: TooltipProps<number, string>) {
if (!active || !payload?.length) return null;

const total = payload.reduce((sum, entry) => sum + Number(entry.value ?? 0), 0);

return (
<div className="rounded-xl border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 shadow-xl">
<p className="mb-2 text-[11px] font-bold uppercase tracking-widest text-on-surface-variant">
Week of {label}
</p>
<div className="flex flex-col gap-1.5">
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-5">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: entry.color }}
aria-hidden="true"
/>
<span className="text-xs font-medium text-on-surface">{entry.name}</span>
</div>
<span className="text-xs font-bold text-on-surface">
{formatUsd(Number(entry.value ?? 0))}
</span>
</div>
))}
<div className="my-1 h-px bg-outline-variant/10" />
<div className="flex items-center justify-between gap-5">
<span className="text-xs font-bold text-on-surface">Token total</span>
<span className="text-xs font-extrabold text-primary">{formatUsd(total)}</span>
</div>
</div>
</div>
);
}

export default function PerTokenVolumeChart({
stats,
invoices,
}: {
stats?: ProtocolContractStats | null;
invoices: Invoice[];
}) {
const [range, setRange] = useState<TokenVolumeRange>(30);
const { buckets, summary } = useMemo(
() => buildPerTokenVolumeData({ stats, invoices, rangeDays: range }),
[invoices, range, stats],
);

return (
<div className="flex flex-col gap-6 rounded-[24px] border border-outline-variant/15 bg-surface-container-lowest p-6 shadow-sm">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<h3 className="font-headline text-xl font-bold text-on-surface">
Volume by Token
</h3>
<p className="text-sm text-on-surface-variant">
Weekly protocol volume stacked by supported token.
</p>
</div>

<div className="flex items-center gap-1 self-start rounded-xl bg-surface-container p-1">
{RANGES.map((days) => (
<button
key={days}
type="button"
onClick={() => setRange(days)}
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-colors ${
range === days
? "bg-primary text-white shadow-sm"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
{days}D
</button>
))}
</div>
</div>

<div className="grid gap-3 sm:grid-cols-4">
<div className="rounded-2xl border border-outline-variant/15 bg-surface-container-low px-4 py-3 sm:col-span-1">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-on-surface-variant">
USD-equivalent
</p>
<p className="mt-1 font-headline text-2xl font-bold text-on-surface">
{formatUsd(summary.totalUsd)}
</p>
</div>
{(["USDC", "EURC", "XLM"] as TokenSymbol[]).map((symbol) => (
<div
key={symbol}
className="rounded-2xl border border-outline-variant/15 bg-surface-container-low px-4 py-3"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: TOKEN_COLORS[symbol] }}
aria-hidden="true"
/>
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-on-surface-variant">
{symbol}
</p>
</div>
<p className="mt-1 font-headline text-xl font-bold text-on-surface">
{formatUsd(summary.totals[symbol])}
</p>
</div>
))}
</div>

<div className="h-[260px] w-full">
{buckets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-outline-variant/20">
<span className="material-symbols-outlined text-4xl text-outline-variant/40" aria-hidden="true">
stacked_bar_chart
</span>
<p className="text-sm font-medium text-on-surface-variant">
No token volume in this period
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 10, right: 12, left: -10, bottom: 0 }}>
<CartesianGrid
stroke="var(--color-outline-variant, #334155)"
strokeDasharray="3 3"
strokeOpacity={0.2}
vertical={false}
/>
<XAxis
dataKey="label"
tick={CHART_TICK_STYLE}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={CHART_TICK_STYLE}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatUsd(value)}
/>
<Tooltip content={<TokenTooltip />} />
<Bar dataKey="USDC" stackId="volume" fill={TOKEN_COLORS.USDC} radius={[6, 6, 0, 0]} />
<Bar dataKey="EURC" stackId="volume" fill={TOKEN_COLORS.EURC} radius={[6, 6, 0, 0]} />
<Bar dataKey="XLM" stackId="volume" fill={TOKEN_COLORS.XLM} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
Loading