389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
'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>
|
|
)
|
|
}
|