From e17458f4b8c97414f393c7c0ee7da89b3cb2dca1 Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 7 Oct 2026 14:14:49 +0100 Subject: [PATCH] feat(frontend): public organisations directory at /organisations stream-factory stores organisations in a map, which Clarity cannot enumerate: the contract knows how many are registered but not who. The directory reads every successful register-dao call from the chain's transaction history, then reads each organisation's live record (name, status, tracked streams) back from the contract, so every entry comes from the chain and links to its registration receipt. The headline total is get-dao-count; any registration the history scan cannot list is counted and disclosed rather than hidden. - /organisations: readout of the live count, a card per organisation (newest first), a "your team could be next" card and a three-step join section, all linking to registration. - GET /api/organisations: the same data for other sites, documented in the integration guide. Cached for a minute per instance, like /api/stats. - The landing nav moves into a shared SiteNav with an Organisations link, and the footer links the page. - Entrance animation respects prefers-reduced-motion. --- docs/INTEGRATION_GUIDE.md | 1 + frontend/src/app/api/organisations/route.ts | 14 + frontend/src/app/globals.css | 19 + frontend/src/app/organisations/page.tsx | 414 +++++++++++++++++++ frontend/src/app/page.tsx | 31 +- frontend/src/components/landing/footer.tsx | 1 + frontend/src/components/landing/site-nav.tsx | 43 ++ frontend/src/lib/openclaw-server.ts | 4 +- frontend/src/lib/organisations.ts | 181 ++++++++ tests/organisations.test.ts | 77 ++++ 10 files changed, 754 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/api/organisations/route.ts create mode 100644 frontend/src/app/organisations/page.tsx create mode 100644 frontend/src/components/landing/site-nav.tsx create mode 100644 frontend/src/lib/organisations.ts create mode 100644 tests/organisations.test.ts diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 7be33b2..6f10d73 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -229,6 +229,7 @@ Public, read-only, no keys. Any website can call these from the browser. | `GET /api/streams/sender/{address}` | IDs of streams an address pays | | `GET /api/streams/recipient/{address}` | IDs of streams an address receives | | `GET /api/daos/{admin}` | An organisation by its admin address | +| `GET /api/organisations` | Every registered organisation, newest first, with the total from the contract | | `GET /api/stats` | Total streams and organisations | | `GET /api/blocks/current` | Current Stacks block height | diff --git a/frontend/src/app/api/organisations/route.ts b/frontend/src/app/api/organisations/route.ts new file mode 100644 index 0000000..d5913f4 --- /dev/null +++ b/frontend/src/app/api/organisations/route.ts @@ -0,0 +1,14 @@ +import { jsonResponse, errorResponse } from "@/lib/openclaw-server"; +import { getOrganisationDirectory } from "@/lib/organisations"; + +export const dynamic = "force-dynamic"; + +// GET /api/organisations: every organisation registered on stream-factory, +// newest first, with the authoritative total from get-dao-count. +export async function GET() { + try { + return jsonResponse(await getOrganisationDirectory()); + } catch (err) { + return errorResponse(err); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 326937e..fcedefd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -137,3 +137,22 @@ body { background: #f97316; animation: float-up 2s ease-out infinite; } + +/* ============================================================================ + Entrance (organisations directory). Off for users who ask for less motion. + ============================================================================ */ + +@keyframes fade-up { + 0% { opacity: 0; transform: translateY(12px); } + 100% { opacity: 1; transform: translateY(0); } +} + +.animate-fade-up { + animation: fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-fade-up { + animation: none; + } +} diff --git a/frontend/src/app/organisations/page.tsx b/frontend/src/app/organisations/page.tsx new file mode 100644 index 0000000..f92ee3f --- /dev/null +++ b/frontend/src/app/organisations/page.tsx @@ -0,0 +1,414 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import type { CSSProperties } from "react"; +import { + ArrowRight, + ArrowUpRight, + Building2, + Link2, + Plus, + Radio, + Waves, +} from "lucide-react"; +import { SiteNav } from "@/components/landing/site-nav"; +import { Footer } from "@/components/landing/footer"; +import { + explorerTxUrl, + getOrganisationDirectory, + organisationInitials, + type Organisation, + type OrganisationDirectory, +} from "@/lib/organisations"; + +// Read from the chain on request (cached for a minute in the data layer), so a +// registration shows up here without a redeploy. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Organisations | StackStream", + description: + "Every organisation registered on StackStream, read live from the contract on Stacks mainnet.", + openGraph: { + title: "Organisations on StackStream", + description: "The teams paying people as they work, read live from the contract.", + type: "website", + }, +}; + +const dateFormat = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", +}); + +const timeFormat = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", +}); + +function formatDate(iso: string | null): string { + return iso ? dateFormat.format(new Date(iso)) : "Date pending"; +} + +function truncateAddress(address: string): string { + return address.length > 14 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address; +} + +/** + * Avatar gradients, all inside the brand's warm range. The pick is derived + * from the admin address, so an organisation keeps its colour across visits. + */ +const AVATAR_GRADIENTS = [ + "from-brand-500 to-amber-400", + "from-orange-600 to-rose-500", + "from-amber-500 to-yellow-300", + "from-brand-400 to-orange-700", + "from-rose-500 to-brand-400", +]; + +function avatarGradient(admin: string): string { + let hash = 0; + for (const char of admin) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + return AVATAR_GRADIENTS[hash % AVATAR_GRADIENTS.length]; +} + +function delay(index: number): CSSProperties { + return { animationDelay: `${Math.min(index, 8) * 70}ms` }; +} + +export default async function OrganisationsPage() { + let directory: OrganisationDirectory | null = null; + try { + directory = await getOrganisationDirectory(); + } catch { + directory = null; + } + + return ( +
+ + +
+ + +
+ {directory ? : } +
+ + +
+ +
+
+ ); +} + +// ============================================================================ +// Hero: the readout +// ============================================================================ + +function Hero({ directory }: { directory: OrganisationDirectory | null }) { + const total = directory?.total ?? null; + const streams = directory?.organisations.reduce((sum, o) => sum + o.streamsTracked, 0) ?? null; + const newest = directory?.organisations[0] ?? null; + + return ( +
+
+
+
+
+ +
+
+ + + + + + Read live from the contract + +
+ +
+
+

