feat: add source nodes and tiered outcome phases to TBFF canvas

Source nodes represent funding origins (Card/Safe/rIdentity) with
configurable flow rates and allocation edges to funnels. Outcome
nodes now support phased funding tiers with progressive task
unlocking as funding thresholds are reached.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 13:29:43 -08:00
parent cb2dd9c51f
commit bd77cd9113
6 changed files with 994 additions and 38 deletions

View File

@ -18,17 +18,19 @@ import '@xyflow/react/dist/style.css'
import FunnelNode from './nodes/FunnelNode'
import OutcomeNode from './nodes/OutcomeNode'
import SourceNode from './nodes/SourceNode'
import AllocationEdge from './edges/AllocationEdge'
import StreamEdge from './edges/StreamEdge'
import IntegrationPanel from './IntegrationPanel'
import { ConnectionContext, type ConnectingFrom } from './ConnectionContext'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets'
import { simulateTick } from '@/lib/simulation'
const nodeTypes = {
funnel: FunnelNode,
outcome: OutcomeNode,
source: SourceNode,
}
const edgeTypes = {
@ -39,12 +41,20 @@ const edgeTypes = {
// Generate edges with proportional Sankey-style widths
function generateEdges(
nodes: FlowNode[],
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void
): FlowEdge[] {
const edges: FlowEdge[] = []
const flowValues: number[] = []
nodes.forEach((node) => {
if (node.type === 'source') {
const data = node.data as SourceNodeData
const rate = data.flowRate || 1
data.targetAllocations?.forEach((alloc) => {
flowValues.push((alloc.percentage / 100) * rate)
})
return
}
if (node.type !== 'funnel') return
const data = node.data as FunnelNodeData
const rate = data.inflowRate || 1
@ -61,6 +71,48 @@ function generateEdges(
const MIN_WIDTH = 3
const MAX_WIDTH = 24
// Source node edges
nodes.forEach((node) => {
if (node.type !== 'source') return
const data = node.data as SourceNodeData
const rate = data.flowRate || 1
const allocCount = data.targetAllocations?.length ?? 0
data.targetAllocations?.forEach((alloc) => {
const flowValue = (alloc.percentage / 100) * rate
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
edges.push({
id: `source-${node.id}-${alloc.targetId}`,
source: node.id,
target: alloc.targetId,
sourceHandle: 'source-out',
animated: true,
style: {
stroke: alloc.color,
strokeWidth,
opacity: 0.8,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: alloc.color,
width: 16,
height: 16,
},
data: {
allocation: alloc.percentage,
color: alloc.color,
edgeType: 'source' as const,
sourceId: node.id,
targetId: alloc.targetId,
siblingCount: allocCount,
onAdjust,
},
type: 'allocation',
})
})
})
nodes.forEach((node) => {
if (node.type !== 'funnel') return
const data = node.data as FunnelNodeData
@ -208,9 +260,46 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
// Adjust allocation percentage inline from edge +/- buttons
const onAdjustAllocation = useCallback(
(sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => {
(sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => {
setNodes((nds) => nds.map((node) => {
if (node.id !== sourceId || node.type !== 'funnel') return node
if (node.id !== sourceId) return node
// Source node allocation adjustment
if (edgeType === 'source' && node.type === 'source') {
const data = node.data as SourceNodeData
const allocs = [...data.targetAllocations]
if (allocs.length <= 1) return node
const idx = allocs.findIndex(a => a.targetId === targetId)
if (idx === -1) return node
const current = allocs[idx].percentage
const newPct = Math.max(5, Math.min(95, current + delta))
const actualDelta = newPct - current
if (actualDelta === 0) return node
const siblings = allocs.filter((_, i) => i !== idx)
const siblingTotal = siblings.reduce((s, a) => s + a.percentage, 0)
const updated = allocs.map((a, i) => {
if (i === idx) return { ...a, percentage: newPct }
const share = siblingTotal > 0 ? a.percentage / siblingTotal : 1 / siblings.length
return { ...a, percentage: Math.max(1, Math.round(a.percentage - actualDelta * share)) }
})
const sum = updated.reduce((s, a) => s + a.percentage, 0)
if (sum !== 100 && updated.length > 1) {
const diff = 100 - sum
const largestSibIdx = updated.reduce((best, a, i) =>
i !== idx && a.percentage > updated[best].percentage ? i : best, idx === 0 ? 1 : 0)
updated[largestSibIdx] = { ...updated[largestSibIdx], percentage: updated[largestSibIdx].percentage + diff }
}
return { ...node, data: { ...data, targetAllocations: updated } }
}
// Funnel node allocation adjustment
if (node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
const allocKey = edgeType === 'overflow' ? 'overflowAllocations' : 'spendingAllocations'
const allocs = [...data[allocKey]]
@ -256,20 +345,29 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
// Smart edge regeneration
const allocationsKey = useMemo(() => {
return JSON.stringify(
nodes
.filter(n => n.type === 'funnel')
.map(n => {
const d = n.data as FunnelNodeData
return {
id: n.id,
overflow: d.overflowAllocations,
spending: d.spendingAllocations,
streams: d.streamAllocations,
rate: d.inflowRate,
}
})
)
const funnels = nodes
.filter(n => n.type === 'funnel')
.map(n => {
const d = n.data as FunnelNodeData
return {
id: n.id,
overflow: d.overflowAllocations,
spending: d.spendingAllocations,
streams: d.streamAllocations,
rate: d.inflowRate,
}
})
const sources = nodes
.filter(n => n.type === 'source')
.map(n => {
const d = n.data as SourceNodeData
return {
id: n.id,
allocations: d.targetAllocations,
rate: d.flowRate,
}
})
return JSON.stringify({ funnels, sources })
}, [nodes])
useEffect(() => {
@ -281,11 +379,42 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
(params: Connection) => {
if (!params.source || !params.target) return
const isSourceOut = params.sourceHandle === 'source-out'
const isOverflow = params.sourceHandle?.startsWith('outflow')
const isSpending = params.sourceHandle === 'spending-out'
const isStream = params.sourceHandle === 'stream-out'
if (!isOverflow && !isSpending && !isStream) return
if (!isSourceOut && !isOverflow && !isSpending && !isStream) return
// Source node connections
if (isSourceOut) {
setNodes((nds) => nds.map((node) => {
if (node.id !== params.source || node.type !== 'source') return node
const data = node.data as SourceNodeData
const existing = data.targetAllocations || []
if (existing.some(a => a.targetId === params.target)) return node
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
const redistributed = existing.map(a => ({
...a,
percentage: Math.floor(a.percentage * existing.length / (existing.length + 1))
}))
return {
...node,
data: {
...data,
targetAllocations: [
...redistributed,
{
targetId: params.target!,
percentage: newPct,
color: SOURCE_COLORS[existing.length % SOURCE_COLORS.length],
},
],
},
}
}))
return
}
setNodes((nds) => nds.map((node) => {
if (node.id !== params.source || node.type !== 'funnel') return node
@ -371,6 +500,24 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const newTargetId = newConnection.target
if (oldTargetId === newTargetId) return
// Source edges: reconnect source allocation
if (edgeData.edgeType === 'source') {
setNodes((nds) => nds.map((node) => {
if (node.id !== oldEdge.source || node.type !== 'source') return node
const data = node.data as SourceNodeData
return {
...node,
data: {
...data,
targetAllocations: data.targetAllocations.map(a =>
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
),
},
}
}))
return
}
// Stream edges: reconnect stream allocation
if (oldEdge.type === 'stream') {
setNodes((nds) => nds.map((node) => {
@ -438,6 +585,28 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
if (change.type === 'remove') {
const edge = edgesRef.current.find(e => e.id === change.id)
if (edge?.data) {
const allocData = edge.data as AllocationEdgeData
// Source edge removal
if (allocData.edgeType === 'source') {
setNodes((nds) => nds.map((node) => {
if (node.id !== edge.source || node.type !== 'source') return node
const data = node.data as SourceNodeData
const filtered = data.targetAllocations.filter(a => a.targetId !== edge.target)
const total = filtered.reduce((s, a) => s + a.percentage, 0)
return {
...node,
data: {
...data,
targetAllocations: total > 0
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
: filtered,
},
}
}))
return
}
setNodes((nds) => nds.map((node) => {
if (node.id !== edge.source || node.type !== 'funnel') return node
const data = node.data as FunnelNodeData
@ -453,7 +622,6 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
}
}
const allocData = edge.data as AllocationEdgeData
if (allocData.edgeType === 'overflow') {
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
const total = filtered.reduce((s, a) => s + a.percentage, 0)
@ -539,6 +707,26 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
])
}, [setNodes, screenToFlowPosition])
// Add source node at viewport center
const addSource = useCallback(() => {
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 - 100 })
const newId = `source-${Date.now()}`
setNodes((nds) => [
...nds,
{
id: newId,
type: 'source',
position: pos,
data: {
label: 'New Source',
sourceType: 'unconfigured',
flowRate: 0,
targetAllocations: [],
} as SourceNodeData,
},
])
}, [setNodes, screenToFlowPosition])
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
useEffect(() => {
if (!isSimulating) return
@ -603,6 +791,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
>
Link Data
</button>
<button
onClick={addSource}
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-all"
>
+ Source
</button>
<button
onClick={addFunnel}
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
@ -633,6 +827,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500 border border-emerald-700" />
<span className="text-slate-600">Sources</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-500" />
<span className="text-slate-600">Inflows (top)</span>

View File

@ -1,21 +1,52 @@
'use client'
import { memo, useState, useCallback, useMemo } from 'react'
import { Handle, Position } from '@xyflow/react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react'
import type { OutcomeNodeData } from '@/lib/types'
import type { OutcomeNodeData, OutcomePhase, PhaseTask } from '@/lib/types'
import { useConnectionState } from '../ConnectionContext'
const PHASE_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4']
function OutcomeNode({ data, selected, id }: NodeProps) {
const nodeData = data as OutcomeNodeData
const { label, description, fundingReceived, fundingTarget, status } = nodeData
const { label, description, fundingReceived, fundingTarget, status, phases } = nodeData
const [isExpanded, setIsExpanded] = useState(false)
const [expandedPhaseIdx, setExpandedPhaseIdx] = useState<number | null>(null)
const [isEditingPhases, setIsEditingPhases] = useState(false)
const connectingFrom = useConnectionState()
const { setNodes } = useReactFlow()
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
const isFunded = fundingReceived >= fundingTarget
const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget
// Phase computations
const sortedPhases = useMemo(() =>
phases ? [...phases].sort((a, b) => a.fundingThreshold - b.fundingThreshold) : [],
[phases]
)
const currentPhaseIdx = useMemo(() => {
if (sortedPhases.length === 0) return -1
let idx = -1
for (let i = 0; i < sortedPhases.length; i++) {
if (fundingReceived >= sortedPhases[i].fundingThreshold) idx = i
}
// If all unlocked, return last; otherwise return last unlocked
return idx
}, [sortedPhases, fundingReceived])
// Auto-expand active phase in modal
const autoExpandIdx = useMemo(() => {
if (sortedPhases.length === 0) return null
// Find first unlocked phase with incomplete tasks
for (let i = 0; i <= currentPhaseIdx; i++) {
if (sortedPhases[i].tasks.some(t => !t.completed)) return i
}
// If all unlocked tasks done, show last unlocked
return currentPhaseIdx >= 0 ? currentPhaseIdx : 0
}, [sortedPhases, currentPhaseIdx])
// Handle highlighting: outcome targets glow when dragging from spending or stream handles
const isTargetHighlighted = useMemo(() => {
if (!connectingFrom || connectingFrom.nodeId === id) return false
@ -128,6 +159,37 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
</div>
</div>
{/* Phase Indicator (compact) */}
{sortedPhases.length > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Phases</span>
<span className="text-[9px] text-slate-500 font-medium">
{currentPhaseIdx >= 0 ? `Phase ${currentPhaseIdx + 1}` : 'Locked'} of {sortedPhases.length}
</span>
</div>
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden flex gap-px">
{sortedPhases.map((phase, i) => {
const isUnlocked = fundingReceived >= phase.fundingThreshold
const allDone = phase.tasks.every(t => t.completed)
return (
<div
key={i}
className="h-full transition-all duration-300 rounded-full"
style={{
flex: 1,
backgroundColor: isUnlocked
? (allDone ? '#10b981' : PHASE_COLORS[i % PHASE_COLORS.length])
: '#e2e8f0',
}}
title={`${phase.label}: $${phase.fundingThreshold.toLocaleString()} ${isUnlocked ? '(unlocked)' : '(locked)'}`}
/>
)
})}
</div>
</div>
)}
<div className="text-center">
<span className="text-[8px] text-slate-400">Double-click for details</span>
</div>
@ -245,8 +307,205 @@ function OutcomeNode({ data, selected, id }: NodeProps) {
</div>
)}
{/* Milestones */}
{nodeData.milestones && nodeData.milestones.length > 0 && (
{/* Phase Accordion */}
{sortedPhases.length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Phases</span>
<button
onClick={() => setIsEditingPhases(!isEditingPhases)}
className="text-[10px] text-blue-500 hover:text-blue-700 font-medium"
>
{isEditingPhases ? 'Done Editing' : 'Edit Phases'}
</button>
</div>
{/* Phase tier segmented bar */}
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex gap-px mb-3">
{sortedPhases.map((phase, i) => {
const isUnlocked = fundingReceived >= phase.fundingThreshold
const allDone = phase.tasks.every(t => t.completed)
return (
<div
key={i}
className="h-full transition-all duration-300 rounded-full"
style={{
flex: 1,
backgroundColor: isUnlocked
? (allDone ? '#10b981' : PHASE_COLORS[i % PHASE_COLORS.length])
: '#e2e8f0',
}}
/>
)
})}
</div>
<div className="space-y-2">
{sortedPhases.map((phase, phaseIdx) => {
const isUnlocked = fundingReceived >= phase.fundingThreshold
const allDone = phase.tasks.every(t => t.completed)
const completedCount = phase.tasks.filter(t => t.completed).length
const isOpen = expandedPhaseIdx === phaseIdx || (expandedPhaseIdx === null && autoExpandIdx === phaseIdx)
return (
<div
key={phaseIdx}
className={`rounded-xl border overflow-hidden transition-all ${
isUnlocked ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-50 opacity-70'
}`}
>
{/* Phase header */}
<button
className="w-full flex items-center gap-2 p-3 text-left"
onClick={() => setExpandedPhaseIdx(isOpen ? null : phaseIdx)}
>
{/* Status icon */}
{isUnlocked ? (
allDone ? (
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<div className="w-4 h-4 rounded-full flex-shrink-0" style={{ backgroundColor: PHASE_COLORS[phaseIdx % PHASE_COLORS.length] }} />
)
) : (
<svg className="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)}
<div className="flex-1 min-w-0">
<span className={`text-xs font-medium block truncate ${isUnlocked ? 'text-slate-800' : 'text-slate-500'}`}>
{phase.label}
</span>
<span className="text-[9px] text-slate-400">
${phase.fundingThreshold.toLocaleString()} threshold
{isUnlocked && ` · ${completedCount}/${phase.tasks.length} tasks`}
</span>
</div>
{/* Chevron */}
<svg className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Phase content */}
{isOpen && (
<div className="px-3 pb-3 border-t border-slate-100">
{/* Funding progress for this phase */}
<div className="flex items-center gap-2 py-2">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (fundingReceived / phase.fundingThreshold) * 100)}%`,
backgroundColor: isUnlocked ? '#10b981' : PHASE_COLORS[phaseIdx % PHASE_COLORS.length],
}}
/>
</div>
<span className="text-[9px] font-mono text-slate-500">
${Math.floor(Math.min(fundingReceived, phase.fundingThreshold)).toLocaleString()} / ${phase.fundingThreshold.toLocaleString()}
</span>
</div>
{/* Tasks */}
<div className="space-y-1">
{phase.tasks.map((task, taskIdx) => (
<label
key={taskIdx}
className={`flex items-center gap-2 text-xs cursor-pointer group ${
!isUnlocked ? 'pointer-events-none' : ''
}`}
>
<input
type="checkbox"
checked={task.completed}
disabled={!isUnlocked}
onChange={() => {
setNodes((nds) => nds.map((node) => {
if (node.id !== id) return node
const d = node.data as OutcomeNodeData
const updatedPhases = [...(d.phases || [])]
const pIdx = updatedPhases.findIndex(p => p.label === phase.label && p.fundingThreshold === phase.fundingThreshold)
if (pIdx === -1) return node
const updatedTasks = [...updatedPhases[pIdx].tasks]
updatedTasks[taskIdx] = { ...updatedTasks[taskIdx], completed: !updatedTasks[taskIdx].completed }
updatedPhases[pIdx] = { ...updatedPhases[pIdx], tasks: updatedTasks }
return { ...node, data: { ...d, phases: updatedPhases } }
}))
}}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
/>
<span className={task.completed ? 'text-slate-500 line-through' : 'text-slate-700'}>
{task.label}
</span>
</label>
))}
</div>
{/* Edit mode: add task */}
{isEditingPhases && isUnlocked && (
<div className="mt-2">
<button
onClick={() => {
const taskLabel = prompt('New task label:')
if (!taskLabel) return
setNodes((nds) => nds.map((node) => {
if (node.id !== id) return node
const d = node.data as OutcomeNodeData
const updatedPhases = [...(d.phases || [])]
const pIdx = updatedPhases.findIndex(p => p.label === phase.label && p.fundingThreshold === phase.fundingThreshold)
if (pIdx === -1) return node
updatedPhases[pIdx] = {
...updatedPhases[pIdx],
tasks: [...updatedPhases[pIdx].tasks, { label: taskLabel, completed: false }],
}
return { ...node, data: { ...d, phases: updatedPhases } }
}))
}}
className="text-[10px] text-blue-500 hover:text-blue-700 font-medium"
>
+ Add Task
</button>
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Add Phase button (edit mode) */}
{isEditingPhases && (
<button
onClick={() => {
const phaseLabel = prompt('Phase label:', `Phase ${sortedPhases.length + 1}`)
if (!phaseLabel) return
const threshold = prompt('Funding threshold ($):', String((sortedPhases.at(-1)?.fundingThreshold ?? 0) + 10000))
if (!threshold) return
setNodes((nds) => nds.map((node) => {
if (node.id !== id) return node
const d = node.data as OutcomeNodeData
return {
...node,
data: {
...d,
phases: [
...(d.phases || []),
{ label: phaseLabel, fundingThreshold: Number(threshold), tasks: [] },
],
},
}
}))
}}
className="w-full mt-2 px-3 py-2 border border-dashed border-slate-300 rounded-lg text-xs text-slate-500 hover:border-blue-400 hover:text-blue-600 transition-colors"
>
+ Add Phase
</button>
)}
</div>
)}
{/* Legacy Milestones (backward compat when no phases) */}
{(!phases || phases.length === 0) && nodeData.milestones && nodeData.milestones.length > 0 && (
<div className="mb-4 p-3 bg-emerald-50 rounded-xl">
<span className="text-[10px] text-emerald-600 uppercase tracking-wide block mb-2">Milestones</span>
<div className="space-y-1.5">

View File

@ -0,0 +1,392 @@
'use client'
import { memo, useState, useCallback, useMemo } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react'
import type { SourceNodeData } from '@/lib/types'
import { useAuthStore } from '@/lib/auth'
const CHAIN_OPTIONS = [
{ id: 1, name: 'Ethereum' },
{ id: 10, name: 'Optimism' },
{ id: 100, name: 'Gnosis' },
{ id: 137, name: 'Polygon' },
{ id: 8453, name: 'Base' },
]
const CURRENCIES = ['USD', 'EUR', 'GBP']
type SourceType = SourceNodeData['sourceType']
const SOURCE_TYPE_META: Record<SourceType, { label: string; color: string }> = {
card: { label: 'Credit Card', color: 'violet' },
safe_wallet: { label: 'Safe / Wallet', color: 'cyan' },
ridentity: { label: 'rIdentity', color: 'purple' },
unconfigured: { label: 'Unconfigured', color: 'slate' },
}
function SourceTypeIcon({ type, className = 'w-4 h-4' }: { type: SourceType; className?: string }) {
switch (type) {
case 'card':
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
)
case 'safe_wallet':
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
case 'ridentity':
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4" />
</svg>
)
default: // unconfigured
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
}
function SourceNode({ data, selected, id }: NodeProps) {
const nodeData = data as SourceNodeData
const { label, sourceType, flowRate, targetAllocations = [] } = nodeData
const [isEditing, setIsEditing] = useState(false)
const { setNodes } = useReactFlow()
const { isAuthenticated, did, login } = useAuthStore()
// Edit form state
const [editLabel, setEditLabel] = useState(label)
const [editSourceType, setEditSourceType] = useState<SourceType>(sourceType)
const [editFlowRate, setEditFlowRate] = useState(flowRate)
const [editCurrency, setEditCurrency] = useState(nodeData.transakConfig?.fiatCurrency || 'USD')
const [editDefaultAmount, setEditDefaultAmount] = useState(nodeData.transakConfig?.defaultAmount?.toString() || '')
const [editWalletAddress, setEditWalletAddress] = useState(nodeData.walletAddress || '')
const [editChainId, setEditChainId] = useState(nodeData.chainId || 1)
const [editSafeAddress, setEditSafeAddress] = useState(nodeData.safeAddress || '')
const meta = SOURCE_TYPE_META[sourceType]
const isUnconfigured = sourceType === 'unconfigured'
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
setEditLabel(label)
setEditSourceType(sourceType)
setEditFlowRate(flowRate)
setEditCurrency(nodeData.transakConfig?.fiatCurrency || 'USD')
setEditDefaultAmount(nodeData.transakConfig?.defaultAmount?.toString() || '')
setEditWalletAddress(nodeData.walletAddress || '')
setEditChainId(nodeData.chainId || 1)
setEditSafeAddress(nodeData.safeAddress || '')
setIsEditing(true)
}, [label, sourceType, flowRate, nodeData])
const handleSave = useCallback(() => {
setNodes((nds) => nds.map((node) => {
if (node.id !== id) return node
const updated: SourceNodeData = {
...(node.data as SourceNodeData),
label: editLabel || 'Source',
sourceType: editSourceType,
flowRate: editFlowRate,
}
if (editSourceType === 'card') {
updated.transakConfig = {
fiatCurrency: editCurrency,
defaultAmount: editDefaultAmount ? parseFloat(editDefaultAmount) : undefined,
}
}
if (editSourceType === 'safe_wallet') {
updated.walletAddress = editWalletAddress
updated.chainId = editChainId
updated.safeAddress = editSafeAddress
}
if (editSourceType === 'ridentity') {
updated.encryptIdUserId = did || undefined
}
return { ...node, data: updated }
}))
setIsEditing(false)
}, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes])
const handleClose = useCallback(() => {
setIsEditing(false)
}, [])
// Allocation bar segments
const allocTotal = useMemo(() =>
targetAllocations.reduce((s, a) => s + a.percentage, 0),
[targetAllocations]
)
return (
<>
<div
className={`
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
transition-all duration-200
${selected ? 'border-emerald-500 shadow-emerald-200' : isUnconfigured ? 'border-dashed border-slate-300' : 'border-emerald-300'}
`}
onDoubleClick={handleDoubleClick}
>
{/* Header */}
<div className={`px-3 py-2 border-b border-slate-100 rounded-t-md ${
isUnconfigured ? 'bg-slate-50' : 'bg-gradient-to-r from-emerald-50 to-teal-50'
}`}>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
isUnconfigured ? 'bg-slate-400' : 'bg-emerald-500'
}`}>
<SourceTypeIcon type={sourceType} className="w-4 h-4 text-white" />
</div>
<div className="min-w-0 flex-1">
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium bg-${meta.color}-100 text-${meta.color}-700`}>
{meta.label}
</span>
</div>
</div>
</div>
{/* Body */}
<div className="p-3 space-y-2">
{/* Flow rate */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Flow Rate</span>
<span className="text-xs font-mono font-medium text-emerald-700">
${flowRate.toLocaleString()}/mo
</span>
</div>
{/* Allocation bars */}
{targetAllocations.length > 0 && (
<div>
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Allocations</span>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex">
{targetAllocations.map((alloc, i) => (
<div
key={alloc.targetId}
className="h-full transition-all duration-300"
style={{
width: `${allocTotal > 0 ? (alloc.percentage / allocTotal) * 100 : 0}%`,
backgroundColor: alloc.color,
}}
title={`${alloc.percentage}% → ${alloc.targetId}`}
/>
))}
</div>
<div className="flex justify-between mt-0.5">
{targetAllocations.map((alloc) => (
<span key={alloc.targetId} className="text-[8px] font-mono" style={{ color: alloc.color }}>
{alloc.percentage}%
</span>
))}
</div>
</div>
)}
{/* Hint */}
{isUnconfigured && (
<div className="text-center">
<span className="text-[8px] text-slate-400">Double-click to configure</span>
</div>
)}
{!isUnconfigured && targetAllocations.length === 0 && (
<div className="text-center">
<span className="text-[8px] text-slate-400">Drag from handle below to connect to funnels</span>
</div>
)}
</div>
{/* Bottom handle - connects to funnels */}
<Handle
type="source"
position={Position.Bottom}
id="source-out"
className={`!w-4 !h-4 !border-2 !border-white !-bottom-2 transition-all ${
isUnconfigured ? '!bg-slate-400' : '!bg-emerald-500'
}`}
/>
</div>
{/* Edit Modal */}
{isEditing && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleClose}
>
<div
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[400px] max-w-lg max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center">
<SourceTypeIcon type={editSourceType} className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-bold text-slate-800">Configure Source</h3>
</div>
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Source Type Picker */}
<div className="mb-4">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Source Type</label>
<div className="grid grid-cols-3 gap-2">
{(['card', 'safe_wallet', 'ridentity'] as SourceType[]).map((type) => {
const typeMeta = SOURCE_TYPE_META[type]
const isSelected = editSourceType === type
return (
<button
key={type}
onClick={() => setEditSourceType(type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all ${
isSelected
? 'border-emerald-500 bg-emerald-50'
: 'border-slate-200 hover:border-slate-300 bg-white'
}`}
>
<SourceTypeIcon type={type} className="w-6 h-6 text-slate-700" />
<span className="text-xs font-medium text-slate-700">{typeMeta.label}</span>
</button>
)
})}
</div>
</div>
{/* Label */}
<div className="mb-3">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Label</label>
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800"
placeholder="Source name..."
/>
</div>
{/* Flow Rate */}
<div className="mb-4">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/month)</label>
<input
type="number"
value={editFlowRate}
onChange={(e) => setEditFlowRate(Number(e.target.value))}
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800 font-mono"
min="0"
step="100"
/>
</div>
{/* Type-specific config */}
{editSourceType === 'card' && (
<div className="p-3 bg-violet-50 rounded-xl mb-4">
<span className="text-[10px] text-violet-600 uppercase tracking-wide block mb-2">Card Settings (Transak)</span>
<label className="text-[10px] text-slate-500 block mb-1">Currency</label>
<select
value={editCurrency}
onChange={(e) => setEditCurrency(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
>
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<label className="text-[10px] text-slate-500 block mb-1">Default Amount (optional)</label>
<input
type="number"
value={editDefaultAmount}
onChange={(e) => setEditDefaultAmount(e.target.value)}
placeholder="100"
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
min="0"
/>
</div>
)}
{editSourceType === 'safe_wallet' && (
<div className="p-3 bg-cyan-50 rounded-xl mb-4">
<span className="text-[10px] text-cyan-600 uppercase tracking-wide block mb-2">Safe / Wallet Settings</span>
<label className="text-[10px] text-slate-500 block mb-1">Wallet Address</label>
<input
type="text"
value={editWalletAddress}
onChange={(e) => setEditWalletAddress(e.target.value)}
placeholder="0x..."
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono"
/>
<label className="text-[10px] text-slate-500 block mb-1">Safe Address (optional)</label>
<input
type="text"
value={editSafeAddress}
onChange={(e) => setEditSafeAddress(e.target.value)}
placeholder="0x..."
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono"
/>
<label className="text-[10px] text-slate-500 block mb-1">Chain</label>
<select
value={editChainId}
onChange={(e) => setEditChainId(Number(e.target.value))}
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
>
{CHAIN_OPTIONS.map((c) => <option key={c.id} value={c.id}>{c.name} ({c.id})</option>)}
</select>
</div>
)}
{editSourceType === 'ridentity' && (
<div className="p-3 bg-purple-50 rounded-xl mb-4">
<span className="text-[10px] text-purple-600 uppercase tracking-wide block mb-2">rIdentity (EncryptID)</span>
{isAuthenticated ? (
<div className="flex items-center gap-2 p-2 bg-white rounded-lg border border-purple-200">
<span className="w-2 h-2 rounded-full bg-purple-500" />
<span className="text-xs text-purple-700 font-medium">Connected</span>
{did && (
<span className="text-[9px] text-purple-500 font-mono truncate ml-auto">{did.slice(0, 20)}...</span>
)}
</div>
) : (
<button
onClick={async () => { try { await login() } catch {} }}
className="w-full text-xs px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-500 transition-colors font-medium"
>
Connect with EncryptID
</button>
)}
</div>
)}
{/* Save / Cancel */}
<div className="flex gap-2">
<button
onClick={handleSave}
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 transition-colors font-medium"
>
Save
</button>
<button
onClick={handleClose}
className="px-4 py-2 text-slate-500 hover:text-slate-700 transition-colors font-medium"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
)
}
export default memo(SourceNode)

View File

@ -1,11 +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[] = [
// Source node (top, above Treasury)
{
id: 'source-donations',
type: 'source',
position: { x: 650, y: -250 },
data: {
label: 'Donations',
sourceType: 'card',
flowRate: 1500,
targetAllocations: [
{ targetId: 'treasury', percentage: 100, color: SOURCE_COLORS[0] },
],
transakConfig: { fiatCurrency: 'USD', defaultAmount: 100 },
} as SourceNodeData,
},
// Main Treasury Funnel (top center)
{
id: 'treasury',
@ -94,6 +110,32 @@ export const demoNodes: FlowNode[] = [
fundingReceived: 22000,
fundingTarget: 30000,
status: 'in-progress',
phases: [
{
label: 'Phase 1: Research',
fundingThreshold: 5000,
tasks: [
{ label: 'Requirements analysis', completed: true },
{ label: 'Architecture design', completed: true },
],
},
{
label: 'Phase 2: Build',
fundingThreshold: 20000,
tasks: [
{ label: 'Core implementation', completed: false },
{ label: 'Testing suite', completed: false },
],
},
{
label: 'Phase 3: Deploy',
fundingThreshold: 30000,
tasks: [
{ label: 'Staging deployment', completed: false },
{ label: 'Production launch', completed: false },
],
},
],
} as OutcomeNodeData,
},
{

View File

@ -5,7 +5,7 @@
* inflow overflow distribution spending drain outcome accumulation
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
export interface SimulationConfig {
tickDivisor: number // inflowRate divided by this per tick
@ -32,6 +32,17 @@ export function simulateTick(
.filter((n) => n.type === 'funnel')
.sort((a, b) => a.position.y - b.position.y)
// Source nodes: sum allocations into funnel inflow
const sourceInflow = new Map<string, number>()
nodes.forEach((node) => {
if (node.type !== 'source') return
const data = node.data as SourceNodeData
for (const alloc of data.targetAllocations) {
const share = (data.flowRate / tickDivisor) * (alloc.percentage / 100)
sourceInflow.set(alloc.targetId, (sourceInflow.get(alloc.targetId) ?? 0) + share)
}
})
// Accumulators for inter-node transfers
const overflowIncoming = new Map<string, number>()
const spendingIncoming = new Map<string, number>()
@ -43,8 +54,8 @@ export function simulateTick(
const src = node.data as FunnelNodeData
const data: FunnelNodeData = { ...src }
// 1. Natural inflow
let value = data.currentValue + data.inflowRate / tickDivisor
// 1. Natural inflow + source node inflow
let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0)
// 2. Overflow received from upstream funnels
value += overflowIncoming.get(node.id) ?? 0
@ -111,10 +122,26 @@ export function simulateTick(
)
let newStatus = data.status
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== 'blocked') {
newStatus = 'completed'
} else if (newReceived > 0 && newStatus === 'not-started') {
newStatus = 'in-progress'
if (newStatus === 'blocked') {
// Don't auto-change blocked status
} else if (data.phases && data.phases.length > 0) {
// Phase-aware status
const anyUnlocked = data.phases.some(p => newReceived >= p.fundingThreshold)
const allUnlocked = data.phases.every(p => newReceived >= p.fundingThreshold)
const allTasksDone = data.phases.every(p => p.tasks.every(t => t.completed))
if (allUnlocked && allTasksDone) {
newStatus = 'completed'
} else if (anyUnlocked) {
newStatus = 'in-progress'
}
} else {
// Fallback: no phases defined
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget) {
newStatus = 'completed'
} else if (newReceived > 0 && newStatus === 'not-started') {
newStatus = 'in-progress'
}
}
return {

View File

@ -85,6 +85,43 @@ export interface FundingSource {
lastUsedAt?: number;
}
// ─── Source Node Types ──────────────────────────────────────
export interface SourceAllocation {
targetId: string
percentage: number
color: string
}
export interface SourceNodeData {
label: string
sourceType: 'card' | 'safe_wallet' | 'ridentity' | 'unconfigured'
flowRate: number
targetAllocations: SourceAllocation[]
// Card-specific
transakConfig?: { fiatCurrency: string; defaultAmount?: number }
// Safe/Wallet-specific
walletAddress?: string
chainId?: number
safeAddress?: string
// rIdentity-specific
encryptIdUserId?: string
[key: string]: unknown
}
// ─── Outcome Phase Types ────────────────────────────────────
export interface PhaseTask {
label: string
completed: boolean
}
export interface OutcomePhase {
label: string // e.g., "Phase 1: Research"
fundingThreshold: number // funding level to unlock this phase
tasks: PhaseTask[]
}
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@ -126,6 +163,7 @@ export interface OutcomeNodeData {
fundingReceived: number
fundingTarget: number
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
phases?: OutcomePhase[] // ordered by fundingThreshold ascending
// Integration metadata
source?: IntegrationSource
// Optional detail fields
@ -134,16 +172,16 @@ export interface OutcomeNodeData {
[key: string]: unknown
}
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData | SourceNodeData>
export interface AllocationEdgeData {
allocation: number // percentage 0-100
color: string
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
edgeType: 'overflow' | 'spending' | 'source' // overflow = sideways, spending = downward, source = top-down from source
sourceId: string
targetId: string
siblingCount: number // how many allocations in this group
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending', delta: number) => void
onAdjust?: (sourceId: string, targetId: string, edgeType: 'overflow' | 'spending' | 'source', delta: number) => void
[key: string]: unknown
}