rfunds-online/app/river/page.tsx

375 lines
15 KiB
TypeScript

'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 { computeSystemSufficiency } from '@/lib/simulation'
import type { FlowNode, FunnelNodeData } from '@/lib/types'
import type { BackendFlow } from '@/lib/api/flows-client'
const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center bg-slate-900">
<div className="flex items-center gap-3">
<div className="w-8 h-8 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin" />
<span className="text-slate-400">Loading river view...</span>
</div>
</div>
),
})
function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) {
const score = Math.round(computeSystemSufficiency(nodes) * 100)
const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' :
score >= 60 ? 'bg-emerald-600/30 text-emerald-300' :
score >= 30 ? 'bg-amber-600/30 text-amber-300' :
'bg-red-600/30 text-red-300'
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
Enoughness: {score}%
</span>
)
}
export default function RiverPage() {
const [mode, setMode] = useState<'demo' | 'live'>('demo')
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
const [isSimulating, setIsSimulating] = useState(true)
const [showConnect, setShowConnect] = useState(false)
const [safeAddress, setSafeAddress] = useState('')
const [connecting, setConnecting] = useState(false)
const [connectedChains, setConnectedChains] = useState<string[]>([])
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
const [deployedFlow, setDeployedFlow] = useState<BackendFlow | null>(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 (
<main className="h-screen w-screen flex flex-col bg-slate-900">
{/* Status Toast */}
{statusMessage && (
<div className={`fixed top-16 left-1/2 -translate-x-1/2 z-[60] px-4 py-2 rounded-lg shadow-lg text-sm font-medium ${
statusMessage.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}>
{statusMessage.text}
</div>
)}
{/* Banner */}
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
rF
</div>
<span className="font-medium">rFunds</span>
</Link>
<span className="text-slate-500">|</span>
<span className="text-cyan-300 font-medium">Budget River</span>
<span className="text-slate-500">|</span>
<EnoughnessPill nodes={nodes} />
{mode === 'live' && connectedChains.length > 0 && (
<>
<span className="text-slate-500">|</span>
<span className="text-xs text-emerald-400">
{connectedChains.join(' + ')}
</span>
</>
)}
{deployedFlow && (
<>
<span className="text-slate-500">|</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
deployedFlow.status === 'active' ? 'bg-emerald-600/30 text-emerald-300' :
'bg-amber-600/30 text-amber-300'
}`}>
{deployedFlow.status} &middot; {deployedFlow.name}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Link
href="/tbff"
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
>
Canvas View
</Link>
<Link
href="/space"
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
>
Editor
</Link>
<div className="w-px h-5 bg-slate-600 mx-1" />
{mode === 'live' ? (
<button
onClick={handleSwitchToDemo}
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
>
Demo
</button>
) : (
<button
onClick={() => setShowConnect(true)}
className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-medium transition-colors"
>
Connect Safe
</button>
)}
<button
onClick={() => setIsSimulating(!isSimulating)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
isSimulating
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
>
{isSimulating ? 'Pause' : 'Simulate'}
</button>
</div>
</div>
{/* River Canvas */}
<div className="flex-1 overflow-auto">
<BudgetRiver nodes={nodes} isSimulating={isSimulating} />
</div>
{/* Connect Safe Dialog */}
{showConnect && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowConnect(false)}>
<div className="bg-slate-800 rounded-xl shadow-2xl p-6 w-96 border border-slate-700" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-white mb-1">Connect Safe Treasury</h3>
<p className="text-xs text-slate-400 mb-4">
Enter a Safe address to load real token balances across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.
</p>
<input
type="text"
value={safeAddress}
onChange={e => 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()}
/>
<div className="flex gap-2 mb-3">
<button
onClick={handleConnect}
disabled={!safeAddress.trim() || connecting}
className="flex-1 px-4 py-2 bg-cyan-600 text-white rounded-lg text-sm font-medium hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{connecting ? 'Connecting...' : 'Connect Safe'}
</button>
<button
onClick={handleLoadFlow}
disabled={!safeAddress.trim()}
className="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Load Flow
</button>
</div>
<button
onClick={() => setShowConnect(false)}
className="w-full px-4 py-2 text-slate-400 hover:text-white text-sm border border-slate-600 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Legend */}
<div className="absolute bottom-4 left-4 bg-slate-800/90 backdrop-blur-sm rounded-lg border border-slate-700/50 p-3 text-xs">
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Legend</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-emerald-500" />
<span className="text-slate-400">Source / Inflow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-cyan-500" />
<span className="text-slate-400">River (healthy)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-amber-500" />
<span className="text-slate-400">Overflow branch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-violet-500" />
<span className="text-slate-400">Spending outflow</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
<span className="text-slate-400">Outcome pool</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-1.5 rounded bg-amber-400" style={{ boxShadow: '0 0 6px #fbbf24' }} />
<span className="text-slate-400">Sufficient (golden)</span>
</div>
</div>
</div>
</main>
)
}