From cb2dd9c51f7225711c720e765b708b9427c21721 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 27 Feb 2026 12:52:29 -0800 Subject: [PATCH] feat: iterate TBFF canvas with infinite zoom, trackpad pan, flow indicators, and expandable outcomes Add infinite scrollable canvas (minZoom 0.005), two-finger trackpad panning, dynamic connection handle highlighting via ConnectionContext, funnel KPI indicators (runway months, output count, overflow status), on-canvas funding source badges, rIdentity/EncryptID funding source type, and double-click expandable outcome nodes with full detail modal. Co-Authored-By: Claude Opus 4.6 --- components/ConnectionContext.tsx | 15 ++ components/FlowCanvas.tsx | 24 +++ components/FundingSourcesPanel.tsx | 36 ++++ components/nodes/FunnelNode.tsx | 102 ++++++++- components/nodes/OutcomeNode.tsx | 331 ++++++++++++++++++++++------- lib/types.ts | 7 +- 6 files changed, 440 insertions(+), 75 deletions(-) create mode 100644 components/ConnectionContext.tsx diff --git a/components/ConnectionContext.tsx b/components/ConnectionContext.tsx new file mode 100644 index 0000000..2199ff5 --- /dev/null +++ b/components/ConnectionContext.tsx @@ -0,0 +1,15 @@ +'use client' + +import { createContext, useContext } from 'react' + +export interface ConnectingFrom { + nodeId: string + handleId: string + handleType: string +} + +export const ConnectionContext = createContext(null) + +export function useConnectionState() { + return useContext(ConnectionContext) +} diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 128addc..1d0bbf8 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -21,6 +21,7 @@ import OutcomeNode from './nodes/OutcomeNode' 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 { simulateTick } from '@/lib/simulation' @@ -191,6 +192,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[]) const [isSimulating, setIsSimulating] = useState(mode === 'demo') + const [connectingFrom, setConnectingFrom] = useState(null) const edgesRef = useRef(edges) edgesRef.current = edges const { screenToFlowPosition } = useReactFlow() @@ -417,6 +419,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra [setNodes] ) + const onConnectStart = useCallback( + (_: unknown, params: { nodeId: string | null; handleId: string | null; handleType: string | null }) => { + if (params.nodeId && params.handleId && params.handleType) { + setConnectingFrom({ nodeId: params.nodeId, handleId: params.handleId, handleType: params.handleType }) + } + }, + [] + ) + + const onConnectEnd = useCallback(() => { + setConnectingFrom(null) + }, []) + const handleEdgesChange = useCallback( (changes: Parameters[0]) => { changes.forEach((change) => { @@ -537,6 +552,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra return (
+ connection.source !== connection.target} @@ -632,6 +655,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
+ {mode === 'space' && ( ) + case 'ridentity': + return ( + + + + ) } } @@ -72,6 +80,7 @@ export default function FundingSourcesPanel({ const [configType, setConfigType] = useState(null) const [transakUrl, setTransakUrl] = useState(null) const [fundingError, setFundingError] = useState(null) + const { isAuthenticated, did, login } = useAuthStore() // Config form state const [configLabel, setConfigLabel] = useState('') @@ -124,6 +133,10 @@ export default function FundingSourcesPanel({ newSource.safeChainId = configSafeChainId } + if (configType === 'ridentity') { + newSource.encryptIdUserId = did || undefined + } + onSourcesChange([...sources, newSource]) resetConfigForm() }, [configType, configLabel, configCurrency, configDefaultAmount, configSafeAddress, configSafeChainId, funnelWalletAddress, sources, onSourcesChange, resetConfigForm]) @@ -319,6 +332,29 @@ export default function FundingSourcesPanel({ )} + {/* rIdentity config */} + {configType === 'ridentity' && ( + <> + + {isAuthenticated ? ( +
+ + Connected + {did && ( + {did.slice(0, 20)}... + )} +
+ ) : ( + + )} + + )} + {/* Safe Treasury config */} {configType === 'safe_treasury' && ( <> diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx index 5e9daf5..36262c9 100644 --- a/components/nodes/FunnelNode.tsx +++ b/components/nodes/FunnelNode.tsx @@ -1,15 +1,49 @@ 'use client' -import { memo, useState, useCallback, useRef, useEffect } from 'react' +import { memo, useState, useCallback, useRef, useEffect, useMemo } from 'react' import { Handle, Position, useReactFlow } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' -import type { FunnelNodeData, OutcomeNodeData, FundingSource } from '@/lib/types' +import type { FunnelNodeData, OutcomeNodeData, FundingSource, FundingSourceType } from '@/lib/types' +import { useConnectionState } from '../ConnectionContext' import SplitsView from '../SplitsView' import FundingSourcesPanel from '../FundingSourcesPanel' +import { DEFAULT_CONFIG } from '@/lib/simulation' const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] +const SOURCE_BADGE_CONFIG: Record = { + card: { icon: 'card', label: 'Card', bgClass: 'bg-violet-100', textClass: 'text-violet-700' }, + crypto_wallet: { icon: 'wallet', label: 'Wallet', bgClass: 'bg-cyan-100', textClass: 'text-cyan-700' }, + safe_treasury: { icon: 'safe', label: 'Safe', bgClass: 'bg-emerald-100', textClass: 'text-emerald-700' }, + bank_transfer: { icon: 'bank', label: 'Bank', bgClass: 'bg-blue-100', textClass: 'text-blue-700' }, + ridentity: { icon: 'rid', label: 'rID', bgClass: 'bg-purple-100', textClass: 'text-purple-700' }, +} + +function SourceBadge({ source }: { source: FundingSource }) { + const config = SOURCE_BADGE_CONFIG[source.type] + return ( + + {source.type === 'card' && ( + + )} + {source.type === 'crypto_wallet' && ( + + )} + {source.type === 'safe_treasury' && ( + + )} + {source.type === 'bank_transfer' && ( + + )} + {source.type === 'ridentity' && ( + + )} + {config.label} + + ) +} + function FunnelNode({ data, selected, id }: NodeProps) { const nodeData = data as FunnelNodeData const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData @@ -30,10 +64,41 @@ function FunnelNode({ data, selected, id }: NodeProps) { const overflowPieRef = useRef(null) const spendingPieRef = useRef(null) + const connectingFrom = useConnectionState() + const isOverflowing = currentValue > maxThreshold const isCritical = currentValue < minThreshold const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100) + // KPI calculations + const kpis = useMemo(() => { + const rate = nodeData.inflowRate || 0 + const spendingRate = rate * DEFAULT_CONFIG.spendingRateHealthy / DEFAULT_CONFIG.tickDivisor + const monthlyDrain = spendingRate * 30 // approximate ticks per month + const runwayMonths = monthlyDrain > 0 && spendingAllocations.length > 0 + ? currentValue / monthlyDrain + : Infinity + const outputsFunded = spendingAllocations.length + const overflowActive = currentValue > nodeData.maxThreshold && overflowAllocations.length > 0 + return { runwayMonths, outputsFunded, overflowActive } + }, [currentValue, nodeData.inflowRate, nodeData.maxThreshold, spendingAllocations.length, overflowAllocations.length]) + + // Handle highlighting logic + const isTargetHighlighted = useMemo(() => { + if (!connectingFrom || connectingFrom.nodeId === id) return false + const handleId = connectingFrom.handleId + // Overflow handles target funnel nodes (this is a funnel, so highlight) + if (handleId.startsWith('outflow')) return true + // Stream handle targets both + if (handleId === 'stream-out') return true + return false + }, [connectingFrom, id]) + + // Enabled funding sources for badges + const enabledSources = useMemo(() => { + return (nodeData.fundingSources || []).filter(s => s.enabled) + }, [nodeData.fundingSources]) + const width = 160 const height = 140 @@ -343,7 +408,9 @@ function FunnelNode({ data, selected, id }: NodeProps) {
@@ -553,6 +620,35 @@ function FunnelNode({ data, selected, id }: NodeProps) {
+ {/* Funding Source Badges */} + {enabledSources.length > 0 && ( +
+ {enabledSources.map((source) => ( + + ))} +
+ )} + + {/* KPI Indicators */} +
+ 6 ? 'bg-emerald-100 text-emerald-700' + : kpis.runwayMonths > 2 ? 'bg-amber-100 text-amber-700' + : 'bg-red-100 text-red-700' + }`}> + {kpis.runwayMonths === Infinity ? '\u221E' : `~${Math.round(kpis.runwayMonths)}mo`} + + + {kpis.outputsFunded} out + + {kpis.overflowActive && ( + + overflow + + )} +
+
{hasOverflow && (
diff --git a/components/nodes/OutcomeNode.tsx b/components/nodes/OutcomeNode.tsx index ace5cd6..e1a8a37 100644 --- a/components/nodes/OutcomeNode.tsx +++ b/components/nodes/OutcomeNode.tsx @@ -1,18 +1,30 @@ 'use client' -import { memo } from 'react' +import { memo, useState, useCallback, useMemo } from 'react' import { Handle, Position } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' import type { OutcomeNodeData } from '@/lib/types' +import { useConnectionState } from '../ConnectionContext' -function OutcomeNode({ data, selected }: NodeProps) { +function OutcomeNode({ data, selected, id }: NodeProps) { const nodeData = data as OutcomeNodeData const { label, description, fundingReceived, fundingTarget, status } = nodeData + const [isExpanded, setIsExpanded] = useState(false) + const connectingFrom = useConnectionState() const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0 const isFunded = fundingReceived >= fundingTarget const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget + // Handle highlighting: outcome targets glow when dragging from spending or stream handles + const isTargetHighlighted = useMemo(() => { + if (!connectingFrom || connectingFrom.nodeId === id) return false + const handleId = connectingFrom.handleId + if (handleId === 'spending-out') return true + if (handleId === 'stream-out') return true + return false + }, [connectingFrom, id]) + const statusColors = { 'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' }, 'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' }, @@ -22,88 +34,265 @@ function OutcomeNode({ data, selected }: NodeProps) { const colors = statusColors[status] || statusColors['not-started'] + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setIsExpanded(true) + }, []) + + const handleCloseExpanded = useCallback(() => { + setIsExpanded(false) + }, []) + return ( -
- + <> +
+ -
-
-
- - - -
-
- {label} - {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? ( - e.stopPropagation()} - > - rVote • score +{nodeData.source.rvoteProposalScore ?? 0} - - - - - ) : nodeData.source?.type === 'rvote' ? ( - - rVote • score +{nodeData.source.rvoteProposalScore ?? 0} - - ) : null} +
+
+
+ + + +
+
+ {label} + {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? ( + e.stopPropagation()} + > + rVote • score +{nodeData.source.rvoteProposalScore ?? 0} + + + + + ) : nodeData.source?.type === 'rvote' ? ( + + rVote • score +{nodeData.source.rvoteProposalScore ?? 0} + + ) : null} +
-
-
- {description && ( -

{description}

- )} - -
- - {status.replace('-', ' ')} - - {isFunded && ( - - - +
+ {description && ( +

{description}

)} -
-
-
- Funding - - ${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()} +
+ + {status.replace('-', ' ')} + {isFunded && ( + + + + )}
-
-
+ +
+
+ Funding + + ${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()} + +
+
+
+
+
+ {progress.toFixed(0)}% +
-
- {progress.toFixed(0)}% + +
+ Double-click for details
-
+ + {/* Expanded Detail Modal */} + {isExpanded && ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ + + +
+
+

{label}

+
+ + {status.replace('-', ' ')} + + {nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && ( + + rVote + + + + + )} +
+
+
+ +
+ + {/* Description */} + {description && ( +
+ Description +

{description}

+
+ )} + + {/* Funding Progress */} +
+ Funding Progress +
+ + ${Math.floor(fundingReceived).toLocaleString()} + + + / ${fundingTarget.toLocaleString()} + +
+
+
+
+
+ {progress.toFixed(1)}% +
+
+ + {/* rVote Details */} + {nodeData.source?.type === 'rvote' && ( +
+ rVote Details +
+
+ Score + +{nodeData.source.rvoteProposalScore ?? 0} +
+ {nodeData.source.rvoteProposalStatus && ( +
+ Status + {nodeData.source.rvoteProposalStatus} +
+ )} + {nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && ( + + View on rVote + + )} +
+
+ )} + + {/* Milestones */} + {nodeData.milestones && nodeData.milestones.length > 0 && ( +
+ Milestones +
+ {nodeData.milestones.map((milestone, i) => ( +
+ {milestone.completedAt ? ( + + + + ) : ( +
+ )} + + {milestone.label} + +
+ ))} +
+
+ )} + + {/* Completion Info */} + {nodeData.completedAt && ( +
+ + + + + Completed {new Date(nodeData.completedAt).toLocaleDateString()} + +
+ )} + + {/* Close Button */} +
+ +
+
+
+ )} + ) } diff --git a/lib/types.ts b/lib/types.ts index 5d8da53..268d826 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -60,7 +60,7 @@ export interface IntegrationConfig { // ─── Funding Sources ──────────────────────────────────────── -export type FundingSourceType = 'card' | 'crypto_wallet' | 'safe_treasury' | 'bank_transfer'; +export type FundingSourceType = 'card' | 'crypto_wallet' | 'safe_treasury' | 'bank_transfer' | 'ridentity'; export interface FundingSource { id: string; @@ -79,6 +79,8 @@ export interface FundingSource { // Safe treasury-specific safeAddress?: string; safeChainId?: number; + // rIdentity-specific + encryptIdUserId?: string; // Common lastUsedAt?: number; } @@ -126,6 +128,9 @@ export interface OutcomeNodeData { status: 'not-started' | 'in-progress' | 'completed' | 'blocked' // Integration metadata source?: IntegrationSource + // Optional detail fields + completedAt?: number + milestones?: Array<{ label: string; completedAt?: number }> [key: string]: unknown }