329 lines
13 KiB
TypeScript
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, {
|
|
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">
|
|
×
|
|
</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>
|
|
)
|
|
}
|