mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-09 20:11:02 +00:00
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user