feat: add cross-app integration panel for Safe wallets and rVote proposals
Link external data sources to rFunds spaces: - Safe Global API client (Gnosis + Optimism) for wallet balances → Funnel nodes - rVote CORS proxy + client for passed proposals → Outcome nodes - Integration panel UI with preview/import workflow - Source badges on nodes (chain icon for Safe, rVote score for proposals) - State persistence for integrations in save/load/share Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b29141d1a
commit
f012502133
|
|
@ -0,0 +1,41 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const RVOTE_BASE = 'https://rvote.online'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
const slug = searchParams.get('slug')
|
||||
|
||||
if (!slug || !endpoint) {
|
||||
return NextResponse.json({ error: 'Missing params' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Whitelist allowed endpoints to prevent open proxy
|
||||
let targetUrl: string
|
||||
if (endpoint === 'space') {
|
||||
targetUrl = `${RVOTE_BASE}/api/spaces/${encodeURIComponent(slug)}`
|
||||
} else if (endpoint === 'proposals') {
|
||||
const status = searchParams.get('status') || 'PASSED'
|
||||
targetUrl = `${RVOTE_BASE}/s/${encodeURIComponent(slug)}/api/proposals?status=${status}&limit=50`
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, { next: { revalidate: 60 } })
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream error: ${res.status}` },
|
||||
{ status: res.status }
|
||||
)
|
||||
}
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'Fetch failed' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import Link from 'next/link'
|
|||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { starterNodes } from '@/lib/presets'
|
||||
import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state'
|
||||
import type { FlowNode, SpaceConfig } from '@/lib/types'
|
||||
import type { FlowNode, SpaceConfig, IntegrationConfig } from '@/lib/types'
|
||||
|
||||
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
||||
ssr: false,
|
||||
|
|
@ -21,6 +21,7 @@ const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
|||
|
||||
export default function SpacePage() {
|
||||
const [currentNodes, setCurrentNodes] = useState<FlowNode[]>(starterNodes)
|
||||
const [integrations, setIntegrations] = useState<IntegrationConfig | undefined>()
|
||||
const [spaceName, setSpaceName] = useState('')
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false)
|
||||
|
|
@ -28,6 +29,12 @@ export default function SpacePage() {
|
|||
const [copied, setCopied] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const nodesRef = useRef<FlowNode[]>(starterNodes)
|
||||
const integrationsRef = useRef<IntegrationConfig | undefined>()
|
||||
|
||||
const handleIntegrationsChange = useCallback((config: IntegrationConfig) => {
|
||||
setIntegrations(config)
|
||||
integrationsRef.current = config
|
||||
}, [])
|
||||
|
||||
// Load from URL hash on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -39,6 +46,10 @@ export default function SpacePage() {
|
|||
if (state) {
|
||||
setCurrentNodes(state.nodes)
|
||||
nodesRef.current = state.nodes
|
||||
if (state.integrations) {
|
||||
setIntegrations(state.integrations)
|
||||
integrationsRef.current = state.integrations
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoaded(true)
|
||||
|
|
@ -49,7 +60,7 @@ export default function SpacePage() {
|
|||
}, [])
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
const compressed = serializeState(nodesRef.current)
|
||||
const compressed = serializeState(nodesRef.current, integrationsRef.current)
|
||||
const url = `${window.location.origin}/space#s=${compressed}`
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true)
|
||||
|
|
@ -59,7 +70,7 @@ export default function SpacePage() {
|
|||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!spaceName.trim()) return
|
||||
saveToLocal(spaceName.trim(), nodesRef.current)
|
||||
saveToLocal(spaceName.trim(), nodesRef.current, integrationsRef.current)
|
||||
setShowSaveDialog(false)
|
||||
setSpaceName('')
|
||||
}, [spaceName])
|
||||
|
|
@ -72,6 +83,10 @@ export default function SpacePage() {
|
|||
const handleLoadSpace = useCallback((config: SpaceConfig) => {
|
||||
setCurrentNodes(config.nodes)
|
||||
nodesRef.current = config.nodes
|
||||
if (config.integrations) {
|
||||
setIntegrations(config.integrations)
|
||||
integrationsRef.current = config.integrations
|
||||
}
|
||||
setShowLoadDialog(false)
|
||||
}, [])
|
||||
|
||||
|
|
@ -150,6 +165,8 @@ export default function SpacePage() {
|
|||
initialNodes={currentNodes}
|
||||
mode="space"
|
||||
onNodesChange={handleNodesChange}
|
||||
integrations={integrations}
|
||||
onIntegrationsChange={handleIntegrationsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import '@xyflow/react/dist/style.css'
|
|||
import FunnelNode from './nodes/FunnelNode'
|
||||
import OutcomeNode from './nodes/OutcomeNode'
|
||||
import AllocationEdge from './edges/AllocationEdge'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||
import IntegrationPanel from './IntegrationPanel'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
|
||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||
|
||||
const nodeTypes = {
|
||||
|
|
@ -148,9 +149,12 @@ interface FlowCanvasInnerProps {
|
|||
initialNodes: FlowNode[]
|
||||
mode: 'demo' | 'space'
|
||||
onNodesChange?: (nodes: FlowNode[]) => void
|
||||
integrations?: IntegrationConfig
|
||||
onIntegrationsChange?: (config: IntegrationConfig) => void
|
||||
}
|
||||
|
||||
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
|
||||
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integrations, onIntegrationsChange }: FlowCanvasInnerProps) {
|
||||
const [showIntegrations, setShowIntegrations] = useState(false)
|
||||
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
|
||||
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
||||
|
|
@ -384,6 +388,11 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
|||
[onEdgesChange, setNodes]
|
||||
)
|
||||
|
||||
// Import nodes from integrations panel
|
||||
const handleImportNodes = useCallback((newNodes: FlowNode[]) => {
|
||||
setNodes((nds) => [...nds, ...newNodes])
|
||||
}, [setNodes])
|
||||
|
||||
// Add funnel node at viewport center
|
||||
const addFunnel = useCallback(() => {
|
||||
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||
|
|
@ -507,6 +516,12 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
|||
<Panel position="top-right" className="m-4 flex gap-2">
|
||||
{mode === 'space' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowIntegrations(true)}
|
||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
||||
>
|
||||
Link Data
|
||||
</button>
|
||||
<button
|
||||
onClick={addFunnel}
|
||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
||||
|
|
@ -555,6 +570,15 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowC
|
|||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
{mode === 'space' && (
|
||||
<IntegrationPanel
|
||||
isOpen={showIntegrations}
|
||||
onClose={() => setShowIntegrations(false)}
|
||||
onImportNodes={handleImportNodes}
|
||||
integrations={integrations}
|
||||
onIntegrationsChange={onIntegrationsChange || (() => {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -564,12 +588,20 @@ interface FlowCanvasProps {
|
|||
initialNodes: FlowNode[]
|
||||
mode?: 'demo' | 'space'
|
||||
onNodesChange?: (nodes: FlowNode[]) => void
|
||||
integrations?: IntegrationConfig
|
||||
onIntegrationsChange?: (config: IntegrationConfig) => void
|
||||
}
|
||||
|
||||
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange }: FlowCanvasProps) {
|
||||
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange, integrations, onIntegrationsChange }: FlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner initialNodes={initialNodes} mode={mode} onNodesChange={onNodesChange} />
|
||||
<FlowCanvasInner
|
||||
initialNodes={initialNodes}
|
||||
mode={mode}
|
||||
onNodesChange={onNodesChange}
|
||||
integrations={integrations}
|
||||
onIntegrationsChange={onIntegrationsChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { FlowNode, IntegrationConfig } from '@/lib/types'
|
||||
import { SUPPORTED_CHAINS, getBalances, detectSafeChains } from '@/lib/api/safe-client'
|
||||
import type { SafeBalance, DetectedChain } from '@/lib/api/safe-client'
|
||||
import { safeBalancesToFunnels } from '@/lib/integrations'
|
||||
|
||||
interface IntegrationPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onImportNodes: (nodes: FlowNode[]) => void
|
||||
integrations?: IntegrationConfig
|
||||
onIntegrationsChange: (config: IntegrationConfig) => void
|
||||
}
|
||||
|
||||
export default function IntegrationPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImportNodes,
|
||||
integrations,
|
||||
onIntegrationsChange,
|
||||
}: IntegrationPanelProps) {
|
||||
// Safe wallet state
|
||||
const [safeAddress, setSafeAddress] = useState(integrations?.safe?.address || '')
|
||||
const [selectedChains, setSelectedChains] = useState<number[]>(
|
||||
integrations?.safe?.chainIds || [100, 10]
|
||||
)
|
||||
const [detectedChains, setDetectedChains] = useState<DetectedChain[]>([])
|
||||
const [balances, setBalances] = useState<Map<number, SafeBalance[]>>(new Map())
|
||||
const [safeFetching, setSafeFetching] = useState(false)
|
||||
const [safeError, setSafeError] = useState('')
|
||||
|
||||
// rVote state
|
||||
const [rvoteSlug, setRvoteSlug] = useState(integrations?.rvote?.spaceSlug || '')
|
||||
const [rvoteFetching, setRvoteFetching] = useState(false)
|
||||
const [rvoteError, setRvoteError] = useState('')
|
||||
const [rvoteSpace, setRvoteSpace] = useState<{ name: string; memberCount: number } | null>(null)
|
||||
const [rvoteProposals, setRvoteProposals] = useState<Array<{ id: string; title: string; score: number }>>([])
|
||||
|
||||
const handleFetchSafe = useCallback(async () => {
|
||||
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
|
||||
setSafeError('Invalid Ethereum address')
|
||||
return
|
||||
}
|
||||
|
||||
setSafeFetching(true)
|
||||
setSafeError('')
|
||||
setBalances(new Map())
|
||||
|
||||
try {
|
||||
const detected = await detectSafeChains(safeAddress, selectedChains)
|
||||
setDetectedChains(detected)
|
||||
|
||||
if (detected.length === 0) {
|
||||
setSafeError('No Safe found on selected chains')
|
||||
setSafeFetching(false)
|
||||
return
|
||||
}
|
||||
|
||||
const balanceMap = new Map<number, SafeBalance[]>()
|
||||
for (const { chainId } of detected) {
|
||||
const chainBalances = await getBalances(safeAddress, chainId)
|
||||
balanceMap.set(chainId, chainBalances)
|
||||
}
|
||||
setBalances(balanceMap)
|
||||
|
||||
onIntegrationsChange({
|
||||
...integrations,
|
||||
safe: {
|
||||
address: safeAddress,
|
||||
chainIds: detected.map((d) => d.chainId),
|
||||
lastSyncedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
setSafeError(e instanceof Error ? e.message : 'Failed to fetch')
|
||||
} finally {
|
||||
setSafeFetching(false)
|
||||
}
|
||||
}, [safeAddress, selectedChains, integrations, onIntegrationsChange])
|
||||
|
||||
const handleImportSafe = useCallback(() => {
|
||||
const allNodes: FlowNode[] = []
|
||||
let xOffset = 0
|
||||
|
||||
balances.forEach((chainBalances, chainId) => {
|
||||
const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, {
|
||||
x: xOffset,
|
||||
y: 100,
|
||||
})
|
||||
allNodes.push(...nodes)
|
||||
xOffset += nodes.length * 280
|
||||
})
|
||||
|
||||
if (allNodes.length > 0) {
|
||||
onImportNodes(allNodes)
|
||||
onClose()
|
||||
}
|
||||
}, [balances, safeAddress, onImportNodes, onClose])
|
||||
|
||||
const handleFetchRvote = useCallback(async () => {
|
||||
if (!rvoteSlug.trim()) {
|
||||
setRvoteError('Enter a space slug')
|
||||
return
|
||||
}
|
||||
|
||||
setRvoteFetching(true)
|
||||
setRvoteError('')
|
||||
|
||||
try {
|
||||
// Fetch via proxy to avoid CORS
|
||||
const spaceRes = await fetch(`/api/proxy/rvote?endpoint=space&slug=${encodeURIComponent(rvoteSlug)}`)
|
||||
if (!spaceRes.ok) {
|
||||
setRvoteError(spaceRes.status === 404 ? 'Space not found' : 'Failed to fetch space')
|
||||
setRvoteFetching(false)
|
||||
return
|
||||
}
|
||||
const space = await spaceRes.json()
|
||||
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
|
||||
|
||||
const proposalsRes = await fetch(
|
||||
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
|
||||
)
|
||||
if (proposalsRes.ok) {
|
||||
const data = await proposalsRes.json()
|
||||
const proposals = data.proposals || data.results || data || []
|
||||
setRvoteProposals(
|
||||
Array.isArray(proposals)
|
||||
? proposals.map((p: { id: string; title: string; score: number }) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
score: p.score,
|
||||
}))
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
onIntegrationsChange({
|
||||
...integrations,
|
||||
rvote: {
|
||||
spaceSlug: rvoteSlug,
|
||||
spaceName: space.name,
|
||||
lastSyncedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
setRvoteError(e instanceof Error ? e.message : 'Failed to fetch')
|
||||
} finally {
|
||||
setRvoteFetching(false)
|
||||
}
|
||||
}, [rvoteSlug, integrations, onIntegrationsChange])
|
||||
|
||||
const handleImportRvote = useCallback(async () => {
|
||||
if (rvoteProposals.length === 0) return
|
||||
|
||||
// Re-fetch full proposal data for import
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
|
||||
)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
const proposals = data.proposals || data.results || data || []
|
||||
if (!Array.isArray(proposals)) return
|
||||
|
||||
const { proposalsToOutcomes } = await import('@/lib/integrations')
|
||||
const nodes = proposalsToOutcomes(proposals, rvoteSlug)
|
||||
if (nodes.length > 0) {
|
||||
onImportNodes(nodes)
|
||||
onClose()
|
||||
}
|
||||
} catch {
|
||||
setRvoteError('Failed to import proposals')
|
||||
}
|
||||
}, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
|
||||
|
||||
const toggleChain = (chainId: number) => {
|
||||
setSelectedChains((prev) =>
|
||||
prev.includes(chainId) ? prev.filter((c) => c !== chainId) : [...prev, chainId]
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const totalBalances = Array.from(balances.values()).flat().filter((b) => parseFloat(b.fiatBalance) > 1)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-slate-200 w-full max-w-lg max-h-[85vh] overflow-y-auto m-4">
|
||||
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between rounded-t-xl">
|
||||
<h2 className="text-lg font-bold text-slate-800">Link External Data</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl font-bold">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* ─── Safe Wallet Section ─── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
Safe Wallet → Funnels
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={safeAddress}
|
||||
onChange={(e) => setSafeAddress(e.target.value)}
|
||||
placeholder="0x... Safe address"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 text-sm font-mono focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(SUPPORTED_CHAINS).map(([id, chain]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggleChain(Number(id))}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-all ${
|
||||
selectedChains.includes(Number(id))
|
||||
? 'border-current text-white'
|
||||
: 'border-slate-200 text-slate-400'
|
||||
}`}
|
||||
style={
|
||||
selectedChains.includes(Number(id))
|
||||
? { backgroundColor: chain.color, borderColor: chain.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{chain.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFetchSafe}
|
||||
disabled={safeFetching || !safeAddress || selectedChains.length === 0}
|
||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium bg-emerald-500 text-white hover:bg-emerald-600 disabled:bg-slate-200 disabled:text-slate-400 transition-all"
|
||||
>
|
||||
{safeFetching ? 'Fetching...' : 'Fetch Balances'}
|
||||
</button>
|
||||
|
||||
{safeError && <p className="text-xs text-red-500">{safeError}</p>}
|
||||
|
||||
{detectedChains.length > 0 && (
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
Safe found on{' '}
|
||||
{detectedChains.map((d) => d.chain.name).join(', ')}
|
||||
{' '}({detectedChains[0]?.safeInfo.owners.length} owners, {detectedChains[0]?.safeInfo.threshold} threshold)
|
||||
</p>
|
||||
|
||||
{totalBalances.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{totalBalances.slice(0, 8).map((b, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-slate-700">{b.symbol}</span>
|
||||
<span className="text-slate-500">
|
||||
{b.balanceFormatted} (${parseFloat(b.fiatBalance).toLocaleString(undefined, { maximumFractionDigits: 0 })})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{totalBalances.length > 8 && (
|
||||
<p className="text-[10px] text-slate-400">
|
||||
+{totalBalances.length - 8} more tokens
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleImportSafe}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium bg-blue-500 text-white hover:bg-blue-600 transition-all"
|
||||
>
|
||||
Import {totalBalances.length} Funnel{totalBalances.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200" />
|
||||
|
||||
{/* ─── rVote Section ─── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-violet-500" />
|
||||
rVote Space → Outcomes
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={rvoteSlug}
|
||||
onChange={(e) => setRvoteSlug(e.target.value)}
|
||||
placeholder="Space slug (e.g., crypto)"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400"
|
||||
/>
|
||||
<span className="flex items-center text-xs text-slate-400">.rvote.online</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFetchRvote}
|
||||
disabled={rvoteFetching || !rvoteSlug.trim()}
|
||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium bg-violet-500 text-white hover:bg-violet-600 disabled:bg-slate-200 disabled:text-slate-400 transition-all"
|
||||
>
|
||||
{rvoteFetching ? 'Fetching...' : 'Fetch Proposals'}
|
||||
</button>
|
||||
|
||||
{rvoteError && <p className="text-xs text-red-500">{rvoteError}</p>}
|
||||
|
||||
{rvoteSpace && (
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
<span className="font-medium text-slate-700">{rvoteSpace.name}</span>
|
||||
{' '}• {rvoteSpace.memberCount} members
|
||||
{' '}• {rvoteProposals.length} passed proposal{rvoteProposals.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{rvoteProposals.length > 0 ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
{rvoteProposals.slice(0, 6).map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-700 truncate mr-2">{p.title}</span>
|
||||
<span className="text-violet-500 font-medium shrink-0">+{p.score}</span>
|
||||
</div>
|
||||
))}
|
||||
{rvoteProposals.length > 6 && (
|
||||
<p className="text-[10px] text-slate-400">
|
||||
+{rvoteProposals.length - 6} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleImportRvote}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium bg-blue-500 text-white hover:bg-blue-600 transition-all"
|
||||
>
|
||||
Import {rvoteProposals.length} Outcome{rvoteProposals.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">No passed proposals found in this space</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -354,6 +354,17 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
|||
{isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
|
||||
</span>
|
||||
</div>
|
||||
{nodeData.source?.type === 'safe' && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full inline-block"
|
||||
style={{ backgroundColor: nodeData.source.safeChainId === 100 ? '#04795b' : nodeData.source.safeChainId === 10 ? '#ff0420' : '#627eea' }}
|
||||
/>
|
||||
<span className="text-[9px] text-slate-400 font-mono">
|
||||
{nodeData.source.tokenSymbol} • {nodeData.source.safeChainId === 100 ? 'Gnosis' : nodeData.source.safeChainId === 10 ? 'Optimism' : `Chain ${nodeData.source.safeChainId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
|
|
|
|||
|
|
@ -43,7 +43,14 @@ function OutcomeNode({ data, selected }: NodeProps) {
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
|
||||
{nodeData.source?.type === 'rvote' && (
|
||||
<span className="text-[9px] text-violet-500 font-medium">
|
||||
rVote • score +{nodeData.source.rvoteProposalScore ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Safe Global API Client for rFunds.online
|
||||
* Ported from rWallet's safe-api.js — TypeScript, Gnosis + Optimism only
|
||||
*/
|
||||
|
||||
export interface ChainConfig {
|
||||
name: string
|
||||
slug: string
|
||||
txService: string
|
||||
explorer: string
|
||||
color: string
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
|
||||
100: {
|
||||
name: 'Gnosis',
|
||||
slug: 'gnosis-chain',
|
||||
txService: 'https://safe-transaction-gnosis-chain.safe.global',
|
||||
explorer: 'https://gnosisscan.io',
|
||||
color: '#04795b',
|
||||
symbol: 'xDAI',
|
||||
},
|
||||
10: {
|
||||
name: 'Optimism',
|
||||
slug: 'optimism',
|
||||
txService: 'https://safe-transaction-optimism.safe.global',
|
||||
explorer: 'https://optimistic.etherscan.io',
|
||||
color: '#ff0420',
|
||||
symbol: 'ETH',
|
||||
},
|
||||
}
|
||||
|
||||
export interface SafeInfo {
|
||||
address: string
|
||||
nonce: number
|
||||
threshold: number
|
||||
owners: string[]
|
||||
version: string
|
||||
chainId: number
|
||||
}
|
||||
|
||||
export interface SafeBalance {
|
||||
tokenAddress: string | null
|
||||
token: {
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
logoUri?: string
|
||||
} | null
|
||||
balance: string
|
||||
balanceFormatted: string
|
||||
symbol: string
|
||||
fiatBalance: string
|
||||
fiatConversion: string
|
||||
}
|
||||
|
||||
export interface DetectedChain {
|
||||
chainId: number
|
||||
chain: ChainConfig
|
||||
safeInfo: SafeInfo
|
||||
}
|
||||
|
||||
function getChain(chainId: number): ChainConfig {
|
||||
const chain = SUPPORTED_CHAINS[chainId]
|
||||
if (!chain) throw new Error(`Unsupported chain ID: ${chainId}`)
|
||||
return chain
|
||||
}
|
||||
|
||||
function apiUrl(chainId: number, path: string): string {
|
||||
return `${getChain(chainId).txService}/api/v1${path}`
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(url: string, retries = 5): Promise<T | null> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const res = await fetch(url)
|
||||
if (res.status === 404) return null
|
||||
if (res.status === 429) {
|
||||
const delay = Math.min(2000 * Math.pow(2, attempt), 32000)
|
||||
await sleep(delay)
|
||||
continue
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getSafeInfo(address: string, chainId: number): Promise<SafeInfo | null> {
|
||||
const data = await fetchJSON<Record<string, unknown>>(apiUrl(chainId, `/safes/${address}/`))
|
||||
if (!data) return null
|
||||
return {
|
||||
address: data.address as string,
|
||||
nonce: data.nonce as number,
|
||||
threshold: data.threshold as number,
|
||||
owners: data.owners as string[],
|
||||
version: data.version as string,
|
||||
chainId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBalances(address: string, chainId: number): Promise<SafeBalance[]> {
|
||||
const data = await fetchJSON<Array<Record<string, unknown>>>(
|
||||
apiUrl(chainId, `/safes/${address}/balances/?trusted=true&exclude_spam=true`)
|
||||
)
|
||||
if (!data) return []
|
||||
|
||||
const chain = SUPPORTED_CHAINS[chainId]
|
||||
return data.map((b) => {
|
||||
const token = b.token as { name: string; symbol: string; decimals: number; logoUri?: string } | null
|
||||
return {
|
||||
tokenAddress: (b.tokenAddress as string) || null,
|
||||
token,
|
||||
balance: b.balance as string,
|
||||
balanceFormatted: token
|
||||
? (parseFloat(b.balance as string) / Math.pow(10, token.decimals)).toFixed(token.decimals > 6 ? 4 : 2)
|
||||
: (parseFloat(b.balance as string) / 1e18).toFixed(4),
|
||||
symbol: token ? token.symbol : chain?.symbol || 'ETH',
|
||||
fiatBalance: (b.fiatBalance as string) || '0',
|
||||
fiatConversion: (b.fiatConversion as string) || '0',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function detectSafeChains(
|
||||
address: string,
|
||||
chainIds?: number[]
|
||||
): Promise<DetectedChain[]> {
|
||||
const ids = chainIds || Object.keys(SUPPORTED_CHAINS).map(Number)
|
||||
const results: DetectedChain[] = []
|
||||
|
||||
// Check chains sequentially with small delay to avoid rate limits
|
||||
for (const chainId of ids) {
|
||||
const chain = SUPPORTED_CHAINS[chainId]
|
||||
if (!chain) continue
|
||||
try {
|
||||
const info = await getSafeInfo(address, chainId)
|
||||
if (info) results.push({ chainId, chain, safeInfo: info })
|
||||
} catch {
|
||||
// skip failed chains
|
||||
}
|
||||
if (ids.length > 1) await sleep(300)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Integration transforms: convert external API data into FlowNodes
|
||||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types'
|
||||
import type { SafeBalance } from './api/safe-client'
|
||||
|
||||
// ─── Safe Balances → Funnel Nodes ────────────────────────────
|
||||
|
||||
export function safeBalancesToFunnels(
|
||||
balances: SafeBalance[],
|
||||
safeAddress: string,
|
||||
chainId: number,
|
||||
startPosition = { x: 0, y: 100 }
|
||||
): FlowNode[] {
|
||||
// Filter to non-zero balances with meaningful fiat value (> $1)
|
||||
const meaningful = balances.filter((b) => {
|
||||
const fiat = parseFloat(b.fiatBalance)
|
||||
return fiat > 1
|
||||
})
|
||||
|
||||
return meaningful.map((b, i) => {
|
||||
const fiatValue = parseFloat(b.fiatBalance)
|
||||
const source: IntegrationSource = {
|
||||
type: 'safe',
|
||||
safeAddress,
|
||||
safeChainId: chainId,
|
||||
tokenAddress: b.tokenAddress,
|
||||
tokenSymbol: b.symbol,
|
||||
tokenDecimals: b.token?.decimals ?? 18,
|
||||
lastFetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
const data: FunnelNodeData = {
|
||||
label: `${b.symbol} Treasury`,
|
||||
currentValue: fiatValue,
|
||||
minThreshold: Math.round(fiatValue * 0.2),
|
||||
maxThreshold: Math.round(fiatValue * 0.8),
|
||||
maxCapacity: Math.round(fiatValue * 1.5),
|
||||
inflowRate: 0,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [],
|
||||
source,
|
||||
}
|
||||
|
||||
return {
|
||||
id: `safe-${chainId}-${b.tokenAddress || 'native'}-${Date.now()}`,
|
||||
type: 'funnel' as const,
|
||||
position: { x: startPosition.x + i * 280, y: startPosition.y },
|
||||
data,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── rVote Proposals → Outcome Nodes ─────────────────────────
|
||||
|
||||
export interface RVoteProposal {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
score: number
|
||||
author?: { id: string; name: string | null }
|
||||
createdAt: string
|
||||
_count?: { votes: number; finalVotes: number }
|
||||
}
|
||||
|
||||
export function proposalsToOutcomes(
|
||||
proposals: RVoteProposal[],
|
||||
spaceSlug: string,
|
||||
startPosition = { x: 0, y: 600 }
|
||||
): FlowNode[] {
|
||||
return proposals.map((p, i) => {
|
||||
const source: IntegrationSource = {
|
||||
type: 'rvote',
|
||||
rvoteSpaceSlug: spaceSlug,
|
||||
rvoteProposalId: p.id,
|
||||
rvoteProposalStatus: p.status,
|
||||
rvoteProposalScore: p.score,
|
||||
lastFetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
const data: OutcomeNodeData = {
|
||||
label: p.title.length > 40 ? p.title.slice(0, 37) + '...' : p.title,
|
||||
description: p.description?.slice(0, 200) || '',
|
||||
fundingReceived: 0,
|
||||
fundingTarget: 0, // user sets this manually
|
||||
status: 'not-started',
|
||||
source,
|
||||
}
|
||||
|
||||
return {
|
||||
id: `rvote-${p.id}-${Date.now()}`,
|
||||
type: 'outcome' as const,
|
||||
position: { x: startPosition.x + i * 250, y: startPosition.y },
|
||||
data,
|
||||
}
|
||||
})
|
||||
}
|
||||
13
lib/state.ts
13
lib/state.ts
|
|
@ -1,34 +1,37 @@
|
|||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
|
||||
import type { FlowNode, SpaceConfig } from './types'
|
||||
import type { FlowNode, SpaceConfig, IntegrationConfig } from './types'
|
||||
|
||||
const STORAGE_PREFIX = 'rfunds-space-'
|
||||
|
||||
interface SerializableState {
|
||||
nodes: FlowNode[]
|
||||
integrations?: IntegrationConfig
|
||||
}
|
||||
|
||||
export function serializeState(nodes: FlowNode[]): string {
|
||||
export function serializeState(nodes: FlowNode[], integrations?: IntegrationConfig): string {
|
||||
const state: SerializableState = { nodes }
|
||||
if (integrations) state.integrations = integrations
|
||||
const json = JSON.stringify(state)
|
||||
return compressToEncodedURIComponent(json)
|
||||
}
|
||||
|
||||
export function deserializeState(compressed: string): { nodes: FlowNode[] } | null {
|
||||
export function deserializeState(compressed: string): { nodes: FlowNode[]; integrations?: IntegrationConfig } | null {
|
||||
try {
|
||||
const json = decompressFromEncodedURIComponent(compressed)
|
||||
if (!json) return null
|
||||
const state = JSON.parse(json) as SerializableState
|
||||
if (!state.nodes || !Array.isArray(state.nodes)) return null
|
||||
return { nodes: state.nodes }
|
||||
return { nodes: state.nodes, integrations: state.integrations }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToLocal(name: string, nodes: FlowNode[]): void {
|
||||
export function saveToLocal(name: string, nodes: FlowNode[], integrations?: IntegrationConfig): void {
|
||||
const config: SpaceConfig = {
|
||||
name,
|
||||
nodes,
|
||||
integrations,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
|
|
|||
67
lib/types.ts
67
lib/types.ts
|
|
@ -1,5 +1,65 @@
|
|||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
// ─── Integration Source Metadata ─────────────────────────────
|
||||
|
||||
export interface IntegrationSource {
|
||||
type: 'rvote' | 'safe' | 'manual'
|
||||
// Safe fields
|
||||
safeAddress?: string
|
||||
safeChainId?: number
|
||||
tokenAddress?: string | null // null = native token
|
||||
tokenSymbol?: string
|
||||
tokenDecimals?: number
|
||||
// rVote fields
|
||||
rvoteSpaceSlug?: string
|
||||
rvoteProposalId?: string
|
||||
rvoteProposalStatus?: string
|
||||
rvoteProposalScore?: number
|
||||
lastFetchedAt?: number
|
||||
}
|
||||
|
||||
// ─── Superfluid Stream (planning only) ───────────────────────
|
||||
|
||||
export interface StreamAllocation {
|
||||
targetId: string
|
||||
flowRate: number // tokens per month
|
||||
tokenSymbol: string // e.g., 'DAIx', 'USDCx'
|
||||
tokenAddress?: string
|
||||
status: 'planned' | 'active' | 'paused'
|
||||
color: string
|
||||
}
|
||||
|
||||
// ─── 0xSplits Configuration (planning only) ──────────────────
|
||||
|
||||
export interface SplitRecipient {
|
||||
address: string
|
||||
label?: string
|
||||
percentage: number // 0-100, must sum to 100
|
||||
}
|
||||
|
||||
export interface SplitsConfig {
|
||||
recipients: SplitRecipient[]
|
||||
distributorFee: number // 0-10%
|
||||
chainId: number
|
||||
}
|
||||
|
||||
// ─── Integration Config (persisted per space) ────────────────
|
||||
|
||||
export interface IntegrationConfig {
|
||||
rvote?: {
|
||||
spaceSlug: string
|
||||
spaceName?: string
|
||||
lastSyncedAt?: number
|
||||
}
|
||||
safe?: {
|
||||
address: string
|
||||
chainIds: number[] // e.g., [100, 10] for Gnosis + Optimism
|
||||
lastSyncedAt?: number
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core Flow Types ─────────────────────────────────────────
|
||||
|
||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||
export interface OverflowAllocation {
|
||||
targetId: string
|
||||
|
|
@ -25,6 +85,10 @@ export interface FunnelNodeData {
|
|||
overflowAllocations: OverflowAllocation[]
|
||||
// Spending goes DOWN to outcomes/outputs
|
||||
spendingAllocations: SpendingAllocation[]
|
||||
// Integration metadata
|
||||
source?: IntegrationSource
|
||||
streamAllocations?: StreamAllocation[]
|
||||
splitsConfig?: SplitsConfig
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +98,8 @@ export interface OutcomeNodeData {
|
|||
fundingReceived: number
|
||||
fundingTarget: number
|
||||
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
||||
// Integration metadata
|
||||
source?: IntegrationSource
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +122,7 @@ export type FlowEdge = Edge<FlowEdgeData>
|
|||
export interface SpaceConfig {
|
||||
name: string
|
||||
nodes: FlowNode[]
|
||||
integrations?: IntegrationConfig
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue