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:
parent
cb2dd9c51f
commit
bd77cd9113
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
44
lib/types.ts
44
lib/types.ts
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue