rfunds-online/components/IntegrationPanel.tsx

329 lines
13 KiB
TypeScript

'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, proposalsToOutcomes } from '@/lib/integrations'
import { fetchSpace, fetchPassedProposals } from '@/lib/api/rvote-client'
import type { RVoteProposal } from '@/lib/api/rvote-client'
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 [fetchedProposals, setFetchedProposals] = useState<RVoteProposal[]>([])
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, undefined, {
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 {
const space = await fetchSpace(rvoteSlug)
setRvoteSpace({ name: space.name, memberCount: space._count?.members || 0 })
const proposals = await fetchPassedProposals(rvoteSlug)
setRvoteProposals(
proposals.map((p) => ({ id: p.id, title: p.title, score: p.score }))
)
setFetchedProposals(proposals)
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(() => {
if (fetchedProposals.length === 0) return
const nodes = proposalsToOutcomes(fetchedProposals, rvoteSlug)
if (nodes.length > 0) {
onImportNodes(nodes)
onClose()
}
}, [fetchedProposals, 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">
&times;
</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>
{' '}&bull; {rvoteSpace.memberCount} members
{' '}&bull; {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>
)
}