/** * — main rFunds application component. * * Views: * "landing" — TBFF info hero + flow list cards * "detail" — Flow detail with tabs: Table | River | Transactions * * Attributes: * space — space slug * flow-id — if set, go straight to detail view * mode — "demo" to use hardcoded demo data (no API) */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "../lib/types"; import { computeSufficiencyState } from "../lib/simulation"; import { demoNodes } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; interface FlowSummary { id: string; name: string; label?: string; status?: string; funnelCount?: number; outcomeCount?: number; totalValue?: number; } interface Transaction { id: string; type: string; amount: number; from?: string; to?: string; timestamp: string; description?: string; } type View = "landing" | "detail"; type Tab = "table" | "river" | "transactions"; class FolkFundsApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: View = "landing"; private tab: Tab = "table"; private flowId = ""; private isDemo = false; private flows: FlowSummary[] = []; private nodes: FlowNode[] = []; private flowName = ""; private transactions: Transaction[] = []; private txLoaded = false; private loading = false; private error = ""; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.flowId = this.getAttribute("flow-id") || ""; this.isDemo = this.getAttribute("mode") === "demo"; if (this.isDemo) { this.view = "detail"; this.flowName = "TBFF Demo Flow"; this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } })); this.render(); } else if (this.flowId) { this.view = "detail"; this.loadFlow(this.flowId); } else { this.view = "landing"; this.loadFlows(); } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/funds/); return match ? `/${match[1]}/funds` : ""; } private async loadFlows() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows`); if (res.ok) { const data = await res.json(); this.flows = Array.isArray(data) ? data : (data.flows || []); } } catch { // Flow service unavailable — landing page still works with demo link } this.loading = false; this.render(); } private async loadFlow(flowId: string) { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`); if (res.ok) { const data = await res.json(); this.nodes = mapFlowToNodes(data); this.flowName = data.name || data.label || flowId; } else { this.error = `Flow not found (${res.status})`; } } catch { this.error = "Failed to load flow"; } this.loading = false; this.render(); } private async loadTransactions() { if (this.txLoaded || this.isDemo) return; this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(this.flowId)}/transactions`); if (res.ok) { const data = await res.json(); this.transactions = Array.isArray(data) ? data : (data.transactions || []); } } catch { // Transactions unavailable } this.txLoaded = true; this.loading = false; this.render(); } private getCssPath(): string { // In rSpace: /modules/funds/funds.css | Standalone: /modules/funds/funds.css // The shell always serves from /modules/funds/ in both modes return "/modules/funds/funds.css"; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading && this.view === "landing" ? '
Loading...
' : ""} ${this.renderView()} `; this.attachListeners(); } private renderView(): string { if (this.view === "detail") return this.renderDetail(); return this.renderLanding(); } // ─── Landing page ────────────────────────────────────── private renderLanding(): string { const demoUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/demo` : "/demo"; return `

rFunds

Token Bonding Flow Funnel

Distribute resources through cascading funnels with sufficiency-based overflow. Each funnel fills to its threshold, then excess flows to the next layer — ensuring every level has enough before abundance cascades forward.

Try the Demo →

Your Flows

${this.flows.length > 0 ? `
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
` : `

No flows yet.

Explore the demo to see how TBFF works.

`}

How TBFF Works

🌊

Sources

Revenue streams and deposits flow into the system, split across funnels by configurable allocation percentages.

🏛

Funnels

Budget buckets with min/max thresholds. When a funnel reaches sufficiency, overflow cascades to the next layer.

🎯

Outcomes

Funding targets that receive spending allocations from funnels. Track progress toward each goal.

`; } private renderFlowCard(f: FlowSummary): string { const detailUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(f.id)}` : `/flow/${encodeURIComponent(f.id)}`; const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : ""; return `
${this.esc(f.name || f.label || f.id)}
${value ? `
${value}
` : ""}
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""} ${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""} ${f.status ? ` · ${f.status}` : ""}
`; } // ─── Detail view with tabs ───────────────────────────── private renderDetail(): string { const backUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/` : "/"; return `
← All Flows

${this.esc(this.flowName || "Flow Detail")}

