rfunds-online/app/space/page.tsx

695 lines
29 KiB
TypeScript

'use client'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useAuthStore } from '@/lib/auth'
import { AuthButton } from '@/components/AuthButton'
import { starterNodes } from '@/lib/presets'
import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state'
import type { FlowNode, SpaceConfig, IntegrationConfig } from '@/lib/types'
import {
deployNodes,
getFlow,
syncNodesToBackend,
deposit,
listFlows,
fromSmallestUnit,
initiateOnRamp,
type BackendFlow,
type DeployResult,
} from '@/lib/api/flows-client'
import TransakWidget from '@/components/TransakWidget'
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center bg-slate-50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-slate-600">Loading flow editor...</span>
</div>
</div>
),
})
export default function SpacePage() {
const { isAuthenticated, token } = useAuthStore()
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)
const [savedSpaces, setSavedSpaces] = useState<SpaceConfig[]>([])
const [copied, setCopied] = useState(false)
const [loaded, setLoaded] = useState(false)
const nodesRef = useRef<FlowNode[]>(starterNodes)
const integrationsRef = useRef<IntegrationConfig | undefined>()
// Backend integration state
const [deployedFlow, setDeployedFlow] = useState<BackendFlow | null>(null)
const [idMap, setIdMap] = useState<Record<string, string>>({})
const [showDeployDialog, setShowDeployDialog] = useState(false)
const [showDepositDialog, setShowDepositDialog] = useState(false)
const [showLoadFlowDialog, setShowLoadFlowDialog] = useState(false)
const [deployName, setDeployName] = useState('')
const [ownerAddress, setOwnerAddress] = useState('')
const [deploying, setDeploying] = useState(false)
const [syncing, setSyncing] = useState(false)
const [depositFunnelId, setDepositFunnelId] = useState('')
const [depositAmount, setDepositAmount] = useState('')
const [depositing, setDepositing] = useState(false)
const [backendFlows, setBackendFlows] = useState<BackendFlow[]>([])
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
const [transakUrl, setTransakUrl] = useState<string | null>(null)
const [showFundDropdown, setShowFundDropdown] = useState(false)
// Load saved owner address
useEffect(() => {
if (typeof window === 'undefined') return
const saved = localStorage.getItem('rfunds-owner-address')
if (saved) setOwnerAddress(saved)
}, [])
const showStatus = useCallback((text: string, type: 'success' | 'error') => {
setStatusMessage({ text, type })
setTimeout(() => setStatusMessage(null), 4000)
}, [])
const handleIntegrationsChange = useCallback((config: IntegrationConfig) => {
setIntegrations(config)
integrationsRef.current = config
}, [])
// Load from URL hash on mount
useEffect(() => {
if (typeof window === 'undefined') return
const hash = window.location.hash.slice(1)
if (hash.startsWith('s=')) {
const compressed = hash.slice(2)
const state = deserializeState(compressed)
if (state) {
setCurrentNodes(state.nodes)
nodesRef.current = state.nodes
if (state.integrations) {
setIntegrations(state.integrations)
integrationsRef.current = state.integrations
}
}
}
setLoaded(true)
}, [])
const handleNodesChange = useCallback((nodes: FlowNode[]) => {
nodesRef.current = nodes
}, [])
const handleShare = useCallback(() => {
const compressed = serializeState(nodesRef.current, integrationsRef.current)
const url = `${window.location.origin}/space#s=${compressed}`
navigator.clipboard.writeText(url).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [])
const handleSave = useCallback(() => {
if (!spaceName.trim()) return
saveToLocal(spaceName.trim(), nodesRef.current, integrationsRef.current)
setShowSaveDialog(false)
setSpaceName('')
}, [spaceName])
const handleLoadOpen = useCallback(() => {
setSavedSpaces(listSavedSpaces())
setShowLoadDialog(true)
}, [])
const handleLoadSpace = useCallback((config: SpaceConfig) => {
setCurrentNodes(config.nodes)
nodesRef.current = config.nodes
if (config.integrations) {
setIntegrations(config.integrations)
integrationsRef.current = config.integrations
}
setShowLoadDialog(false)
}, [])
const handleDeleteSpace = useCallback((name: string) => {
deleteFromLocal(name)
setSavedSpaces(listSavedSpaces())
}, [])
const handleReset = useCallback(() => {
if (confirm('Reset canvas to a single empty funnel? This cannot be undone.')) {
setCurrentNodes([...starterNodes])
nodesRef.current = [...starterNodes]
setDeployedFlow(null)
setIdMap({})
window.history.replaceState(null, '', window.location.pathname)
}
}, [])
// ─── Backend Integration Handlers ───────────────────────────
const handleDeploy = useCallback(async () => {
if (!deployName.trim() || !ownerAddress.trim()) return
setDeploying(true)
try {
localStorage.setItem('rfunds-owner-address', ownerAddress.trim())
const result: DeployResult = await deployNodes(
deployName.trim(),
ownerAddress.trim(),
nodesRef.current
)
setDeployedFlow(result.flow)
setIdMap(result.idMap)
setShowDeployDialog(false)
setDeployName('')
showStatus(`Deployed "${result.flow.name}" (${result.flow.funnels.length} funnels, ${result.flow.outcomes.length} outcomes)`, 'success')
} catch (err) {
showStatus(`Deploy failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
} finally {
setDeploying(false)
}
}, [deployName, ownerAddress, showStatus])
const handleSync = useCallback(async () => {
if (!deployedFlow) return
setSyncing(true)
try {
const latest = await getFlow(deployedFlow.id)
setDeployedFlow(latest)
const updated = syncNodesToBackend(nodesRef.current, latest, idMap)
setCurrentNodes(updated)
nodesRef.current = updated
showStatus('Synced live balances from backend', 'success')
} catch (err) {
showStatus(`Sync failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
} finally {
setSyncing(false)
}
}, [deployedFlow, idMap, showStatus])
const handleDeposit = useCallback(async () => {
if (!deployedFlow || !depositFunnelId || !depositAmount) return
setDepositing(true)
try {
const amount = parseFloat(depositAmount)
if (isNaN(amount) || amount <= 0) throw new Error('Invalid amount')
const tx = await deposit(deployedFlow.id, depositFunnelId, amount)
showStatus(`Deposited $${amount} USDC (tx: ${tx.status})`, 'success')
setShowDepositDialog(false)
setDepositAmount('')
// Auto-sync after deposit
const latest = await getFlow(deployedFlow.id)
setDeployedFlow(latest)
const updated = syncNodesToBackend(nodesRef.current, latest, idMap)
setCurrentNodes(updated)
nodesRef.current = updated
} catch (err) {
showStatus(`Deposit failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
} finally {
setDepositing(false)
}
}, [deployedFlow, depositFunnelId, depositAmount, idMap, showStatus])
const handlePayWithCard = useCallback(async () => {
if (!deployedFlow || deployedFlow.funnels.length === 0) return
setShowFundDropdown(false)
try {
const funnelId = depositFunnelId || deployedFlow.funnels[0].id
const amount = depositAmount ? parseFloat(depositAmount) : 100
const result = await initiateOnRamp(deployedFlow.id, funnelId, amount, 'USD')
if (result.widgetUrl) {
setTransakUrl(result.widgetUrl)
} else {
showStatus('No widget URL returned from on-ramp provider', 'error')
}
} catch (err) {
showStatus(`Card payment failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
}
}, [deployedFlow, depositFunnelId, depositAmount, showStatus])
const handleLoadFlowOpen = useCallback(async () => {
if (!ownerAddress.trim()) {
showStatus('Set your wallet address first (via Deploy dialog)', 'error')
return
}
try {
const flows = await listFlows(ownerAddress.trim())
setBackendFlows(flows)
setShowLoadFlowDialog(true)
} catch (err) {
showStatus(`Failed to list flows: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
}
}, [ownerAddress, showStatus])
const handleLoadFlow = useCallback((flow: BackendFlow) => {
setDeployedFlow(flow)
// Build idMap from backend flow — map by index (best effort without original mapping)
const newIdMap: Record<string, string> = {}
const funnelNodes = nodesRef.current.filter(n => n.type === 'funnel')
const outcomeNodes = nodesRef.current.filter(n => n.type === 'outcome')
flow.funnels.forEach((f, i) => {
if (funnelNodes[i]) newIdMap[funnelNodes[i].id] = f.id
})
flow.outcomes.forEach((o, i) => {
if (outcomeNodes[i]) newIdMap[outcomeNodes[i].id] = o.id
})
setIdMap(newIdMap)
setShowLoadFlowDialog(false)
showStatus(`Loaded flow "${flow.name}" (${flow.status})`, 'success')
}, [showStatus])
// Set auth cookie for middleware (so server-side can check)
useEffect(() => {
if (token) {
document.cookie = `encryptid_token=${token}; path=/; max-age=86400; SameSite=Lax`
} else {
document.cookie = 'encryptid_token=; path=/; max-age=0'
}
}, [token])
if (!loaded) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-slate-50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-slate-600">Loading...</span>
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<div className="h-screen w-screen flex flex-col items-center justify-center bg-slate-50 gap-6">
<div className="text-center">
<h1 className="text-2xl font-bold text-slate-800 mb-2">Sign in to access your Space</h1>
<p className="text-slate-500">EncryptID passkey authentication is required to create and edit funding flows.</p>
</div>
<AuthButton />
<Link href="/" className="text-sm text-slate-400 hover:text-blue-500 underline">
Back to home
</Link>
</div>
)
}
return (
<main className="h-screen w-screen flex flex-col">
{/* Status Toast */}
{statusMessage && (
<div className={`fixed top-16 left-1/2 -translate-x-1/2 z-[60] px-4 py-2 rounded-lg shadow-lg text-sm font-medium transition-all ${
statusMessage.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
}`}>
{statusMessage.text}
</div>
)}
{/* Toolbar */}
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0">
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
rF
</div>
<span className="font-medium">rFunds</span>
</Link>
<span className="text-slate-500">|</span>
<span className="text-slate-300">Your Space</span>
<span className="text-slate-500">|</span>
<AuthButton />
{deployedFlow && (
<>
<span className="text-slate-500">|</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
deployedFlow.status === 'active' ? 'bg-emerald-600/30 text-emerald-300' :
deployedFlow.status === 'paused' ? 'bg-amber-600/30 text-amber-300' :
'bg-slate-600/30 text-slate-400'
}`}>
{deployedFlow.status} &middot; {deployedFlow.name}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{/* Local operations */}
<button
onClick={() => setShowSaveDialog(true)}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs font-medium transition-colors"
>
Save
</button>
<button
onClick={handleLoadOpen}
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
>
Load
</button>
<button
onClick={handleShare}
className="px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded text-xs font-medium transition-colors"
>
{copied ? 'Copied!' : 'Share'}
</button>
{/* Separator */}
<div className="w-px h-5 bg-slate-600 mx-1" />
{/* Backend operations */}
<button
onClick={() => setShowDeployDialog(true)}
className="px-3 py-1 bg-violet-600 hover:bg-violet-500 rounded text-xs font-medium transition-colors"
>
Deploy
</button>
{deployedFlow && (
<>
<button
onClick={handleSync}
disabled={syncing}
className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-medium transition-colors disabled:opacity-50"
>
{syncing ? 'Syncing...' : 'Sync'}
</button>
<div className="relative">
<button
onClick={() => setShowFundDropdown(!showFundDropdown)}
className="px-3 py-1 bg-green-600 hover:bg-green-500 rounded text-xs font-medium transition-colors flex items-center gap-1"
>
Fund
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showFundDropdown && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowFundDropdown(false)} />
<div className="absolute top-full right-0 mt-1 bg-white rounded-lg shadow-xl border border-slate-200 py-1 min-w-[160px] z-50">
<button
onClick={() => {
if (deployedFlow.funnels.length > 0) {
setDepositFunnelId(deployedFlow.funnels[0].id)
}
setShowDepositDialog(true)
setShowFundDropdown(false)
}}
className="w-full text-left px-3 py-2 text-xs text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<svg className="w-3.5 h-3.5 text-cyan-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Deposit (Crypto)
</button>
<button
onClick={handlePayWithCard}
className="w-full text-left px-3 py-2 text-xs text-slate-700 hover:bg-slate-50 flex items-center gap-2"
>
<svg className="w-3.5 h-3.5 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
Pay with Card
</button>
</div>
</>
)}
</div>
</>
)}
<button
onClick={handleLoadFlowOpen}
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
title="Load a previously deployed flow"
>
Flows
</button>
{/* Separator */}
<div className="w-px h-5 bg-slate-600 mx-1" />
<button
onClick={handleReset}
className="px-3 py-1 bg-slate-700 hover:bg-red-600 rounded text-xs font-medium transition-colors"
>
Reset
</button>
</div>
</div>
{/* Canvas */}
<div className="flex-1">
<FlowCanvas
key={JSON.stringify(currentNodes.map(n => n.id))}
initialNodes={currentNodes}
mode="space"
onNodesChange={handleNodesChange}
integrations={integrations}
onIntegrationsChange={handleIntegrationsChange}
/>
</div>
{/* Save Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowSaveDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-80" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-4">Save Space</h3>
<input
type="text"
value={spaceName}
onChange={e => setSpaceName(e.target.value)}
placeholder="Space name..."
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-4 text-slate-800"
autoFocus
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={!spaceName.trim()}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
<button
onClick={() => setShowSaveDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Load Dialog */}
{showLoadDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96 max-h-[60vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-4">Load Space</h3>
{savedSpaces.length === 0 ? (
<p className="text-sm text-slate-500 py-4 text-center">No saved spaces yet.</p>
) : (
<div className="space-y-2">
{savedSpaces.map((space) => (
<div
key={space.name}
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<button
onClick={() => handleLoadSpace(space)}
className="flex-1 text-left"
>
<div className="text-sm font-medium text-slate-800">{space.name}</div>
<div className="text-[10px] text-slate-500">
{space.nodes.filter(n => n.type === 'funnel').length} funnels &bull;{' '}
{space.nodes.filter(n => n.type === 'outcome').length} outcomes &bull;{' '}
{new Date(space.updatedAt).toLocaleDateString()}
</div>
</button>
<button
onClick={() => handleDeleteSpace(space.name)}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
<button
onClick={() => setShowLoadDialog(false)}
className="w-full mt-4 px-4 py-2 text-slate-600 hover:text-slate-800 text-sm border border-slate-200 rounded-lg"
>
Close
</button>
</div>
</div>
)}
{/* Deploy Dialog */}
{showDeployDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowDeployDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-1">Deploy to Backend</h3>
<p className="text-xs text-slate-500 mb-4">
Creates a live flow on the server with real threshold logic, overflow distribution, and fiat on-ramp support.
</p>
<input
type="text"
value={deployName}
onChange={e => setDeployName(e.target.value)}
placeholder="Flow name..."
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800"
autoFocus
/>
<input
type="text"
value={ownerAddress}
onChange={e => setOwnerAddress(e.target.value)}
placeholder="Owner wallet address (0x...)"
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800 font-mono text-xs"
/>
<div className="text-[10px] text-slate-400 mb-4">
{nodesRef.current.filter(n => n.type === 'funnel').length} funnels &bull;{' '}
{nodesRef.current.filter(n => n.type === 'outcome').length} outcomes will be created on Base Sepolia (USDC)
</div>
<div className="flex gap-2">
<button
onClick={handleDeploy}
disabled={!deployName.trim() || !ownerAddress.trim() || deploying}
className="flex-1 px-4 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{deploying ? 'Deploying...' : 'Deploy'}
</button>
<button
onClick={() => setShowDeployDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Deposit Dialog */}
{showDepositDialog && deployedFlow && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowDepositDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-1">Deposit USDC</h3>
<p className="text-xs text-slate-500 mb-4">
Simulate a deposit into a funnel. If the deposit pushes the balance above MAX threshold, overflow will automatically distribute.
</p>
<select
value={depositFunnelId}
onChange={e => setDepositFunnelId(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-3 text-slate-800"
>
{deployedFlow.funnels.map(f => (
<option key={f.id} value={f.id}>
{f.name} (balance: ${fromSmallestUnit(f.balance).toLocaleString()})
</option>
))}
</select>
<div className="relative mb-4">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">$</span>
<input
type="number"
value={depositAmount}
onChange={e => setDepositAmount(e.target.value)}
placeholder="Amount (USDC)"
className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
min="0"
step="0.01"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleDeposit}
disabled={!depositAmount || depositing}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{depositing ? 'Depositing...' : 'Deposit'}
</button>
<button
onClick={() => setShowDepositDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Transak Widget */}
{transakUrl && (
<TransakWidget
widgetUrl={transakUrl}
onClose={() => setTransakUrl(null)}
onComplete={(orderId) => {
showStatus(`Card payment completed (order: ${orderId})`, 'success')
// Auto-sync after card payment
if (deployedFlow) {
getFlow(deployedFlow.id).then((latest) => {
setDeployedFlow(latest)
const updated = syncNodesToBackend(nodesRef.current, latest, idMap)
setCurrentNodes(updated)
nodesRef.current = updated
}).catch(() => {})
}
}}
/>
)}
{/* Load Backend Flow Dialog */}
{showLoadFlowDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadFlowDialog(false)}>
<div className="bg-white rounded-xl shadow-2xl p-6 w-96 max-h-[60vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-4">Deployed Flows</h3>
{backendFlows.length === 0 ? (
<p className="text-sm text-slate-500 py-4 text-center">No deployed flows yet.</p>
) : (
<div className="space-y-2">
{backendFlows.map((flow) => (
<button
key={flow.id}
onClick={() => handleLoadFlow(flow)}
className="w-full text-left p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-slate-800">{flow.name}</div>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
flow.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
flow.status === 'paused' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-600'
}`}>
{flow.status}
</span>
</div>
<div className="text-[10px] text-slate-500 mt-1">
{flow.funnels.length} funnels &bull; {flow.outcomes.length} outcomes &bull;{' '}
{new Date(flow.updatedAt).toLocaleDateString()}
</div>
</button>
))}
</div>
)}
<button
onClick={() => setShowLoadFlowDialog(false)}
className="w-full mt-4 px-4 py-2 text-slate-600 hover:text-slate-800 text-sm border border-slate-200 rounded-lg"
>
Close
</button>
</div>
</div>
)}
</main>
)
}