feat: add Superfluid stream edges, 0xSplits view, and rVote API client
- StreamEdge component with animated dash pattern for Superfluid flow visualization (planned/active/paused status, flow rate + token labels) - SplitsView component showing recipient addresses, percentage bars, and export config - rVote API client module (fetchSpace, fetchPassedProposals) via CORS proxy - Stream-out handle on FunnelNode for creating stream connections - Splits and streams display in FunnelNode edit modal - Edge type system refactored to support allocation + stream edge data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f012502133
commit
8c3baed4b8
|
|
@ -19,8 +19,9 @@ import '@xyflow/react/dist/style.css'
|
|||
import FunnelNode from './nodes/FunnelNode'
|
||||
import OutcomeNode from './nodes/OutcomeNode'
|
||||
import AllocationEdge from './edges/AllocationEdge'
|
||||
import StreamEdge from './edges/StreamEdge'
|
||||
import IntegrationPanel from './IntegrationPanel'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig } from '@/lib/types'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||
|
||||
const nodeTypes = {
|
||||
|
|
@ -30,6 +31,7 @@ const nodeTypes = {
|
|||
|
||||
const edgeTypes = {
|
||||
allocation: AllocationEdge,
|
||||
stream: StreamEdge,
|
||||
}
|
||||
|
||||
// Generate edges with proportional Sankey-style widths
|
||||
|
|
@ -140,6 +142,36 @@ function generateEdges(
|
|||
type: 'allocation',
|
||||
})
|
||||
})
|
||||
|
||||
// Stream edges (Superfluid visual planning)
|
||||
data.streamAllocations?.forEach((stream) => {
|
||||
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
|
||||
edges.push({
|
||||
id: `stream-${node.id}-${stream.targetId}`,
|
||||
source: node.id,
|
||||
target: stream.targetId,
|
||||
sourceHandle: 'stream-out',
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: statusColor,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: statusColor,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
data: {
|
||||
flowRate: stream.flowRate,
|
||||
tokenSymbol: stream.tokenSymbol,
|
||||
status: stream.status,
|
||||
sourceId: node.id,
|
||||
targetId: stream.targetId,
|
||||
},
|
||||
type: 'stream',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return edges
|
||||
|
|
@ -230,6 +262,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
id: n.id,
|
||||
overflow: d.overflowAllocations,
|
||||
spending: d.spendingAllocations,
|
||||
streams: d.streamAllocations,
|
||||
rate: d.inflowRate,
|
||||
}
|
||||
})
|
||||
|
|
@ -247,8 +280,9 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
|
||||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||
const isSpending = params.sourceHandle === 'spending-out'
|
||||
const isStream = params.sourceHandle === 'stream-out'
|
||||
|
||||
if (!isOverflow && !isSpending) return
|
||||
if (!isOverflow && !isSpending && !isStream) return
|
||||
|
||||
setNodes((nds) => nds.map((node) => {
|
||||
if (node.id !== params.source || node.type !== 'funnel') return node
|
||||
|
|
@ -276,7 +310,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
} else if (isSpending) {
|
||||
const existing = data.spendingAllocations || []
|
||||
if (existing.some(a => a.targetId === params.target)) return node
|
||||
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||
|
|
@ -298,6 +332,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
],
|
||||
},
|
||||
}
|
||||
} else if (isStream) {
|
||||
const existing = data.streamAllocations || []
|
||||
if (existing.some(s => s.targetId === params.target)) return node
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
streamAllocations: [
|
||||
...existing,
|
||||
{
|
||||
targetId: params.target!,
|
||||
flowRate: 100,
|
||||
tokenSymbol: 'DAIx',
|
||||
status: 'planned' as const,
|
||||
color: '#22c55e',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return node
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
|
@ -306,13 +361,31 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
|
||||
const onReconnect = useCallback(
|
||||
(oldEdge: FlowEdge, newConnection: Connection) => {
|
||||
const edgeData = oldEdge.data
|
||||
const edgeData = oldEdge.data as AllocationEdgeData | undefined
|
||||
if (!edgeData || !newConnection.target) return
|
||||
|
||||
const oldTargetId = oldEdge.target
|
||||
const newTargetId = newConnection.target
|
||||
if (oldTargetId === newTargetId) return
|
||||
|
||||
// Stream edges: reconnect stream allocation
|
||||
if (oldEdge.type === 'stream') {
|
||||
setNodes((nds) => nds.map((node) => {
|
||||
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
||||
const data = node.data as FunnelNodeData
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
streamAllocations: (data.streamAllocations || []).map(s =>
|
||||
s.targetId === oldTargetId ? { ...s, targetId: newTargetId } : s
|
||||
),
|
||||
},
|
||||
}
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
setNodes((nds) => nds.map((node) => {
|
||||
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
||||
const data = node.data as FunnelNodeData
|
||||
|
|
@ -353,7 +426,19 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||
const data = node.data as FunnelNodeData
|
||||
|
||||
if (edge.data!.edgeType === 'overflow') {
|
||||
// Stream edge removal
|
||||
if (edge.type === 'stream') {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...data,
|
||||
streamAllocations: (data.streamAllocations || []).filter(s => s.targetId !== edge.target),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const allocData = edge.data as AllocationEdgeData
|
||||
if (allocData.edgeType === 'overflow') {
|
||||
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
||||
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||
return {
|
||||
|
|
@ -564,6 +649,10 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-600">Spending (bottom)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
|
||||
<span className="text-slate-600">Stream (Superfluid)</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
||||
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ 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'
|
||||
import { safeBalancesToFunnels, proposalsToOutcomes } from '@/lib/integrations'
|
||||
import { fetchSpace, fetchPassedProposals } from '@/lib/api/rvote-client'
|
||||
import type { RVoteProposal } from '@/lib/api/rvote-client'
|
||||
|
||||
interface IntegrationPanelProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -37,6 +39,7 @@ export default function IntegrationPanel({
|
|||
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 [fetchedProposals, setFetchedProposals] = useState<RVoteProposal[]>([])
|
||||
|
||||
const handleFetchSafe = useCallback(async () => {
|
||||
if (!safeAddress.match(/^0x[a-fA-F0-9]{40}$/)) {
|
||||
|
|
@ -109,32 +112,14 @@ export default function IntegrationPanel({
|
|||
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()
|
||||
const space = await fetchSpace(rvoteSlug)
|
||||
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
|
||||
|
||||
const proposalsRes = await fetch(
|
||||
`/api/proxy/rvote?endpoint=proposals&slug=${encodeURIComponent(rvoteSlug)}&status=PASSED`
|
||||
const proposals = await fetchPassedProposals(rvoteSlug)
|
||||
setRvoteProposals(
|
||||
proposals.map((p) => ({ id: p.id, title: p.title, score: p.score }))
|
||||
)
|
||||
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,
|
||||
}))
|
||||
: []
|
||||
)
|
||||
}
|
||||
setFetchedProposals(proposals)
|
||||
|
||||
onIntegrationsChange({
|
||||
...integrations,
|
||||
|
|
@ -151,29 +136,14 @@ export default function IntegrationPanel({
|
|||
}
|
||||
}, [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')
|
||||
const handleImportRvote = useCallback(() => {
|
||||
if (fetchedProposals.length === 0) return
|
||||
const nodes = proposalsToOutcomes(fetchedProposals, rvoteSlug)
|
||||
if (nodes.length > 0) {
|
||||
onImportNodes(nodes)
|
||||
onClose()
|
||||
}
|
||||
}, [rvoteProposals, rvoteSlug, onImportNodes, onClose])
|
||||
}, [fetchedProposals, rvoteSlug, onImportNodes, onClose])
|
||||
|
||||
const toggleChain = (chainId: number) => {
|
||||
setSelectedChains((prev) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { SplitsConfig } from '@/lib/types'
|
||||
|
||||
interface SplitsViewProps {
|
||||
config: SplitsConfig
|
||||
}
|
||||
|
||||
const CHAIN_NAMES: Record<number, string> = {
|
||||
100: 'Gnosis',
|
||||
10: 'Optimism',
|
||||
1: 'Ethereum',
|
||||
}
|
||||
|
||||
function truncateAddress(addr: string): string {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
const BAR_COLORS = [
|
||||
'#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981',
|
||||
'#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#f97316',
|
||||
]
|
||||
|
||||
export default function SplitsView({ config }: SplitsViewProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const exportData = {
|
||||
type: '0xSplits',
|
||||
chainId: config.chainId,
|
||||
distributorFee: config.distributorFee,
|
||||
recipients: config.recipients.map((r) => ({
|
||||
address: r.address,
|
||||
percentAllocation: r.percentage * 10000, // 0xSplits uses basis points (ppm)
|
||||
})),
|
||||
}
|
||||
navigator.clipboard.writeText(JSON.stringify(exportData, null, 2)).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [config])
|
||||
|
||||
const totalPct = config.recipients.reduce((s, r) => s + r.percentage, 0)
|
||||
const chainName = CHAIN_NAMES[config.chainId] || `Chain ${config.chainId}`
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-700 uppercase tracking-wide">
|
||||
0xSplits Config
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded">
|
||||
{chainName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Fee: {config.distributorFee}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Combined percentage bar */}
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden flex">
|
||||
{config.recipients.map((r, i) => (
|
||||
<div
|
||||
key={r.address}
|
||||
className="h-full transition-all"
|
||||
style={{
|
||||
width: `${(r.percentage / totalPct) * 100}%`,
|
||||
backgroundColor: BAR_COLORS[i % BAR_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recipient list */}
|
||||
<div className="space-y-1.5">
|
||||
{config.recipients.map((r, i) => (
|
||||
<div key={r.address} className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: BAR_COLORS[i % BAR_COLORS.length] }}
|
||||
/>
|
||||
<span className="font-mono text-slate-600 flex-1 truncate" title={r.address}>
|
||||
{r.label || truncateAddress(r.address)}
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-800">{r.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPct !== 100 && (
|
||||
<p className="text-[10px] text-amber-600">
|
||||
Total is {totalPct}% (should be 100%)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-xs font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-all"
|
||||
>
|
||||
{copied ? 'Copied to clipboard!' : 'Export Config JSON'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
BaseEdge,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import type { FlowEdgeData } from '@/lib/types'
|
||||
import type { AllocationEdgeData } from '@/lib/types'
|
||||
|
||||
export default function AllocationEdge({
|
||||
id,
|
||||
|
|
@ -21,7 +21,7 @@ export default function AllocationEdge({
|
|||
style,
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const edgeData = data as FlowEdgeData | undefined
|
||||
const edgeData = data as AllocationEdgeData | undefined
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import type { StreamEdgeData } from '@/lib/types'
|
||||
|
||||
const STATUS_COLORS: Record<string, { stroke: string; bg: string; text: string; label: string }> = {
|
||||
planned: { stroke: '#22c55e', bg: 'rgba(34,197,94,0.12)', text: '#16a34a', label: 'Planned' },
|
||||
active: { stroke: '#3b82f6', bg: 'rgba(59,130,246,0.12)', text: '#2563eb', label: 'Active' },
|
||||
paused: { stroke: '#f97316', bg: 'rgba(249,115,22,0.12)', text: '#ea580c', label: 'Paused' },
|
||||
}
|
||||
|
||||
export default function StreamEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const edgeData = data as StreamEdgeData | undefined
|
||||
const status = edgeData?.status ?? 'planned'
|
||||
const flowRate = edgeData?.flowRate ?? 0
|
||||
const tokenSymbol = edgeData?.tokenSymbol ?? 'DAIx'
|
||||
const colors = STATUS_COLORS[status] || STATUS_COLORS.planned
|
||||
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
// Animated dashed line to simulate flowing tokens
|
||||
const isPaused = status === 'paused'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background glow path */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={colors.stroke}
|
||||
strokeWidth={8}
|
||||
strokeOpacity={0.15}
|
||||
/>
|
||||
{/* Main path with animated dash */}
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
stroke: colors.stroke,
|
||||
strokeWidth: 3,
|
||||
strokeDasharray: '8 4',
|
||||
animation: isPaused ? 'none' : 'streamFlow 1s linear infinite',
|
||||
opacity: isPaused ? 0.5 : 0.9,
|
||||
}}
|
||||
/>
|
||||
{/* Inject animation keyframes */}
|
||||
<foreignObject width={0} height={0}>
|
||||
<style>{`
|
||||
@keyframes streamFlow {
|
||||
to { stroke-dashoffset: -12; }
|
||||
}
|
||||
`}</style>
|
||||
</foreignObject>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="nodrag nopan"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* Flow rate label */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'monospace',
|
||||
color: colors.text,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${colors.stroke}`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{flowRate.toLocaleString()} {tokenSymbol}/mo
|
||||
</span>
|
||||
{/* Status pill */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: colors.text,
|
||||
background: colors.bg,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{colors.label}
|
||||
</span>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { memo, useState, useCallback, useRef, useEffect } from 'react'
|
|||
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||
import type { NodeProps } from '@xyflow/react'
|
||||
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||
import SplitsView from '../SplitsView'
|
||||
|
||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||
|
|
@ -597,6 +598,15 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
|||
id="spending-out"
|
||||
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
|
||||
/>
|
||||
|
||||
{/* Stream handle (Superfluid) - right side, lower */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="stream-out"
|
||||
className="!w-3.5 !h-3.5 !bg-green-500 !border-2 !border-white !rounded-full"
|
||||
style={{ top: '65%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
|
|
@ -826,6 +836,36 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{nodeData.splitsConfig && (
|
||||
<div className="mt-4 p-4 bg-slate-50 rounded-xl">
|
||||
<SplitsView config={nodeData.splitsConfig} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-green-50 rounded-xl">
|
||||
<span className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-2">
|
||||
Superfluid Streams
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{nodeData.streamAllocations.map((stream, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
stream.status === 'active' ? 'bg-blue-500' :
|
||||
stream.status === 'paused' ? 'bg-orange-500' : 'bg-green-500'
|
||||
}`} />
|
||||
<span className="text-slate-600">{stream.targetId}</span>
|
||||
</div>
|
||||
<span className="font-mono text-green-700">
|
||||
{stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-[10px] text-slate-400 mt-4">
|
||||
Drag pie slices to adjust • Click + to add new items
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
export interface RVoteSpace {
|
||||
name: string
|
||||
slug: string
|
||||
_count?: { members: number }
|
||||
}
|
||||
|
||||
export interface RVoteProposal {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
score: number
|
||||
status: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const PROXY_BASE = '/api/proxy/rvote'
|
||||
|
||||
export async function fetchSpace(slug: string): Promise<RVoteSpace> {
|
||||
const res = await fetch(`${PROXY_BASE}?endpoint=space&slug=${encodeURIComponent(slug)}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error('Space not found')
|
||||
throw new Error(`Failed to fetch space: ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchPassedProposals(slug: string): Promise<RVoteProposal[]> {
|
||||
const res = await fetch(
|
||||
`${PROXY_BASE}?endpoint=proposals&slug=${encodeURIComponent(slug)}&status=PASSED`
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch proposals: ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const proposals = data.proposals || data.results || data || []
|
||||
return Array.isArray(proposals) ? proposals : []
|
||||
}
|
||||
|
|
@ -57,11 +57,11 @@ export function safeBalancesToFunnels(
|
|||
export interface RVoteProposal {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
description?: string
|
||||
status: string
|
||||
score: number
|
||||
author?: { id: string; name: string | null }
|
||||
createdAt: string
|
||||
createdAt?: string
|
||||
_count?: { votes: number; finalVotes: number }
|
||||
}
|
||||
|
||||
|
|
|
|||
13
lib/types.ts
13
lib/types.ts
|
|
@ -105,7 +105,7 @@ export interface OutcomeNodeData {
|
|||
|
||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
||||
|
||||
export interface FlowEdgeData {
|
||||
export interface AllocationEdgeData {
|
||||
allocation: number // percentage 0-100
|
||||
color: string
|
||||
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
||||
|
|
@ -116,6 +116,17 @@ export interface FlowEdgeData {
|
|||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface StreamEdgeData {
|
||||
flowRate: number
|
||||
tokenSymbol: string
|
||||
status: 'planned' | 'active' | 'paused'
|
||||
sourceId: string
|
||||
targetId: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FlowEdgeData = AllocationEdgeData | StreamEdgeData
|
||||
|
||||
export type FlowEdge = Edge<FlowEdgeData>
|
||||
|
||||
// Serializable space config (no xyflow internals)
|
||||
|
|
|
|||
Loading…
Reference in New Issue