diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 5360fb2..4eb324c 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -1087,6 +1087,58 @@ font-size: 13px; } +/* ── Faucet source node ──────────────────────────────── */ +.faucet-pipe { transition: stroke 0.2s; } +.faucet-valve { transition: fill 0.2s; cursor: pointer; } +.faucet-valve:hover { filter: brightness(1.15); } +.faucet-handle { transition: transform 0.3s ease; } +.faucet-stream { opacity: 0.45; } +.faucet-spigot { transition: fill 0.2s; } + +@keyframes faucet-drip { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 0.25; } +} +.faucet-stream { animation: faucet-drip 2.5s ease-in-out infinite; } + +/* ── Source Purchase Modal ───────────────────────────── */ +.source-modal { + position: fixed; inset: 0; z-index: 99999; + display: flex; align-items: center; justify-content: center; +} +.spm-backdrop { + position: absolute; inset: 0; + background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(2px); +} +.spm-card { + position: relative; z-index: 1; + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rflows-modal-border, #334155); + border-radius: 16px; padding: 28px; + width: 440px; max-width: 92vw; + max-height: 85vh; overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); +} +.spm-method-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; + margin-bottom: 4px; +} +.spm-method-btn { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 12px 8px; border: 2px solid var(--rs-border-strong, #334155); + border-radius: 10px; background: none; cursor: pointer; + color: var(--rs-text-secondary, #94a3b8); font-size: 12px; font-weight: 500; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} +.spm-method-btn:hover { + border-color: var(--rs-text-muted, #64748b); + background: var(--rs-bg-surface-raised, #334155); +} +.spm-method-btn--active { + border-color: #10b981; background: rgba(16, 185, 129, 0.1); + color: var(--rs-text-primary, #e2e8f0); +} + /* ── Mobile responsive ──────────────────────────────── */ @media (max-width: 768px) { .flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 78c03ca..aafe7fb 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -115,6 +115,9 @@ class FolkFlowsApp extends HTMLElement { private draggingEdgeKey: string | null = null; private edgeDragPointerId: number | null = null; + // Source purchase modal state + private sourceModalNodeId: string | null = null; + // Inline config panel state private inlineEditNodeId: string | null = null; private inlineConfigTab: "config" | "analytics" | "allocations" = "config"; @@ -930,6 +933,11 @@ class FolkFlowsApp extends HTMLElement { + + + + + @@ -1061,10 +1069,7 @@ class FolkFlowsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") { - const d = n.data as SourceNodeData; - const baseW = 180; - const w = Math.round(baseW + Math.min(120, Math.sqrt(d.flowRate / 100) * 20)); - return { w, h: 120 }; + return { w: 200, h: 160 }; } if (n.type === "funnel") { const d = n.data as FunnelNodeData; @@ -1701,39 +1706,68 @@ class FolkFlowsApp extends HTMLElement { const d = n.data as SourceNodeData; const s = this.getNodeSize(n); const x = n.position.x, y = n.position.y, w = s.w, h = s.h; - const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; - const icon = icons[d.sourceType] || "\u{1F4B0}"; - // Allocation bar segments as inline HTML - let allocBarHtml = ""; + // Valve color encodes sourceType + const valveColors: Record = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" }; + const valveColor = valveColors[d.sourceType] || "#64748b"; + const isConfigured = d.sourceType !== "unconfigured"; + + // Pipe header dimensions + const pipeH = 22; + const pipeY = 0; + const pipeRx = 6; + + // Valve body + const valveR = 28; + const valveCx = w / 2; + const valveCy = pipeY + pipeH + valveR + 4; + + // Valve handle rotation: 45° configured, 90° unconfigured + const handleAngle = isConfigured ? 45 : 90; + + // Spigot: trapezoid below valve + const spigotTop = valveCy + valveR + 2; + const spigotTopW = 24; + const spigotBotW = 14; + const spigotH = 20; + const spigotPath = `M ${valveCx - spigotTopW / 2},${spigotTop} L ${valveCx + spigotTopW / 2},${spigotTop} L ${valveCx + spigotBotW / 2},${spigotTop + spigotH} L ${valveCx - spigotBotW / 2},${spigotTop + spigotH} Z`; + + // Flow stream at bottom — width proportional to flowRate + const streamMaxW = w - 40; + const streamW = Math.round(8 + Math.min(streamMaxW - 8, Math.sqrt(d.flowRate / 100) * (streamMaxW / 6))); + const streamY = spigotTop + spigotH; + const streamH = h - streamY; + + // Amount text + const amountY = valveCy + valveR + spigotH + 8; + + // Allocation bar as SVG rects + let allocBar = ""; if (d.targetAllocations && d.targetAllocations.length > 0) { - const segs = d.targetAllocations.map(a => - `
` - ).join(""); - allocBarHtml = `
${segs}
`; + const barY = h - 10; + const barW = w - 40; + const barX = 20; + let cx = barX; + allocBar = d.targetAllocations.map(a => { + const segW = (a.percentage / 100) * barW; + const rect = ``; + cx += segW + 1; + return rect; + }).join(""); } - // Flow-width bar: visual river-width proportional to flowRate - const flowBarMaxW = w - 24; - const flowBarW = Math.round(12 + Math.min(flowBarMaxW - 12, Math.sqrt(d.flowRate / 100) * (flowBarMaxW / 6))); - return ` - - -
-
-
- ${icon} - ${this.esc(d.label)} -
-
-
-
$${d.flowRate.toLocaleString()}/mo
-
- ${allocBarHtml} -
-
- + + + ${this.esc(d.label)} + + + + + + + $${d.flowRate.toLocaleString()}/mo + ${allocBar} ${this.renderPortsSvg(n)}
`; } @@ -2676,6 +2710,11 @@ class FolkFlowsApp extends HTMLElement { // ─── Inline config panel ───────────────────────────── private enterInlineEdit(nodeId: string) { + const clickedNode = this.nodes.find((n) => n.id === nodeId); + if (clickedNode?.type === "source") { + this.openSourcePurchaseModal(nodeId); + return; + } if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) { this.exitInlineEdit(); } @@ -3287,6 +3326,7 @@ class FolkFlowsApp extends HTMLElement { } private redrawNodeInlineEdit(node: FlowNode) { + if (node.type === "source") { this.redrawNodeOnly(node); return; } this.drawCanvasContent(); // Re-enter inline edit to show appropriate handles/panel this.enterInlineEdit(node.id); @@ -3538,6 +3578,174 @@ class FolkFlowsApp extends HTMLElement { }); } + // ─── Source Purchase Modal ───────────────────────────── + + private openSourcePurchaseModal(nodeId: string) { + if (this.sourceModalNodeId) return; // guard re-entry + const node = this.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== "source") return; + this.sourceModalNodeId = nodeId; + const sd = node.data as SourceNodeData; + + const valveColors: Record = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" }; + const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface, #1e293b);color:var(--rs-text-primary, #e2e8f0)`; + + const modal = document.createElement("div"); + modal.className = "source-modal"; + + const renderMethodDetail = () => { + const detailEl = modal.querySelector(".spm-method-detail") as HTMLElement; + if (!detailEl) return; + if (sd.sourceType === "card") { + detailEl.innerHTML = ``; + } else if (sd.sourceType === "metamask") { + const addr = sd.walletAddress ? `
Connected: ${sd.walletAddress}
` : ""; + detailEl.innerHTML = `${addr}`; + } else if (sd.sourceType === "ridentity") { + const session = getSession(); + detailEl.innerHTML = `
${session ? `Linked as ${session.claims.username || session.claims.sub}` : "Not signed in"}
`; + } else { + detailEl.innerHTML = ""; + } + // Re-attach detail listeners + modal.querySelector("[data-spm-action='fund-card']")?.addEventListener("click", (e: Event) => { + e.stopPropagation(); + this.openUserOnRamp(nodeId).catch((err) => console.error("[UserOnRamp] Error:", err)); + }); + modal.querySelector("[data-spm-action='connect-metamask']")?.addEventListener("click", async (e: Event) => { + e.stopPropagation(); + await this.connectMetaMask(nodeId); + renderMethodDetail(); + }); + }; + + const updateMethodBtns = () => { + modal.querySelectorAll(".spm-method-btn").forEach((btn) => { + const type = (btn as HTMLElement).dataset.spmType || ""; + btn.classList.toggle("spm-method-btn--active", type === sd.sourceType); + }); + }; + + // Build allocation display + let allocHtml = ""; + if (sd.targetAllocations && sd.targetAllocations.length > 0) { + const barSegs = sd.targetAllocations.map(a => + `
` + ).join(""); + const labels = sd.targetAllocations.map(a => + `
${this.esc(this.getNodeLabel(a.targetId))} — ${a.percentage}%
` + ).join(""); + allocHtml = `
+
Allocations
+
${barSegs}
+
${labels}
+
`; + } + + modal.innerHTML = ` +
+
+

Configure Source

+ + +
Payment Method
+
+ + + +
+
+ ${allocHtml} +
+ +
+ +
+
`; + + document.body.appendChild(modal); + renderMethodDetail(); + + // Live field updates + const labelInput = modal.querySelector(".spm-label-input") as HTMLInputElement; + const amountInput = modal.querySelector(".spm-amount-input") as HTMLInputElement; + + const applyChanges = () => { + this.redrawNodeOnly(node); + this.redrawEdges(); + this.scheduleSave(); + }; + + labelInput.addEventListener("input", () => { sd.label = labelInput.value; applyChanges(); }); + amountInput.addEventListener("input", () => { sd.flowRate = parseFloat(amountInput.value) || 0; applyChanges(); }); + + // Method selection + modal.querySelectorAll(".spm-method-btn").forEach((btn) => { + btn.addEventListener("click", (e: Event) => { + e.stopPropagation(); + sd.sourceType = ((btn as HTMLElement).dataset.spmType || "unconfigured") as SourceNodeData["sourceType"]; + updateMethodBtns(); + renderMethodDetail(); + applyChanges(); + }); + }); + + // Close / Delete + const closeModal = () => { + this.sourceModalNodeId = null; + modal.remove(); + }; + + modal.querySelector(".spm-close-btn")!.addEventListener("click", closeModal); + modal.querySelector(".spm-backdrop")!.addEventListener("click", closeModal); + modal.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Escape") closeModal(); + }); + + modal.querySelector(".spm-delete-btn")!.addEventListener("click", () => { + closeModal(); + this.nodes = this.nodes.filter((nn) => nn.id !== nodeId); + this.drawCanvasContent(); + this.scheduleSave(); + }); + + labelInput.focus(); + } + + private async connectMetaMask(nodeId: string) { + const node = this.nodes.find((n) => n.id === nodeId); + if (!node || node.type !== "source") return; + const sd = node.data as SourceNodeData; + const ethereum = (window as any).ethereum; + if (!ethereum) { + alert("MetaMask not detected. Please install the MetaMask browser extension."); + return; + } + try { + const accounts: string[] = await ethereum.request({ method: "eth_requestAccounts" }); + const chainId: string = await ethereum.request({ method: "eth_chainId" }); + sd.walletAddress = accounts[0]; + sd.chainId = parseInt(chainId, 16); + this.redrawNodeOnly(node); + this.redrawEdges(); + this.scheduleSave(); + } catch (err) { + console.error("[MetaMask] Connection failed:", err); + } + } + /** * Open on-ramp widget. Coinbase blocks iframing (CSP frame-ancestors), * so we open it in a popup window. Transak allows iframing. @@ -4036,7 +4244,7 @@ class FolkFlowsApp extends HTMLElement { if (!node || node.type !== "source") return; const d = node.data as SourceNodeData; - const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; + const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", metamask: "\u{1F98A}", unconfigured: "\u{2699}" }; const labels: Record = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" }; let configHtml = ""; diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index f7270f6..619bce4 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -90,7 +90,7 @@ export interface OutcomeNodeData { export interface SourceNodeData { label: string; flowRate: number; - sourceType: "card" | "safe_wallet" | "ridentity" | "unconfigured"; + sourceType: "card" | "safe_wallet" | "ridentity" | "metamask" | "unconfigured"; targetAllocations: SourceAllocation[]; walletAddress?: string; chainId?: number;