feat: rFunds landing page overhaul with EncryptID auth and space-scoped flows
Enhanced landing page with gradient hero, 5-card features grid, auth-aware "Your Flows" section, and 3-step "How TBFF Works" walkthrough. Added EncryptID token verification on flow creation, space_flows DB table for per-space flow association, and space-scoped flow listing API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
23ebfd2a0d
commit
86fc403138
|
|
@ -12,7 +12,7 @@
|
|||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "../lib/types";
|
||||
import { computeSufficiencyState } from "../lib/simulation";
|
||||
import { computeSufficiencyState, computeSystemSufficiency } from "../lib/simulation";
|
||||
import { demoNodes } from "../lib/presets";
|
||||
import { mapFlowToNodes } from "../lib/map-flow";
|
||||
|
||||
|
|
@ -37,13 +37,29 @@ interface Transaction {
|
|||
}
|
||||
|
||||
type View = "landing" | "detail";
|
||||
type Tab = "table" | "river" | "transactions";
|
||||
type Tab = "diagram" | "table" | "river" | "transactions";
|
||||
|
||||
// ─── Auth helpers (reads EncryptID session from localStorage) ──
|
||||
|
||||
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem("encryptid_session");
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw);
|
||||
if (!session?.accessToken) return null;
|
||||
return session;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function isAuthenticated(): boolean { return getSession() !== null; }
|
||||
function getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
|
||||
function getUsername(): string | null { return getSession()?.claims?.username ?? null; }
|
||||
|
||||
class FolkFundsApp extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private space = "";
|
||||
private view: View = "landing";
|
||||
private tab: Tab = "table";
|
||||
private tab: Tab = "diagram";
|
||||
private flowId = "";
|
||||
private isDemo = false;
|
||||
|
||||
|
|
@ -91,7 +107,8 @@ class FolkFundsApp extends HTMLElement {
|
|||
this.render();
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/flows`);
|
||||
const params = this.space ? `?space=${encodeURIComponent(this.space)}` : "";
|
||||
const res = await fetch(`${base}/api/flows${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.flows = Array.isArray(data) ? data : (data.flows || []);
|
||||
|
|
@ -171,6 +188,8 @@ class FolkFundsApp extends HTMLElement {
|
|||
|
||||
private renderLanding(): string {
|
||||
const demoUrl = this.getApiBase() ? `${this.getApiBase().replace(/\/funds$/, "")}/funds/demo` : "/demo";
|
||||
const authed = isAuthenticated();
|
||||
const username = getUsername();
|
||||
|
||||
return `
|
||||
<div class="funds-landing">
|
||||
|
|
@ -178,46 +197,92 @@ class FolkFundsApp extends HTMLElement {
|
|||
<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 —
|
||||
Design transparent resource flows with sufficiency-based cascading.
|
||||
Funnels fill to their threshold, then overflow routes surplus 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 class="funds-hero__actions">
|
||||
<a href="${this.esc(demoUrl)}" class="funds-hero__cta">Try the Demo →</a>
|
||||
${authed
|
||||
? `<button class="funds-hero__cta funds-hero__cta--secondary" data-action="create-flow">Create Flow</button>`
|
||||
: `<span class="funds-hero__auth-hint">Sign in to create flows</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="funds-features">
|
||||
<div class="funds-features__grid">
|
||||
<div class="funds-features__card">
|
||||
<div class="funds-features__icon">💰</div>
|
||||
<h3>Sources</h3>
|
||||
<p>Revenue streams split across funnels by configurable allocation percentages.</p>
|
||||
</div>
|
||||
<div class="funds-features__card">
|
||||
<div class="funds-features__icon">🏛</div>
|
||||
<h3>Funnels</h3>
|
||||
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
|
||||
</div>
|
||||
<div class="funds-features__card">
|
||||
<div class="funds-features__icon">🎯</div>
|
||||
<h3>Outcomes</h3>
|
||||
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
|
||||
</div>
|
||||
<div class="funds-features__card">
|
||||
<div class="funds-features__icon">🌊</div>
|
||||
<h3>River View</h3>
|
||||
<p>Animated sankey diagram showing live fund flows through your entire system.</p>
|
||||
</div>
|
||||
<div class="funds-features__card">
|
||||
<div class="funds-features__icon">✨</div>
|
||||
<h3>Enoughness</h3>
|
||||
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="funds-flows">
|
||||
<h2 class="funds-flows__heading">Your Flows</h2>
|
||||
<div class="funds-flows__header">
|
||||
<h2 class="funds-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
|
||||
${authed ? `<span class="funds-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
|
||||
</div>
|
||||
${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>
|
||||
${authed
|
||||
? `<p>No flows in this space yet.</p>
|
||||
<p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</p>`
|
||||
: `<p>Sign in to see your space’s flows, or <a href="${this.esc(demoUrl)}">explore the demo</a>.</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 class="funds-about__steps">
|
||||
<div class="funds-about__step">
|
||||
<div class="funds-about__step-num">1</div>
|
||||
<div>
|
||||
<h3>Define Sources</h3>
|
||||
<p>Add revenue streams — grants, donations, sales, or any recurring income — with allocation splits.</p>
|
||||
</div>
|
||||
</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 class="funds-about__step">
|
||||
<div class="funds-about__step-num">2</div>
|
||||
<div>
|
||||
<h3>Configure Funnels</h3>
|
||||
<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
|
||||
</div>
|
||||
</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 class="funds-about__step">
|
||||
<div class="funds-about__step-num">3</div>
|
||||
<div>
|
||||
<h3>Track Outcomes</h3>
|
||||
<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -258,8 +323,9 @@ class FolkFundsApp extends HTMLElement {
|
|||
</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 === "diagram" ? "funds-tab--active" : ""}" data-tab="diagram">Diagram</button>
|
||||
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
|
||||
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
|
||||
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -270,6 +336,7 @@ class FolkFundsApp extends HTMLElement {
|
|||
}
|
||||
|
||||
private renderTab(): string {
|
||||
if (this.tab === "diagram") return this.renderDiagramTab();
|
||||
if (this.tab === "river") return this.renderRiverTab();
|
||||
if (this.tab === "transactions") return this.renderTransactionsTab();
|
||||
return this.renderTableTab();
|
||||
|
|
@ -441,6 +508,234 @@ class FolkFundsApp extends HTMLElement {
|
|||
return (node.data as any).label || id;
|
||||
}
|
||||
|
||||
// ─── Diagram tab ──────────────────────────────────────
|
||||
|
||||
private renderDiagramTab(): string {
|
||||
if (this.nodes.length === 0) {
|
||||
return '<div class="funds-loading">No nodes to display.</div>';
|
||||
}
|
||||
|
||||
const sources = this.nodes.filter((n) => n.type === "source");
|
||||
const funnels = this.nodes.filter((n) => n.type === "funnel");
|
||||
const outcomes = this.nodes.filter((n) => n.type === "outcome");
|
||||
|
||||
// Layout constants
|
||||
const NODE_W = 200;
|
||||
const SOURCE_H = 54;
|
||||
const FUNNEL_H = 120;
|
||||
const OUTCOME_H = 70;
|
||||
const PAD = 60;
|
||||
const ROW_GAP = 160;
|
||||
const COL_GAP = 40;
|
||||
|
||||
// Compute layers: root funnels (no overflow targeting them) vs child funnels
|
||||
const overflowTargets = new Set<string>();
|
||||
funnels.forEach((n) => {
|
||||
const d = n.data as FunnelNodeData;
|
||||
d.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId));
|
||||
});
|
||||
const rootFunnels = funnels.filter((n) => !overflowTargets.has(n.id));
|
||||
const childFunnels = funnels.filter((n) => overflowTargets.has(n.id));
|
||||
|
||||
// Assign positions
|
||||
const positions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
const placeRow = (nodes: { id: string }[], y: number, h: number) => {
|
||||
const totalW = nodes.length * NODE_W + (nodes.length - 1) * COL_GAP;
|
||||
const startX = PAD + Math.max(0, (svgW - 2 * PAD - totalW) / 2);
|
||||
nodes.forEach((n, i) => {
|
||||
positions.set(n.id, { x: startX + i * (NODE_W + COL_GAP), y, w: NODE_W, h });
|
||||
});
|
||||
};
|
||||
|
||||
// Estimate SVG size
|
||||
const maxCols = Math.max(sources.length, rootFunnels.length, childFunnels.length, outcomes.length, 1);
|
||||
const svgW = Math.max(800, maxCols * (NODE_W + COL_GAP) + 2 * PAD);
|
||||
let currentY = PAD;
|
||||
|
||||
if (sources.length > 0) { placeRow(sources, currentY, SOURCE_H); currentY += SOURCE_H + ROW_GAP; }
|
||||
if (rootFunnels.length > 0) { placeRow(rootFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; }
|
||||
if (childFunnels.length > 0) { placeRow(childFunnels, currentY, FUNNEL_H); currentY += FUNNEL_H + ROW_GAP; }
|
||||
if (outcomes.length > 0) { placeRow(outcomes, currentY, OUTCOME_H); currentY += OUTCOME_H + PAD; }
|
||||
const svgH = currentY;
|
||||
|
||||
// Build SVG
|
||||
const defs = `<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
|
||||
</marker>
|
||||
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#10b981"/>
|
||||
</marker>
|
||||
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#f59e0b"/>
|
||||
</marker>
|
||||
<marker id="arrow-purple" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8b5cf6"/>
|
||||
</marker>
|
||||
</defs>`;
|
||||
|
||||
// Edges
|
||||
let edges = "";
|
||||
|
||||
// Source → Funnel edges
|
||||
sources.forEach((sn) => {
|
||||
const sd = sn.data as SourceNodeData;
|
||||
const sp = positions.get(sn.id);
|
||||
if (!sp) return;
|
||||
sd.targetAllocations?.forEach((alloc) => {
|
||||
const tp = positions.get(alloc.targetId);
|
||||
if (!tp) return;
|
||||
const x1 = sp.x + sp.w / 2;
|
||||
const y1 = sp.y + sp.h;
|
||||
const x2 = tp.x + tp.w / 2;
|
||||
const y2 = tp.y;
|
||||
const cy1 = y1 + (y2 - y1) * 0.4;
|
||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||
edges += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="#10b981" stroke-width="2.5" stroke-opacity="0.7" marker-end="url(#arrow-green)"/>`;
|
||||
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="#10b981" font-size="11" font-weight="500">${alloc.percentage}%</text>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Overflow edges (funnel → funnel)
|
||||
funnels.forEach((fn) => {
|
||||
const fd = fn.data as FunnelNodeData;
|
||||
const fp = positions.get(fn.id);
|
||||
if (!fp) return;
|
||||
fd.overflowAllocations?.forEach((alloc) => {
|
||||
const tp = positions.get(alloc.targetId);
|
||||
if (!tp) return;
|
||||
const x1 = fp.x + fp.w / 2;
|
||||
const y1 = fp.y + fp.h;
|
||||
const x2 = tp.x + tp.w / 2;
|
||||
const y2 = tp.y;
|
||||
// If same row, draw sideways
|
||||
if (Math.abs(y1 - fp.h - (y2 - tp?.h)) < 10) {
|
||||
const sideY = fp.y + fp.h / 2;
|
||||
const midX = (fp.x + fp.w + tp.x) / 2;
|
||||
edges += `<path d="M ${fp.x + fp.w} ${sideY} C ${midX} ${sideY}, ${midX} ${tp.y + tp.h / 2}, ${tp.x} ${tp.y + tp.h / 2}" fill="none" stroke="${alloc.color || "#f59e0b"}" stroke-width="2.5" stroke-opacity="0.7" stroke-dasharray="6 3" marker-end="url(#arrow-amber)"/>`;
|
||||
edges += `<text x="${midX}" y="${sideY - 8}" fill="${alloc.color || "#f59e0b"}" font-size="11" font-weight="500" text-anchor="middle">${alloc.percentage}%</text>`;
|
||||
} else {
|
||||
const cy1 = y1 + (y2 - y1) * 0.4;
|
||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||
edges += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${alloc.color || "#f59e0b"}" stroke-width="2.5" stroke-opacity="0.7" stroke-dasharray="6 3" marker-end="url(#arrow-amber)"/>`;
|
||||
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="${alloc.color || "#f59e0b"}" font-size="11" font-weight="500">${alloc.percentage}%</text>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Spending edges (funnel → outcome)
|
||||
funnels.forEach((fn) => {
|
||||
const fd = fn.data as FunnelNodeData;
|
||||
const fp = positions.get(fn.id);
|
||||
if (!fp) return;
|
||||
fd.spendingAllocations?.forEach((alloc, i) => {
|
||||
const tp = positions.get(alloc.targetId);
|
||||
if (!tp) return;
|
||||
const x1 = fp.x + fp.w / 2;
|
||||
const y1 = fp.y + fp.h;
|
||||
const x2 = tp.x + tp.w / 2;
|
||||
const y2 = tp.y;
|
||||
const cy1 = y1 + (y2 - y1) * 0.4;
|
||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||
edges += `<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${alloc.color || "#8b5cf6"}" stroke-width="2" stroke-opacity="0.6" marker-end="url(#arrow-purple)"/>`;
|
||||
edges += `<text x="${(x1 + x2) / 2 + 8}" y="${(y1 + y2) / 2 - 4}" fill="${alloc.color || "#8b5cf6"}" font-size="10" font-weight="500">${alloc.percentage}%</text>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Render nodes
|
||||
let nodesSvg = "";
|
||||
|
||||
// Sources
|
||||
sources.forEach((sn) => {
|
||||
const sd = sn.data as SourceNodeData;
|
||||
const p = positions.get(sn.id)!;
|
||||
nodesSvg += `<g>
|
||||
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="8" fill="#064e3b" stroke="#10b981" stroke-width="2"/>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + 22}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(sd.label)}</text>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + 40}" text-anchor="middle" fill="#6ee7b7" font-size="11">$${sd.flowRate.toLocaleString()}/mo · ${sd.sourceType}</text>
|
||||
</g>`;
|
||||
});
|
||||
|
||||
// Funnels
|
||||
funnels.forEach((fn) => {
|
||||
const fd = fn.data as FunnelNodeData;
|
||||
const p = positions.get(fn.id)!;
|
||||
const sufficiency = computeSufficiencyState(fd);
|
||||
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
|
||||
const threshold = fd.sufficientThreshold ?? fd.maxThreshold;
|
||||
const fillPct = Math.min(1, fd.currentValue / (fd.maxCapacity || 1));
|
||||
const fillH = fillPct * (p.h - 36);
|
||||
const fillY = p.y + 36 + (p.h - 36) - fillH;
|
||||
|
||||
const borderColor = fd.currentValue > fd.maxThreshold ? "#f59e0b"
|
||||
: fd.currentValue < fd.minThreshold ? "#ef4444"
|
||||
: isSufficient ? "#fbbf24" : "#0ea5e9";
|
||||
const fillColor = fd.currentValue > fd.maxThreshold ? "#f59e0b"
|
||||
: fd.currentValue < fd.minThreshold ? "#ef4444"
|
||||
: isSufficient ? "#fbbf24" : "#0ea5e9";
|
||||
const statusLabel = sufficiency === "abundant" ? "Abundant"
|
||||
: sufficiency === "sufficient" ? "Sufficient"
|
||||
: fd.currentValue < fd.minThreshold ? "Critical" : "Seeking";
|
||||
|
||||
nodesSvg += `<g>
|
||||
${isSufficient ? `<rect x="${p.x - 3}" y="${p.y - 3}" width="${p.w + 6}" height="${p.h + 6}" rx="12" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.5"/>` : ""}
|
||||
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="10" fill="#1e293b" stroke="${borderColor}" stroke-width="2"/>
|
||||
<rect x="${p.x + 2}" y="${fillY}" width="${p.w - 4}" height="${fillH}" rx="0" fill="${fillColor}" opacity="0.25"/>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + 20}" text-anchor="middle" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(fd.label)}</text>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + p.h - 12}" text-anchor="middle" fill="#94a3b8" font-size="10">$${Math.floor(fd.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
||||
<text x="${p.x + p.w - 8}" y="${p.y + 20}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500">${statusLabel}</text>
|
||||
<rect x="${p.x + 8}" y="${p.y + p.h - 6}" width="${p.w - 16}" height="3" rx="1.5" fill="#334155"/>
|
||||
<rect x="${p.x + 8}" y="${p.y + p.h - 6}" width="${(p.w - 16) * fillPct}" height="3" rx="1.5" fill="${fillColor}"/>
|
||||
</g>`;
|
||||
});
|
||||
|
||||
// Outcomes
|
||||
outcomes.forEach((on) => {
|
||||
const od = on.data as OutcomeNodeData;
|
||||
const p = positions.get(on.id)!;
|
||||
const fillPct = od.fundingTarget > 0 ? Math.min(1, od.fundingReceived / od.fundingTarget) : 0;
|
||||
const statusColor = od.status === "completed" ? "#10b981"
|
||||
: od.status === "blocked" ? "#ef4444"
|
||||
: od.status === "in-progress" ? "#3b82f6" : "#64748b";
|
||||
|
||||
nodesSvg += `<g>
|
||||
<rect x="${p.x}" y="${p.y}" width="${p.w}" height="${p.h}" rx="8" fill="#1e293b" stroke="${statusColor}" stroke-width="1.5"/>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + 20}" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">${this.esc(od.label)}</text>
|
||||
<rect x="${p.x + 8}" y="${p.y + 32}" width="${p.w - 16}" height="5" rx="2.5" fill="#334155"/>
|
||||
<rect x="${p.x + 8}" y="${p.y + 32}" width="${(p.w - 16) * fillPct}" height="5" rx="2.5" fill="${statusColor}" opacity="0.8"/>
|
||||
<text x="${p.x + p.w / 2}" y="${p.y + 54}" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% · $${Math.floor(od.fundingReceived).toLocaleString()}</text>
|
||||
</g>`;
|
||||
});
|
||||
|
||||
// Sufficiency badge
|
||||
const score = computeSystemSufficiency(this.nodes);
|
||||
const scorePct = Math.round(score * 100);
|
||||
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
|
||||
|
||||
return `
|
||||
<div class="funds-diagram" id="diagram-container">
|
||||
<svg viewBox="0 0 ${svgW} ${svgH}" width="100%" style="max-height:70vh;background:#0f172a;border-radius:12px;border:1px solid #334155">
|
||||
${defs}
|
||||
${edges}
|
||||
${nodesSvg}
|
||||
<g transform="translate(${svgW - 70}, 16)">
|
||||
<circle cx="24" cy="24" r="22" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
|
||||
<text x="24" y="22" text-anchor="middle" fill="${scoreColor}" font-size="12" font-weight="700">${scorePct}%</text>
|
||||
<text x="24" y="34" text-anchor="middle" fill="#94a3b8" font-size="7">ENOUGH</text>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="funds-diagram__legend">
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#10b981"></span>Source</span>
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#0ea5e9"></span>Funnel</span>
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#f59e0b"></span>Overflow</span>
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#8b5cf6"></span>Spending</span>
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#3b82f6"></span>Outcome</span>
|
||||
<span class="funds-diagram__legend-item"><span class="funds-diagram__dot" style="background:#fbbf24"></span>Sufficient</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── River tab ────────────────────────────────────────
|
||||
|
||||
private renderRiverTab(): string {
|
||||
|
|
@ -544,6 +839,62 @@ class FolkFundsApp extends HTMLElement {
|
|||
if (this.tab === "river" && this.nodes.length > 0) {
|
||||
this.mountRiver();
|
||||
}
|
||||
|
||||
// Create flow button (landing page, auth-gated)
|
||||
const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
|
||||
createBtn?.addEventListener("click", () => this.handleCreateFlow());
|
||||
}
|
||||
|
||||
private async handleCreateFlow() {
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
const name = prompt("Flow name:");
|
||||
if (!name) return;
|
||||
|
||||
this.loading = true;
|
||||
this.render();
|
||||
try {
|
||||
const base = this.getApiBase();
|
||||
const res = await fetch(`${base}/api/flows`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const flowId = data.id || data.flowId;
|
||||
// Associate with current space
|
||||
if (flowId && this.space) {
|
||||
await fetch(`${base}/api/space-flows`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ space: this.space, flowId }),
|
||||
});
|
||||
}
|
||||
// Navigate to the new flow
|
||||
if (flowId) {
|
||||
const detailUrl = this.getApiBase()
|
||||
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(flowId)}`
|
||||
: `/flow/${encodeURIComponent(flowId)}`;
|
||||
window.location.href = detailUrl;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
this.error = (err as any).error || `Failed to create flow (${res.status})`;
|
||||
}
|
||||
} catch {
|
||||
this.error = "Failed to create flow";
|
||||
}
|
||||
this.loading = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
|
|
|
|||
|
|
@ -14,30 +14,56 @@ body[data-theme="light"] main {
|
|||
|
||||
.funds-hero {
|
||||
text-align: center;
|
||||
padding: 48px 20px 40px;
|
||||
padding: 56px 20px 48px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.funds-hero__title {
|
||||
font-size: 36px; font-weight: 700; margin: 0 0 8px;
|
||||
font-size: 42px; font-weight: 800; margin: 0 0 8px;
|
||||
background: linear-gradient(135deg, #0ea5e9, #6366f1, #ec4899);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.funds-hero__subtitle { font-size: 18px; color: #94a3b8; margin: 0 0 16px; font-weight: 500; }
|
||||
.funds-hero__desc { font-size: 14px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 24px; }
|
||||
.funds-hero__desc { font-size: 15px; color: #64748b; line-height: 1.7; max-width: 560px; margin: 0 auto 28px; }
|
||||
.funds-hero__desc em { color: #fbbf24; font-style: normal; font-weight: 600; }
|
||||
.funds-hero__actions { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; }
|
||||
.funds-hero__cta {
|
||||
display: inline-block; padding: 10px 24px; border-radius: 8px;
|
||||
background: #4f46e5; color: #fff; text-decoration: none; font-weight: 600; font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
border: none; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.funds-hero__cta:hover { background: #6366f1; }
|
||||
.funds-hero__cta--secondary {
|
||||
background: transparent; border: 1px solid #4f46e5; color: #a5b4fc;
|
||||
}
|
||||
.funds-hero__cta--secondary:hover { background: rgba(79,70,229,0.15); }
|
||||
.funds-hero__auth-hint { font-size: 13px; color: #64748b; }
|
||||
|
||||
/* Features grid */
|
||||
.funds-features { margin-bottom: 48px; }
|
||||
.funds-features__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px;
|
||||
}
|
||||
.funds-features__card {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.funds-features__card:hover { border-color: #475569; }
|
||||
.funds-features__icon { font-size: 24px; margin-bottom: 8px; }
|
||||
.funds-features__card h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 6px; }
|
||||
.funds-features__card p { font-size: 12px; color: #94a3b8; line-height: 1.6; margin: 0; }
|
||||
|
||||
/* Flow list */
|
||||
.funds-flows { margin-bottom: 48px; }
|
||||
.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
|
||||
.funds-flows__header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; }
|
||||
.funds-flows__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0; }
|
||||
.funds-flows__user { font-size: 12px; color: #64748b; }
|
||||
.funds-flows__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.funds-flows__empty { text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px; }
|
||||
.funds-flows__empty {
|
||||
text-align: center; color: #64748b; padding: 32px 16px; font-size: 14px;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
|
||||
}
|
||||
.funds-flows__empty a { color: #6366f1; text-decoration: none; }
|
||||
.funds-flows__empty a:hover { text-decoration: underline; }
|
||||
|
||||
|
|
@ -53,7 +79,23 @@ body[data-theme="light"] main {
|
|||
|
||||
/* About / how-it-works section */
|
||||
.funds-about { margin-bottom: 48px; }
|
||||
.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 16px; }
|
||||
.funds-about__heading { font-size: 18px; font-weight: 600; color: #e2e8f0; margin: 0 0 20px; }
|
||||
|
||||
/* Steps layout (replaces the old card grid for "how it works") */
|
||||
.funds-about__steps { display: flex; flex-direction: column; gap: 16px; }
|
||||
.funds-about__step {
|
||||
display: flex; gap: 16px; align-items: flex-start;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
|
||||
}
|
||||
.funds-about__step-num {
|
||||
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
||||
background: #4f46e5; color: #fff; font-weight: 700; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.funds-about__step h3 { font-size: 14px; font-weight: 600; color: #e2e8f0; margin: 0 0 4px; }
|
||||
.funds-about__step p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0; }
|
||||
|
||||
/* Legacy about grid (kept for compat) */
|
||||
.funds-about__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||
.funds-about__card {
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 20px;
|
||||
|
|
@ -141,6 +183,16 @@ body[data-theme="light"] main {
|
|||
.funds-status--seeking { color: #0ea5e9; }
|
||||
.funds-status--critical { color: #ef4444; }
|
||||
|
||||
/* ── Diagram tab ────────────────────────────────────── */
|
||||
.funds-diagram { overflow-x: auto; }
|
||||
.funds-diagram svg { display: block; margin: 0 auto; }
|
||||
.funds-diagram__legend {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;
|
||||
margin-top: 12px; font-size: 12px; color: #94a3b8;
|
||||
}
|
||||
.funds-diagram__legend-item { display: flex; align-items: center; gap: 5px; }
|
||||
.funds-diagram__dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
|
||||
|
||||
/* ── River tab ───────────────────────────────────────── */
|
||||
.funds-river-container { min-height: 500px; }
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
-- rFunds module schema
|
||||
CREATE SCHEMA IF NOT EXISTS rfunds;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rfunds.space_flows (
|
||||
space_slug TEXT NOT NULL,
|
||||
flow_id TEXT NOT NULL,
|
||||
added_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (space_slug, flow_id)
|
||||
);
|
||||
|
|
@ -5,12 +5,30 @@
|
|||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "../../shared/db/pool";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
|
||||
|
||||
// ── DB initialization ──
|
||||
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
||||
|
||||
async function initDB() {
|
||||
try {
|
||||
await sql.unsafe(SCHEMA_SQL);
|
||||
console.log("[Funds] DB schema initialized");
|
||||
} catch (e) {
|
||||
console.error("[Funds] DB init error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
initDB();
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ─── Flow Service API proxy ─────────────────────────────
|
||||
|
|
@ -19,6 +37,33 @@ const routes = new Hono();
|
|||
|
||||
routes.get("/api/flows", async (c) => {
|
||||
const owner = c.req.header("X-Owner-Address") || "";
|
||||
const space = c.req.query("space") || "";
|
||||
|
||||
// If space filter provided, get flow IDs from space_flows table
|
||||
if (space) {
|
||||
try {
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT flow_id FROM rfunds.space_flows WHERE space_slug = $1",
|
||||
[space],
|
||||
);
|
||||
if (rows.length === 0) return c.json([]);
|
||||
|
||||
// Fetch each flow from flow-service
|
||||
const flows = await Promise.all(
|
||||
rows.map(async (r: any) => {
|
||||
try {
|
||||
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${r.flow_id}`);
|
||||
if (res.ok) return await res.json();
|
||||
} catch {}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
return c.json(flows.filter(Boolean));
|
||||
} catch {
|
||||
// Fall through to unfiltered fetch
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`);
|
||||
return c.json(await res.json(), res.status as any);
|
||||
});
|
||||
|
|
@ -29,10 +74,20 @@ routes.get("/api/flows/:flowId", async (c) => {
|
|||
});
|
||||
|
||||
routes.post("/api/flows", async (c) => {
|
||||
// Auth-gated: require EncryptID token
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.text();
|
||||
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Owner-Address": claims.sub,
|
||||
},
|
||||
body,
|
||||
});
|
||||
return c.json(await res.json(), res.status as any);
|
||||
|
|
@ -93,6 +148,43 @@ routes.get("/api/flows/:flowId/transactions", async (c) => {
|
|||
return c.json(await res.json(), res.status as any);
|
||||
});
|
||||
|
||||
// ─── Space-flow association endpoints ────────────────────
|
||||
|
||||
routes.post("/api/space-flows", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const { space, flowId } = await c.req.json();
|
||||
if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400);
|
||||
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rfunds.space_flows (space_slug, flow_id, added_by)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
[space, flowId, claims.sub],
|
||||
);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
routes.delete("/api/space-flows/:flowId", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const flowId = c.req.param("flowId");
|
||||
const space = c.req.query("space") || "";
|
||||
if (!space) return c.json({ error: "space query param required" }, 400);
|
||||
|
||||
await sql.unsafe(
|
||||
"DELETE FROM rfunds.space_flows WHERE space_slug = $1 AND flow_id = $2",
|
||||
[space, flowId],
|
||||
);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Page routes ────────────────────────────────────────
|
||||
|
||||
const fundsScripts = `
|
||||
|
|
|
|||
Loading…
Reference in New Issue