'use client' import { memo, useState, useCallback, useRef, useEffect } from 'react' import { Handle, Position, useReactFlow } from '@xyflow/react' import type { NodeProps } from '@xyflow/react' import type { FunnelNodeData, OutcomeNodeData, FundingSource } from '@/lib/types' import SplitsView from '../SplitsView' import FundingSourcesPanel from '../FundingSourcesPanel' const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1'] const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c'] function FunnelNode({ data, selected, id }: NodeProps) { const nodeData = data as FunnelNodeData const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData const { getNode, setNodes } = useReactFlow() const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold) const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold) const [isEditing, setIsEditing] = useState(false) const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null) const [localOverflow, setLocalOverflow] = useState(overflowAllocations) const [localSpending, setLocalSpending] = useState(spendingAllocations) const [showAddOutflow, setShowAddOutflow] = useState(false) const [showAddOutcome, setShowAddOutcome] = useState(false) const [newItemName, setNewItemName] = useState('') const sliderRef = useRef(null) const overflowPieRef = useRef(null) const spendingPieRef = useRef(null) const isOverflowing = currentValue > maxThreshold const isCritical = currentValue < minThreshold const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100) const width = 160 const height = 140 const minPercent = minThreshold / maxCapacity const maxPercent = maxThreshold / maxCapacity const overflowZoneHeight = (1 - maxPercent) * height * 0.4 + 15 const healthyZoneHeight = (maxPercent - minPercent) * height * 0.8 + 30 const drainZoneHeight = height - overflowZoneHeight - healthyZoneHeight const topWidth = 130 const midWidth = 100 const bottomWidth = 30 const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() setLocalOverflow([...overflowAllocations]) setLocalSpending([...spendingAllocations]) setIsEditing(true) }, [overflowAllocations, spendingAllocations]) const handleCloseEdit = useCallback(() => { setNodes((nds) => nds.map((node) => { if (node.id !== id) return node const prevData = node.data as FunnelNodeData return { ...node, data: { ...prevData, overflowAllocations: localOverflow, spendingAllocations: localSpending, minThreshold, maxThreshold, }, } })) setIsEditing(false) setShowAddOutflow(false) setShowAddOutcome(false) setNewItemName('') }, [id, localOverflow, localSpending, minThreshold, maxThreshold, setNodes]) const [draggingThreshold, setDraggingThreshold] = useState<'min' | 'max' | null>(null) const handleThresholdMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => { e.stopPropagation() setDraggingThreshold(type) }, []) useEffect(() => { if (!draggingThreshold || !sliderRef.current) return const handleMove = (e: MouseEvent) => { const rect = sliderRef.current!.getBoundingClientRect() const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left)) const value = Math.round((x / rect.width) * maxCapacity) if (draggingThreshold === 'min') { setMinThreshold(Math.min(value, maxThreshold - 1000)) } else { setMaxThreshold(Math.max(value, minThreshold + 1000)) } } const handleUp = () => setDraggingThreshold(null) window.addEventListener('mousemove', handleMove) window.addEventListener('mouseup', handleUp) return () => { window.removeEventListener('mousemove', handleMove) window.removeEventListener('mouseup', handleUp) } }, [draggingThreshold, maxCapacity, minThreshold, maxThreshold]) useEffect(() => { if (!draggingPie) return const handleMove = (e: MouseEvent) => { const pieRef = draggingPie.type === 'overflow' ? overflowPieRef.current : spendingPieRef.current if (!pieRef) return const rect = pieRef.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI) + 90 const normalizedAngle = ((angle % 360) + 360) % 360 const percentage = Math.round((normalizedAngle / 360) * 100) if (draggingPie.type === 'overflow') { setLocalOverflow(prev => { const newAllocs = [...prev] if (newAllocs.length > 1) { const otherIdx = (draggingPie.index + 1) % newAllocs.length const newCurrent = Math.max(5, Math.min(95, percentage)) newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) } } return newAllocs }) } else { setLocalSpending(prev => { const newAllocs = [...prev] if (newAllocs.length > 1) { const otherIdx = (draggingPie.index + 1) % newAllocs.length const newCurrent = Math.max(5, Math.min(95, percentage)) newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent } newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) } } return newAllocs }) } } const handleUp = () => setDraggingPie(null) window.addEventListener('mousemove', handleMove) window.addEventListener('mouseup', handleUp) return () => { window.removeEventListener('mousemove', handleMove) window.removeEventListener('mouseup', handleUp) } }, [draggingPie]) const handleAddOutflow = useCallback(() => { if (!newItemName.trim()) return const currentNode = getNode(id) if (!currentNode) return const newId = `funnel-${Date.now()}` const newNodeData: FunnelNodeData = { label: newItemName, currentValue: 0, minThreshold: 10000, maxThreshold: 40000, maxCapacity: 50000, inflowRate: 0, overflowAllocations: [], spendingAllocations: [], } setNodes((nodes) => [ ...nodes, { id: newId, type: 'funnel', position: { x: currentNode.position.x + 250, y: currentNode.position.y }, data: newNodeData, }, ]) const newAllocation = { targetId: newId, percentage: localOverflow.length === 0 ? 100 : Math.floor(100 / (localOverflow.length + 1)), color: OVERFLOW_COLORS[localOverflow.length % OVERFLOW_COLORS.length], } const newOverflow = localOverflow.map(a => ({ ...a, percentage: Math.floor(a.percentage * localOverflow.length / (localOverflow.length + 1)) })) newOverflow.push(newAllocation) setLocalOverflow(newOverflow) setShowAddOutflow(false) setNewItemName('') }, [newItemName, id, getNode, setNodes, localOverflow]) const handleAddOutcome = useCallback(() => { if (!newItemName.trim()) return const currentNode = getNode(id) if (!currentNode) return const newId = `outcome-${Date.now()}` const newNodeData: OutcomeNodeData = { label: newItemName, description: '', fundingReceived: 0, fundingTarget: 20000, status: 'not-started', } setNodes((nodes) => [ ...nodes, { id: newId, type: 'outcome', position: { x: currentNode.position.x, y: currentNode.position.y + 300 }, data: newNodeData, }, ]) const newAllocation = { targetId: newId, percentage: localSpending.length === 0 ? 100 : Math.floor(100 / (localSpending.length + 1)), color: SPENDING_COLORS[localSpending.length % SPENDING_COLORS.length], } const newSpending = localSpending.map(a => ({ ...a, percentage: Math.floor(a.percentage * localSpending.length / (localSpending.length + 1)) })) newSpending.push(newAllocation) setLocalSpending(newSpending) setShowAddOutcome(false) setNewItemName('') }, [newItemName, id, getNode, setNodes, localSpending]) const handleRemoveOutflow = useCallback((index: number) => { setLocalOverflow(prev => { const newAllocs = prev.filter((_, i) => i !== index) if (newAllocs.length > 0) { const total = newAllocs.reduce((s, a) => s + a.percentage, 0) return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) } return newAllocs }) }, []) const handleRemoveSpending = useCallback((index: number) => { setLocalSpending(prev => { const newAllocs = prev.filter((_, i) => i !== index) if (newAllocs.length > 0) { const total = newAllocs.reduce((s, a) => s + a.percentage, 0) return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) })) } return newAllocs }) }, []) const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => { if (allocations.length === 0) return null const center = size / 2 const radius = size / 2 - 4 let currentAngle = -90 return allocations.map((alloc, idx) => { const angle = (alloc.percentage / 100) * 360 const startAngle = currentAngle const endAngle = currentAngle + angle currentAngle = endAngle const startRad = (startAngle * Math.PI) / 180 const endRad = (endAngle * Math.PI) / 180 const x1 = center + radius * Math.cos(startRad) const y1 = center + radius * Math.sin(startRad) const x2 = center + radius * Math.cos(endRad) const y2 = center + radius * Math.sin(endRad) const largeArc = angle > 180 ? 1 : 0 return ( { e.stopPropagation() setDraggingPie({ type, index: idx }) }} /> ) }) } const renderSimpleBars = (allocations: typeof overflowAllocations, colors: string[], direction: 'horizontal' | 'vertical') => { if (allocations.length === 0) return null return (
{allocations.map((alloc, idx) => (
))}
) } const hasOverflow = overflowAllocations.length > 0 const hasSpending = spendingAllocations.length > 0 return ( <>
{label} {isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
{nodeData.source?.type === 'safe' && (
{nodeData.source.tokenSymbol} • {nodeData.source.safeChainId === 100 ? 'Gnosis' : nodeData.source.safeChainId === 10 ? 'Optimism' : `Chain ${nodeData.source.safeChainId}`}
)}
Inflow
MAX MIN {isOverflowing && hasOverflow && ( <> )} {hasSpending && ( )}
${Math.floor(currentValue / 1000)}k
{hasOverflow && (
← Out →
{renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')}
)} {hasSpending && (
↓ Fund
{renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')}
)}
Double-click to edit
{/* Stream handle (Superfluid) - right side, lower */}
{isEditing && (
e.stopPropagation()} >

{label}

${Math.floor(currentValue).toLocaleString()} / ${maxCapacity.toLocaleString()}
MIN: ${(minThreshold/1000).toFixed(0)}k MAX: ${(maxThreshold/1000).toFixed(0)}k
handleThresholdMouseDown(e, 'min')} />
handleThresholdMouseDown(e, 'max')} />
$0 Drag handles to adjust ${(maxCapacity/1000).toFixed(0)}k
→ Outflows
{localOverflow.length > 0 ? ( <> {renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 100)}
{localOverflow.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
) : (

No outflows yet

)} {showAddOutflow && (
setNewItemName(e.target.value)} placeholder="New funnel name..." className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" autoFocus />
)}
↓ Outcomes
{localSpending.length > 0 ? ( <> {renderPieChart(localSpending, SPENDING_COLORS, 'spending', 100)}
{localSpending.map((alloc, idx) => (
{alloc.targetId} {alloc.percentage}%
))}
) : (

No outcomes yet

)} {showAddOutcome && (
setNewItemName(e.target.value)} placeholder="New outcome name..." className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2" autoFocus />
)}
{nodeData.splitsConfig && (
)} {/* Funding Sources */}
Funding Sources {(nodeData.fundingSources || []).length > 0 && ( {(nodeData.fundingSources || []).length} )}
{ setNodes((nds) => nds.map((node) => { if (node.id !== id) return node return { ...node, data: { ...(node.data as FunnelNodeData), fundingSources: newSources, }, } }) ) }} funnelWalletAddress={undefined} flowId={undefined} funnelId={undefined} isDeployed={false} />
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
Superfluid Streams
{nodeData.streamAllocations.map((stream, i) => (
{stream.targetId}
{stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo
))}
)}

Drag pie slices to adjust • Click + to add new items

)} ) } export default memo(FunnelNode)