synor/apps/desktop-wallet/src/pages/Vaults/VaultsDashboard.tsx
Gulshan Yadav f08eb965c2 a
2026-02-02 15:00:13 +05:30

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"
>
&times;
</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>
);
}