From d7402d2d301e47ebe352f80dad3e6f8def53b202 Mon Sep 17 00:00:00 2001 From: Adeyemi-cmd Date: Fri, 27 Mar 2026 11:53:25 +0100 Subject: [PATCH] feat(treasury): hookify treasury page with /api/treasury/stats + /api/treasury/activity, add loading skeleton and empty state --- src/hooks/useTreasury.ts | 78 +++++ src/pages/Treasury.tsx | 635 +++++++++++++++++++-------------------- 2 files changed, 385 insertions(+), 328 deletions(-) create mode 100644 src/hooks/useTreasury.ts diff --git a/src/hooks/useTreasury.ts b/src/hooks/useTreasury.ts new file mode 100644 index 00000000..b2f37055 --- /dev/null +++ b/src/hooks/useTreasury.ts @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query" + +export interface TreasuryStats { + total_deposited_usdc: string + total_disbursed_usdc: string + scholars_funded: number + active_proposals: number + donors_count: number +} + +export interface TreasuryEvent { + type: "deposit" | "disburse" + amount?: string + address?: string + scholar?: string + tx_hash: string + created_at: string +} + +const API_BASE = + (import.meta.env.VITE_API_BASE_URL as string | undefined) || + (import.meta.env.VITE_SERVER_URL as string | undefined) || + "/api" + +async function fetchTreasuryStats(): Promise { + const response = await fetch(`${API_BASE}/treasury/stats`) + if (!response.ok) { + throw new Error("Failed to load treasury stats") + } + const data = (await response.json()) as TreasuryStats + return data +} + +async function fetchTreasuryActivity(): Promise { + const response = await fetch(`${API_BASE}/treasury/activity?limit=20`) + if (!response.ok) { + throw new Error("Failed to load treasury activity") + } + const data = (await response.json()) as { events?: TreasuryEvent[] } + return data.events ?? [] +} + +export function useTreasury() { + const { + data: stats, + isLoading: isStatsLoading, + error: statsError, + refetch: refetchStats, + } = useQuery({ + queryKey: ["treasury", "stats"], + queryFn: fetchTreasuryStats, + staleTime: 30_000, + refetchInterval: 60_000, + }) + + const { + data: activity, + isLoading: isActivityLoading, + error: activityError, + refetch: refetchActivity, + } = useQuery({ + queryKey: ["treasury", "activity"], + queryFn: fetchTreasuryActivity, + staleTime: 30_000, + refetchInterval: 60_000, + }) + + return { + stats, + activity: activity ?? [], + isLoading: isStatsLoading || isActivityLoading, + isError: Boolean(statsError || activityError), + refetch: () => { + refetchStats() + refetchActivity() + }, + } +} diff --git a/src/pages/Treasury.tsx b/src/pages/Treasury.tsx index 8ae5daa1..3c5b71fa 100644 --- a/src/pages/Treasury.tsx +++ b/src/pages/Treasury.tsx @@ -1,374 +1,353 @@ -import React, { Suspense, useEffect, useState } from "react" +import React, { Suspense } from "react" import { Helmet } from "react-helmet" import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, +Area, +AreaChart, +CartesianGrid, +ResponsiveContainer, +Tooltip, +XAxis, +YAxis, } from "recharts" import TxHashLink from "../components/TxHashLink" -import { useContractIds } from "../hooks/useContractIds" -import { useUSDC } from "../hooks/useUSDC" - -const API_BASE = import.meta.env.VITE_SERVER_URL || "http://localhost:4000" +import { useTreasury } from "../hooks/useTreasury" +import { DashboardStatsSkeleton, EmptyState } from "../components/SkeletonLoader" interface TreasuryStats { - total_deposited_usdc: string - total_disbursed_usdc: string - scholars_funded: number - active_proposals: number - donors_count: number +total_deposited_usdc: string +total_disbursed_usdc: string +scholars_funded: number +active_proposals: number +donors_count: number } interface TreasuryEvent { - type: "deposit" | "disburse" - amount?: string - address?: string - scholar?: string - tx_hash: string - created_at: string +type: "deposit" | "disburse" +amount?: string +address?: string +scholar?: string +tx_hash: string +created_at: string } const Treasury: React.FC = () => { - const { scholarshipTreasury } = useContractIds() - const { balance: treasuryUSDC, isLoading: treasuryLoading } = - useUSDC(scholarshipTreasury) - - const [stats, setStats] = useState(null) - const [activity, setActivity] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchTreasuryData = async () => { - try { - const [statsRes, activityRes] = await Promise.all([ - fetch(`${API_BASE}/api/treasury/stats`), - fetch(`${API_BASE}/api/treasury/activity?limit=20`), - ]) - - if (statsRes.ok) { - const statsData = await statsRes.json() - setStats(statsData) - } +const { stats, activity, isLoading, isError } = useTreasury() - if (activityRes.ok) { - const activityData = await activityRes.json() - setActivity(activityData.events || []) - } - } catch (err) { - console.error("Failed to fetch treasury data:", err) - } finally { - setLoading(false) - } - } - - void fetchTreasuryData() - }, []) - - const data = [ - { name: "Mon", inflows: 4000, outflows: 2400 }, - { name: "Tue", inflows: 3000, outflows: 1398 }, - { name: "Wed", inflows: 2000, outflows: 9800 }, - { name: "Thu", inflows: 2780, outflows: 3908 }, - { name: "Fri", inflows: 1890, outflows: 4800 }, - { name: "Sat", inflows: 2390, outflows: 3800 }, - { name: "Sun", inflows: 3490, outflows: 4300 }, - ] +const formatUSDC = (stroops: string) => { +const usdc = Number(stroops) / 10000000 +return usdc.toLocaleString("en-US", { +minimumFractionDigits: 0, +maximumFractionDigits: 2, +}) +} - const formatUSDC = (stroops: string) => { - const usdc = Number(stroops) / 10000000 - return usdc.toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }) - } +const formatAmount = (stroops: string) => { +const usdc = Number(stroops) / 10000000 +return usdc.toLocaleString("en-US", { +minimumFractionDigits: 0, +maximumFractionDigits: 2, +}) +} - const formatAmount = (stroops: string) => { - const usdc = Number(stroops) / 10000000 - return usdc.toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }) - } +const formatAddress = (address: string) => { +if (address.length <= 8) return address +return `${address.slice(0, 4)}...${address.slice(-4)}` +} - const formatAddress = (address: string) => { - if (address.length <= 8) return address - return `${address.slice(0, 4)}...${address.slice(-4)}` - } +const formatTime = (timestamp: string) => { +const date = new Date(timestamp) +const now = new Date() +const diffMs = now.getTime() - date.getTime() +const diffMins = Math.floor(diffMs / 60000) +const diffHours = Math.floor(diffMins / 60) +const diffDays = Math.floor(diffHours / 24) - const formatTime = (timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMins / 60) - const diffDays = Math.floor(diffHours / 24) +if (diffMins < 60) return `${diffMins}m ago` +if (diffHours < 24) return `${diffHours}h ago` +return `${diffDays}d ago` +} - if (diffMins < 60) return `${diffMins}m ago` - if (diffHours < 24) return `${diffHours}h ago` - return `${diffDays}d ago` - } +const displayStats = stats +? { +totalTreasury: `${formatUSDC(stats.total_deposited_usdc)} USDC`, +totalDisbursed: `${formatUSDC(stats.total_disbursed_usdc)} USDC`, +scholarsFunded: stats.scholars_funded.toString(), +donorsCount: stats.donors_count.toString(), +} +: { +totalTreasury: isLoading ? "Loading…" : "0 USDC", +totalDisbursed: isLoading ? "Loading…" : "0 USDC", +scholarsFunded: isLoading ? "..." : "0", +donorsCount: isLoading ? "..." : "0", +} - const displayStats = stats - ? { - // Use contract balance if available, otherwise use API data - totalTreasury: treasuryLoading - ? "Loading…" - : treasuryUSDC !== undefined - ? `${treasuryUSDC.toLocaleString(undefined, { maximumFractionDigits: 2 })} USDC` - : `${formatUSDC(stats.total_deposited_usdc)} USDC`, - totalDisbursed: `${formatUSDC(stats.total_disbursed_usdc)} USDC`, - scholarsFunded: stats.scholars_funded.toString(), - donorsCount: stats.donors_count.toString(), - } - : { - totalTreasury: treasuryLoading - ? "Loading…" - : treasuryUSDC !== undefined - ? `${treasuryUSDC.toLocaleString(undefined, { maximumFractionDigits: 2 })} USDC` - : "Loading...", - totalDisbursed: "Loading...", - scholarsFunded: "...", - donorsCount: "...", - } +const deposits = (activity ?? []) +.filter((e) => e.type === "deposit") +.slice(0, 2) +const disbursements = (activity ?? []) +.filter((e) => e.type === "disburse") +.slice(0, 2) - const deposits = activity.filter((e) => e.type === "deposit").slice(0, 2) - const disbursements = activity - .filter((e) => e.type === "disburse") - .slice(0, 2) +const siteUrl = "https://learnvault.app" +const title = `Treasury - ${displayStats.totalTreasury} - ${displayStats.scholarsFunded} Scholars Funded - LearnVault` +const description = `LearnVault's decentralized scholarship treasury holds ${displayStats.totalTreasury} and has funded ${displayStats.scholarsFunded} scholars. View real-time inflows and disbursements.` - const siteUrl = "https://learnvault.app" - const title = `Treasury - ${displayStats.totalTreasury} - ${displayStats.scholarsFunded} Scholars Funded - LearnVault` - const description = `LearnVault's decentralized scholarship treasury holds ${displayStats.totalTreasury} and has funded ${displayStats.scholarsFunded} scholars. View real-time inflows and disbursements.` +const chartData = [ +{ name: "Mon", inflows: 4000, outflows: 2400 }, +{ name: "Tue", inflows: 3000, outflows: 1398 }, +{ name: "Wed", inflows: 2000, outflows: 9800 }, +{ name: "Thu", inflows: 2780, outflows: 3908 }, +{ name: "Fri", inflows: 1890, outflows: 4800 }, +{ name: "Sat", inflows: 2390, outflows: 3800 }, +{ name: "Sun", inflows: 3490, outflows: 4300 }, +] - return ( -
- - {title} - - - - - - +return ( +
+ +{title} + + + + + + -
-
-

- Treasury Dashboard -

-

- Real-time transparency into the LearnVault decentralized scholarship - fund. -

-
+
+
+

+Treasury Dashboard +

+

+Real-time transparency into the LearnVault decentralized scholarship +fund. +

+
-
- - - - -
+{isLoading ? ( + +) : isError ? ( +
+Failed to load treasury stats. +
+) : ( +
+ + + + +
+)} -
-
-
-
-

Treasury Health

-

- Comparison of community inflows vs scholarship outflows. -

-
-
- - -
-
-
- - } - > - - -
-
-
+
+
+
+
+

