375 lines
15 KiB
TypeScript
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} · {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>
|
|
)
|
|
}
|