feat: reorganize wallet dashboard and add blockchain token lookup

- Move Recent Activity and NFTs to left column
- Move token balances to right column under action buttons
- Add Token modal now fetches asset info from blockchain
- Shows symbol, name, decimals before adding
- Search by asset ID with Enter key support
This commit is contained in:
2026-02-06 11:26:25 +03:00
parent 51976e0e2d
commit 6ad5e151ea
2 changed files with 254 additions and 118 deletions
+174 -34
View File
@@ -9,7 +9,16 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react'; import { AlertCircle, Search, CheckCircle, Loader2 } from 'lucide-react';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
interface TokenInfo {
assetId: number;
symbol: string;
name: string;
decimals: number;
exists: boolean;
}
interface AddTokenModalProps { interface AddTokenModalProps {
isOpen: boolean; isOpen: boolean;
@@ -22,95 +31,226 @@ export const AddTokenModal: React.FC<AddTokenModalProps> = ({
onClose, onClose,
onAddToken, onAddToken,
}) => { }) => {
const { assetHubApi, isAssetHubReady } = usePezkuwi();
const [assetId, setAssetId] = useState(''); const [assetId, setAssetId] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [tokenInfo, setTokenInfo] = useState<TokenInfo | null>(null);
const handleSubmit = async (e: React.FormEvent) => { // Helper to decode hex string to UTF-8
e.preventDefault(); const hexToString = (hex: string): string => {
if (!hex || hex === '0x') return '';
try {
const hexStr = hex.startsWith('0x') ? hex.slice(2) : hex;
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []);
return new TextDecoder('utf-8').decode(bytes).replace(/\0/g, '');
} catch {
return '';
}
};
const handleSearch = async () => {
setError(''); setError('');
setTokenInfo(null);
const id = parseInt(assetId); const id = parseInt(assetId);
if (isNaN(id) || id < 0) { if (isNaN(id) || id < 0) {
setError('Please enter a valid asset ID (number)'); setError('Please enter a valid asset ID (positive number)');
return; return;
} }
setIsLoading(true); if (!assetHubApi || !isAssetHubReady) {
setError('Asset Hub connection not ready. Please wait...');
return;
}
setIsSearching(true);
try { try {
await onAddToken(id); // Check if asset exists
setAssetId(''); const assetInfo = await assetHubApi.query.assets.asset(id);
setError('');
} catch { if (!assetInfo || assetInfo.isNone) {
setError('Failed to add token. Please check the asset ID and try again.'); setError(`Asset #${id} not found on blockchain`);
setIsSearching(false);
return;
}
// Get asset metadata
const metadata = await assetHubApi.query.assets.metadata(id);
const metaJson = metadata.toJSON() as { symbol?: string; name?: string; decimals?: number };
// Decode hex strings
let symbol = metaJson.symbol || '';
let name = metaJson.name || '';
if (typeof symbol === 'string' && symbol.startsWith('0x')) {
symbol = hexToString(symbol);
}
if (typeof name === 'string' && name.startsWith('0x')) {
name = hexToString(name);
}
// Fallback if no metadata
if (!symbol) symbol = `Asset #${id}`;
if (!name) name = `Unknown Asset`;
setTokenInfo({
assetId: id,
symbol: symbol.trim(),
name: name.trim(),
decimals: metaJson.decimals || 12,
exists: true,
});
} catch (err) {
console.error('Failed to fetch asset:', err);
setError('Failed to fetch asset from blockchain');
} finally { } finally {
setIsLoading(false); setIsSearching(false);
}
};
const handleAdd = async () => {
if (!tokenInfo) return;
setIsAdding(true);
try {
await onAddToken(tokenInfo.assetId);
handleClose();
} catch {
setError('Failed to add token');
} finally {
setIsAdding(false);
} }
}; };
const handleClose = () => { const handleClose = () => {
setAssetId(''); setAssetId('');
setError(''); setError('');
setTokenInfo(null);
onClose(); onClose();
}; };
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-md"> <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Add Custom Token</DialogTitle> <DialogTitle className="text-xl">Add Custom Token</DialogTitle>
<DialogDescription className="text-gray-400"> <DialogDescription className="text-gray-400">
Enter the asset ID of the token you want to track. Enter the asset ID to fetch token details from blockchain.
Note: Core tokens (HEZ, PEZ) are already displayed separately.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{/* Search Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="assetId" className="text-sm text-gray-300"> <Label htmlFor="assetId" className="text-sm text-gray-300">
Asset ID Asset ID
</Label> </Label>
<Input <div className="flex gap-2">
id="assetId" <Input
type="number" id="assetId"
value={assetId} type="number"
onChange={(e) => setAssetId(e.target.value)} value={assetId}
placeholder="e.g., 3" onChange={(e) => {
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500" setAssetId(e.target.value);
min="0" setTokenInfo(null);
required setError('');
/> }}
onKeyPress={handleKeyPress}
placeholder="e.g., 1001"
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 flex-1"
min="0"
/>
<Button
type="button"
onClick={handleSearch}
disabled={isSearching || !assetId}
className="bg-cyan-600 hover:bg-cyan-700"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Each token on the network has a unique asset ID Known assets: 1001 (DOT), 1002 (ETH), 1003 (BTC)
</p> </p>
</div> </div>
{/* Token Info Display */}
{tokenInfo && (
<div className="p-4 bg-gray-800/50 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-green-400 font-medium">Token Found!</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Symbol:</span>
<span className="text-white font-semibold">{tokenInfo.symbol}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Name:</span>
<span className="text-white">{tokenInfo.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Decimals:</span>
<span className="text-white">{tokenInfo.decimals}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Asset ID:</span>
<span className="text-white font-mono">#{tokenInfo.assetId}</span>
</div>
</div>
</div>
)}
{/* Error Display */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-md"> <div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-md">
<AlertCircle className="h-4 w-4 text-red-400" /> <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0" />
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
)} )}
<div className="flex justify-end gap-3"> {/* Action Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={handleClose} onClick={handleClose}
disabled={isLoading} disabled={isAdding}
className="border border-gray-700 hover:bg-gray-800" className="border border-gray-700 hover:bg-gray-800"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="button"
disabled={isLoading} onClick={handleAdd}
className="bg-cyan-600 hover:bg-cyan-700" disabled={!tokenInfo || isAdding}
className="bg-green-600 hover:bg-green-700"
> >
{isLoading ? 'Adding...' : 'Add Token'} {isAdding ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
) : (
'Add Token'
)}
</Button> </Button>
</div> </div>
</form> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+80 -84
View File
@@ -245,12 +245,86 @@ const WalletDashboard: React.FC = () => {
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Balance */} {/* Left Column - Recent Activity & NFTs */}
<div className="lg:col-span-1"> <div className="lg:col-span-1 space-y-6">
<AccountBalance /> {/* Recent Activity */}
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Recent Activity</h3>
<Button
variant="ghost"
size="icon"
onClick={fetchRecentTransactions}
disabled={isLoadingRecent}
className="text-gray-400 hover:text-white"
>
<RefreshCw className={`w-4 h-4 ${isLoadingRecent ? 'animate-spin' : ''}`} />
</Button>
</div>
{isLoadingRecent ? (
<div className="text-center py-8">
<RefreshCw className="w-10 h-10 text-gray-600 mx-auto mb-3 animate-spin" />
<p className="text-gray-400 text-sm">Loading...</p>
</div>
) : recentTransactions.length === 0 ? (
<div className="text-center py-8">
<History className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm">No recent transactions</p>
</div>
) : (
<div className="space-y-2">
{recentTransactions.slice(0, 5).map((tx) => (
<div
key={`${tx.blockNumber}-${tx.extrinsicIndex}`}
className="bg-gray-800/50 border border-gray-700 rounded-lg p-3 hover:bg-gray-800 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isIncoming(tx) ? (
<div className="bg-green-500/20 p-1.5 rounded-lg">
<ArrowDownRight className="w-3 h-3 text-green-400" />
</div>
) : (
<div className="bg-yellow-500/20 p-1.5 rounded-lg">
<ArrowUpRight className="w-3 h-3 text-yellow-400" />
</div>
)}
<div>
<div className="text-white font-semibold text-xs">
{isIncoming(tx) ? 'Received' : 'Sent'}
</div>
<div className="text-xs text-gray-500">
#{tx.blockNumber}
</div>
</div>
</div>
<div className="text-right">
<div className="text-white font-mono text-xs">
{isIncoming(tx) ? '+' : '-'}{formatAmount(tx.amount || '0')}
</div>
</div>
</div>
</div>
))}
</div>
)}
<Button
onClick={() => setIsHistoryModalOpen(true)}
variant="outline"
size="sm"
className="mt-3 w-full border-gray-700 hover:bg-gray-800 text-xs"
>
View All
</Button>
</div>
{/* NFT Collection */}
<NftList />
</div> </div>
{/* Right Column - Actions */} {/* Right Column - Actions & Tokens */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
@@ -281,86 +355,8 @@ const WalletDashboard: React.FC = () => {
</Button> </Button>
</div> </div>
{/* Recent Activity */} {/* Token Balances */}
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6"> <AccountBalance />
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Recent Activity</h3>
<Button
variant="ghost"
size="icon"
onClick={fetchRecentTransactions}
disabled={isLoadingRecent}
className="text-gray-400 hover:text-white"
>
<RefreshCw className={`w-4 h-4 ${isLoadingRecent ? 'animate-spin' : ''}`} />
</Button>
</div>
{isLoadingRecent ? (
<div className="text-center py-12">
<RefreshCw className="w-12 h-12 text-gray-600 mx-auto mb-3 animate-spin" />
<p className="text-gray-400">Loading transactions...</p>
</div>
) : recentTransactions.length === 0 ? (
<div className="text-center py-12">
<History className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500">No recent transactions found</p>
<p className="text-gray-600 text-sm mt-1">
Recent activity from last 10 blocks
</p>
</div>
) : (
<div className="space-y-3">
{recentTransactions.map((tx) => (
<div
key={`${tx.blockNumber}-${tx.extrinsicIndex}`}
className="bg-gray-800/50 border border-gray-700 rounded-lg p-3 hover:bg-gray-800 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isIncoming(tx) ? (
<div className="bg-green-500/20 p-2 rounded-lg">
<ArrowDownRight className="w-4 h-4 text-green-400" />
</div>
) : (
<div className="bg-yellow-500/20 p-2 rounded-lg">
<ArrowUpRight className="w-4 h-4 text-yellow-400" />
</div>
)}
<div>
<div className="text-white font-semibold text-sm">
{isIncoming(tx) ? 'Received' : 'Sent'}
</div>
<div className="text-xs text-gray-400">
Block #{tx.blockNumber}
</div>
</div>
</div>
<div className="text-right">
<div className="text-white font-mono text-sm">
{isIncoming(tx) ? '+' : '-'}{formatAmount(tx.amount || '0')}
</div>
<div className="text-xs text-gray-400">
{tx.section}.{tx.method}
</div>
</div>
</div>
</div>
))}
</div>
)}
<Button
onClick={() => setIsHistoryModalOpen(true)}
variant="outline"
className="mt-4 w-full border-gray-700 hover:bg-gray-800"
>
View All Transactions
</Button>
</div>
{/* NFT Collection */}
<NftList />
</div> </div>
</div> </div>
</div> </div>