rfunds-online/components/nodes/SourceNode.tsx

393 lines
17 KiB
TypeScript

'use client'
import { memo, useState, useCallback, useMemo } from 'react'
import { Handle, Position, useReactFlow } from '@xyflow/react'
import type { NodeProps } from '@xyflow/react'
import type { SourceNodeData } from '@/lib/types'
import { useAuthStore } from '@/lib/auth'
const CHAIN_OPTIONS = [
{ id: 1, name: 'Ethereum' },
{ id: 10, name: 'Optimism' },
{ id: 100, name: 'Gnosis' },
{ id: 137, name: 'Polygon' },
{ id: 8453, name: 'Base' },
]
const CURRENCIES = ['USD', 'EUR', 'GBP']
type SourceType = SourceNodeData['sourceType']
const SOURCE_TYPE_META: Record<SourceType, { label: string; color: string }> = {
card: { label: 'Credit Card', color: 'violet' },
safe_wallet: { label: 'Safe / Wallet', color: 'cyan' },
ridentity: { label: 'rIdentity', color: 'purple' },
unconfigured: { label: 'Unconfigured', color: 'slate' },
}
function SourceTypeIcon({ type, className = 'w-4 h-4' }: { type: SourceType; className?: string }) {
switch (type) {
case 'card':
return (
<svg className={className} 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 'safe_wallet':
return (
<svg className={className} 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 'ridentity':
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4" />
</svg>
)
default: // unconfigured
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
}
function SourceNode({ data, selected, id }: NodeProps) {
const nodeData = data as SourceNodeData
const { label, sourceType, flowRate, targetAllocations = [] } = nodeData
const [isEditing, setIsEditing] = useState(false)
const { setNodes } = useReactFlow()
const { isAuthenticated, did, login } = useAuthStore()
// Edit form state
const [editLabel, setEditLabel] = useState(label)
const [editSourceType, setEditSourceType] = useState<SourceType>(sourceType)
const [editFlowRate, setEditFlowRate] = useState(flowRate)
const [editCurrency, setEditCurrency] = useState(nodeData.transakConfig?.fiatCurrency || 'USD')
const [editDefaultAmount, setEditDefaultAmount] = useState(nodeData.transakConfig?.defaultAmount?.toString() || '')
const [editWalletAddress, setEditWalletAddress] = useState(nodeData.walletAddress || '')
const [editChainId, setEditChainId] = useState(nodeData.chainId || 1)
const [editSafeAddress, setEditSafeAddress] = useState(nodeData.safeAddress || '')
const meta = SOURCE_TYPE_META[sourceType]
const isUnconfigured = sourceType === 'unconfigured'
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
setEditLabel(label)
setEditSourceType(sourceType)
setEditFlowRate(flowRate)
setEditCurrency(nodeData.transakConfig?.fiatCurrency || 'USD')
setEditDefaultAmount(nodeData.transakConfig?.defaultAmount?.toString() || '')
setEditWalletAddress(nodeData.walletAddress || '')
setEditChainId(nodeData.chainId || 1)
setEditSafeAddress(nodeData.safeAddress || '')
setIsEditing(true)
}, [label, sourceType, flowRate, nodeData])
const handleSave = useCallback(() => {
setNodes((nds) => nds.map((node) => {
if (node.id !== id) return node
const updated: SourceNodeData = {
...(node.data as SourceNodeData),
label: editLabel || 'Source',
sourceType: editSourceType,
flowRate: editFlowRate,
}
if (editSourceType === 'card') {
updated.transakConfig = {
fiatCurrency: editCurrency,
defaultAmount: editDefaultAmount ? parseFloat(editDefaultAmount) : undefined,
}
}
if (editSourceType === 'safe_wallet') {
updated.walletAddress = editWalletAddress
updated.chainId = editChainId
updated.safeAddress = editSafeAddress
}
if (editSourceType === 'ridentity') {
updated.encryptIdUserId = did || undefined
}
return { ...node, data: updated }
}))
setIsEditing(false)
}, [id, editLabel, editSourceType, editFlowRate, editCurrency, editDefaultAmount, editWalletAddress, editChainId, editSafeAddress, did, setNodes])
const handleClose = useCallback(() => {
setIsEditing(false)
}, [])
// Allocation bar segments
const allocTotal = useMemo(() =>
targetAllocations.reduce((s, a) => s + a.percentage, 0),
[targetAllocations]
)
return (
<>
<div
className={`
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
transition-all duration-200
${selected ? 'border-emerald-500 shadow-emerald-200' : isUnconfigured ? 'border-dashed border-slate-300' : 'border-emerald-300'}
`}
onDoubleClick={handleDoubleClick}
>
{/* Header */}
<div className={`px-3 py-2 border-b border-slate-100 rounded-t-md ${
isUnconfigured ? 'bg-slate-50' : 'bg-gradient-to-r from-emerald-50 to-teal-50'
}`}>
<div className="flex items-center gap-2">
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
isUnconfigured ? 'bg-slate-400' : 'bg-emerald-500'
}`}>
<SourceTypeIcon type={sourceType} className="w-4 h-4 text-white" />
</div>
<div className="min-w-0 flex-1">
<span className="font-semibold text-slate-800 text-sm truncate block">{label}</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded-full font-medium bg-${meta.color}-100 text-${meta.color}-700`}>
{meta.label}
</span>
</div>
</div>
</div>
{/* Body */}
<div className="p-3 space-y-2">
{/* Flow rate */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Flow Rate</span>
<span className="text-xs font-mono font-medium text-emerald-700">
${flowRate.toLocaleString()}/mo
</span>
</div>
{/* Allocation bars */}
{targetAllocations.length > 0 && (
<div>
<span className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Allocations</span>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden flex">
{targetAllocations.map((alloc, i) => (
<div
key={alloc.targetId}
className="h-full transition-all duration-300"
style={{
width: `${allocTotal > 0 ? (alloc.percentage / allocTotal) * 100 : 0}%`,
backgroundColor: alloc.color,
}}
title={`${alloc.percentage}% → ${alloc.targetId}`}
/>
))}
</div>
<div className="flex justify-between mt-0.5">
{targetAllocations.map((alloc) => (
<span key={alloc.targetId} className="text-[8px] font-mono" style={{ color: alloc.color }}>
{alloc.percentage}%
</span>
))}
</div>
</div>
)}
{/* Hint */}
{isUnconfigured && (
<div className="text-center">
<span className="text-[8px] text-slate-400">Double-click to configure</span>
</div>
)}
{!isUnconfigured && targetAllocations.length === 0 && (
<div className="text-center">
<span className="text-[8px] text-slate-400">Drag from handle below to connect to funnels</span>
</div>
)}
</div>
{/* Bottom handle - connects to funnels */}
<Handle
type="source"
position={Position.Bottom}
id="source-out"
className={`!w-4 !h-4 !border-2 !border-white !-bottom-2 transition-all ${
isUnconfigured ? '!bg-slate-400' : '!bg-emerald-500'
}`}
/>
</div>
{/* Edit Modal */}
{isEditing && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleClose}
>
<div
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[400px] max-w-lg max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-500 rounded-lg flex items-center justify-center">
<SourceTypeIcon type={editSourceType} className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-bold text-slate-800">Configure Source</h3>
</div>
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600">
<svg className="w-5 h-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>
{/* Source Type Picker */}
<div className="mb-4">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-2">Source Type</label>
<div className="grid grid-cols-3 gap-2">
{(['card', 'safe_wallet', 'ridentity'] as SourceType[]).map((type) => {
const typeMeta = SOURCE_TYPE_META[type]
const isSelected = editSourceType === type
return (
<button
key={type}
onClick={() => setEditSourceType(type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all ${
isSelected
? 'border-emerald-500 bg-emerald-50'
: 'border-slate-200 hover:border-slate-300 bg-white'
}`}
>
<SourceTypeIcon type={type} className="w-6 h-6 text-slate-700" />
<span className="text-xs font-medium text-slate-700">{typeMeta.label}</span>
</button>
)
})}
</div>
</div>
{/* Label */}
<div className="mb-3">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Label</label>
<input
type="text"
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800"
placeholder="Source name..."
/>
</div>
{/* Flow Rate */}
<div className="mb-4">
<label className="text-[10px] text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/month)</label>
<input
type="number"
value={editFlowRate}
onChange={(e) => setEditFlowRate(Number(e.target.value))}
className="w-full text-sm px-3 py-2 border border-slate-200 rounded-lg text-slate-800 font-mono"
min="0"
step="100"
/>
</div>
{/* Type-specific config */}
{editSourceType === 'card' && (
<div className="p-3 bg-violet-50 rounded-xl mb-4">
<span className="text-[10px] text-violet-600 uppercase tracking-wide block mb-2">Card Settings (Transak)</span>
<label className="text-[10px] text-slate-500 block mb-1">Currency</label>
<select
value={editCurrency}
onChange={(e) => setEditCurrency(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={editDefaultAmount}
onChange={(e) => setEditDefaultAmount(e.target.value)}
placeholder="100"
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
min="0"
/>
</div>
)}
{editSourceType === 'safe_wallet' && (
<div className="p-3 bg-cyan-50 rounded-xl mb-4">
<span className="text-[10px] text-cyan-600 uppercase tracking-wide block mb-2">Safe / Wallet Settings</span>
<label className="text-[10px] text-slate-500 block mb-1">Wallet Address</label>
<input
type="text"
value={editWalletAddress}
onChange={(e) => setEditWalletAddress(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">Safe Address (optional)</label>
<input
type="text"
value={editSafeAddress}
onChange={(e) => setEditSafeAddress(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={editChainId}
onChange={(e) => setEditChainId(Number(e.target.value))}
className="w-full text-xs px-2 py-1.5 border border-slate-200 rounded text-slate-800"
>
{CHAIN_OPTIONS.map((c) => <option key={c.id} value={c.id}>{c.name} ({c.id})</option>)}
</select>
</div>
)}
{editSourceType === 'ridentity' && (
<div className="p-3 bg-purple-50 rounded-xl mb-4">
<span className="text-[10px] text-purple-600 uppercase tracking-wide block mb-2">rIdentity (EncryptID)</span>
{isAuthenticated ? (
<div className="flex items-center gap-2 p-2 bg-white rounded-lg border border-purple-200">
<span className="w-2 h-2 rounded-full bg-purple-500" />
<span className="text-xs text-purple-700 font-medium">Connected</span>
{did && (
<span className="text-[9px] text-purple-500 font-mono truncate ml-auto">{did.slice(0, 20)}...</span>
)}
</div>
) : (
<button
onClick={async () => { try { await login() } catch {} }}
className="w-full text-xs px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-500 transition-colors font-medium"
>
Connect with EncryptID
</button>
)}
</div>
)}
{/* Save / Cancel */}
<div className="flex gap-2">
<button
onClick={handleSave}
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 transition-colors font-medium"
>
Save
</button>
<button
onClick={handleClose}
className="px-4 py-2 text-slate-500 hover:text-slate-700 transition-colors font-medium"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
)
}
export default memo(SourceNode)