'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: () => (
),
})
export default function SpacePage() {
const { isAuthenticated, token } = useAuthStore()
const [currentNodes, setCurrentNodes] = useState(starterNodes)
const [integrations, setIntegrations] = useState()
const [spaceName, setSpaceName] = useState('')
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showLoadDialog, setShowLoadDialog] = useState(false)
const [savedSpaces, setSavedSpaces] = useState([])
const [copied, setCopied] = useState(false)
const [loaded, setLoaded] = useState(false)
const nodesRef = useRef(starterNodes)
const integrationsRef = useRef()
// Backend integration state
const [deployedFlow, setDeployedFlow] = useState(null)
const [idMap, setIdMap] = useState>({})
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([])
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
const [transakUrl, setTransakUrl] = useState(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 = {}
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 (
)
}
if (!isAuthenticated) {
return (
Sign in to access your Space
EncryptID passkey authentication is required to create and edit funding flows.
Back to home
)
}
return (
{/* Status Toast */}
{statusMessage && (
{statusMessage.text}
)}
{/* Toolbar */}
rF
rFunds
|
Your Space
|
{deployedFlow && (
<>
|
{deployedFlow.status} · {deployedFlow.name}
>
)}
{/* Local operations */}
setShowSaveDialog(true)}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs font-medium transition-colors"
>
Save
Load
{copied ? 'Copied!' : 'Share'}
{/* Separator */}
{/* Backend operations */}
setShowDeployDialog(true)}
className="px-3 py-1 bg-violet-600 hover:bg-violet-500 rounded text-xs font-medium transition-colors"
>
Deploy
{deployedFlow && (
<>
{syncing ? 'Syncing...' : 'Sync'}
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
{showFundDropdown && (
<>
setShowFundDropdown(false)} />
{
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"
>
Deposit (Crypto)
Pay with Card
>
)}
>
)}
Flows
{/* Separator */}
Reset
{/* Canvas */}
n.id))}
initialNodes={currentNodes}
mode="space"
onNodesChange={handleNodesChange}
integrations={integrations}
onIntegrationsChange={handleIntegrationsChange}
/>
{/* Save Dialog */}
{showSaveDialog && (
setShowSaveDialog(false)}>
e.stopPropagation()}>
Save Space
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()}
/>
Save
setShowSaveDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
)}
{/* Load Dialog */}
{showLoadDialog && (
setShowLoadDialog(false)}>
e.stopPropagation()}>
Load Space
{savedSpaces.length === 0 ? (
No saved spaces yet.
) : (
{savedSpaces.map((space) => (
handleLoadSpace(space)}
className="flex-1 text-left"
>
{space.name}
{space.nodes.filter(n => n.type === 'funnel').length} funnels •{' '}
{space.nodes.filter(n => n.type === 'outcome').length} outcomes •{' '}
{new Date(space.updatedAt).toLocaleDateString()}
handleDeleteSpace(space.name)}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
>
))}
)}
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
)}
{/* Deploy Dialog */}
{showDeployDialog && (
setShowDeployDialog(false)}>
e.stopPropagation()}>
Deploy to Backend
Creates a live flow on the server with real threshold logic, overflow distribution, and fiat on-ramp support.
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
/>
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"
/>
{nodesRef.current.filter(n => n.type === 'funnel').length} funnels •{' '}
{nodesRef.current.filter(n => n.type === 'outcome').length} outcomes will be created on Base Sepolia (USDC)
{deploying ? 'Deploying...' : 'Deploy'}
setShowDeployDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
)}
{/* Deposit Dialog */}
{showDepositDialog && deployedFlow && (
setShowDepositDialog(false)}>
e.stopPropagation()}>
Deposit USDC
Simulate a deposit into a funnel. If the deposit pushes the balance above MAX threshold, overflow will automatically distribute.
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 => (
{f.name} (balance: ${fromSmallestUnit(f.balance).toLocaleString()})
))}
$
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"
/>
{depositing ? 'Depositing...' : 'Deposit'}
setShowDepositDialog(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
>
Cancel
)}
{/* Transak Widget */}
{transakUrl && (
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 && (
setShowLoadFlowDialog(false)}>
e.stopPropagation()}>
Deployed Flows
{backendFlows.length === 0 ? (
No deployed flows yet.
) : (
{backendFlows.map((flow) => (
handleLoadFlow(flow)}
className="w-full text-left p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
{flow.name}
{flow.status}
{flow.funnels.length} funnels • {flow.outcomes.length} outcomes •{' '}
{new Date(flow.updatedAt).toLocaleDateString()}
))}
)}
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
)}
)
}