'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 } 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: () => (
),
})
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 (Gnosis, Optimism)', 'error')
setConnecting(false)
return
}
const chainNames = detected.map(d => d.chain.name)
setConnectedChains(chainNames)
// Fetch balances from all detected chains and create funnel nodes
const allFunnelNodes: FlowNode[] = []
for (const chain of detected) {
const balances = await getBalances(safeAddress.trim(), chain.chainId)
const funnels = safeBalancesToFunnels(
balances,
safeAddress.trim(),
chain.chainId,
{ 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 from Gnosis and Optimism chains.
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 */}
)
}