mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +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 { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
@@ -22,95 +31,226 @@ export const AddTokenModal: React.FC<AddTokenModalProps> = ({
|
||||
onClose,
|
||||
onAddToken,
|
||||
}) => {
|
||||
const { assetHubApi, isAssetHubReady } = usePezkuwi();
|
||||
const [assetId, setAssetId] = 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) => {
|
||||
e.preventDefault();
|
||||
// Helper to decode hex string to UTF-8
|
||||
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('');
|
||||
setTokenInfo(null);
|
||||
|
||||
const id = parseInt(assetId);
|
||||
if (isNaN(id) || id < 0) {
|
||||
setError('Please enter a valid asset ID (number)');
|
||||
setError('Please enter a valid asset ID (positive number)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (!assetHubApi || !isAssetHubReady) {
|
||||
setError('Asset Hub connection not ready. Please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await onAddToken(id);
|
||||
setAssetId('');
|
||||
setError('');
|
||||
} catch {
|
||||
setError('Failed to add token. Please check the asset ID and try again.');
|
||||
// Check if asset exists
|
||||
const assetInfo = await assetHubApi.query.assets.asset(id);
|
||||
|
||||
if (!assetInfo || assetInfo.isNone) {
|
||||
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 {
|
||||
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 = () => {
|
||||
setAssetId('');
|
||||
setError('');
|
||||
setTokenInfo(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Add Custom Token</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Enter the asset ID of the token you want to track.
|
||||
Note: Core tokens (HEZ, PEZ) are already displayed separately.
|
||||
Enter the asset ID to fetch token details from blockchain.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Search Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assetId" className="text-sm text-gray-300">
|
||||
Asset ID
|
||||
</Label>
|
||||
<Input
|
||||
id="assetId"
|
||||
type="number"
|
||||
value={assetId}
|
||||
onChange={(e) => setAssetId(e.target.value)}
|
||||
placeholder="e.g., 3"
|
||||
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="assetId"
|
||||
type="number"
|
||||
value={assetId}
|
||||
onChange={(e) => {
|
||||
setAssetId(e.target.value);
|
||||
setTokenInfo(null);
|
||||
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">
|
||||
Each token on the network has a unique asset ID
|
||||
Known assets: 1001 (DOT), 1002 (ETH), 1003 (BTC)
|
||||
</p>
|
||||
</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 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
disabled={isAdding}
|
||||
className="border border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -245,12 +245,86 @@ const WalletDashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Balance */}
|
||||
<div className="lg:col-span-1">
|
||||
<AccountBalance />
|
||||
{/* Left Column - Recent Activity & NFTs */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{/* 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>
|
||||
|
||||
{/* Right Column - Actions */}
|
||||
{/* Right Column - Actions & Tokens */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
@@ -281,86 +355,8 @@ const WalletDashboard: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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-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 />
|
||||
{/* Token Balances */}
|
||||
<AccountBalance />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user