mirror of
https://github.com/pezkuwichain/pezkuwi-telegram-miniapp.git
synced 2026-07-22 21:25:42 +00:00
941 lines
35 KiB
TypeScript
941 lines
35 KiB
TypeScript
/**
|
||
* Tokens Card Component
|
||
* Token management with search, add/remove, show/hide functionality
|
||
* Fetches live data from blockchain and CoinGecko prices
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
Coins,
|
||
Search,
|
||
Plus,
|
||
Settings,
|
||
Eye,
|
||
EyeOff,
|
||
X,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
RefreshCw,
|
||
Send,
|
||
TrendingUp,
|
||
TrendingDown,
|
||
Fuel,
|
||
} from 'lucide-react';
|
||
import { useWallet } from '@/contexts/WalletContext';
|
||
import { useTelegram } from '@/hooks/useTelegram';
|
||
import {
|
||
subscribeToConnection,
|
||
getLastError,
|
||
getAssetHubAPI,
|
||
getPeopleAPI,
|
||
getConnectionState,
|
||
} from '@/lib/rpc-manager';
|
||
import { FundFeesModal } from './FundFeesModal';
|
||
|
||
// Asset IDs matching pwap/web configuration
|
||
const ASSET_IDS = {
|
||
WHEZ: 0, // Wrapped HEZ (12 decimals)
|
||
PEZ: 1, // PEZ token (12 decimals)
|
||
WUSDT: 1000, // Wrapped USDT (6 decimals) - displayed as USDT
|
||
DOT: 1001, // wDOT (10 decimals)
|
||
ETH: 1002, // wETH (18 decimals)
|
||
BTC: 1003, // wBTC (8 decimals)
|
||
};
|
||
|
||
// LP Token IDs (from poolAssets pallet)
|
||
const LP_TOKEN_IDS = {
|
||
HEZ_PEZ: 0, // HEZ-PEZ LP (12 decimals)
|
||
HEZ_USDT: 1, // HEZ-USDT LP (12 decimals)
|
||
HEZ_DOT: 2, // HEZ-DOT LP (12 decimals)
|
||
};
|
||
|
||
// CoinGecko ID mapping for tokens
|
||
const COINGECKO_IDS: Record<string, string> = {
|
||
DOT: 'polkadot',
|
||
BTC: 'bitcoin',
|
||
ETH: 'ethereum',
|
||
USDT: 'tether',
|
||
HEZ: 'hez-token', // Will fallback to DOT/3 if not found
|
||
PEZ: 'pez-token', // Will fallback to DOT/10 if not found
|
||
};
|
||
|
||
interface PriceData {
|
||
usd: number;
|
||
usd_24h_change: number;
|
||
}
|
||
|
||
// Token configurations
|
||
interface TokenConfig {
|
||
assetId: number;
|
||
symbol: string;
|
||
displaySymbol: string;
|
||
name: string;
|
||
decimals: number;
|
||
logo: string;
|
||
isDefault: boolean;
|
||
priority: number; // Lower = higher in list
|
||
}
|
||
|
||
const DEFAULT_TOKENS: TokenConfig[] = [
|
||
{
|
||
assetId: -1, // Special: native token
|
||
symbol: 'HEZ',
|
||
displaySymbol: 'HEZ',
|
||
name: 'HEZ Native Token',
|
||
decimals: 12,
|
||
logo: '/tokens/HEZ.png',
|
||
isDefault: true,
|
||
priority: 0,
|
||
},
|
||
{
|
||
assetId: ASSET_IDS.PEZ,
|
||
symbol: 'PEZ',
|
||
displaySymbol: 'PEZ',
|
||
name: 'PEZ Governance Token',
|
||
decimals: 12,
|
||
logo: '/tokens/PEZ.png',
|
||
isDefault: true,
|
||
priority: 1,
|
||
},
|
||
{
|
||
assetId: ASSET_IDS.WUSDT,
|
||
symbol: 'wUSDT',
|
||
displaySymbol: 'USDT', // User sees USDT, backend uses wUSDT
|
||
name: 'Tether USD',
|
||
decimals: 6,
|
||
logo: '/tokens/USDT.png',
|
||
isDefault: true,
|
||
priority: 2,
|
||
},
|
||
{
|
||
assetId: ASSET_IDS.DOT,
|
||
symbol: 'wDOT',
|
||
displaySymbol: 'DOT',
|
||
name: 'Polkadot',
|
||
decimals: 10,
|
||
logo: '/tokens/DOT.png',
|
||
isDefault: true,
|
||
priority: 3,
|
||
},
|
||
{
|
||
assetId: ASSET_IDS.BTC,
|
||
symbol: 'wBTC',
|
||
displaySymbol: 'BTC',
|
||
name: 'Bitcoin',
|
||
decimals: 8,
|
||
logo: '/tokens/BTC.png',
|
||
isDefault: true,
|
||
priority: 4,
|
||
},
|
||
{
|
||
assetId: ASSET_IDS.ETH,
|
||
symbol: 'wETH',
|
||
displaySymbol: 'ETH',
|
||
name: 'Ethereum',
|
||
decimals: 18,
|
||
logo: '/tokens/ETH.png',
|
||
isDefault: true,
|
||
priority: 5,
|
||
},
|
||
];
|
||
|
||
// LP Token configurations (separate from regular tokens)
|
||
const LP_TOKENS: TokenConfig[] = [
|
||
{
|
||
assetId: -100 - LP_TOKEN_IDS.HEZ_PEZ, // Use negative IDs starting from -100 to distinguish from regular tokens
|
||
symbol: 'HEZ-PEZ-LP',
|
||
displaySymbol: 'HEZ-PEZ LP',
|
||
name: 'HEZ-PEZ Liquidity Pool',
|
||
decimals: 12,
|
||
logo: '', // Uses initials fallback
|
||
isDefault: true,
|
||
priority: 10,
|
||
},
|
||
{
|
||
assetId: -100 - LP_TOKEN_IDS.HEZ_USDT, // -101
|
||
symbol: 'HEZ-USDT-LP',
|
||
displaySymbol: 'HEZ-USDT LP',
|
||
name: 'HEZ-USDT Liquidity Pool',
|
||
decimals: 12,
|
||
logo: '', // Uses initials fallback
|
||
isDefault: true,
|
||
priority: 11,
|
||
},
|
||
{
|
||
assetId: -100 - LP_TOKEN_IDS.HEZ_DOT, // -102
|
||
symbol: 'HEZ-DOT-LP',
|
||
displaySymbol: 'HEZ-DOT LP',
|
||
name: 'HEZ-DOT Liquidity Pool',
|
||
decimals: 12,
|
||
logo: '', // Uses initials fallback
|
||
isDefault: true,
|
||
priority: 12,
|
||
},
|
||
];
|
||
|
||
interface TokenBalance extends TokenConfig {
|
||
balance: string;
|
||
isHidden: boolean;
|
||
priceUsd?: number;
|
||
priceChange24h?: number;
|
||
valueUsd?: number;
|
||
}
|
||
|
||
interface Props {
|
||
onSendToken?: (token: TokenBalance) => void;
|
||
}
|
||
|
||
export function TokensCard({ onSendToken }: Props) {
|
||
const { address, balance: hezBalance } = useWallet();
|
||
const { hapticImpact } = useTelegram();
|
||
const [rpcConnected, setRpcConnected] = useState(false);
|
||
const [endpointName, setEndpointName] = useState<string | null>(null);
|
||
|
||
// Track RPC connection state
|
||
useEffect(() => {
|
||
// Get initial state
|
||
const initialState = getConnectionState();
|
||
setRpcConnected(initialState.isConnected);
|
||
setEndpointName(initialState.endpoint?.name || null);
|
||
|
||
// Subscribe to changes
|
||
const unsubscribe = subscribeToConnection((connected, endpoint) => {
|
||
setRpcConnected(connected);
|
||
setEndpointName(endpoint?.name || null);
|
||
});
|
||
return () => unsubscribe();
|
||
}, []);
|
||
|
||
// Fetch multi-chain HEZ balances (Asset Hub & People Chain)
|
||
useEffect(() => {
|
||
if (!address) return;
|
||
|
||
const fetchMultiChainBalances = async () => {
|
||
// Asset Hub HEZ balance
|
||
const assetHubApi = getAssetHubAPI();
|
||
if (assetHubApi) {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const accountInfo = (await (assetHubApi.query.system as any).account(address)) as {
|
||
data: { free: { toString(): string } };
|
||
};
|
||
const free = accountInfo.data.free.toString();
|
||
const balanceNum = Number(free) / 1e12;
|
||
setAssetHubHezBalance(balanceNum.toFixed(4));
|
||
} catch (err) {
|
||
console.error('Error fetching Asset Hub HEZ balance:', err);
|
||
setAssetHubHezBalance('0.0000');
|
||
}
|
||
}
|
||
|
||
// People Chain HEZ balance
|
||
const peopleApi = getPeopleAPI();
|
||
if (peopleApi) {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const accountInfo = (await (peopleApi.query.system as any).account(address)) as {
|
||
data: { free: { toString(): string } };
|
||
};
|
||
const free = accountInfo.data.free.toString();
|
||
const balanceNum = Number(free) / 1e12;
|
||
setPeopleHezBalance(balanceNum.toFixed(4));
|
||
} catch (err) {
|
||
console.error('Error fetching People Chain HEZ balance:', err);
|
||
setPeopleHezBalance('0.0000');
|
||
}
|
||
}
|
||
};
|
||
|
||
fetchMultiChainBalances();
|
||
// Refresh every 30 seconds
|
||
const interval = setInterval(fetchMultiChainBalances, 30000);
|
||
return () => clearInterval(interval);
|
||
}, [address, rpcConnected]);
|
||
|
||
// Initialize with default tokens immediately (no API required)
|
||
const [tokens, setTokens] = useState<TokenBalance[]>(() =>
|
||
DEFAULT_TOKENS.map((config) => ({
|
||
...config,
|
||
balance: '--', // Placeholder until connected
|
||
isHidden: false,
|
||
}))
|
||
);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [showAddToken, setShowAddToken] = useState(false);
|
||
const [newAssetId, setNewAssetId] = useState('');
|
||
const [isExpanded, setIsExpanded] = useState(true);
|
||
const [hiddenTokens, setHiddenTokens] = useState<number[]>(() => {
|
||
const stored = localStorage.getItem('hiddenTokens');
|
||
return stored ? JSON.parse(stored) : [];
|
||
});
|
||
const [customTokenIds, setCustomTokenIds] = useState<number[]>(() => {
|
||
const stored = localStorage.getItem('customTokenIds');
|
||
return stored ? JSON.parse(stored) : [];
|
||
});
|
||
const [prices, setPrices] = useState<Record<string, PriceData>>({});
|
||
const [isPriceLoading, setIsPriceLoading] = useState(false);
|
||
// Multi-chain HEZ balances
|
||
const [assetHubHezBalance, setAssetHubHezBalance] = useState<string>('--');
|
||
const [peopleHezBalance, setPeopleHezBalance] = useState<string>('--');
|
||
const [showFundFeesModal, setShowFundFeesModal] = useState(false);
|
||
|
||
// Fetch prices from CoinGecko
|
||
const fetchPrices = useCallback(async () => {
|
||
setIsPriceLoading(true);
|
||
try {
|
||
// Fetch prices for known tokens
|
||
const ids = Object.values(COINGECKO_IDS).join(',');
|
||
const response = await fetch(
|
||
`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd&include_24hr_change=true`
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error('CoinGecko API error');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Map CoinGecko response to our token symbols
|
||
const priceMap: Record<string, PriceData> = {};
|
||
|
||
for (const [symbol, geckoId] of Object.entries(COINGECKO_IDS)) {
|
||
if (data[geckoId]) {
|
||
priceMap[symbol] = {
|
||
usd: data[geckoId].usd,
|
||
usd_24h_change: data[geckoId].usd_24h_change || 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Calculate HEZ and PEZ prices from DOT if not available on CoinGecko
|
||
const dotPrice = priceMap['DOT'];
|
||
if (dotPrice) {
|
||
// HEZ = DOT / 3 (if not found on CoinGecko)
|
||
if (!priceMap['HEZ']) {
|
||
priceMap['HEZ'] = {
|
||
usd: dotPrice.usd / 3,
|
||
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
|
||
};
|
||
}
|
||
// PEZ = DOT / 10 (if not found on CoinGecko)
|
||
if (!priceMap['PEZ']) {
|
||
priceMap['PEZ'] = {
|
||
usd: dotPrice.usd / 10,
|
||
usd_24h_change: dotPrice.usd_24h_change, // Use DOT's change
|
||
};
|
||
}
|
||
}
|
||
|
||
setPrices(priceMap);
|
||
} catch (err) {
|
||
console.error('Failed to fetch prices:', err);
|
||
} finally {
|
||
setIsPriceLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Fetch prices on mount and every 60 seconds
|
||
useEffect(() => {
|
||
fetchPrices();
|
||
const interval = setInterval(fetchPrices, 60000);
|
||
return () => clearInterval(interval);
|
||
}, [fetchPrices]);
|
||
|
||
// Update tokens with price data (works without API)
|
||
const updateTokensWithPrices = useCallback(() => {
|
||
setTokens((prev) =>
|
||
prev.map((token) => {
|
||
const priceData = prices[token.displaySymbol];
|
||
const numBalance = parseFloat(token.balance) || 0;
|
||
return {
|
||
...token,
|
||
isHidden: hiddenTokens.includes(token.assetId),
|
||
priceUsd: priceData?.usd,
|
||
priceChange24h: priceData?.usd_24h_change,
|
||
valueUsd:
|
||
priceData?.usd && token.balance !== '--' ? numBalance * priceData.usd : undefined,
|
||
};
|
||
})
|
||
);
|
||
}, [prices, hiddenTokens]);
|
||
|
||
// Fetch token balances from blockchain (only when API is available)
|
||
const fetchTokenBalances = useCallback(async () => {
|
||
// Update prices even without API
|
||
updateTokensWithPrices();
|
||
|
||
// Get Asset Hub API from rpc-manager (PEZ, USDT etc are on Asset Hub)
|
||
// Note: Native HEZ balance comes from WalletContext (hezBalance), not directly from API
|
||
const assetHubApi = getAssetHubAPI();
|
||
|
||
// If no address, keep showing "--" for balances
|
||
if (!address) {
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
try {
|
||
// Fetch all tokens (default + custom + LP tokens)
|
||
const allTokenConfigs = [
|
||
...DEFAULT_TOKENS,
|
||
...LP_TOKENS, // Include LP tokens
|
||
...customTokenIds
|
||
.filter((id) => !DEFAULT_TOKENS.find((t) => t.assetId === id))
|
||
.map((id) => ({
|
||
assetId: id,
|
||
symbol: `Token #${id}`,
|
||
displaySymbol: `Token #${id}`,
|
||
name: `Custom Token`,
|
||
decimals: 12,
|
||
logo: '',
|
||
isDefault: false,
|
||
priority: 100 + id,
|
||
})),
|
||
];
|
||
|
||
const tokenBalances: TokenBalance[] = [];
|
||
|
||
for (const config of allTokenConfigs) {
|
||
let balance = '0';
|
||
|
||
if (config.assetId === -1) {
|
||
// Native HEZ balance (from relay chain)
|
||
balance = hezBalance ?? '0.0000';
|
||
} else if (config.assetId <= -100) {
|
||
// LP Token balance (from poolAssets pallet)
|
||
// Convert back to pool asset ID: -100 - assetId
|
||
const poolAssetId = -100 - config.assetId;
|
||
if (!assetHubApi) {
|
||
balance = '--';
|
||
} else {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const lpBalance = await (assetHubApi.query.poolAssets as any).account(
|
||
poolAssetId,
|
||
address
|
||
);
|
||
|
||
if (lpBalance && lpBalance.isSome) {
|
||
const data = lpBalance.unwrap();
|
||
const rawBalance = data.balance.toString();
|
||
const numBalance = parseInt(rawBalance) / Math.pow(10, config.decimals);
|
||
// Show more decimals for LP tokens to catch small amounts
|
||
balance =
|
||
numBalance < 0.0001 && numBalance > 0
|
||
? numBalance.toExponential(2)
|
||
: numBalance.toFixed(4);
|
||
} else {
|
||
balance = '0.0000';
|
||
}
|
||
} catch {
|
||
balance = '0.0000';
|
||
}
|
||
}
|
||
} else {
|
||
// Asset balance - PEZ, USDT etc are on Asset Hub!
|
||
// Use Asset Hub API instead of relay chain API
|
||
if (!assetHubApi) {
|
||
balance = '--'; // Asset Hub not connected
|
||
} else {
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const assetBalance = await (assetHubApi.query.assets as any).account(
|
||
config.assetId,
|
||
address
|
||
);
|
||
|
||
if (assetBalance && assetBalance.isSome) {
|
||
const data = assetBalance.unwrap();
|
||
const rawBalance = data.balance.toString();
|
||
const formatted = (parseInt(rawBalance) / Math.pow(10, config.decimals)).toFixed(
|
||
config.decimals === 6 ? 2 : 4
|
||
);
|
||
balance = formatted;
|
||
} else {
|
||
// User has no balance for this asset
|
||
balance = config.decimals === 6 ? '0.00' : '0.0000';
|
||
}
|
||
} catch {
|
||
// Token might not exist or user has no balance
|
||
balance = config.decimals === 6 ? '0.00' : '0.0000';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add price data
|
||
const priceData = prices[config.displaySymbol];
|
||
const numBalance = parseFloat(balance) || 0;
|
||
|
||
tokenBalances.push({
|
||
...config,
|
||
balance,
|
||
isHidden: hiddenTokens.includes(config.assetId),
|
||
priceUsd: priceData?.usd,
|
||
priceChange24h: priceData?.usd_24h_change,
|
||
valueUsd: priceData?.usd ? numBalance * priceData.usd : undefined,
|
||
});
|
||
}
|
||
|
||
// Sort by priority
|
||
tokenBalances.sort((a, b) => a.priority - b.priority);
|
||
setTokens(tokenBalances);
|
||
} catch (err) {
|
||
console.error('Failed to fetch token balances:', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [address, hezBalance, hiddenTokens, customTokenIds, prices, updateTokensWithPrices]);
|
||
|
||
useEffect(() => {
|
||
fetchTokenBalances();
|
||
}, [fetchTokenBalances]);
|
||
|
||
// Toggle token visibility
|
||
const toggleTokenVisibility = (assetId: number) => {
|
||
hapticImpact('light');
|
||
setHiddenTokens((prev) => {
|
||
const newHidden = prev.includes(assetId)
|
||
? prev.filter((id) => id !== assetId)
|
||
: [...prev, assetId];
|
||
localStorage.setItem('hiddenTokens', JSON.stringify(newHidden));
|
||
return newHidden;
|
||
});
|
||
};
|
||
|
||
// Add custom token
|
||
const handleAddToken = () => {
|
||
const id = parseInt(newAssetId);
|
||
if (isNaN(id) || id < 0) return;
|
||
|
||
if (customTokenIds.includes(id) || DEFAULT_TOKENS.find((t) => t.assetId === id)) {
|
||
return; // Already exists
|
||
}
|
||
|
||
hapticImpact('medium');
|
||
const newIds = [...customTokenIds, id];
|
||
setCustomTokenIds(newIds);
|
||
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
|
||
setNewAssetId('');
|
||
setShowAddToken(false);
|
||
fetchTokenBalances();
|
||
};
|
||
|
||
// Remove custom token
|
||
const removeCustomToken = (assetId: number) => {
|
||
hapticImpact('medium');
|
||
const newIds = customTokenIds.filter((id) => id !== assetId);
|
||
setCustomTokenIds(newIds);
|
||
localStorage.setItem('customTokenIds', JSON.stringify(newIds));
|
||
setTokens((prev) => prev.filter((t) => t.assetId !== assetId));
|
||
};
|
||
|
||
// Filter tokens based on search
|
||
const filteredTokens = tokens.filter((token) => {
|
||
if (!searchQuery) return !token.isHidden || showSettings;
|
||
const query = searchQuery.toLowerCase();
|
||
return (
|
||
token.displaySymbol.toLowerCase().includes(query) ||
|
||
token.name.toLowerCase().includes(query) ||
|
||
token.symbol.toLowerCase().includes(query)
|
||
);
|
||
});
|
||
|
||
// Get token gradient based on symbol
|
||
const getTokenGradient = (symbol: string) => {
|
||
const gradients: Record<string, string> = {
|
||
HEZ: 'from-green-500/20 to-yellow-500/20 border-green-500/30',
|
||
PEZ: 'from-blue-500/20 to-purple-500/20 border-blue-500/30',
|
||
USDT: 'from-emerald-500/20 to-teal-500/20 border-emerald-500/30',
|
||
DOT: 'from-pink-500/20 to-purple-500/20 border-pink-500/30',
|
||
BTC: 'from-orange-500/20 to-yellow-500/20 border-orange-500/30',
|
||
ETH: 'from-blue-500/20 to-indigo-500/20 border-blue-500/30',
|
||
'HEZ-PEZ LP': 'from-green-500/20 to-blue-500/20 border-cyan-500/30',
|
||
'HEZ-USDT LP': 'from-green-500/20 to-emerald-500/20 border-teal-500/30',
|
||
'HEZ-DOT LP': 'from-green-500/20 to-pink-500/20 border-pink-500/30',
|
||
};
|
||
return gradients[symbol] || 'from-gray-500/20 to-gray-600/20 border-gray-500/30';
|
||
};
|
||
|
||
return (
|
||
<div className="bg-muted/50 border border-border rounded-xl overflow-hidden">
|
||
{/* Header */}
|
||
<div
|
||
className="p-4 flex items-center justify-between cursor-pointer"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Coins className="w-5 h-5 text-cyan-400" />
|
||
<h3 className="font-semibold">Tokens</h3>
|
||
<span className="text-xs text-muted-foreground">
|
||
({tokens.filter((t) => !t.isHidden).length})
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
hapticImpact('light');
|
||
fetchTokenBalances();
|
||
fetchPrices();
|
||
}}
|
||
disabled={isLoading || isPriceLoading}
|
||
className="p-1.5 text-muted-foreground hover:text-white rounded"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isLoading || isPriceLoading ? 'animate-spin' : ''}`} />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
hapticImpact('light');
|
||
setShowSettings(!showSettings);
|
||
}}
|
||
className={`p-1.5 rounded ${showSettings ? 'text-cyan-400 bg-cyan-400/10' : 'text-muted-foreground hover:text-white'}`}
|
||
>
|
||
<Settings className="w-4 h-4" />
|
||
</button>
|
||
{isExpanded ? (
|
||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||
) : (
|
||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<>
|
||
{/* Search & Add */}
|
||
<div className="px-4 pb-3 space-y-2">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Token bigere..."
|
||
className="w-full pl-9 pr-4 py-2 bg-background rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
|
||
{showSettings && (
|
||
<button
|
||
onClick={() => {
|
||
hapticImpact('light');
|
||
setShowAddToken(!showAddToken);
|
||
}}
|
||
className="w-full py-2 border border-dashed border-border rounded-lg text-sm text-muted-foreground hover:text-white hover:border-cyan-500/50 flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" />
|
||
Token Zêde Bike
|
||
</button>
|
||
)}
|
||
|
||
{showAddToken && (
|
||
<div className="p-3 bg-background rounded-lg space-y-2">
|
||
<input
|
||
type="number"
|
||
value={newAssetId}
|
||
onChange={(e) => setNewAssetId(e.target.value)}
|
||
placeholder="Asset ID binivîse (mînak: 3)"
|
||
className="w-full px-3 py-2 bg-muted rounded-lg text-sm"
|
||
min="0"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowAddToken(false)}
|
||
className="flex-1 py-2 bg-muted rounded-lg text-sm"
|
||
>
|
||
Betal
|
||
</button>
|
||
<button
|
||
onClick={handleAddToken}
|
||
disabled={!newAssetId}
|
||
className="flex-1 py-2 bg-cyan-600 rounded-lg text-sm disabled:opacity-50"
|
||
>
|
||
Zêde Bike
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Connection Status Banner */}
|
||
{rpcConnected ? (
|
||
<div className="mx-4 mb-2 px-3 py-2 bg-green-500/10 border border-green-500/30 rounded-lg flex items-center gap-2">
|
||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||
<div>
|
||
<p className="text-xs text-green-400 font-medium">Pezkuwichain Girêdayî</p>
|
||
{endpointName && <p className="text-[10px] text-green-400/70">{endpointName}</p>}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mx-4 mb-2 px-3 py-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
|
||
<RefreshCw className="w-4 h-4 text-yellow-400 animate-spin flex-shrink-0" />
|
||
<div>
|
||
<p className="text-xs text-yellow-400 font-medium">Girêdana Blockchain...</p>
|
||
<p className="text-[10px] text-yellow-400/70">
|
||
{getLastError() || 'RPC serverê tê girêdan...'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Token List */}
|
||
<div className="px-4 pb-4 space-y-2 max-h-[400px] overflow-y-auto">
|
||
{filteredTokens.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<Coins className="w-6 h-6 text-muted-foreground mx-auto mb-2" />
|
||
<p className="text-sm text-muted-foreground">Token nehat dîtin</p>
|
||
</div>
|
||
) : (
|
||
filteredTokens.map((token) =>
|
||
// Special rendering for HEZ (multi-chain)
|
||
token.assetId === -1 ? (
|
||
<div
|
||
key={token.assetId}
|
||
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
|
||
token.isHidden ? 'opacity-50' : ''
|
||
}`}
|
||
>
|
||
{/* HEZ Header */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={token.logo}
|
||
alt={token.displaySymbol}
|
||
className="w-10 h-10 rounded-full"
|
||
/>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold">{token.displaySymbol}</span>
|
||
<span className="text-[10px] bg-green-500/20 text-green-400 px-1.5 py-0.5 rounded">
|
||
Multi-Chain
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{token.priceUsd !== undefined ? (
|
||
<>
|
||
<span className="text-xs text-muted-foreground">
|
||
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
|
||
</span>
|
||
{token.priceChange24h !== undefined && (
|
||
<span
|
||
className={`text-[10px] flex items-center gap-0.5 ${
|
||
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||
}`}
|
||
>
|
||
{token.priceChange24h >= 0 ? (
|
||
<TrendingUp className="w-3 h-3" />
|
||
) : (
|
||
<TrendingDown className="w-3 h-3" />
|
||
)}
|
||
{Math.abs(token.priceChange24h).toFixed(2)}%
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
hapticImpact('light');
|
||
setShowFundFeesModal(true);
|
||
}}
|
||
className="px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/30 rounded-lg flex items-center gap-1.5 text-yellow-400 text-xs font-medium hover:bg-yellow-500/30 transition-colors"
|
||
>
|
||
<Fuel className="w-3.5 h-3.5" />
|
||
Add Fee
|
||
</button>
|
||
</div>
|
||
|
||
{/* Multi-chain balances */}
|
||
<div className="space-y-2 mt-2">
|
||
{/* Relay Chain */}
|
||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||
<span className="text-xs text-muted-foreground">Relay Chain</span>
|
||
</div>
|
||
<span className="text-sm font-mono">{token.balance} HEZ</span>
|
||
</div>
|
||
|
||
{/* Asset Hub */}
|
||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500" />
|
||
<span className="text-xs text-muted-foreground">Asset Hub</span>
|
||
{parseFloat(assetHubHezBalance) < 0.1 && assetHubHezBalance !== '--' && (
|
||
<span className="text-[10px] text-yellow-400">⚠️</span>
|
||
)}
|
||
</div>
|
||
<span className="text-sm font-mono">{assetHubHezBalance} HEZ</span>
|
||
</div>
|
||
|
||
{/* People Chain */}
|
||
<div className="flex items-center justify-between py-1.5 px-2 bg-black/20 rounded-lg">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 rounded-full bg-purple-500" />
|
||
<span className="text-xs text-muted-foreground">People Chain</span>
|
||
{parseFloat(peopleHezBalance) < 0.1 && peopleHezBalance !== '--' && (
|
||
<span className="text-[10px] text-yellow-400">⚠️</span>
|
||
)}
|
||
</div>
|
||
<span className="text-sm font-mono">{peopleHezBalance} HEZ</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Total Value */}
|
||
{token.valueUsd !== undefined && token.balance !== '--' && (
|
||
<div className="mt-2 pt-2 border-t border-white/10 flex justify-between items-center">
|
||
<span className="text-xs text-muted-foreground">Toplam</span>
|
||
<span className="text-sm font-semibold">
|
||
≈ $
|
||
{(
|
||
(parseFloat(token.balance) +
|
||
parseFloat(assetHubHezBalance === '--' ? '0' : assetHubHezBalance) +
|
||
parseFloat(peopleHezBalance === '--' ? '0' : peopleHezBalance)) *
|
||
(token.priceUsd || 0)
|
||
).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
// Regular token card
|
||
<div
|
||
key={token.assetId}
|
||
className={`p-3 rounded-xl border bg-gradient-to-br ${getTokenGradient(token.displaySymbol)} ${
|
||
token.isHidden ? 'opacity-50' : ''
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
{token.logo ? (
|
||
<img
|
||
src={token.logo}
|
||
alt={token.displaySymbol}
|
||
className="w-10 h-10 rounded-full"
|
||
/>
|
||
) : (
|
||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||
<span className="text-xs font-bold">
|
||
{token.displaySymbol.slice(0, 2)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-semibold">{token.displaySymbol}</span>
|
||
{token.assetId <= -100 && (
|
||
<span className="text-[10px] bg-purple-500/20 text-purple-400 px-1.5 py-0.5 rounded">
|
||
LP
|
||
</span>
|
||
)}
|
||
{!token.isDefault && (
|
||
<span className="text-[10px] bg-cyan-500/20 text-cyan-400 px-1.5 py-0.5 rounded">
|
||
Custom
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{token.priceUsd !== undefined ? (
|
||
<>
|
||
<span className="text-xs text-muted-foreground">
|
||
${token.priceUsd.toFixed(token.priceUsd < 1 ? 4 : 2)}
|
||
</span>
|
||
{token.priceChange24h !== undefined && (
|
||
<span
|
||
className={`text-[10px] flex items-center gap-0.5 ${
|
||
token.priceChange24h >= 0 ? 'text-green-400' : 'text-red-400'
|
||
}`}
|
||
>
|
||
{token.priceChange24h >= 0 ? (
|
||
<TrendingUp className="w-3 h-3" />
|
||
) : (
|
||
<TrendingDown className="w-3 h-3" />
|
||
)}
|
||
{Math.abs(token.priceChange24h).toFixed(2)}%
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="text-right">
|
||
<p
|
||
className={`font-semibold font-mono ${token.balance === '--' ? 'text-muted-foreground' : ''}`}
|
||
>
|
||
{token.balance}
|
||
</p>
|
||
{token.balance === '--' ? (
|
||
<p className="text-xs text-muted-foreground">Tê barkirin...</p>
|
||
) : token.valueUsd !== undefined ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
≈ ${token.valueUsd.toFixed(2)}
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">{token.displaySymbol}</p>
|
||
)}
|
||
</div>
|
||
|
||
{showSettings ? (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => toggleTokenVisibility(token.assetId)}
|
||
className="p-1.5 text-muted-foreground hover:text-white rounded"
|
||
>
|
||
{token.isHidden ? (
|
||
<EyeOff className="w-4 h-4" />
|
||
) : (
|
||
<Eye className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
{!token.isDefault && (
|
||
<button
|
||
onClick={() => removeCustomToken(token.assetId)}
|
||
className="p-1.5 text-red-400 hover:text-red-300 rounded"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
onSendToken &&
|
||
token.balance !== '--' &&
|
||
parseFloat(token.balance) > 0 && (
|
||
<button
|
||
onClick={() => {
|
||
hapticImpact('light');
|
||
onSendToken(token);
|
||
}}
|
||
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
)
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Fund Fees Modal for XCM Teleport */}
|
||
<FundFeesModal isOpen={showFundFeesModal} onClose={() => setShowFundFeesModal(false)} />
|
||
</div>
|
||
);
|
||
}
|