+ The teams paying people + as they work. +

+

+ Every organisation below registered on StackStream from its own wallet, on Stacks + mainnet. Nothing here is typed in by us: each name, date and stream count comes + straight from the chain, and every entry links to its receipt. +

+
+ + {/* The readout */} +
+
+

+ Organisations registered +

+

+ {total ?? "–"} +

+
+
+

Streams tracked

+

+ {streams ?? "–"} +

+
+
+

Newest

+

+ {newest ? newest.name : "–"} +

+

+ {newest ? formatDate(newest.registeredAt) : ""} +

+
+
+ {directory && ( +

+ + {directory.network} · updated {timeFormat.format(new Date(directory.asOf))} UTC +

+ )} +
+
+
+
+ ); +} + +// ============================================================================ +// Directory +// ============================================================================ + +function Directory({ directory }: { directory: OrganisationDirectory }) { + const { organisations, unlisted } = directory; + + return ( + <> +
+

+ Directory +

+

Newest first

+
+ +
+ {organisations.map((organisation, index) => ( + + ))} + +
+ + {unlisted > 0 && ( +

+ The contract counts {unlisted} more{" "} + {unlisted === 1 ? "registration" : "registrations"} that could not be listed from the + transaction history yet. They are included in the total above. +

+ )} + + ); +} + +function OrganisationCard({ + organisation, + network, + isNewest, + index, +}: { + organisation: Organisation; + network: "mainnet" | "testnet"; + isNewest: boolean; + index: number; +}) { + return ( +
+
+ +
+ +
+
+

+ {organisation.name} +

+ {isNewest && ( + + Newest + + )} +
+

+ {truncateAddress(organisation.admin)} +

+
+
+ +
+
+
Joined
+
{formatDate(organisation.registeredAt)}
+
+
+
Streams
+
+ {organisation.streamsTracked} +
+
+
+
Status
+
+ + + {organisation.isActive ? "Active" : "Inactive"} + +
+
+
+ + + Registration receipt, block {organisation.registeredAtBlock.toLocaleString("en-US")} + + +
+ ); +} + +function JoinCard({ index, first }: { index: number; first: boolean }) { + return ( + +
+ +
+
+

+ {first ? "Be the first team here" : "Your team could be next"} +

+

+ Register once, then pay your people as they work, in any token on Stacks. +

+ + Register your organisation + + +
+ + ); +} + +function Unavailable() { + return ( +
+

The chain could not be read just now

+

+ The directory is read live from Stacks, and the node did not answer. Refresh in a moment, + or check the live count at{" "} + + /api/stats + + . +

+
+ ); +} + +// ============================================================================ +// How to join +// ============================================================================ + +const STEPS = [ + { + icon: Building2, + title: "Register once", + body: "One transaction from your organisation's wallet puts you in this directory.", + }, + { + icon: Link2, + title: "Open a stream", + body: "Pick who, how much and how long, in sBTC, USDA or your own token. One link does it.", + }, + { + icon: Waves, + title: "They claim as they earn", + body: "Balances grow every few seconds. You can pause, top up or cancel at any time.", + }, +]; + +function HowToJoin() { + return ( +
+
+

