feat: add funding sources — card payments via Transak and funding source management

- FundingSource types: card, crypto_wallet, safe_treasury, bank_transfer
- TransakWidget component with iframe postMessage handling
- FundingSourcesPanel with CRUD, config forms per type, and Fund Now action
- FunnelNode edit modal: new Funding Sources section
- Space page: Fund dropdown with Deposit (Crypto) + Pay with Card options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:21:44 -07:00
parent f6e3ef6487
commit 6411604852
5 changed files with 610 additions and 12 deletions

View File

@ -15,9 +15,11 @@ import {
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,
@ -59,6 +61,8 @@ export default function SpacePage() {
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(() => {
@ -210,6 +214,23 @@ export default function SpacePage() {
}
}, [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')
@ -354,17 +375,48 @@ export default function SpacePage() {
>
{syncing ? 'Syncing...' : 'Sync'}
</button>
<button
onClick={() => {
if (deployedFlow.funnels.length > 0) {
setDepositFunnelId(deployedFlow.funnels[0].id)
}
setShowDepositDialog(true)
}}
className="px-3 py-1 bg-green-600 hover:bg-green-500 rounded text-xs font-medium transition-colors"
>
Deposit
</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
@ -575,6 +627,26 @@ export default function SpacePage() {
</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)}>

View File

@ -0,0 +1,388 @@
'use client'
import { useState, useCallback } from 'react'
import type { FundingSource, FundingSourceType } from '@/lib/types'
import { initiateOnRamp } from '@/lib/api/flows-client'
import TransakWidget from './TransakWidget'
interface FundingSourcesPanelProps {
sources: FundingSource[]
onSourcesChange: (sources: FundingSource[]) => void
funnelWalletAddress?: string
flowId?: string
funnelId?: string
isDeployed: boolean
}
const SOURCE_TYPE_META: Record<FundingSourceType, { icon: string; label: string; color: string }> = {
card: { icon: 'credit-card', label: 'Card', color: 'violet' },
crypto_wallet: { icon: 'wallet', label: 'Crypto Wallet', color: 'cyan' },
safe_treasury: { icon: 'safe', label: 'Safe Treasury', color: 'emerald' },
bank_transfer: { icon: 'bank', label: 'Bank Transfer', color: 'blue' },
}
const CURRENCIES = ['USD', 'EUR', 'GBP']
const CHAIN_OPTIONS = [
{ id: 1, name: 'Ethereum' },
{ id: 10, name: 'Optimism' },
{ id: 100, name: 'Gnosis' },
{ id: 137, name: 'Polygon' },
{ id: 8453, name: 'Base' },
{ id: 84532, name: 'Base Sepolia' },
]
function SourceIcon({ type }: { type: FundingSourceType }) {
switch (type) {
case 'card':
return (
<svg className="w-4 h-4" 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>
)
case 'crypto_wallet':
return (
<svg className="w-4 h-4" 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>
)
case 'safe_treasury':
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
case 'bank_transfer':
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
)
}
}
export default function FundingSourcesPanel({
sources,
onSourcesChange,
funnelWalletAddress,
flowId,
funnelId,
isDeployed,
}: FundingSourcesPanelProps) {
const [showTypePicker, setShowTypePicker] = useState(false)
const [configType, setConfigType] = useState<FundingSourceType | null>(null)
const [transakUrl, setTransakUrl] = useState<string | null>(null)
const [fundingError, setFundingError] = useState<string | null>(null)
// Config form state
const [configLabel, setConfigLabel] = useState('')
const [configCurrency, setConfigCurrency] = useState('USD')
const [configDefaultAmount, setConfigDefaultAmount] = useState('')
const [configSafeAddress, setConfigSafeAddress] = useState('')
const [configSafeChainId, setConfigSafeChainId] = useState(1)
const [copied, setCopied] = useState(false)
const resetConfigForm = useCallback(() => {
setConfigType(null)
setShowTypePicker(false)
setConfigLabel('')
setConfigCurrency('USD')
setConfigDefaultAmount('')
setConfigSafeAddress('')
setConfigSafeChainId(1)
}, [])
const handleSelectType = useCallback((type: FundingSourceType) => {
setConfigType(type)
setShowTypePicker(false)
// Pre-fill label
setConfigLabel(SOURCE_TYPE_META[type].label)
}, [])
const handleSaveSource = useCallback(() => {
if (!configType) return
const newSource: FundingSource = {
id: crypto.randomUUID(),
type: configType,
label: configLabel || SOURCE_TYPE_META[configType].label,
enabled: true,
}
if (configType === 'card' || configType === 'bank_transfer') {
newSource.transakConfig = {
fiatCurrency: configCurrency,
defaultAmount: configDefaultAmount ? parseFloat(configDefaultAmount) : undefined,
}
}
if (configType === 'crypto_wallet') {
newSource.walletAddress = funnelWalletAddress || ''
}
if (configType === 'safe_treasury') {
newSource.safeAddress = configSafeAddress
newSource.safeChainId = configSafeChainId
}
onSourcesChange([...sources, newSource])
resetConfigForm()
}, [configType, configLabel, configCurrency, configDefaultAmount, configSafeAddress, configSafeChainId, funnelWalletAddress, sources, onSourcesChange, resetConfigForm])
const handleToggleSource = useCallback((id: string) => {
onSourcesChange(
sources.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s))
)
}, [sources, onSourcesChange])
const handleDeleteSource = useCallback((id: string) => {
onSourcesChange(sources.filter((s) => s.id !== id))
}, [sources, onSourcesChange])
const handleFundNow = useCallback(async (source: FundingSource) => {
if (!flowId || !funnelId) return
setFundingError(null)
try {
const amount = source.transakConfig?.defaultAmount || 100
const currency = source.transakConfig?.fiatCurrency || 'USD'
const result = await initiateOnRamp(flowId, funnelId, amount, currency)
if (result.widgetUrl) {
setTransakUrl(result.widgetUrl)
} else {
setFundingError('No widget URL returned from on-ramp')
}
} catch (err) {
setFundingError(err instanceof Error ? err.message : 'Failed to initiate on-ramp')
}
}, [flowId, funnelId])
const handleCopyAddress = useCallback(() => {
if (funnelWalletAddress) {
navigator.clipboard.writeText(funnelWalletAddress)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [funnelWalletAddress])
const cardOrBankSource = sources.find(
(s) => (s.type === 'card' || s.type === 'bank_transfer') && s.enabled
)
return (
<div>
{/* Source list */}
{sources.length > 0 && (
<div className="space-y-1.5 mb-3">
{sources.map((source) => {
const meta = SOURCE_TYPE_META[source.type]
return (
<div
key={source.id}
className="flex items-center gap-2 p-2 bg-white rounded-lg border border-slate-100 group"
>
<span className={`text-${meta.color}-600`}>
<SourceIcon type={source.type} />
</span>
<span className="text-xs text-slate-700 flex-1 truncate">{source.label}</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded-full bg-${meta.color}-100 text-${meta.color}-700`}>
{meta.label}
</span>
{/* Enable/disable toggle */}
<button
onClick={() => handleToggleSource(source.id)}
className={`w-7 h-4 rounded-full transition-colors relative ${
source.enabled ? 'bg-emerald-500' : 'bg-slate-300'
}`}
>
<span
className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-transform ${
source.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
{/* Delete button */}
<button
onClick={() => handleDeleteSource(source.id)}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-opacity"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
})}
</div>
)}
{/* Fund Now button (for deployed flows with card/bank source) */}
{isDeployed && cardOrBankSource && (
<button
onClick={() => handleFundNow(cardOrBankSource)}
className="w-full mb-3 px-3 py-2 bg-emerald-600 text-white rounded-lg text-xs font-medium hover:bg-emerald-500 transition-colors flex items-center justify-center gap-2"
>
<svg className="w-3.5 h-3.5" 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>
Fund Now ({cardOrBankSource.transakConfig?.fiatCurrency || 'USD'})
</button>
)}
{fundingError && (
<p className="text-[10px] text-red-600 mb-2">{fundingError}</p>
)}
{/* Type picker */}
{showTypePicker && !configType && (
<div className="grid grid-cols-2 gap-1.5 mb-3">
{(Object.entries(SOURCE_TYPE_META) as [FundingSourceType, typeof SOURCE_TYPE_META['card']][]).map(
([type, meta]) => (
<button
key={type}
onClick={() => handleSelectType(type)}
className="flex items-center gap-2 p-2 bg-white rounded-lg border border-slate-200 hover:border-slate-400 transition-colors text-left"
>
<span className={`text-${meta.color}-600`}>
<SourceIcon type={type} />
</span>
<span className="text-xs text-slate-700">{meta.label}</span>
</button>
)
)}
</div>
)}
{/* Config form based on selected type */}
{configType && (
<div className="p-3 bg-white rounded-lg border border-slate-200 mb-3">
<div className="flex items-center gap-2 mb-3">
<SourceIcon type={configType} />
<span className="text-xs font-semibold text-slate-700">
Configure {SOURCE_TYPE_META[configType].label}
</span>
</div>
{/* Label input (all types) */}
<input
type="text"
value={configLabel}
onChange={(e) => setConfigLabel(e.target.value)}
placeholder="Label..."
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
/>
{/* Card / Bank Transfer config */}
{(configType === 'card' || configType === 'bank_transfer') && (
<>
<label className="text-[10px] text-slate-500 block mb-1">Currency</label>
<select
value={configCurrency}
onChange={(e) => setConfigCurrency(e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="text-[10px] text-slate-500 block mb-1">Default amount (optional)</label>
<input
type="number"
value={configDefaultAmount}
onChange={(e) => setConfigDefaultAmount(e.target.value)}
placeholder="100"
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
min="0"
step="1"
/>
</>
)}
{/* Crypto Wallet config */}
{configType === 'crypto_wallet' && (
<>
<label className="text-[10px] text-slate-500 block mb-1">Funnel wallet address</label>
<div className="flex items-center gap-1 mb-2">
<input
type="text"
value={funnelWalletAddress || 'Not deployed yet'}
readOnly
className="flex-1 text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-500 bg-slate-50 font-mono"
/>
{funnelWalletAddress && (
<button
onClick={handleCopyAddress}
className="px-2 py-1.5 text-xs bg-slate-100 hover:bg-slate-200 rounded transition-colors text-slate-600"
>
{copied ? 'Copied' : 'Copy'}
</button>
)}
</div>
</>
)}
{/* Safe Treasury config */}
{configType === 'safe_treasury' && (
<>
<label className="text-[10px] text-slate-500 block mb-1">Safe address</label>
<input
type="text"
value={configSafeAddress}
onChange={(e) => setConfigSafeAddress(e.target.value)}
placeholder="0x..."
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800 font-mono"
/>
<label className="text-[10px] text-slate-500 block mb-1">Chain</label>
<select
value={configSafeChainId}
onChange={(e) => setConfigSafeChainId(Number(e.target.value))}
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded mb-2 text-slate-800"
>
{CHAIN_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>{c.name} ({c.id})</option>
))}
</select>
</>
)}
<div className="flex gap-1.5 mt-1">
<button
onClick={handleSaveSource}
className="flex-1 text-xs px-3 py-1.5 bg-emerald-600 text-white rounded hover:bg-emerald-500 transition-colors font-medium"
>
Add Source
</button>
<button
onClick={resetConfigForm}
className="text-xs px-3 py-1.5 text-slate-500 hover:text-slate-700 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Add button */}
{!showTypePicker && !configType && (
<button
onClick={() => setShowTypePicker(true)}
className="w-full px-3 py-2 border border-dashed border-slate-300 rounded-lg text-xs text-slate-500 hover:border-emerald-400 hover:text-emerald-600 transition-colors flex items-center justify-center gap-1.5"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Funding Source
</button>
)}
{/* Transak Widget */}
{transakUrl && (
<TransakWidget
widgetUrl={transakUrl}
onClose={() => setTransakUrl(null)}
onComplete={(orderId) => {
console.log('Transak order completed:', orderId)
}}
/>
)}
</div>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { useEffect, useCallback } from 'react'
interface TransakWidgetProps {
widgetUrl: string
onClose: () => void
onComplete?: (orderId: string) => void
}
export default function TransakWidget({ widgetUrl, onClose, onComplete }: TransakWidgetProps) {
const handleMessage = useCallback(
(event: MessageEvent) => {
// Transak sends postMessage events from its iframe
const data = event.data
if (!data || typeof data !== 'object') return
switch (data.event_id || data.eventName) {
case 'TRANSAK_ORDER_SUCCESSFUL': {
const orderId = data.data?.id || data.data?.orderId || 'unknown'
onComplete?.(orderId)
onClose()
break
}
case 'TRANSAK_ORDER_FAILED': {
// Brief display then close — the parent can show a status message
onClose()
break
}
case 'TRANSAK_WIDGET_CLOSE': {
onClose()
break
}
}
},
[onClose, onComplete]
)
useEffect(() => {
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [handleMessage])
return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[70]"
onClick={onClose}
>
<div
className="relative bg-white rounded-2xl shadow-2xl overflow-hidden"
style={{ width: 500, height: 650 }}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 w-8 h-8 bg-white/90 hover:bg-white rounded-full flex items-center justify-center shadow-md transition-colors"
>
<svg className="w-4 h-4 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<iframe
src={widgetUrl}
title="Transak On-Ramp"
className="w-full h-full border-0"
allow="camera;microphone;payment"
/>
</div>
</div>
)
}

View File

@ -3,8 +3,9 @@
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 type { FunnelNodeData, OutcomeNodeData, FundingSource } from '@/lib/types'
import SplitsView from '../SplitsView'
import FundingSourcesPanel from '../FundingSourcesPanel'
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
@ -842,6 +843,44 @@ function FunnelNode({ data, selected, id }: NodeProps) {
</div>
)}
{/* Funding Sources */}
<div className="mt-4 p-4 bg-violet-50 rounded-xl">
<div className="flex items-center gap-2 mb-3">
<svg className="w-4 h-4 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-xs font-semibold text-violet-700 uppercase tracking-wide">
Funding Sources
</span>
{(nodeData.fundingSources || []).length > 0 && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-violet-200 text-violet-700 font-mono">
{(nodeData.fundingSources || []).length}
</span>
)}
</div>
<FundingSourcesPanel
sources={nodeData.fundingSources || []}
onSourcesChange={(newSources: FundingSource[]) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id !== id) return node
return {
...node,
data: {
...(node.data as FunnelNodeData),
fundingSources: newSources,
},
}
})
)
}}
funnelWalletAddress={undefined}
flowId={undefined}
funnelId={undefined}
isDeployed={false}
/>
</div>
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
<div className="mt-4 p-4 bg-green-50 rounded-xl">
<span className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-2">

View File

@ -58,6 +58,31 @@ export interface IntegrationConfig {
}
}
// ─── Funding Sources ────────────────────────────────────────
export type FundingSourceType = 'card' | 'crypto_wallet' | 'safe_treasury' | 'bank_transfer';
export interface FundingSource {
id: string;
type: FundingSourceType;
label: string;
enabled: boolean;
// Card/bank-specific
transakConfig?: {
fiatCurrency: string;
defaultAmount?: number;
};
// Crypto wallet-specific
walletAddress?: string;
chainId?: number;
tokenAddress?: string;
// Safe treasury-specific
safeAddress?: string;
safeChainId?: number;
// Common
lastUsedAt?: number;
}
// ─── Core Flow Types ─────────────────────────────────────────
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
@ -89,6 +114,7 @@ export interface FunnelNodeData {
source?: IntegrationSource
streamAllocations?: StreamAllocation[]
splitsConfig?: SplitsConfig
fundingSources?: FundingSource[]
[key: string]: unknown
}