merge: dev into main — TBFF canvas iteration with infinite zoom, trackpad pan, flow indicators

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 12:54:04 -08:00
commit 016728bef0
6 changed files with 441 additions and 75 deletions

View File

@ -0,0 +1,15 @@
'use client'
import { createContext, useContext } from 'react'
export interface ConnectingFrom {
nodeId: string
handleId: string
handleType: string
}
export const ConnectionContext = createContext<ConnectingFrom | null>(null)
export function useConnectionState() {
return useContext(ConnectionContext)
}

View File

@ -22,8 +22,10 @@ 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, 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,
@ -235,6 +237,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
const [panelsCollapsed, setPanelsCollapsed] = useState(false)
const [connectingFrom, setConnectingFrom] = useState<ConnectingFrom | null>(null)
const edgesRef = useRef(edges)
edgesRef.current = edges
const { screenToFlowPosition } = useReactFlow()
@ -519,6 +522,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<typeof onEdgesChange>[0]) => {
changes.forEach((change) => {
@ -772,6 +788,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
return (
<div className="w-full h-full">
<ConnectionContext.Provider value={connectingFrom}>
<ReactFlow
nodes={nodes}
edges={edges}
@ -780,12 +797,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
onNodesDelete={onNodesDelete}
onConnect={onConnect}
onReconnect={onReconnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
edgesReconnectable={true}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.005}
maxZoom={4}
panOnScroll={true}
zoomOnPinch={true}
zoomActivationKeyCode="Meta"
className="bg-slate-50"
connectionLineStyle={{ stroke: '#94a3b8', strokeWidth: 3 }}
isValidConnection={(connection) => connection.source !== connection.target}
@ -918,6 +942,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
</div>
</Panel>
</ReactFlow>
</ConnectionContext.Provider>
{mode === 'space' && (
<IntegrationPanel
isOpen={showIntegrations}

View File

@ -3,6 +3,7 @@
import { useState, useCallback } from 'react'
import type { FundingSource, FundingSourceType } from '@/lib/types'
import { initiateOnRamp } from '@/lib/api/flows-client'
import { useAuthStore } from '@/lib/auth'
import TransakWidget from './TransakWidget'
interface FundingSourcesPanelProps {
@ -19,6 +20,7 @@ const SOURCE_TYPE_META: Record<FundingSourceType, { icon: string; label: string;
crypto_wallet: { icon: 'wallet', label: 'Crypto Wallet', color: 'cyan' },
safe_treasury: { icon: 'safe', label: 'Safe Treasury', color: 'emerald' },
bank_transfer: { icon: 'bank', label: 'Bank Transfer', color: 'blue' },
ridentity: { icon: 'fingerprint', label: 'rIdentity', color: 'purple' },
}
const CURRENCIES = ['USD', 'EUR', 'GBP']
@ -57,6 +59,12 @@ function SourceIcon({ type }: { type: FundingSourceType }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
)
case 'ridentity':
return (
<svg className="w-4 h-4" 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>
)
}
}
@ -72,6 +80,7 @@ export default function FundingSourcesPanel({
const [configType, setConfigType] = useState<FundingSourceType | null>(null)
const [transakUrl, setTransakUrl] = useState<string | null>(null)
const [fundingError, setFundingError] = useState<string | null>(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' && (
<>
<label className="text-[10px] text-slate-500 block mb-1">EncryptID Connection</label>
{isAuthenticated ? (
<div className="flex items-center gap-2 p-2 bg-purple-50 rounded-lg border border-purple-200 mb-2">
<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 mb-2"
>
Connect with EncryptID
</button>
)}
</>
)}
{/* Safe Treasury config */}
{configType === 'safe_treasury' && (
<>

View File

@ -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<FundingSourceType, { icon: string; label: string; bgClass: string; textClass: string }> = {
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 (
<span className={`inline-flex items-center gap-0.5 text-[8px] px-1 py-0.5 rounded-full ${config.bgClass} ${config.textClass}`}>
{source.type === 'card' && (
<svg className="w-2.5 h-2.5" 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>
)}
{source.type === 'crypto_wallet' && (
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" /></svg>
)}
{source.type === 'safe_treasury' && (
<svg className="w-2.5 h-2.5" 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>
)}
{source.type === 'bank_transfer' && (
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
)}
{source.type === 'ridentity' && (
<svg className="w-2.5 h-2.5" 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>
)}
{config.label}
</span>
)
}
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<SVGSVGElement>(null)
const spendingPieRef = useRef<SVGSVGElement>(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) {
<Handle
type="target"
position={Position.Top}
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-top-2"
className={`!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-top-2 transition-all ${
isTargetHighlighted ? '!animate-pulse !ring-2 !ring-amber-400 !scale-125' : ''
}`}
/>
<div className="px-3 py-2 border-b border-slate-100">
@ -553,6 +620,35 @@ function FunnelNode({ data, selected, id }: NodeProps) {
</span>
</div>
{/* Funding Source Badges */}
{enabledSources.length > 0 && (
<div className="flex items-center justify-center gap-1 mt-1.5 flex-wrap">
{enabledSources.map((source) => (
<SourceBadge key={source.id} source={source} />
))}
</div>
)}
{/* KPI Indicators */}
<div className="flex items-center justify-center gap-1 mt-1.5 flex-wrap">
<span className={`text-[9px] px-1.5 py-0.5 rounded-full font-mono ${
kpis.runwayMonths === Infinity ? 'bg-slate-100 text-slate-500'
: kpis.runwayMonths > 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`}
</span>
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 font-mono">
{kpis.outputsFunded} out
</span>
{kpis.overflowActive && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 font-mono">
overflow
</span>
)}
</div>
<div className="mt-3 space-y-2">
{hasOverflow && (
<div className="flex items-center gap-2">

View File

@ -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 (
<div
className={`
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
transition-all duration-200
${selected ? 'border-pink-500 shadow-pink-200' : 'border-slate-200'}
`}
>
<Handle
type="target"
position={Position.Top}
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2"
/>
<>
<div
className={`
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
transition-all duration-200
${selected ? 'border-pink-500 shadow-pink-200' : 'border-slate-200'}
`}
onDoubleClick={handleDoubleClick}
>
<Handle
type="target"
position={Position.Top}
className={`!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2 transition-all ${
isTargetHighlighted ? '!animate-pulse !ring-2 !ring-blue-400 !scale-125' : ''
}`}
/>
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-md">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-pink-500 rounded flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="min-w-0">
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? (
<a
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
target="_blank"
rel="noopener noreferrer"
className="text-[9px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5 transition-colors"
onClick={(e) => e.stopPropagation()}
>
rVote &bull; score +{nodeData.source.rvoteProposalScore ?? 0}
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : nodeData.source?.type === 'rvote' ? (
<span className="text-[9px] text-violet-500 font-medium">
rVote &bull; score +{nodeData.source.rvoteProposalScore ?? 0}
</span>
) : null}
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-md">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-pink-500 rounded flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="min-w-0">
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId ? (
<a
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
target="_blank"
rel="noopener noreferrer"
className="text-[9px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5 transition-colors"
onClick={(e) => e.stopPropagation()}
>
rVote &bull; score +{nodeData.source.rvoteProposalScore ?? 0}
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : nodeData.source?.type === 'rvote' ? (
<span className="text-[9px] text-violet-500 font-medium">
rVote &bull; score +{nodeData.source.rvoteProposalScore ?? 0}
</span>
) : null}
</div>
</div>
</div>
</div>
<div className="p-3 space-y-2">
{description && (
<p className="text-xs text-slate-500 line-clamp-2">{description}</p>
)}
<div className="flex items-center justify-between">
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
{status.replace('-', ' ')}
</span>
{isFunded && (
<svg className="w-4 h-4 text-emerald-500" 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="p-3 space-y-2">
{description && (
<p className="text-xs text-slate-500 line-clamp-2">{description}</p>
)}
</div>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Funding</span>
<span className="text-xs font-mono text-slate-700">
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
<div className="flex items-center justify-between">
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
{status.replace('-', ' ')}
</span>
{isFunded && (
<svg className="w-4 h-4 text-emerald-500" 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>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
}`}
style={{ width: `${progress}%` }}
/>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Funding</span>
<span className="text-xs font-mono text-slate-700">
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
}`}
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-right mt-0.5">
<span className="text-[10px] font-medium text-slate-500">{progress.toFixed(0)}%</span>
</div>
</div>
<div className="text-right mt-0.5">
<span className="text-[10px] font-medium text-slate-500">{progress.toFixed(0)}%</span>
<div className="text-center">
<span className="text-[8px] text-slate-400">Double-click for details</span>
</div>
</div>
</div>
</div>
{/* Expanded Detail Modal */}
{isExpanded && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleCloseExpanded}
>
<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-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
<div className="flex items-center gap-2">
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
{status.replace('-', ' ')}
</span>
{nodeData.source?.type === 'rvote' && nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && (
<a
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-violet-500 font-medium hover:text-violet-700 hover:underline inline-flex items-center gap-0.5"
>
rVote
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</div>
</div>
<button
onClick={handleCloseExpanded}
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>
{/* Description */}
{description && (
<div className="mb-4 p-3 bg-slate-50 rounded-xl">
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Description</span>
<p className="text-sm text-slate-700">{description}</p>
</div>
)}
{/* Funding Progress */}
<div className="mb-4 p-3 bg-gradient-to-r from-pink-50 to-purple-50 rounded-xl">
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Funding Progress</span>
<div className="flex justify-between items-center mb-2">
<span className="text-2xl font-bold font-mono text-slate-800">
${Math.floor(fundingReceived).toLocaleString()}
</span>
<span className="text-sm text-slate-500">
/ ${fundingTarget.toLocaleString()}
</span>
</div>
<div className="h-3 bg-white/60 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 rounded-full ${
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
}`}
style={{ width: `${progress}%` }}
/>
</div>
<div className="text-right mt-1">
<span className="text-xs font-medium text-slate-600">{progress.toFixed(1)}%</span>
</div>
</div>
{/* rVote Details */}
{nodeData.source?.type === 'rvote' && (
<div className="mb-4 p-3 bg-violet-50 rounded-xl">
<span className="text-[10px] text-violet-600 uppercase tracking-wide block mb-2">rVote Details</span>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Score</span>
<span className="font-mono text-violet-700 font-medium">+{nodeData.source.rvoteProposalScore ?? 0}</span>
</div>
{nodeData.source.rvoteProposalStatus && (
<div className="flex justify-between">
<span className="text-slate-500">Status</span>
<span className="text-violet-700 font-medium capitalize">{nodeData.source.rvoteProposalStatus}</span>
</div>
)}
{nodeData.source.rvoteSpaceSlug && nodeData.source.rvoteProposalId && (
<a
href={`https://rvote.online/s/${nodeData.source.rvoteSpaceSlug}/proposal/${nodeData.source.rvoteProposalId}`}
target="_blank"
rel="noopener noreferrer"
className="block text-center mt-2 px-3 py-1.5 bg-violet-600 text-white rounded-lg text-xs font-medium hover:bg-violet-500 transition-colors"
>
View on rVote
</a>
)}
</div>
</div>
)}
{/* Milestones */}
{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">
{nodeData.milestones.map((milestone, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{milestone.completedAt ? (
<svg className="w-3.5 h-3.5 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-3.5 h-3.5 rounded-full border-2 border-slate-300 flex-shrink-0" />
)}
<span className={milestone.completedAt ? 'text-slate-500 line-through' : 'text-slate-700'}>
{milestone.label}
</span>
</div>
))}
</div>
</div>
)}
{/* Completion Info */}
{nodeData.completedAt && (
<div className="mb-4 p-3 bg-emerald-50 rounded-xl flex items-center gap-2">
<svg className="w-5 h-5 text-emerald-500" 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>
<span className="text-sm text-emerald-700 font-medium">
Completed {new Date(nodeData.completedAt).toLocaleDateString()}
</span>
</div>
)}
{/* Close Button */}
<div className="flex justify-center mt-4">
<button
onClick={handleCloseExpanded}
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
>
Close
</button>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -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;
}
@ -147,6 +149,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
}