-
Edge width = relative flow amount
+
+ setPanelsCollapsed((c) => !c)}
+ >
+
+
Legend
+ {panelsCollapsed && (
+
+ )}
+
+ {!panelsCollapsed && (
+
+
+
+
+
Source (funding origin)
+
+
+
+
Funnel (threshold pool)
+
+
+
+
Outcome (deliverable)
+
+
+
+ Edge width = relative flow • Select + Delete to remove
+
+
+
+ )}
diff --git a/components/nodes/SourceNode.tsx b/components/nodes/SourceNode.tsx
new file mode 100644
index 0000000..98189d7
--- /dev/null
+++ b/components/nodes/SourceNode.tsx
@@ -0,0 +1,208 @@
+'use client'
+
+import { memo, useState, useCallback } from 'react'
+import { Handle, Position, useReactFlow } from '@xyflow/react'
+import type { NodeProps } from '@xyflow/react'
+import type { SourceNodeData } from '@/lib/types'
+
+const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
+
+function SourceNode({ data, selected, id }: NodeProps) {
+ const nodeData = data as SourceNodeData
+ const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
+ const { setNodes } = useReactFlow()
+
+ const [isEditing, setIsEditing] = useState(false)
+ const [editLabel, setEditLabel] = useState(label)
+ const [editRate, setEditRate] = useState(String(flowRate))
+ const [editType, setEditType] = useState(sourceType)
+
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation()
+ setEditLabel(label)
+ setEditRate(String(flowRate))
+ setEditType(sourceType)
+ setIsEditing(true)
+ }, [label, flowRate, sourceType])
+
+ const handleSave = useCallback(() => {
+ setNodes((nds) => nds.map((node) => {
+ if (node.id !== id) return node
+ return {
+ ...node,
+ data: {
+ ...(node.data as SourceNodeData),
+ label: editLabel.trim() || 'Source',
+ flowRate: Math.max(0, Number(editRate) || 0),
+ sourceType: editType,
+ },
+ }
+ }))
+ setIsEditing(false)
+ }, [id, editLabel, editRate, editType, setNodes])
+
+ const typeLabels = {
+ 'recurring': 'Recurring',
+ 'one-time': 'One-time',
+ 'treasury': 'Treasury',
+ }
+
+ const typeColors = {
+ 'recurring': 'bg-emerald-100 text-emerald-700',
+ 'one-time': 'bg-blue-100 text-blue-700',
+ 'treasury': 'bg-violet-100 text-violet-700',
+ }
+
+ return (
+ <>
+
+ {/* Header */}
+
+
+ {/* Body */}
+
+
+
+ {typeLabels[sourceType]}
+
+
+
+
+
+ ${flowRate.toLocaleString()}
+
+ /mo
+
+
+ {/* Allocation bars */}
+ {targetAllocations.length > 0 && (
+
+
Flow
+
+ {targetAllocations.map((alloc, idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* Flow indicator */}
+
+
+
+ Double-click to edit
+
+
+
+ {/* Bottom handle — connects to funnel top */}
+
+
+
+ {/* Edit Modal */}
+ {isEditing && (
+
setIsEditing(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
Edit Source
+
+
+
+
+
+
+ setEditLabel(e.target.value)}
+ className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
+ autoFocus
+ />
+
+
+
+
+ setEditRate(e.target.value)}
+ className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono"
+ min="0"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default memo(SourceNode)
diff --git a/lib/presets.ts b/lib/presets.ts
index 2e487df..a010203 100644
--- a/lib/presets.ts
+++ b/lib/presets.ts
@@ -1,12 +1,27 @@
-import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
+import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
// Colors for allocations
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
+export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
-// Demo preset: Treasury → 3 sub-funnels → 7 outcomes
+// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
export const demoNodes: FlowNode[] = [
- // Main Treasury Funnel (top center)
+ // Revenue source (top)
+ {
+ id: 'revenue',
+ type: 'source',
+ position: { x: 660, y: -200 },
+ data: {
+ label: 'Revenue Stream',
+ flowRate: 5000,
+ sourceType: 'recurring',
+ targetAllocations: [
+ { targetId: 'treasury', percentage: 100, color: '#10b981' },
+ ],
+ } as SourceNodeData,
+ },
+ // Main Treasury Funnel
{
id: 'treasury',
type: 'funnel',
diff --git a/lib/types.ts b/lib/types.ts
index 5d8da53..2d5843b 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -83,6 +83,22 @@ export interface FundingSource {
lastUsedAt?: number;
}
+// ─── Source Node Types ───────────────────────────────────────
+
+export interface SourceAllocation {
+ targetId: string
+ percentage: number // 0-100
+ color: string
+}
+
+export interface SourceNodeData {
+ label: string
+ flowRate: number // tokens per month flowing out
+ sourceType: 'recurring' | 'one-time' | 'treasury'
+ targetAllocations: SourceAllocation[]
+ [key: string]: unknown
+}
+
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@@ -129,7 +145,7 @@ export interface OutcomeNodeData {
[key: string]: unknown
}
-export type FlowNode = Node
+export type FlowNode = Node
export interface AllocationEdgeData {
allocation: number // percentage 0-100