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"); + }); +});