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
19 changes: 13 additions & 6 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useTokenMetadata } from "@/hooks/use-token-metadata";
import type { StreamData } from "@/lib/stacks";
import { STREAM_STATUS, unresolvableTokenLabel } from "@/lib/constants";
import { TopUpDialog } from "@/components/stream/top-up-dialog";
import { NetworkStats } from "@/components/layout/network-stats";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { PlusCircle, Zap, Users, Coins, TrendingUp } from "lucide-react";
Expand Down Expand Up @@ -47,17 +48,23 @@ export default function DashboardPage() {

if (!isConnected) {
return (
<EmptyState
icon={<Zap className="h-12 w-12" />}
title="Connect your wallet"
description="Connect a Stacks wallet to open streams and manage real-time payments, settled on Bitcoin."
/>
<div className="space-y-6">
<NetworkStats />
<EmptyState
icon={<Zap className="h-12 w-12" />}
title="Connect your wallet"
description="Connect a Stacks wallet to open streams and manage real-time payments, settled on Bitcoin."
/>
</div>
);
}

return (
<div className="space-y-6">
{/* Stats row */}
<NetworkStats />

{/* Your streams */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">Your streams</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 sm:gap-4">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
Expand Down
26 changes: 19 additions & 7 deletions frontend/src/app/organisations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,27 @@ function Hero({ directory }: { directory: OrganisationDirectory | null }) {
style={delay(2)}
>
<div className="pointer-events-none absolute -right-16 -top-16 h-48 w-48 rounded-full bg-brand-500/10 blur-3xl" />
<p className="text-xs font-medium uppercase tracking-widest text-zinc-500">
Organisations registered
</p>
<p className="mt-3 font-mono text-7xl font-semibold tabular-nums leading-none text-brand-400 sm:text-8xl">
{total ?? "–"}
</p>
<div className="grid grid-cols-2 gap-6">
<div>
<p className="whitespace-nowrap text-xs font-medium uppercase tracking-widest text-zinc-500">
Organisations
</p>
<p className="mt-3 font-mono text-6xl font-semibold tabular-nums leading-none text-brand-400 sm:text-7xl">
{total ?? "–"}
</p>
</div>
<div>
<p className="whitespace-nowrap text-xs font-medium uppercase tracking-widest text-zinc-500">
Streams
</p>
<p className="mt-3 font-mono text-6xl font-semibold tabular-nums leading-none text-brand-400 sm:text-7xl">
{directory?.streamsCreated ?? "–"}
</p>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-6 border-t border-border/60 pt-6">
<div>
<p className="text-xs uppercase tracking-wider text-zinc-600">Streams tracked</p>
<p className="text-xs uppercase tracking-wider text-zinc-600">From organisations</p>
<p className="mt-1.5 font-mono text-2xl tabular-nums text-zinc-200">
{streams ?? "–"}
</p>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/landing/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { LiveStreamCard } from "@/components/landing/live-stream-card";
import { LiveStats } from "@/components/landing/live-stats";
import { ArrowRight, Zap } from "lucide-react";

export function Hero() {
Expand Down Expand Up @@ -81,6 +82,16 @@ export function Hero() {
</Link>
</motion.div>

{/* Live usage, read from the contract */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="mt-8 flex justify-center"
>
<LiveStats />
</motion.div>

{/* Animated streaming preview */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/landing/live-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import Link from "next/link";
import { useNetworkStats } from "@/hooks/use-network-stats";

/**
* The live usage line under the hero. Read from the contract through
* /api/stats; it renders nothing until the numbers arrive, so a visitor never
* sees a placeholder or a guessed figure.
*/
export function LiveStats() {
const { stats: data } = useNetworkStats();
if (!data) return null;

const orgs = data.workspacesRegistered;
return (
<Link
href="/organisations"
className="inline-flex items-center gap-2.5 rounded-full border border-border bg-surface-1/70 px-4 py-2 text-xs text-zinc-400 transition-colors hover:border-brand-500/30 hover:text-zinc-200"
>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</span>
<span>
<span className="font-mono tabular-nums text-zinc-200">{data.streamsCreated}</span> streams on
mainnet
</span>
<span className="text-zinc-700">·</span>
<span>
<span className="font-mono tabular-nums text-zinc-200">{orgs}</span>{" "}
{orgs === 1 ? "organisation" : "organisations"}
</span>
<span className="hidden text-zinc-600 sm:inline">· read live from the contract</span>
</Link>
);
}
54 changes: 54 additions & 0 deletions frontend/src/components/layout/network-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useNetworkStats } from "@/hooks/use-network-stats";

/**
* StackStream's live usage on mainnet, shown at the top of the dashboard
* whether or not a wallet is connected. Both figures come from the contract.
*/
export function NetworkStats() {
const { stats, isLoading } = useNetworkStats();

if (isLoading) return <Skeleton className="h-36 rounded-2xl" />;
if (!stats) return null;

return (
<div className="glass relative overflow-hidden rounded-2xl p-5 sm:p-6">
<div className="pointer-events-none absolute -right-20 -top-20 h-56 w-56 rounded-full bg-brand-500/10 blur-3xl" />
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-2 text-xs font-medium uppercase tracking-widest text-zinc-500">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</span>
StackStream on mainnet
</p>
<Link
href="/organisations"
className="inline-flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-brand-400"
>
See organisations <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="mt-4 grid grid-cols-2 gap-6">
<div>
<p className="font-mono text-4xl font-semibold tabular-nums leading-none text-brand-400 sm:text-5xl">
{stats.streamsCreated}
</p>
<p className="mt-2 text-sm text-zinc-400">Streams created</p>
</div>
<div>
<p className="font-mono text-4xl font-semibold tabular-nums leading-none text-brand-400 sm:text-5xl">
{stats.workspacesRegistered}
</p>
<p className="mt-2 text-sm text-zinc-400">
{stats.workspacesRegistered === 1 ? "Organisation registered" : "Organisations registered"}
</p>
</div>
</div>
</div>
);
}
33 changes: 33 additions & 0 deletions frontend/src/hooks/use-network-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { OPENCLAW_API_URL } from "@/lib/constants";

export interface NetworkStats {
/** Every stream ever opened on mainnet, by anyone (stream-manager's stream counter). */
streamsCreated: number;
/** Every organisation registered on stream-factory. */
workspacesRegistered: number;
asOf: string;
}

async function fetchNetworkStats(): Promise<NetworkStats> {
const res = await fetch(`${OPENCLAW_API_URL}/api/stats`);
if (!res.ok) throw new Error(`stats ${res.status}`);
return (await res.json()) as NetworkStats;
}

/**
* Network-wide usage, read from the contract through /api/stats. Both numbers
* only go up: each new stream and each new registration adds one, whoever makes
* it. Refreshed every minute, which is also the API's cache window.
*/
export function useNetworkStats() {
const query = useQuery({
queryKey: ["network-stats"],
queryFn: fetchNetworkStats,
refetchInterval: 60_000,
staleTime: 30_000,
});
return { stats: query.data ?? null, isLoading: query.isLoading };
}
14 changes: 13 additions & 1 deletion frontend/src/lib/organisations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getDao,
getDaoCount,
getNetwork,
getStreamNonce,
HIRO_API_BASE,
STREAM_FACTORY_CONTRACT,
} from "./openclaw-server";
Expand All @@ -36,6 +37,12 @@ export interface OrganisationDirectory {
network: "mainnet" | "testnet";
/** `get-dao-count`, the authoritative total. */
total: number;
/**
* Every stream ever opened on mainnet (`get-stream-nonce`), by anyone, whether
* or not an organisation sent it. Distinct from the streams organisations
* have linked, which is the sum of `streamsTracked`.
*/
streamsCreated: number;
/** Newest first. */
organisations: Organisation[];
/**
Expand Down Expand Up @@ -148,7 +155,11 @@ let cached: { directory: OrganisationDirectory; expires: number } | null = null;
export async function getOrganisationDirectory(): Promise<OrganisationDirectory> {
if (cached && Date.now() < cached.expires) return cached.directory;

const [rows, total] = await Promise.all([fetchFactoryTransactions(), getDaoCount()]);
const [rows, total, streamsCreated] = await Promise.all([
fetchFactoryTransactions(),
getDaoCount(),
getStreamNonce(),
]);
const registrations = parseRegistrations(rows, STREAM_FACTORY_CONTRACT);

// The contract is the source of truth for each record. A registration whose
Expand All @@ -172,6 +183,7 @@ export async function getOrganisationDirectory(): Promise<OrganisationDirectory>
const directory: OrganisationDirectory = {
network: getNetwork(),
total,
streamsCreated,
organisations,
unlisted: Math.max(0, total - organisations.length),
asOf: new Date().toISOString(),
Expand Down
Loading