559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
/**
|
|
* <folk-funds-app> — 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 = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
</style>
|
|
<link rel="stylesheet" href="${this.getCssPath()}">
|
|
${this.error ? `<div class="funds-error">${this.esc(this.error)}</div>` : ""}
|
|
${this.loading && this.view === "landing" ? '<div class="funds-loading">Loading...</div>' : ""}
|
|
${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 `
|
|
<div class="funds-landing">
|
|
<div class="funds-hero">
|
|
<h1 class="funds-hero__title">rFunds</h1>
|
|
<p class="funds-hero__subtitle">Token Bonding Flow Funnel</p>
|
|
<p class="funds-hero__desc">
|
|
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 <em>enough</em> before abundance cascades forward.
|
|
</p>
|
|
<a href="${this.esc(demoUrl)}" class="funds-hero__cta">Try the Demo →</a>
|
|
</div>
|
|
|
|
<div class="funds-flows">
|
|
<h2 class="funds-flows__heading">Your Flows</h2>
|
|
${this.flows.length > 0 ? `
|
|
<div class="funds-flows__grid">
|
|
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
|
|
</div>
|
|
` : `
|
|
<div class="funds-flows__empty">
|
|
<p>No flows yet.</p>
|
|
<p>
|
|
<a href="${this.esc(demoUrl)}">Explore the demo</a> to see how TBFF works.
|
|
</p>
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
<div class="funds-about">
|
|
<h2 class="funds-about__heading">How TBFF Works</h2>
|
|
<div class="funds-about__grid">
|
|
<div class="funds-about__card">
|
|
<div class="funds-about__icon">🌊</div>
|
|
<h3>Sources</h3>
|
|
<p>Revenue streams and deposits flow into the system, split across funnels by configurable allocation percentages.</p>
|
|
</div>
|
|
<div class="funds-about__card">
|
|
<div class="funds-about__icon">🏛</div>
|
|
<h3>Funnels</h3>
|
|
<p>Budget buckets with min/max thresholds. When a funnel reaches sufficiency, overflow cascades to the next layer.</p>
|
|
</div>
|
|
<div class="funds-about__card">
|
|
<div class="funds-about__icon">🎯</div>
|
|
<h3>Outcomes</h3>
|
|
<p>Funding targets that receive spending allocations from funnels. Track progress toward each goal.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<a href="${this.esc(detailUrl)}" class="funds-flow-card" data-flow="${this.esc(f.id)}">
|
|
<div class="funds-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
|
|
${value ? `<div class="funds-flow-card__value">${value}</div>` : ""}
|
|
<div class="funds-flow-card__meta">
|
|
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
|
|
${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""}
|
|
${f.status ? ` · ${f.status}` : ""}
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
// ─── Detail view with tabs ─────────────────────────────
|
|
|
|
private renderDetail(): string {
|
|
const backUrl = this.getApiBase()
|
|
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/`
|
|
: "/";
|
|
|
|
return `
|
|
<div class="funds-detail">
|
|
<div class="funds-detail__header">
|
|
<a href="${this.esc(backUrl)}" class="funds-detail__back">← All Flows</a>
|
|
<h1 class="funds-detail__title">${this.esc(this.flowName || "Flow Detail")}</h1>
|
|
${this.isDemo ? '<span class="funds-detail__badge">Demo</span>' : ""}
|
|
</div>
|
|
|
|
<div class="funds-tabs">
|
|
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
|
|
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
|
|
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
|
|
</div>
|
|
|
|
<div class="funds-tab-content">
|
|
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="funds-table">
|
|
${sources.length > 0 ? `
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Sources</h3>
|
|
<div class="funds-cards">
|
|
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
|
|
</div>
|
|
</div>
|
|
` : ""}
|
|
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Funnels</h3>
|
|
<div class="funds-cards">
|
|
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Outcomes</h3>
|
|
<div class="funds-cards">
|
|
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderSourceCard(data: SourceNodeData, id: string): string {
|
|
const allocations = data.targetAllocations || [];
|
|
return `
|
|
<div class="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">💰</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__type">${data.sourceType}</span>
|
|
</div>
|
|
<div class="funds-card__stat">
|
|
<span class="funds-card__stat-value">$${data.flowRate.toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/month</span>
|
|
</div>
|
|
${allocations.length > 0 ? `
|
|
<div class="funds-card__allocs">
|
|
${allocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">🏛</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__status ${statusClass}">${statusLabel}</span>
|
|
</div>
|
|
<div class="funds-card__bar-container">
|
|
<div class="funds-card__bar" style="width:${fillPct}%"></div>
|
|
<div class="funds-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
|
|
</div>
|
|
<div class="funds-card__stats">
|
|
<div>
|
|
<span class="funds-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
|
|
</div>
|
|
<div>
|
|
<span class="funds-card__stat-value">${Math.round(suffPct)}%</span>
|
|
<span class="funds-card__stat-label">sufficiency</span>
|
|
</div>
|
|
</div>
|
|
<div class="funds-card__thresholds">
|
|
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
|
|
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
|
|
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
|
|
</div>
|
|
${data.overflowAllocations.length > 0 ? `
|
|
<div class="funds-card__allocs">
|
|
<div class="funds-card__alloc-title">Overflow</div>
|
|
${data.overflowAllocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
${data.spendingAllocations.length > 0 ? `
|
|
<div class="funds-card__allocs">
|
|
<div class="funds-card__alloc-title">Spending</div>
|
|
${data.spendingAllocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">🎯</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__status" style="color:${statusColor}">${data.status}</span>
|
|
</div>
|
|
${data.description ? `<div class="funds-card__desc">${this.esc(data.description)}</div>` : ""}
|
|
<div class="funds-card__bar-container">
|
|
<div class="funds-card__bar funds-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
|
|
</div>
|
|
<div class="funds-card__stats">
|
|
<div>
|
|
<span class="funds-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
|
|
</div>
|
|
<div>
|
|
<span class="funds-card__stat-value">${Math.round(fillPct)}%</span>
|
|
<span class="funds-card__stat-label">funded</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `<div class="funds-river-container" id="river-mount"></div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="funds-tx-empty">
|
|
<p>Transaction history is not available in demo mode.</p>
|
|
</div>`;
|
|
}
|
|
|
|
if (!this.txLoaded) {
|
|
return '<div class="funds-loading">Loading transactions...</div>';
|
|
}
|
|
|
|
if (this.transactions.length === 0) {
|
|
return `
|
|
<div class="funds-tx-empty">
|
|
<p>No transactions yet for this flow.</p>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="funds-tx-list">
|
|
${this.transactions.map((tx) => `
|
|
<div class="funds-tx">
|
|
<div class="funds-tx__icon">${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}</div>
|
|
<div class="funds-tx__body">
|
|
<div class="funds-tx__desc">${this.esc(tx.description || tx.type)}</div>
|
|
<div class="funds-tx__meta">
|
|
${tx.from ? `From: ${this.esc(tx.from)}` : ""}
|
|
${tx.to ? ` → ${this.esc(tx.to)}` : ""}
|
|
</div>
|
|
</div>
|
|
<div class="funds-tx__amount ${tx.type === "deposit" ? "funds-tx__amount--positive" : "funds-tx__amount--negative"}">
|
|
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
|
|
</div>
|
|
<div class="funds-tx__time">${this.formatTime(tx.timestamp)}</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
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, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-funds-app", FolkFundsApp);
|