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,
|
||||
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)}>
|
||||
|
|
|
|||
|
|
@ -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 { 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">
|
||||
|
|
|
|||
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 ─────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue