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:
parent
f6e3ef6487
commit
6411604852
|
|
@ -15,9 +15,11 @@ import {
|
||||||
deposit,
|
deposit,
|
||||||
listFlows,
|
listFlows,
|
||||||
fromSmallestUnit,
|
fromSmallestUnit,
|
||||||
|
initiateOnRamp,
|
||||||
type BackendFlow,
|
type BackendFlow,
|
||||||
type DeployResult,
|
type DeployResult,
|
||||||
} from '@/lib/api/flows-client'
|
} from '@/lib/api/flows-client'
|
||||||
|
import TransakWidget from '@/components/TransakWidget'
|
||||||
|
|
||||||
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -59,6 +61,8 @@ export default function SpacePage() {
|
||||||
const [depositing, setDepositing] = useState(false)
|
const [depositing, setDepositing] = useState(false)
|
||||||
const [backendFlows, setBackendFlows] = useState<BackendFlow[]>([])
|
const [backendFlows, setBackendFlows] = useState<BackendFlow[]>([])
|
||||||
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
|
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
|
// Load saved owner address
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -210,6 +214,23 @@ export default function SpacePage() {
|
||||||
}
|
}
|
||||||
}, [deployedFlow, depositFunnelId, depositAmount, idMap, showStatus])
|
}, [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 () => {
|
const handleLoadFlowOpen = useCallback(async () => {
|
||||||
if (!ownerAddress.trim()) {
|
if (!ownerAddress.trim()) {
|
||||||
showStatus('Set your wallet address first (via Deploy dialog)', 'error')
|
showStatus('Set your wallet address first (via Deploy dialog)', 'error')
|
||||||
|
|
@ -354,17 +375,48 @@ export default function SpacePage() {
|
||||||
>
|
>
|
||||||
{syncing ? 'Syncing...' : 'Sync'}
|
{syncing ? 'Syncing...' : 'Sync'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => {
|
<button
|
||||||
if (deployedFlow.funnels.length > 0) {
|
onClick={() => setShowFundDropdown(!showFundDropdown)}
|
||||||
setDepositFunnelId(deployedFlow.funnels[0].id)
|
className="px-3 py-1 bg-green-600 hover:bg-green-500 rounded text-xs font-medium transition-colors flex items-center gap-1"
|
||||||
}
|
>
|
||||||
setShowDepositDialog(true)
|
Fund
|
||||||
}}
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className="px-3 py-1 bg-green-600 hover:bg-green-500 rounded text-xs font-medium transition-colors"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
>
|
</svg>
|
||||||
Deposit
|
</button>
|
||||||
</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
|
<button
|
||||||
|
|
@ -575,6 +627,26 @@ export default function SpacePage() {
|
||||||
</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 */}
|
{/* Load Backend Flow Dialog */}
|
||||||
{showLoadFlowDialog && (
|
{showLoadFlowDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadFlowDialog(false)}>
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadFlowDialog(false)}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
import { memo, useState, useCallback, useRef, useEffect } from 'react'
|
import { memo, useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
import type { NodeProps } 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 SplitsView from '../SplitsView'
|
||||||
|
import FundingSourcesPanel from '../FundingSourcesPanel'
|
||||||
|
|
||||||
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
@ -842,6 +843,44 @@ function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
</div>
|
</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 && (
|
{nodeData.streamAllocations && nodeData.streamAllocations.length > 0 && (
|
||||||
<div className="mt-4 p-4 bg-green-50 rounded-xl">
|
<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">
|
<span className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-2">
|
||||||
|
|
|
||||||
26
lib/types.ts
26
lib/types.ts
|
|
@ -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 ─────────────────────────────────────────
|
// ─── Core Flow Types ─────────────────────────────────────────
|
||||||
|
|
||||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||||
|
|
@ -89,6 +114,7 @@ export interface FunnelNodeData {
|
||||||
source?: IntegrationSource
|
source?: IntegrationSource
|
||||||
streamAllocations?: StreamAllocation[]
|
streamAllocations?: StreamAllocation[]
|
||||||
splitsConfig?: SplitsConfig
|
splitsConfig?: SplitsConfig
|
||||||
|
fundingSources?: FundingSource[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue