diff --git a/components/FlowCanvas.tsx b/components/FlowCanvas.tsx index 6e7f0c6..0238fbb 100644 --- a/components/FlowCanvas.tsx +++ b/components/FlowCanvas.tsx @@ -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
Spending (bottom)
+
+
+ Stream (Superfluid) +
Edge width = relative flow amount
diff --git a/components/IntegrationPanel.tsx b/components/IntegrationPanel.tsx index cfeed9b..370cdb3 100644 --- a/components/IntegrationPanel.tsx +++ b/components/IntegrationPanel.tsx @@ -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>([]) + const [fetchedProposals, setFetchedProposals] = useState([]) 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) => diff --git a/components/SplitsView.tsx b/components/SplitsView.tsx new file mode 100644 index 0000000..8776e3e --- /dev/null +++ b/components/SplitsView.tsx @@ -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 = { + 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 ( +
+
+
+ + 0xSplits Config + + + {chainName} + +
+ + Fee: {config.distributorFee}% + +
+ + {/* Combined percentage bar */} +
+ {config.recipients.map((r, i) => ( +
+ ))} +
+ + {/* Recipient list */} +
+ {config.recipients.map((r, i) => ( +
+
+ + {r.label || truncateAddress(r.address)} + + {r.percentage}% +
+ ))} +
+ + {totalPct !== 100 && ( +

+ Total is {totalPct}% (should be 100%) +

+ )} + + +
+ ) +} diff --git a/components/edges/AllocationEdge.tsx b/components/edges/AllocationEdge.tsx index 3645bf0..6b086c0 100644 --- a/components/edges/AllocationEdge.tsx +++ b/components/edges/AllocationEdge.tsx @@ -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, diff --git a/components/edges/StreamEdge.tsx b/components/edges/StreamEdge.tsx new file mode 100644 index 0000000..b17f731 --- /dev/null +++ b/components/edges/StreamEdge.tsx @@ -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 = { + 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 */} + + {/* Main path with animated dash */} + + {/* Inject animation keyframes */} + + + + +
+ {/* Flow rate label */} + + {flowRate.toLocaleString()} {tokenSymbol}/mo + + {/* Status pill */} + + {colors.label} + +
+
+ + ) +} diff --git a/components/nodes/FunnelNode.tsx b/components/nodes/FunnelNode.tsx index 88628da..57a5bbc 100644 --- a/components/nodes/FunnelNode.tsx +++ b/components/nodes/FunnelNode.tsx @@ -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 */} +
{isEditing && ( @@ -826,6 +836,36 @@ function FunnelNode({ data, selected, id }: NodeProps) {
+ {nodeData.splitsConfig && ( +
+ +
+ )} + + {nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && ( +
+ + Superfluid Streams + +
+ {nodeData.streamAllocations.map((stream, i) => ( +
+
+ + {stream.targetId} +
+ + {stream.flowRate.toLocaleString()} {stream.tokenSymbol}/mo + +
+ ))} +
+
+ )} +

Drag pie slices to adjust • Click + to add new items

diff --git a/lib/api/rvote-client.ts b/lib/api/rvote-client.ts new file mode 100644 index 0000000..ac170be --- /dev/null +++ b/lib/api/rvote-client.ts @@ -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 { + 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 { + 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 : [] +} diff --git a/lib/integrations.ts b/lib/integrations.ts index d63160f..4609e25 100644 --- a/lib/integrations.ts +++ b/lib/integrations.ts @@ -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 } } diff --git a/lib/types.ts b/lib/types.ts index 5e4e16c..8effba1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -105,7 +105,7 @@ export interface OutcomeNodeData { export type FlowNode = Node -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 // Serializable space config (no xyflow internals)