${this.isDemo ? 'Demo' : ""}
${this.loading ? '
Loading...
' : this.renderTab()}
`; } private renderTab(): string { if (this.tab === "river") return this.renderRiverTab(); if (this.tab === "transactions") return this.renderTransactionsTab(); return this.renderTableTab(); } // ─── Table tab ──────────────────────────────────────── private renderTableTab(): string { const funnels = this.nodes.filter((n) => n.type === "funnel"); const outcomes = this.nodes.filter((n) => n.type === "outcome"); const sources = this.nodes.filter((n) => n.type === "source"); return `
${sources.length > 0 ? `

Sources

${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
` : ""}

Funnels

${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}

Outcomes

${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
`; } private renderSourceCard(data: SourceNodeData, id: string): string { const allocations = data.targetAllocations || []; return `
💰 ${this.esc(data.label)} ${data.sourceType}
$${data.flowRate.toLocaleString()} /month
${allocations.length > 0 ? `
${allocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderFunnelCard(data: FunnelNodeData, id: string): string { const sufficiency = computeSufficiencyState(data); const threshold = data.sufficientThreshold ?? data.maxThreshold; const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100); const statusClass = sufficiency === "abundant" ? "funds-status--abundant" : sufficiency === "sufficient" ? "funds-status--sufficient" : data.currentValue < data.minThreshold ? "funds-status--critical" : "funds-status--seeking"; const statusLabel = sufficiency === "abundant" ? "Abundant" : sufficiency === "sufficient" ? "Sufficient" : data.currentValue < data.minThreshold ? "Critical" : "Seeking"; return `
🏛 ${this.esc(data.label)} ${statusLabel}
$${Math.floor(data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}
${Math.round(suffPct)}% sufficiency
Min: $${Math.floor(data.minThreshold).toLocaleString()} Max: $${Math.floor(data.maxThreshold).toLocaleString()} Cap: $${Math.floor(data.maxCapacity).toLocaleString()}
${data.overflowAllocations.length > 0 ? `
Overflow
${data.overflowAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""} ${data.spendingAllocations.length > 0 ? `
Spending
${data.spendingAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderOutcomeCard(data: OutcomeNodeData, id: string): string { const fillPct = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; const statusColor = data.status === "completed" ? "#10b981" : data.status === "blocked" ? "#ef4444" : data.status === "in-progress" ? "#3b82f6" : "#64748b"; return `
🎯 ${this.esc(data.label)} ${data.status}
${data.description ? `
${this.esc(data.description)}
` : ""}
$${Math.floor(data.fundingReceived).toLocaleString()} / $${Math.floor(data.fundingTarget).toLocaleString()}
${Math.round(fillPct)}% funded
`; } private getNodeLabel(id: string): string { const node = this.nodes.find((n) => n.id === id); if (!node) return id; return (node.data as any).label || id; } // ─── River tab ──────────────────────────────────────── private renderRiverTab(): string { return `
`; } private mountRiver() { const mount = this.shadow.getElementById("river-mount"); if (!mount) return; // Check if already mounted if (mount.querySelector("folk-budget-river")) return; const river = document.createElement("folk-budget-river") as any; river.setAttribute("simulate", "true"); mount.appendChild(river); // Pass nodes after the element is connected requestAnimationFrame(() => { if (typeof river.setNodes === "function") { river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } }))); } }); } // ─── Transactions tab ───────────────────────────────── private renderTransactionsTab(): string { if (this.isDemo) { return `

Transaction history is not available in demo mode.

`; } if (!this.txLoaded) { return '
Loading transactions...
'; } if (this.transactions.length === 0) { return `

No transactions yet for this flow.

`; } return `
${this.transactions.map((tx) => `
${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}
${this.esc(tx.description || tx.type)}
${tx.from ? `From: ${this.esc(tx.from)}` : ""} ${tx.to ? ` → ${this.esc(tx.to)}` : ""}
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
${this.formatTime(tx.timestamp)}
`).join("")}
`; } private formatTime(ts: string): string { try { const d = new Date(ts); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } catch { return ts; } } // ─── Event listeners ────────────────────────────────── private attachListeners() { // Tab switching this.shadow.querySelectorAll("[data-tab]").forEach((el) => { el.addEventListener("click", () => { const newTab = (el as HTMLElement).dataset.tab as Tab; if (newTab === this.tab) return; this.tab = newTab; this.render(); if (newTab === "transactions" && !this.txLoaded) { this.loadTransactions(); } }); }); // Mount river component when river tab is active if (this.tab === "river" && this.nodes.length > 0) { this.mountRiver(); } } private esc(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } } customElements.define("folk-funds-app", FolkFundsApp);