+ Joining takes a few minutes. +

+

+ No fee from us. Funds sit in an audited contract, never with us. +

+ +
    + {STEPS.map((step, i) => ( +
  1. +
    + 0{i + 1} + +
    +

    {step.title}

    +

    {step.body}

    +
  2. + ))} +
+ + + Register your organisation + + +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5f841cf..80a4548 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -7,39 +7,12 @@ import { AssistantSection } from "@/components/landing/assistant-section"; import { FAQ } from "@/components/landing/faq"; import { CTASection } from "@/components/landing/cta-section"; import { Footer } from "@/components/landing/footer"; -import Link from "next/link"; -import Image from "next/image"; +import { SiteNav } from "@/components/landing/site-nav"; export default function LandingPage() { return (
- {/* Landing nav */} - + diff --git a/frontend/src/components/landing/footer.tsx b/frontend/src/components/landing/footer.tsx index 7db10ec..98ff27e 100644 --- a/frontend/src/components/landing/footer.tsx +++ b/frontend/src/components/landing/footer.tsx @@ -41,6 +41,7 @@ const footerLinks = [ { heading: "Protocol", links: [ + { label: "Organisations", href: "/organisations" }, { label: "Dashboard", href: "/dashboard" }, { label: "Earn", href: "/earn" }, { label: "Create Stream", href: "/dashboard/create" }, diff --git a/frontend/src/components/landing/site-nav.tsx b/frontend/src/components/landing/site-nav.tsx new file mode 100644 index 0000000..586683f --- /dev/null +++ b/frontend/src/components/landing/site-nav.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import Image from "next/image"; + +const links = [ + { href: "/organisations", label: "Organisations" }, + { href: "/dashboard", label: "Dashboard" }, + { href: "/earn", label: "Earn" }, +]; + +/** Top navigation for the public pages (landing, organisations). */ +export function SiteNav() { + return ( + + ); +} diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ff454e8..53c236f 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -25,9 +25,9 @@ const CONTRACT_DEPLOYER = "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79"; const STREAM_MANAGER_CONTRACT = `${CONTRACT_DEPLOYER}.stream-manager`; -const STREAM_FACTORY_CONTRACT = `${CONTRACT_DEPLOYER}.stream-factory`; +export const STREAM_FACTORY_CONTRACT = `${CONTRACT_DEPLOYER}.stream-factory`; -const HIRO_API_BASE = IS_MAINNET +export const HIRO_API_BASE = IS_MAINNET ? "https://api.mainnet.hiro.so" : "https://api.testnet.hiro.so"; diff --git a/frontend/src/lib/organisations.ts b/frontend/src/lib/organisations.ts new file mode 100644 index 0000000..1a3af10 --- /dev/null +++ b/frontend/src/lib/organisations.ts @@ -0,0 +1,181 @@ +/** + * The public directory of organisations registered on StackStream. + * + * stream-factory keeps organisations in a map keyed by admin address, and + * Clarity maps cannot be enumerated: the contract can say whether an address is + * registered and how many are, but not who they are. The list therefore comes + * from the chain's transaction history (every successful `register-dao` call), + * and each entry's live record (name, streams, status) is then read back from + * the contract, so nothing shown here comes from us. + * + * Server-only: it pages through the Hiro API and makes read-only calls. + */ + +import { + getDao, + getDaoCount, + getNetwork, + HIRO_API_BASE, + STREAM_FACTORY_CONTRACT, +} from "./openclaw-server"; + +/** One organisation, as shown on /organisations and returned by /api/organisations. */ +export interface Organisation { + name: string; + admin: string; + isActive: boolean; + /** Streams the organisation has linked to its workspace via `track-stream`. */ + streamsTracked: number; + registeredAtBlock: number; + /** ISO time of the Bitcoin block that anchored the registration, if known. */ + registeredAt: string | null; + registrationTxId: string; +} + +export interface OrganisationDirectory { + network: "mainnet" | "testnet"; + /** `get-dao-count`, the authoritative total. */ + total: number; + /** Newest first. */ + organisations: Organisation[]; + /** + * Registrations the count includes but the history scan could not list, for + * example a `register-dao` reached through another contract. Shown, never hidden. + */ + unlisted: number; + asOf: string; +} + +// ============================================================================ +// Parsing (pure, unit-tested) +// ============================================================================ + +/** The fields this module reads from a Hiro `/transactions` row. */ +export interface HiroTxRow { + tx_id?: string; + tx_type?: string; + tx_status?: string; + sender_address?: string; + block_height?: number; + burn_block_time_iso?: string; + contract_call?: { contract_id?: string; function_name?: string }; +} + +export interface Registration { + admin: string; + txId: string; + blockHeight: number; + registeredAt: string | null; +} + +/** + * Pick the successful `register-dao` calls on the factory out of a page of + * transactions. Failed calls (a duplicate name, an address that already + * registered) are dropped because they registered nothing. An admin can only + * register once, but if the history ever showed two, the earliest is the real one. + */ +export function parseRegistrations( + rows: readonly HiroTxRow[], + factoryContract: string +): Registration[] { + const byAdmin = new Map(); + for (const row of rows) { + if (row.tx_type !== "contract_call" || row.tx_status !== "success") continue; + if (row.contract_call?.contract_id !== factoryContract) continue; + if (row.contract_call.function_name !== "register-dao") continue; + if (!row.sender_address || !row.tx_id || typeof row.block_height !== "number") continue; + + const registration: Registration = { + admin: row.sender_address, + txId: row.tx_id, + blockHeight: row.block_height, + registeredAt: row.burn_block_time_iso ?? null, + }; + const existing = byAdmin.get(registration.admin); + if (!existing || registration.blockHeight < existing.blockHeight) { + byAdmin.set(registration.admin, registration); + } + } + return [...byAdmin.values()].sort((a, b) => b.blockHeight - a.blockHeight); +} + +/** Two-letter monogram for an organisation's avatar. */ +export function organisationInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "?"; + const letters = + words.length === 1 ? [...words[0]].slice(0, 2) : [[...words[0]][0], [...words[1]][0]]; + return letters.join("").toUpperCase(); +} + +/** Explorer link for a transaction on the network the app runs on. */ +export function explorerTxUrl(txId: string, network: "mainnet" | "testnet"): string { + return `https://explorer.hiro.so/txid/${txId}?chain=${network}`; +} + +// ============================================================================ +// Fetching +// ============================================================================ + +const PAGE_SIZE = 50; +/** Hard stop on paging, so a slow or misbehaving API cannot loop forever. */ +const MAX_PAGES = 40; + +async function fetchFactoryTransactions(): Promise { + const rows: HiroTxRow[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const url = + `${HIRO_API_BASE}/extended/v1/address/${STREAM_FACTORY_CONTRACT}/transactions` + + `?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`; + const res = await fetch(url, { cache: "no-store", headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Hiro transactions request failed (${res.status})`); + const data = (await res.json()) as { total?: number; results?: HiroTxRow[] }; + const results = data.results ?? []; + rows.push(...results); + if (results.length < PAGE_SIZE || rows.length >= (data.total ?? 0)) break; + } + return rows; +} + +/** + * Registrations change rarely and the page may be shared widely, so the + * directory is cached per warm instance for a minute, the same policy as + * /api/stats. + */ +const CACHE_TTL_MS = 60_000; +let cached: { directory: OrganisationDirectory; expires: number } | null = null; + +export async function getOrganisationDirectory(): Promise { + if (cached && Date.now() < cached.expires) return cached.directory; + + const [rows, total] = await Promise.all([fetchFactoryTransactions(), getDaoCount()]); + const registrations = parseRegistrations(rows, STREAM_FACTORY_CONTRACT); + + // The contract is the source of truth for each record. A registration whose + // record is gone (it never should be) is left out rather than shown stale. + const records = await Promise.all(registrations.map((r) => getDao(r.admin))); + const organisations: Organisation[] = []; + registrations.forEach((registration, i) => { + const record = records[i]; + if (!record) return; + organisations.push({ + name: record.name, + admin: record.admin, + isActive: record.isActive, + streamsTracked: record.totalStreamsCreated, + registeredAtBlock: record.createdAtBlock, + registeredAt: registration.registeredAt, + registrationTxId: registration.txId, + }); + }); + + const directory: OrganisationDirectory = { + network: getNetwork(), + total, + organisations, + unlisted: Math.max(0, total - organisations.length), + asOf: new Date().toISOString(), + }; + cached = { directory, expires: Date.now() + CACHE_TTL_MS }; + return directory; +} diff --git a/tests/organisations.test.ts b/tests/organisations.test.ts new file mode 100644 index 0000000..4813228 --- /dev/null +++ b/tests/organisations.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; + +import { + parseRegistrations, + organisationInitials, + explorerTxUrl, + type HiroTxRow, +} from "../frontend/src/lib/organisations"; + +const FACTORY = "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79.stream-factory"; +const GRIMALDO = "SP1A5M0ZRSNQMF8BNPQPM8WWC5PJ6HJ4GEKFP8W4M"; +const OTHER = "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR"; + +function registration(overrides: Partial = {}): HiroTxRow { + return { + tx_id: "0x0e1d", + tx_type: "contract_call", + tx_status: "success", + sender_address: GRIMALDO, + block_height: 9_134_238, + burn_block_time_iso: "2026-10-06T15:33:44.000Z", + contract_call: { contract_id: FACTORY, function_name: "register-dao" }, + ...overrides, + }; +} + +describe("parseRegistrations", () => { + it("reads a successful register-dao call", () => { + expect(parseRegistrations([registration()], FACTORY)).toEqual([ + { + admin: GRIMALDO, + txId: "0x0e1d", + blockHeight: 9_134_238, + registeredAt: "2026-10-06T15:33:44.000Z", + }, + ]); + }); + + it("ignores failed calls, other functions, other contracts and the deploy", () => { + const rows: HiroTxRow[] = [ + registration({ tx_status: "abort_by_response" }), + registration({ contract_call: { contract_id: FACTORY, function_name: "track-stream" } }), + registration({ contract_call: { contract_id: "SP000.other", function_name: "register-dao" } }), + { tx_id: "0xdeploy", tx_type: "smart_contract", tx_status: "success", block_height: 1 }, + ]; + expect(parseRegistrations(rows, FACTORY)).toEqual([]); + }); + + it("keeps the earliest registration per admin and lists newest first", () => { + const rows: HiroTxRow[] = [ + registration({ tx_id: "0xlater", block_height: 9_200_000 }), + registration({ tx_id: "0xfirst", block_height: 9_134_238 }), + registration({ tx_id: "0xother", sender_address: OTHER, block_height: 9_150_000 }), + ]; + const parsed = parseRegistrations(rows, FACTORY); + expect(parsed.map((r) => r.txId)).toEqual(["0xother", "0xfirst"]); + }); + + it("keeps a registration whose anchor time is not known yet", () => { + const [parsed] = parseRegistrations([registration({ burn_block_time_iso: undefined })], FACTORY); + expect(parsed.registeredAt).toBeNull(); + }); +}); + +describe("organisationInitials", () => { + it("uses the first two letters of a single word, and the first of two words", () => { + expect(organisationInitials("Grimaldo")).toBe("GR"); + expect(organisationInitials("Zero Authority DAO")).toBe("ZA"); + expect(organisationInitials(" ")).toBe("?"); + }); +}); + +describe("explorerTxUrl", () => { + it("links to the network the app runs on", () => { + expect(explorerTxUrl("0xabc", "mainnet")).toBe("https://explorer.hiro.so/txid/0xabc?chain=mainnet"); + }); +});