517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Plus,
|
|
Lock,
|
|
Unlock,
|
|
Trash2,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Download,
|
|
Timer,
|
|
Info,
|
|
} from 'lucide-react';
|
|
|
|
// Using Timer as VaultIcon since lucide-react doesn't export Vault
|
|
const VaultIcon = Timer;
|
|
import {
|
|
useVaultsStore,
|
|
Vault,
|
|
formatTimeRemaining,
|
|
getVaultStatusColor,
|
|
LOCK_DURATION_PRESETS,
|
|
} from '../../store/vaults';
|
|
import { LoadingSpinner } from '../../components/LoadingStates';
|
|
|
|
/**
|
|
* Create Vault Modal
|
|
*/
|
|
function CreateVaultModal({ onClose }: { onClose: () => void }) {
|
|
const { createVault, isLoading } = useVaultsStore();
|
|
const [name, setName] = useState('');
|
|
const [amount, setAmount] = useState('');
|
|
const [duration, setDuration] = useState(86400); // 24 hours default
|
|
const [customDuration, setCustomDuration] = useState('');
|
|
const [useCustom, setUseCustom] = useState(false);
|
|
const [description, setDescription] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
const amountNum = parseFloat(amount);
|
|
if (!name.trim()) {
|
|
setError('Vault name is required');
|
|
return;
|
|
}
|
|
if (isNaN(amountNum) || amountNum <= 0) {
|
|
setError('Invalid amount');
|
|
return;
|
|
}
|
|
|
|
const lockDuration = useCustom
|
|
? parseInt(customDuration) * 3600 // Custom is in hours
|
|
: duration;
|
|
|
|
if (lockDuration < 60) {
|
|
setError('Lock duration must be at least 1 minute');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await createVault({
|
|
name: name.trim(),
|
|
amount: Math.floor(amountNum * 100_000_000), // Convert SYN to sompi
|
|
lockDurationSecs: lockDuration,
|
|
description: description.trim() || undefined,
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create vault');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
|
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
<VaultIcon className="text-synor-400" size={24} />
|
|
Create Time-Locked Vault
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-white"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Vault Name</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g., Savings Goal, Emergency Fund"
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Amount (SYN)</label>
|
|
<input
|
|
type="number"
|
|
step="0.00000001"
|
|
min="0"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
placeholder="0.00000000"
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Lock Duration</label>
|
|
<div className="grid grid-cols-4 gap-2 mb-2">
|
|
{LOCK_DURATION_PRESETS.slice(0, 4).map((preset) => (
|
|
<button
|
|
key={preset.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setDuration(preset.value);
|
|
setUseCustom(false);
|
|
}}
|
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
|
!useCustom && duration === preset.value
|
|
? 'bg-synor-600 text-white'
|
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{LOCK_DURATION_PRESETS.slice(4).map((preset) => (
|
|
<button
|
|
key={preset.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setDuration(preset.value);
|
|
setUseCustom(false);
|
|
}}
|
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
|
!useCustom && duration === preset.value
|
|
? 'bg-synor-600 text-white'
|
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{preset.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setUseCustom(true)}
|
|
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
|
useCustom
|
|
? 'bg-synor-600 text-white'
|
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
Custom
|
|
</button>
|
|
</div>
|
|
{useCustom && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={customDuration}
|
|
onChange={(e) => setCustomDuration(e.target.value)}
|
|
placeholder="Duration in hours"
|
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
|
|
/>
|
|
<span className="text-gray-400">hours</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Description (Optional)</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="What is this vault for?"
|
|
rows={2}
|
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-3 flex items-center gap-2 text-sm text-red-200">
|
|
<AlertCircle size={16} />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-yellow-500/20 border border-yellow-500/30 rounded-lg p-3 flex items-start gap-2 text-sm text-yellow-200">
|
|
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
|
<p>
|
|
<strong>Warning:</strong> Funds locked in a vault cannot be accessed until the lock
|
|
period expires. Make sure you don't need these funds during the lock period.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading}
|
|
className="flex-1 px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<LoadingSpinner size={18} />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Lock size={18} />
|
|
Lock Funds
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Individual Vault Card
|
|
*/
|
|
function VaultCard({ vault }: { vault: Vault }) {
|
|
const { withdrawVault, deleteVault, isLoading } = useVaultsStore();
|
|
const [timeRemaining, setTimeRemaining] = useState(vault.remainingSecs);
|
|
const [showConfirm, setShowConfirm] = useState<'withdraw' | 'delete' | null>(null);
|
|
|
|
// Update countdown timer
|
|
useEffect(() => {
|
|
if (vault.status !== 'locked') return;
|
|
|
|
const interval = setInterval(() => {
|
|
setTimeRemaining((prev) => Math.max(0, prev - 1));
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [vault.status]);
|
|
|
|
const handleWithdraw = async () => {
|
|
try {
|
|
await withdrawVault(vault.id);
|
|
setShowConfirm(null);
|
|
} catch (err) {
|
|
// Error handled by store
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await deleteVault(vault.id);
|
|
setShowConfirm(null);
|
|
} catch (err) {
|
|
// Error handled by store
|
|
}
|
|
};
|
|
|
|
const StatusIcon = vault.status === 'locked' ? Lock : vault.status === 'unlocked' ? Unlock : CheckCircle;
|
|
|
|
return (
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div>
|
|
<h3 className="font-semibold text-white">{vault.name}</h3>
|
|
{vault.description && (
|
|
<p className="text-sm text-gray-500 mt-1">{vault.description}</p>
|
|
)}
|
|
</div>
|
|
<div className={`flex items-center gap-1 text-sm ${getVaultStatusColor(vault.status)}`}>
|
|
<StatusIcon size={14} />
|
|
<span className="capitalize">{vault.status}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-2xl font-bold text-white mb-2">
|
|
{vault.amountHuman}
|
|
</div>
|
|
|
|
{vault.status === 'locked' && (
|
|
<>
|
|
{/* Progress bar */}
|
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
|
<div
|
|
className="h-full bg-synor-500 transition-all duration-1000"
|
|
style={{ width: `${vault.progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-gray-400 flex items-center gap-1">
|
|
<Timer size={14} />
|
|
Unlocks in:
|
|
</span>
|
|
<span className="font-mono text-synor-400">
|
|
{formatTimeRemaining(timeRemaining)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{vault.status === 'unlocked' && !showConfirm && (
|
|
<button
|
|
onClick={() => setShowConfirm('withdraw')}
|
|
disabled={isLoading}
|
|
className="w-full mt-3 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Download size={18} />
|
|
Withdraw Funds
|
|
</button>
|
|
)}
|
|
|
|
{vault.status === 'withdrawn' && !showConfirm && (
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">
|
|
Withdrawn {vault.txId && `(${vault.txId.slice(0, 12)}...)`}
|
|
</span>
|
|
<button
|
|
onClick={() => setShowConfirm('delete')}
|
|
className="text-gray-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showConfirm && (
|
|
<div className="mt-3 bg-gray-800 rounded-lg p-3">
|
|
<p className="text-sm text-gray-300 mb-3">
|
|
{showConfirm === 'withdraw'
|
|
? 'Are you sure you want to withdraw funds from this vault?'
|
|
: 'Are you sure you want to delete this vault record?'}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowConfirm(null)}
|
|
className="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={showConfirm === 'withdraw' ? handleWithdraw : handleDelete}
|
|
disabled={isLoading}
|
|
className={`flex-1 px-3 py-1.5 rounded text-sm ${
|
|
showConfirm === 'withdraw'
|
|
? 'bg-green-600 hover:bg-green-700'
|
|
: 'bg-red-600 hover:bg-red-700'
|
|
}`}
|
|
>
|
|
{isLoading ? 'Processing...' : 'Confirm'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 pt-3 border-t border-gray-800 flex justify-between text-xs text-gray-500">
|
|
<span>Created: {new Date(vault.createdAt * 1000).toLocaleDateString()}</span>
|
|
<span>Unlock: {new Date(vault.unlockAt * 1000).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main Vaults Dashboard
|
|
*/
|
|
export default function VaultsDashboard() {
|
|
const { vaults, summary, isLoading, error, fetchVaults, fetchSummary } = useVaultsStore();
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
|
|
// Fetch vaults on mount
|
|
useEffect(() => {
|
|
fetchVaults();
|
|
fetchSummary();
|
|
|
|
// Refresh every minute
|
|
const interval = setInterval(() => {
|
|
fetchVaults();
|
|
fetchSummary();
|
|
}, 60000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [fetchVaults, fetchSummary]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
|
<VaultIcon className="text-synor-400" />
|
|
Time-Locked Vaults
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">
|
|
Lock funds for a period to enforce saving goals or vesting schedules
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => {
|
|
fetchVaults();
|
|
fetchSummary();
|
|
}}
|
|
disabled={isLoading}
|
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
|
>
|
|
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors flex items-center gap-2"
|
|
>
|
|
<Plus size={18} />
|
|
Create Vault
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/20 border border-red-500/30 rounded-lg p-4 flex items-center gap-3">
|
|
<AlertCircle className="text-red-400" />
|
|
<span className="text-red-200">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary Cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<p className="text-sm text-gray-400 mb-1">Total Locked</p>
|
|
<p className="text-xl font-bold text-synor-400">{summary.totalLockedHuman}</p>
|
|
</div>
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<p className="text-sm text-gray-400 mb-1">Active Vaults</p>
|
|
<p className="text-xl font-bold text-white">{summary.lockedVaults}</p>
|
|
</div>
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<p className="text-sm text-gray-400 mb-1">Ready to Withdraw</p>
|
|
<p className="text-xl font-bold text-green-400">{summary.unlockedVaults}</p>
|
|
</div>
|
|
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
|
|
<p className="text-sm text-gray-400 mb-1">Next Unlock</p>
|
|
<p className="text-xl font-bold text-white">
|
|
{summary.nextUnlock
|
|
? new Date(summary.nextUnlock * 1000).toLocaleDateString()
|
|
: '-'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Vaults List */}
|
|
{isLoading && vaults.length === 0 ? (
|
|
<div className="flex items-center justify-center h-48">
|
|
<LoadingSpinner size={32} />
|
|
</div>
|
|
) : vaults.length === 0 ? (
|
|
<div className="bg-gray-900 rounded-xl p-8 text-center border border-gray-800">
|
|
<VaultIcon size={48} className="mx-auto text-gray-600 mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-400 mb-2">No Vaults Yet</h3>
|
|
<p className="text-gray-500 mb-4">
|
|
Create a time-locked vault to start saving with enforced holding periods.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="px-4 py-2 bg-synor-600 hover:bg-synor-700 rounded-lg transition-colors inline-flex items-center gap-2"
|
|
>
|
|
<Plus size={18} />
|
|
Create Your First Vault
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{vaults.map((vault) => (
|
|
<VaultCard key={vault.id} vault={vault} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info Box */}
|
|
<div className="bg-gray-900/50 rounded-lg p-4 border border-gray-800 flex items-start gap-3">
|
|
<Info className="text-gray-500 mt-0.5" size={18} />
|
|
<div className="text-sm text-gray-400">
|
|
<p className="font-medium text-gray-300 mb-1">About Time-Locked Vaults</p>
|
|
<p>
|
|
Vaults use time-locked transactions to enforce a holding period. Once funds are
|
|
deposited, they cannot be withdrawn until the lock period expires. This is useful
|
|
for savings goals, vesting schedules, or preventing impulsive spending. The lock
|
|
is enforced at the protocol level, so even you cannot access the funds early.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Modal */}
|
|
{showCreateModal && <CreateVaultModal onClose={() => setShowCreateModal(false)} />}
|
|
</div>
|
|
);
|
|
}
|