Treasury Health

+

+Comparison of community inflows vs scholarship outflows. +

+
+
+ + +
+
+
+ +} +> + + +
+
+
-
- ({ - user: formatAddress(event.address || "unknown"), - amount: `+${formatAmount(event.amount || "0")} USDC`, - time: formatTime(event.created_at), - type: "deposit" as const, - txHash: event.tx_hash, - }))} - loading={loading} - /> - ({ - user: formatAddress(event.scholar || "unknown"), - amount: `-${formatAmount(event.amount || "0")} USDC`, - time: formatTime(event.created_at), - type: "disburse" as const, - txHash: event.tx_hash, - }))} - loading={loading} - /> -
+{isLoading ? ( +
+ + +
+) : activity.length === 0 ? ( + +) : ( +
+ ({ +user: formatAddress(event.address || "unknown"), +amount: `+${formatAmount(event.amount || "0")} USDC`, +time: formatTime(event.created_at), +type: "deposit" as const, +txHash: event.tx_hash, +}))} +loading={false} +/> + ({ +user: formatAddress(event.scholar || "unknown"), +amount: `-${formatAmount(event.amount || "0")} USDC`, +time: formatTime(event.created_at), +type: "disburse" as const, +txHash: event.tx_hash, +}))} +loading={false} +/> +
+)} -
- -
-
- ) +
+ +
+
+) } const StatCard: React.FC<{ - label: string - value: string - icon: string - color: string +label: string +value: string +icon: string +color: string }> = ({ label, value, icon, color }) => ( -
-
- {icon} -
-

- {label} -

-

{value}

-
+
+
+{icon} +
+

+{label} +

+

{value}

+
) const LegendItem: React.FC<{ color: string; label: string }> = ({ - color, - label, +color, +label, }) => ( -
-
- {label} -
+
+
+{label} +
) const TreasuryHealthChart: React.FC<{ - data: { name: string; inflows: number; outflows: number }[] +data: { name: string; inflows: number; outflows: number }[] }> = ({ data }) => ( - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ) const ActivityFeed: React.FC<{ - title: string - items: { - user: string - amount: string - time: string - type: "deposit" | "disburse" - txHash: string - }[] - loading?: boolean +title: string +items: { +user: string +amount: string +time: string +type: "deposit" | "disburse" +txHash: string +}[] +loading?: boolean }> = ({ title, items, loading = false }) => ( -
-

- {title} -

-
- {loading ? ( -
Loading...
- ) : items.length === 0 ? ( -
No activity yet
- ) : ( - items.map((item, i) => ( -
-
-
-
-

{item.user}

-

- {item.time} -

- -
-
-

- {item.amount} -

-
- )) - )} -
-
+
+

+{title} +

+
+{loading ? ( +
Loading...
+) : items.length === 0 ? ( +
No activity yet
+) : ( +items.map((item, i) => ( +
+
+
+
+

{item.user}

+

+{item.time} +

+ +
+
+

+{item.amount} +

+
+)) +)} +
+
) export default Treasury