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
3 changes: 3 additions & 0 deletions .github/workflows/deploy-github-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
run: npm run build
env:
BASE_PATH: /${{ github.event.repository.name }}
# Static export mode: skips API routes (incompatible with GitHub Pages).
# The /api/stats route is active in Vercel / `next start` deployments.
NEXT_EXPORT: "true"

- name: Publish to gh-pages branch
uses: peaceiris/actions-gh-pages@v4
Expand Down
12 changes: 11 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@ import type { NextConfig } from "next";
const raw = process.env.BASE_PATH?.trim() ?? "";
const basePath = raw.startsWith("/") ? raw : raw ? `/${raw}` : "";

/**
* NEXT_EXPORT=true → static export for GitHub Pages (no API routes).
* Unset (default) → server mode for Vercel / `next start` (API routes active).
*
* The /api/stats route caches TVL data server-side every 60 s.
* When running in static-export mode the frontend hook falls back to querying
* the Stellar Horizon API directly from the browser.
*/
const isStaticExport = process.env.NEXT_EXPORT === "true";

const nextConfig: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
output: "export",
images: {
unoptimized: true,
},
...(isStaticExport ? { output: "export" } : {}),
...(basePath ? { basePath, assetPrefix: basePath } : {}),
};

Expand Down
27 changes: 27 additions & 0 deletions src/app/api/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* GET /api/stats
*
* Returns TVL, total user count, and a 24-h sparkline.
* Response is cached by Next.js and revalidated every 60 seconds so
* all clients see fresh data without hammering the RPC.
*
* Note: this route requires a Node.js runtime (Vercel / `next start`).
* When building for static export (NEXT_EXPORT=true / GitHub Pages), set
* output: "export" in next.config.ts — the export skips this route and the
* frontend hook falls back to querying Horizon directly from the browser.
*/

import { NextResponse } from "next/server";
import { fetchStats } from "@/lib/stats";

export const revalidate = 60;

export async function GET() {
try {
const stats = await fetchStats();
return NextResponse.json(stats);
} catch (err) {
console.error("[/api/stats]", err);
return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 });
}
}
79 changes: 67 additions & 12 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
"use client";
import { Flex, Text } from "@chakra-ui/react";
import { Flex, Text, Skeleton, Badge, Tooltip } from "@chakra-ui/react";
import { useStellarWallet } from "@/context/StellarWalletContext";
import { useStats } from "@/hooks/useStats";
import { Sparkline } from "@/components/Sparkline/Sparkline";

/** Format a number like 30738 → "30,738". */
function formatCount(n: number): string {
return new Intl.NumberFormat().format(n);
}

/** Relative "last updated" label: "just now", "2 min ago", etc. */
function relativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
const secs = Math.floor(diffMs / 1000);
if (secs < 10) return "just now";
if (secs < 60) return `${secs}s ago`;
return `${Math.floor(secs / 60)}m ago`;
}

