'use client' import dynamic from 'next/dynamic' import Link from 'next/link' import { useState, useCallback, useEffect } from 'react' import { demoNodes } from '@/lib/presets' import { detectSafeChains, getBalances, computeTransferSummary } from '@/lib/api/safe-client' import { safeBalancesToFunnels } from '@/lib/integrations' import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client' import type { FlowNode, FunnelNodeData } from '@/lib/types' import type { BackendFlow } from '@/lib/api/flows-client' const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), { ssr: false, loading: () => (
Loading river view...
), }) export default function RiverPage() { const [mode, setMode] = useState<'demo' | 'live'>('demo') const [nodes, setNodes] = useState(demoNodes) const [isSimulating, setIsSimulating] = useState(true) const [showConnect, setShowConnect] = useState(false) const [safeAddress, setSafeAddress] = useState('') const [connecting, setConnecting] = useState(false) const [connectedChains, setConnectedChains] = useState([]) const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null) const [deployedFlow, setDeployedFlow] = useState(null) // Load saved address useEffect(() => { if (typeof window === 'undefined') return const saved = localStorage.getItem('rfunds-owner-address') if (saved) setSafeAddress(saved) }, []) const showStatus = useCallback((text: string, type: 'success' | 'error') => { setStatusMessage({ text, type }) setTimeout(() => setStatusMessage(null), 4000) }, []) const handleConnect = useCallback(async () => { if (!safeAddress.trim()) return setConnecting(true) try { localStorage.setItem('rfunds-owner-address', safeAddress.trim()) const detected = await detectSafeChains(safeAddress.trim()) if (detected.length === 0) { showStatus('No Safe found on supported chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error') setConnecting(false) return } const chainNames = detected.map(d => d.chain.name) setConnectedChains(chainNames) // Fetch balances + transfer history from all detected chains const allFunnelNodes: FlowNode[] = [] for (const chain of detected) { const [balances, transferSummary] = await Promise.all([ getBalances(safeAddress.trim(), chain.chainId), computeTransferSummary(safeAddress.trim(), chain.chainId), ]) const funnels = safeBalancesToFunnels( balances, safeAddress.trim(), chain.chainId, transferSummary, { x: allFunnelNodes.length * 280, y: 100 } ) allFunnelNodes.push(...funnels) } if (allFunnelNodes.length === 0) { showStatus('Safe found but no token balances > $1', 'error') setConnecting(false) return } setNodes(allFunnelNodes) setMode('live') setIsSimulating(false) setShowConnect(false) showStatus(`Connected: ${allFunnelNodes.length} tokens across ${chainNames.join(', ')}`, 'success') } catch (err) { showStatus(`Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') } finally { setConnecting(false) } }, [safeAddress, showStatus]) const handleLoadFlow = useCallback(async () => { if (!safeAddress.trim()) { showStatus('Enter a wallet address first', 'error') return } try { const flows = await listFlows(safeAddress.trim()) if (flows.length === 0) { showStatus('No deployed flows found for this address', 'error') return } // Load the most recent active flow const activeFlow = flows.find(f => f.status === 'active') || flows[0] const flow = await getFlow(activeFlow.id) setDeployedFlow(flow) // Convert backend flow to visual nodes const funnelNodes: FlowNode[] = flow.funnels.map((f, i) => ({ id: f.id, type: 'funnel' as const, position: { x: i * 280, y: 100 }, data: { label: f.name, currentValue: fromSmallestUnit(f.balance), minThreshold: fromSmallestUnit(f.minThreshold), maxThreshold: fromSmallestUnit(f.maxThreshold), maxCapacity: fromSmallestUnit(f.maxThreshold) * 1.5, inflowRate: f.inflowRate ? fromSmallestUnit(f.inflowRate) : 0, overflowAllocations: f.overflowAllocations.map((a, j) => ({ targetId: a.targetId, percentage: a.percentage, color: ['#f59e0b', '#ef4444', '#f97316'][j % 3], })), spendingAllocations: f.spendingAllocations.map((a, j) => ({ targetId: a.targetId, percentage: a.percentage, color: ['#3b82f6', '#8b5cf6', '#ec4899'][j % 3], })), } as FunnelNodeData, })) const outcomeNodes: FlowNode[] = flow.outcomes.map((o, i) => ({ id: o.id, type: 'outcome' as const, position: { x: i * 250, y: 600 }, data: { label: o.name, description: o.description || '', fundingReceived: fromSmallestUnit(o.currentAmount), fundingTarget: fromSmallestUnit(o.targetAmount), status: o.status === 'completed' ? 'completed' as const : fromSmallestUnit(o.currentAmount) > 0 ? 'in-progress' as const : 'not-started' as const, }, })) setNodes([...funnelNodes, ...outcomeNodes]) setMode('live') setIsSimulating(false) showStatus(`Loaded flow "${flow.name}" (${flow.funnels.length} funnels, ${flow.outcomes.length} outcomes)`, 'success') } catch (err) { showStatus(`Failed to load flow: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error') } }, [safeAddress, showStatus]) // Auto-refresh in live mode useEffect(() => { if (mode !== 'live' || !deployedFlow) return const interval = setInterval(async () => { try { const latest = await getFlow(deployedFlow.id) setDeployedFlow(latest) setNodes(prev => syncNodesToBackend(prev, latest, Object.fromEntries( prev.map(n => [n.id, n.id]) ))) } catch { // Silent refresh failure } }, 30000) return () => clearInterval(interval) }, [mode, deployedFlow]) const handleSwitchToDemo = useCallback(() => { setNodes(demoNodes) setMode('demo') setIsSimulating(true) setDeployedFlow(null) setConnectedChains([]) }, []) return (
{/* Status Toast */} {statusMessage && (
{statusMessage.text}
)} {/* Banner */}
rF
rFunds | Budget River {mode === 'live' && connectedChains.length > 0 && ( <> | {connectedChains.join(' + ')} )} {deployedFlow && ( <> | {deployedFlow.status} · {deployedFlow.name} )}
Canvas View Editor
{mode === 'live' ? ( ) : ( )}
{/* River Canvas */}
{/* Connect Safe Dialog */} {showConnect && (
setShowConnect(false)}>
e.stopPropagation()}>

Connect Safe Treasury

Enter a Safe address to load real token balances across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.

setSafeAddress(e.target.value)} placeholder="Safe address (0x...)" className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm text-white font-mono text-xs mb-3 focus:border-cyan-500 focus:outline-none" autoFocus onKeyDown={e => e.key === 'Enter' && handleConnect()} />
)} {/* Legend */}
Legend
Source / Inflow
River (healthy)
Overflow branch
Spending outflow
Outcome pool
) }