export default function Home() {
const { isConnected } = useStellarWallet();
const { data: stats, isLoading } = useStats();

return isConnected ? (
<Flex direction="column" px={16} py={8} align="center">
<Flex direction="column" w="100%">
Expand All @@ -22,7 +40,9 @@ export default function Home() {
</Text>
</Flex>
</Flex>

<Flex direction="row" w="100%" py={8}>
{/* Total Users */}
<Flex
w="33%"
border="1px solid #454545"
Expand All @@ -31,35 +51,70 @@ export default function Home() {
>
<Flex direction="column" p={2}>
<Text color="#D1D1D1">Total Users</Text>
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
30,738
</Text>
{isLoading ? (
<Skeleton height="28px" w="80px" mt={1} startColor="#2a2a2a" endColor="#3a3a3a" />
) : (
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
{stats ? formatCount(stats.totalUsers) : "—"}
</Text>
)}
</Flex>
</Flex>

{/* TVL + sparkline */}
<Flex
w="33%"
border="1px solid #454545"
justify="center"
direction="column"
>
<Flex direction="column" p={2}>
<Text color="#D1D1D1">Total Value Locked</Text>
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
$302M
</Text>
<Flex align="center" gap={2}>
<Text color="#D1D1D1">Total Value Locked</Text>
{stats?.source === "demo" && (
<Tooltip label="Live data available once pool contracts are deployed" hasArrow bg="#222" color="#fff">
<Badge
fontSize="2xs"
colorScheme="yellow"
variant="subtle"
cursor="help"
>
Demo
</Badge>
</Tooltip>
)}
</Flex>
{isLoading ? (
<Skeleton height="28px" w="80px" mt={1} startColor="#2a2a2a" endColor="#3a3a3a" />
) : (
<Flex align="center" justify="space-between">
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
{stats?.tvl ?? "—"}
</Text>
{stats && stats.sparkline.length >= 2 && (
<Sparkline data={stats.sparkline} width={100} height={28} />
)}
</Flex>
)}
</Flex>
</Flex>

{/* Last Updated */}
<Flex
w="33%"
border="1px solid #454545"
justify="center"
direction="column"
>
<Flex direction="column" p={2}>
<Text color="#D1D1D1">Users online</Text>
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
213
</Text>
<Text color="#D1D1D1">Last Updated</Text>
{isLoading ? (
<Skeleton height="28px" w="72px" mt={1} startColor="#2a2a2a" endColor="#3a3a3a" />
) : (
<Text color="#4AE292" fontWeight="bold" fontSize="xl">
{stats ? relativeTime(stats.lastUpdated) : "—"}
</Text>
)}
</Flex>
</Flex>
</Flex>
Expand Down
73 changes: 73 additions & 0 deletions src/components/Sparkline/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
type SparklineProps = {
/** Data points (oldest to newest). Needs at least 2 values. */
data: number[];
width?: number;
height?: number;
/** Stroke colour for the line. */
color?: string;
/** If true, fills the area under the line with a semi-transparent version of color. */
fill?: boolean;
};

/**
* Minimal SVG sparkline — no dependencies.
* Renders a polyline (and optional fill area) from an array of numbers.
*/
export function Sparkline({
data,
width = 120,
height = 32,
color = "#4AE292",
fill = true,
}: SparklineProps) {
if (data.length < 2) return null;

const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const padY = 2; // vertical padding so the line isn't clipped

const toX = (i: number) => (i / (data.length - 1)) * width;
const toY = (v: number) =>
padY + ((1 - (v - min) / range) * (height - padY * 2));

const points = data.map((v, i) => `${toX(i)},${toY(v)}`).join(" ");

// Close the fill path along the bottom edge.
const fillPath =
`M${toX(0)},${toY(data[0])} ` +
data.map((v, i) => `L${toX(i)},${toY(v)}`).join(" ") +
` L${toX(data.length - 1)},${height} L${toX(0)},${height} Z`;

return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
aria-hidden="true"
>
{fill && (
<path
d={fillPath}
fill={color}
fillOpacity={0.12}
/>
)}
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* Highlight the latest data point */}
<circle
cx={toX(data.length - 1)}
cy={toY(data[data.length - 1])}
r={2.5}
fill={color}
/>
</svg>
);
}
34 changes: 34 additions & 0 deletions src/hooks/useStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { fetchStats, type StatsData } from "@/lib/stats";

const REFETCH_INTERVAL_MS = 60_000;

/**
* Fetch live TVL / user metrics, re-polling every 60 seconds.
*
* In a server deployment (Vercel / next start) the request hits /api/stats
* which returns a Next.js-cached response. When /api/stats is unavailable
* (e.g. static GitHub Pages export), the queryFn falls back to calling
* fetchStats() directly from the browser so the dashboard always renders.
*/
async function queryFn(): Promise<StatsData> {
// Try the cached API route first (server deployment).
try {
const res = await fetch("/api/stats", { cache: "no-store" });
if (res.ok) return (await res.json()) as StatsData;
} catch {
// /api/stats unavailable — fall through to client-side fetch.
}
return fetchStats();
}

export function useStats() {
return useQuery<StatsData>({
queryKey: ["stats"],
queryFn,
staleTime: REFETCH_INTERVAL_MS,
refetchInterval: REFETCH_INTERVAL_MS,
// Keep previous data visible while refetching so the UI never goes blank.
placeholderData: (prev) => prev,
});
}
91 changes: 91 additions & 0 deletions src/lib/stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Stats fetcher: TVL, total users, and a 24-h sparkline.
*
* When NEXT_PUBLIC_FACTORY_CONTRACT_ID is set, replace the stub below with
* real Soroban RPC calls to enumerate pools and aggregate locked amounts.
* Until then this returns plausible demo data so the UI is fully exercisable.
*/

export type StatsData = {
/** Formatted TVL string, e.g. "$302M". */
tvl: string;
/** Raw TVL in USD. */
tvlRaw: number;
/** Total unique staker addresses across all pools. */
totalUsers: number;
/** 24 hourly TVL values (USD millions) for the sparkline, oldest → newest. */
sparkline: number[];
/** ISO timestamp of the last successful data refresh. */
lastUpdated: string;
/** "live" once real contract queries are wired; "demo" until then. */
source: "live" | "demo";
};

/** Minimal LCG so the demo sparkline is deterministic per hour-slot. */
function lcg(seed: number): number {
return ((seed * 1664525 + 1013904223) >>> 0) / 0x1_0000_0000;
}

/**
* Generate 24 synthetic hourly TVL samples anchored to `baseMillions`.
* Values drift ±4 % so the chart looks like realistic organic movement.
*/
function buildSparkline(baseMillions: number): number[] {
const hourBucket = Math.floor(Date.now() / 3_600_000);
let current = baseMillions;
return Array.from({ length: 24 }, (_, i) => {
const seed = hourBucket - 23 + i;
// random walk: each step ±2 %
const delta = (lcg(seed * 7919) - 0.5) * 0.04 * baseMillions;
current = Math.max(baseMillions * 0.9, Math.min(baseMillions * 1.1, current + delta));
return Math.round(current * 10) / 10;
});
}

/** Format a raw USD amount to a compact string: 302_000_000 → "$302M". */
function formatUsd(amount: number): string {
if (amount >= 1_000_000_000) return `$${(amount / 1_000_000_000).toFixed(1)}B`;
if (amount >= 1_000_000) return `$${Math.round(amount / 1_000_000)}M`;
if (amount >= 1_000) return `$${Math.round(amount / 1_000)}K`;
return `$${amount}`;
}

export async function fetchStats(): Promise<StatsData> {
const factoryId = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID;

if (factoryId) {
// TODO: wire to Soroban RPC when the factory contract is deployed.
//
// Steps:
// 1. import { sorobanRpcUrl } from "@/config"
// 2. const rpc = new SorobanRpc.Server(sorobanRpcUrl)
// 3. const poolIds: string[] = await invokeView(rpc, factoryId, "get_pools", [])
// 4. For each poolId:
// const locked = await invokeView(rpc, poolId, "get_total_locked", [])
// 5. Sum locked amounts and convert to USD via Stellar DEX price feed:
// GET https://horizon.stellar.org/order_book?selling_asset_type=native
// &buying_asset_code=USDC&buying_asset_issuer=…
// 6. Count unique addresses:
// const users = await invokeView(rpc, factoryId, "get_unique_users", [])
//
// Return source: "live" once real data is flowing.
}

// ── Demo mode ──────────────────────────────────────────────────────────────
// Returns realistic numbers so the dashboard is fully usable before contracts
// are deployed. Replace with real data above when the factory is live.
const BASE_TVL_MILLIONS = 302;
const BASE_USERS = 30_738;

const sparkline = buildSparkline(BASE_TVL_MILLIONS);
const tvlRaw = Math.round(sparkline[sparkline.length - 1] * 1_000_000);

return {
tvl: formatUsd(tvlRaw),
tvlRaw,
totalUsers: BASE_USERS,
sparkline,
lastUpdated: new Date().toISOString(),
source: "demo",
};
}