Files
pwap/web/src/components/AccountBalance.tsx
T
pezkuwichain 4f683538d3 feat: complete i18n support for all components (6 languages)
Add full internationalization across 127+ components and pages.
790+ translation keys in en, tr, kmr, ckb, ar, fa locales.
Remove duplicate keys and delete unused .json locale files.
2026-02-22 04:48:20 +03:00

1163 lines
47 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Wallet, TrendingUp, RefreshCw, Award, Plus, Coins, Send, Shield, Users, Fuel, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
import { AddTokenModal } from './AddTokenModal';
import { TransferModal } from './TransferModal';
import { XCMTeleportModal } from './XCMTeleportModal';
import { LPStakeModal } from './LPStakeModal';
import { getAllScores, type UserScores } from '@pezkuwi/lib/scores';
interface TokenBalance {
assetId: number;
symbol: string;
name: string;
balance: string;
decimals: number;
usdValue: number;
isLpToken?: boolean; // LP tokens from poolAssets pallet
}
export const AccountBalance: React.FC = () => {
const { t } = useTranslation();
const { api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount } = usePezkuwi();
const [balance, setBalance] = useState<{
free: string;
reserved: string;
total: string;
}>({
free: '0',
reserved: '0',
total: '0',
});
// HEZ balances on different chains
const [assetHubHezBalance, setAssetHubHezBalance] = useState<string>('0');
const [peopleHezBalance, setPeopleHezBalance] = useState<string>('0');
const [pezBalance, setPezBalance] = useState<string>('0');
const [usdtBalance, setUsdtBalance] = useState<string>('0');
const [whezBalance, setWhezBalance] = useState<string>('0');
const [lpTokens, setLpTokens] = useState<TokenBalance[]>([]);
const [hezUsdPrice, setHezUsdPrice] = useState<number>(0);
const [pezUsdPrice, setPezUsdPrice] = useState<number>(0);
const [scores, setScores] = useState<UserScores>({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
const [loadingScores, setLoadingScores] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [otherTokens, setOtherTokens] = useState<TokenBalance[]>([]);
const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false);
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false);
const [isXCMTeleportModalOpen, setIsXCMTeleportModalOpen] = useState(false);
const [selectedTokenForTransfer, setSelectedTokenForTransfer] = useState<TokenBalance | null>(null);
const [customTokenIds, setCustomTokenIds] = useState<number[]>(() => {
const stored = localStorage.getItem('customTokenIds');
return stored ? JSON.parse(stored) : [];
});
const [isLPStakeModalOpen, setIsLPStakeModalOpen] = useState(false);
const [selectedLPForStake, setSelectedLPForStake] = useState<TokenBalance | null>(null);
// Helper function to get asset decimals
const getAssetDecimals = (assetId: number): number => {
if (assetId === ASSET_IDS.WUSDT) return 6; // wUSDT has 6 decimals
return 12; // wHEZ, PEZ and others have 12 decimals by default
};
// 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 '';
}
};
// Token logo mapping
const TOKEN_LOGOS: Record<string, string> = {
HEZ: '/tokens/HEZ.png',
PEZ: '/tokens/PEZ.png',
USDT: '/tokens/USDT.png',
wUSDT: '/tokens/USDT.png',
wHEZ: '/tokens/HEZ.png', // wHEZ uses same logo as HEZ
BNB: '/tokens/BNB.png',
BTC: '/tokens/BTC.png',
DOT: '/tokens/DOT.png',
ETH: '/tokens/ETH.png',
'HEZ-PEZ LP': '/tokens/LP.png',
'HEZ-USDT LP': '/tokens/LP.png',
'HEZ-DOT LP': '/tokens/LP.png',
};
// Get token logo URL
const getTokenLogo = (symbol: string): string | null => {
return TOKEN_LOGOS[symbol] || TOKEN_LOGOS[symbol.toUpperCase()] || null;
};
// Get token color based on assetId
const getTokenColor = (assetId: number) => {
const colors = {
[ASSET_IDS.WHEZ]: { bg: 'from-green-500/20 to-yellow-500/20', text: 'text-green-400', border: 'border-green-500/30' },
[ASSET_IDS.WUSDT]: { bg: 'from-emerald-500/20 to-teal-500/20', text: 'text-emerald-400', border: 'border-emerald-500/30' },
};
return colors[assetId] || { bg: 'from-cyan-500/20 to-blue-500/20', text: 'text-cyan-400', border: 'border-cyan-500/30' };
};
// Fetch token prices from CoinGecko with fallback logic
// Priority: CoinGecko direct > DOT-based calculation > DEX pool
const fetchTokenPrices = async () => {
try {
if (import.meta.env.DEV) console.log('💰 Fetching token prices from CoinGecko...');
// CoinGecko API - fetch DOT, HEZ, PEZ prices
// Note: HEZ and PEZ may not be listed yet, so we use DOT as fallback
const coingeckoIds = 'polkadot,pezkuwichain,pez-token'; // DOT is always available
// Use our proxy to avoid CORS and rate limits
const response = await fetch(
`https://api.pezkuwichain.io/api/prices?ids=${coingeckoIds}&vs_currencies=usd&include_24hr_change=true`
);
let hezPrice = 0;
let pezPrice = 0;
if (response.ok) {
const data = await response.json();
if (import.meta.env.DEV) console.log('📊 CoinGecko response:', data);
const dotPrice = data['polkadot']?.usd || 0;
const directHezPrice = data['pezkuwichain']?.usd || 0;
const directPezPrice = data['pez-token']?.usd || 0;
// Use direct CoinGecko price if available, otherwise calculate from DOT
if (directHezPrice > 0) {
hezPrice = directHezPrice;
if (import.meta.env.DEV) console.log('✅ HEZ price from CoinGecko:', hezPrice, 'USD');
} else if (dotPrice > 0) {
// HEZ = DOT / 3
hezPrice = dotPrice / 3;
if (import.meta.env.DEV) console.log('✅ HEZ price (DOT/3):', hezPrice, 'USD');
}
if (directPezPrice > 0) {
pezPrice = directPezPrice;
if (import.meta.env.DEV) console.log('✅ PEZ price from CoinGecko:', pezPrice, 'USD');
} else if (dotPrice > 0) {
// PEZ = DOT / 10
pezPrice = dotPrice / 10;
if (import.meta.env.DEV) console.log('✅ PEZ price (DOT/10):', pezPrice, 'USD');
}
}
// If CoinGecko failed or returned 0, try DEX pool as fallback
if ((hezPrice === 0 || pezPrice === 0) && api && isApiReady) {
if (import.meta.env.DEV) console.log('⚠️ CoinGecko incomplete, trying DEX pool fallback...');
await fetchDexPoolPrices(hezPrice, pezPrice);
} else {
setHezUsdPrice(hezPrice);
setPezUsdPrice(pezPrice);
}
} catch (error) {
if (import.meta.env.DEV) console.error('❌ CoinGecko fetch failed, trying DEX pool:', error);
// Fallback to DEX pool prices
if (api && isApiReady) {
await fetchDexPoolPrices(0, 0);
}
}
};
// Fallback: Fetch prices from DEX pools
const fetchDexPoolPrices = async (existingHezPrice: number, existingPezPrice: number) => {
if (!api || !isApiReady) return;
try {
const { stringToU8a } = await import('@pezkuwi/util');
const { blake2AsU8a } = await import('@pezkuwi/util-crypto');
const PALLET_ID = stringToU8a('py/ascon');
let hezPrice = existingHezPrice;
let pezPrice = existingPezPrice;
// Only fetch HEZ from DEX if not already set
if (hezPrice === 0) {
const whezPoolId = api.createType('(u32, u32)', [ASSET_IDS.WHEZ, ASSET_IDS.WUSDT]);
const whezPalletIdType = api.createType('[u8; 8]', PALLET_ID);
const whezFullTuple = api.createType('([u8; 8], (u32, u32))', [whezPalletIdType, whezPoolId]);
const whezAccountHash = blake2AsU8a(whezFullTuple.toU8a(), 256);
const whezPoolAccountId = api.createType('AccountId32', whezAccountHash);
const whezReserve0Query = await api.query.assets.account(ASSET_IDS.WHEZ, whezPoolAccountId);
const whezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, whezPoolAccountId);
if (whezReserve0Query.isSome && whezReserve1Query.isSome) {
const reserve0Data = whezReserve0Query.unwrap();
const reserve1Data = whezReserve1Query.unwrap();
const reserve0 = BigInt(reserve0Data.balance.toString());
const reserve1 = BigInt(reserve1Data.balance.toString());
hezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6));
if (import.meta.env.DEV) console.log('✅ HEZ price from DEX:', hezPrice, 'USD');
}
}
// Only fetch PEZ from DEX if not already set
if (pezPrice === 0) {
const pezPoolId = api.createType('(u32, u32)', [1, ASSET_IDS.WUSDT]);
const pezPalletIdType = api.createType('[u8; 8]', PALLET_ID);
const pezFullTuple = api.createType('([u8; 8], (u32, u32))', [pezPalletIdType, pezPoolId]);
const pezAccountHash = blake2AsU8a(pezFullTuple.toU8a(), 256);
const pezPoolAccountId = api.createType('AccountId32', pezAccountHash);
const pezReserve0Query = await api.query.assets.account(1, pezPoolAccountId);
const pezReserve1Query = await api.query.assets.account(ASSET_IDS.WUSDT, pezPoolAccountId);
if (pezReserve0Query.isSome && pezReserve1Query.isSome) {
const reserve0Data = pezReserve0Query.unwrap();
const reserve1Data = pezReserve1Query.unwrap();
const reserve0 = BigInt(reserve0Data.balance.toString());
const reserve1 = BigInt(reserve1Data.balance.toString());
pezPrice = Number(reserve1 * BigInt(10 ** 12)) / Number(reserve0 * BigInt(10 ** 6));
if (import.meta.env.DEV) console.log('✅ PEZ price from DEX:', pezPrice, 'USD');
}
}
setHezUsdPrice(hezPrice);
setPezUsdPrice(pezPrice);
} catch (error) {
if (import.meta.env.DEV) console.error('❌ DEX pool price fetch failed:', error);
}
};
// Fetch other tokens (only custom tokens - wrapped tokens are backend-only)
// IMPORTANT: Assets are on Asset Hub, use assetHubApi (not relay chain api)
const fetchOtherTokens = async () => {
if (!assetHubApi || !isAssetHubReady || !selectedAccount) return;
try {
const tokens: TokenBalance[] = [];
// IMPORTANT: Only show custom tokens added by user
// Core tokens are shown in their own dedicated cards - exclude them here
// Using hardcoded IDs to avoid env variable issues
const excludedAssetIds = [
1, // PEZ
2, // wHEZ
3, // Old USDT (deprecated)
1000, // wUSDT (USDT)
];
const assetIdsToCheck = customTokenIds.filter((id) => !excludedAssetIds.includes(id));
for (const assetId of assetIdsToCheck) {
try {
// First check if asset exists on blockchain
const assetInfo = await assetHubApi.query.assets.asset(assetId);
if (!assetInfo || assetInfo.isNone) {
// Asset doesn't exist on blockchain - skip it
if (import.meta.env.DEV) console.log(`Asset ${assetId} not found on blockchain, skipping`);
continue;
}
// Asset exists - get metadata
const assetMetadata = await assetHubApi.query.assets.metadata(assetId);
const metadata = assetMetadata.toJSON() as { symbol?: string; name?: string; decimals?: number };
// Decode hex strings properly
let symbol = metadata.symbol || '';
let name = metadata.name || '';
if (typeof symbol === 'string' && symbol.startsWith('0x')) {
symbol = hexToString(symbol);
}
if (typeof name === 'string' && name.startsWith('0x')) {
name = hexToString(name);
}
// Fallback to known symbols if metadata is empty
if (!symbol || symbol.trim() === '') {
symbol = getAssetSymbol(assetId);
}
if (!name || name.trim() === '') {
name = symbol;
}
const decimals = metadata.decimals || getAssetDecimals(assetId);
// Get balance (may be 0 if user hasn't received any)
let balanceFormatted = '0';
const assetBalance = await assetHubApi.query.assets.account(assetId, selectedAccount.address);
if (assetBalance.isSome) {
const assetData = assetBalance.unwrap();
const balance = assetData.balance.toString();
balanceFormatted = (parseInt(balance) / Math.pow(10, decimals)).toFixed(6);
}
// Simple USD calculation (would use real price feed in production)
let usdValue = 0;
if (assetId === ASSET_IDS.WUSDT) {
usdValue = parseFloat(balanceFormatted); // 1 wUSDT = 1 USD
}
// Only show tokens that exist on blockchain
tokens.push({
assetId,
symbol: symbol.trim(),
name: name.trim(),
balance: balanceFormatted,
decimals,
usdValue
});
} catch (error) {
if (import.meta.env.DEV) console.error(`Failed to fetch token ${assetId}:`, error);
}
}
setOtherTokens(tokens);
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch other tokens:', error);
}
};
const fetchBalance = async () => {
if (!api || !isApiReady || !selectedAccount) return;
setIsLoading(true);
try {
// Fetch HEZ balance
const { data: balanceData } = await api.query.system.account(selectedAccount.address);
const free = balanceData.free.toString();
const reserved = balanceData.reserved.toString();
// Convert from plancks to tokens (12 decimals)
const decimals = 12;
const divisor = Math.pow(10, decimals);
const freeTokens = (parseInt(free) / divisor).toFixed(4);
const reservedTokens = (parseInt(reserved) / divisor).toFixed(4);
const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4);
setBalance({
free: freeTokens,
reserved: reservedTokens,
total: totalTokens,
});
// Fetch HEZ balance on Asset Hub (for PEZ transfer fees)
try {
if (assetHubApi && isAssetHubReady) {
const { data: assetHubBalanceData } = await assetHubApi.query.system.account(selectedAccount.address);
const assetHubFree = assetHubBalanceData.free.toString();
const assetHubHezTokens = (parseInt(assetHubFree) / divisor).toFixed(4);
setAssetHubHezBalance(assetHubHezTokens);
} else {
setAssetHubHezBalance('0.0000');
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch Asset Hub HEZ balance:', error);
setAssetHubHezBalance('0.0000');
}
// Fetch HEZ balance on People Chain (for identity/KYC fees)
try {
if (peopleApi && isPeopleReady) {
const { data: peopleBalanceData } = await peopleApi.query.system.account(selectedAccount.address);
const peopleFree = peopleBalanceData.free.toString();
const peopleHezTokens = (parseInt(peopleFree) / divisor).toFixed(4);
setPeopleHezBalance(peopleHezTokens);
} else {
setPeopleHezBalance('0.0000');
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch People Chain HEZ balance:', error);
setPeopleHezBalance('0.0000');
}
// Fetch PEZ balance (Asset ID: 1) from Asset Hub
try {
if (assetHubApi && isAssetHubReady) {
const pezAssetBalance = await assetHubApi.query.assets.account(1, selectedAccount.address);
if (pezAssetBalance.isSome) {
const assetData = pezAssetBalance.unwrap();
const pezAmount = assetData.balance.toString();
const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4);
setPezBalance(pezTokens);
} else {
setPezBalance('0.0000');
}
} else {
if (import.meta.env.DEV) console.log('Asset Hub not ready, PEZ balance pending...');
setPezBalance('0.0000');
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch PEZ balance from Asset Hub:', error);
setPezBalance('0.0000');
}
// Fetch USDT balance (wUSDT - Asset ID: 1000) from Asset Hub
try {
if (assetHubApi && isAssetHubReady) {
const usdtAssetBalance = await assetHubApi.query.assets.account(ASSET_IDS.WUSDT, selectedAccount.address);
if (usdtAssetBalance.isSome) {
const assetData = usdtAssetBalance.unwrap();
const usdtAmount = assetData.balance.toString();
const usdtDecimals = 6; // wUSDT has 6 decimals
const usdtDivisor = Math.pow(10, usdtDecimals);
const usdtTokens = (parseInt(usdtAmount) / usdtDivisor).toFixed(2);
setUsdtBalance(usdtTokens);
} else {
setUsdtBalance('0');
}
} else {
if (import.meta.env.DEV) console.log('Asset Hub not ready, wUSDT balance pending...');
setUsdtBalance('0');
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch USDT balance:', error);
setUsdtBalance('0');
}
// Fetch wHEZ balance (Asset ID: 2) from Asset Hub
try {
if (assetHubApi && isAssetHubReady) {
const whezAssetBalance = await assetHubApi.query.assets.account(ASSET_IDS.WHEZ, selectedAccount.address);
if (whezAssetBalance.isSome) {
const assetData = whezAssetBalance.unwrap();
const whezAmount = assetData.balance.toString();
const whezTokens = (parseInt(whezAmount) / divisor).toFixed(4);
setWhezBalance(whezTokens);
} else {
setWhezBalance('0');
}
} else {
if (import.meta.env.DEV) console.log('Asset Hub not ready, wHEZ balance pending...');
setWhezBalance('0');
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch wHEZ balance:', error);
setWhezBalance('0');
}
// Fetch LP Token balances from poolAssets pallet on Asset Hub
try {
if (assetHubApi && isAssetHubReady) {
const lpTokensData: TokenBalance[] = [];
// HEZ-PEZ LP Token (ID: 0)
const hezPezLp = await assetHubApi.query.poolAssets.account(0, selectedAccount.address);
if (hezPezLp.isSome) {
const lpBalance = hezPezLp.unwrap().balance.toString();
const lpTokens = (parseInt(lpBalance) / divisor).toFixed(4);
if (parseFloat(lpTokens) > 0) {
lpTokensData.push({
assetId: 0,
symbol: 'HEZ-PEZ LP',
name: 'HEZ-PEZ Liquidity',
balance: lpTokens,
decimals: 12,
usdValue: 0, // TODO: Calculate LP value
isLpToken: true,
});
}
}
// HEZ-USDT LP Token (ID: 1)
const hezUsdtLp = await assetHubApi.query.poolAssets.account(1, selectedAccount.address);
if (hezUsdtLp.isSome) {
const lpBalance = hezUsdtLp.unwrap().balance.toString();
const lpTokens = (parseInt(lpBalance) / divisor).toFixed(4);
if (parseFloat(lpTokens) > 0) {
lpTokensData.push({
assetId: 1,
symbol: 'HEZ-USDT LP',
name: 'HEZ-USDT Liquidity',
balance: lpTokens,
decimals: 12,
usdValue: 0, // TODO: Calculate LP value
isLpToken: true,
});
}
}
// HEZ-DOT LP Token (ID: 2)
const hezDotLp = await assetHubApi.query.poolAssets.account(2, selectedAccount.address);
if (hezDotLp.isSome) {
const lpBalance = hezDotLp.unwrap().balance.toString();
const lpTokens = (parseInt(lpBalance) / divisor).toFixed(4);
if (parseFloat(lpTokens) > 0) {
lpTokensData.push({
assetId: 2,
symbol: 'HEZ-DOT LP',
name: 'HEZ-DOT Liquidity',
balance: lpTokens,
decimals: 12,
usdValue: 0, // TODO: Calculate LP value
isLpToken: true,
});
}
}
setLpTokens(lpTokensData);
if (import.meta.env.DEV) console.log('✅ LP tokens fetched:', lpTokensData);
}
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch LP token balances:', error);
setLpTokens([]);
}
// Fetch token prices from pools
await fetchTokenPrices();
// Fetch other tokens
await fetchOtherTokens();
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to fetch balance:', error);
} finally {
setIsLoading(false);
}
};
// Add custom token handler
const handleAddToken = async (assetId: number) => {
if (customTokenIds.includes(assetId)) {
alert(t('balance.tokenAlreadyAdded'));
return;
}
// Update custom tokens list
const updatedTokenIds = [...customTokenIds, assetId];
setCustomTokenIds(updatedTokenIds);
localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds));
// Fetch the new token
await fetchOtherTokens();
setIsAddTokenModalOpen(false);
};
// Remove token handler (unused but kept for future feature)
// const handleRemoveToken = (assetId: number) => {
// const updatedTokenIds = customTokenIds.filter(id => id !== assetId);
// setCustomTokenIds(updatedTokenIds);
// localStorage.setItem('customTokenIds', JSON.stringify(updatedTokenIds));
//
// // Remove from displayed tokens
// setOtherTokens(prev => prev.filter(t => t.assetId !== assetId));
// };
useEffect(() => {
fetchBalance();
fetchTokenPrices(); // Fetch token USD prices from pools
// Fetch All Scores from blockchain with frontend fallback
const fetchAllScores = async () => {
if (!api || !isApiReady || !selectedAccount?.address) {
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
return;
}
setLoadingScores(true);
try {
// Use fallback function: peopleApi for on-chain scores, api (Relay) for staking data
const userScores = await getAllScores(peopleApi || null, selectedAccount.address);
setScores(userScores);
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to fetch scores:', err);
setScores({
trustScore: 0,
referralScore: 0,
stakingScore: 0,
tikiScore: 0,
totalScore: 0
});
} finally {
setLoadingScores(false);
}
};
fetchAllScores();
// Subscribe to HEZ balance updates
let unsubscribeHez: () => void;
let unsubscribePez: () => void;
let unsubscribeUsdt: () => void;
const subscribeBalance = async () => {
if (!api || !isApiReady || !selectedAccount) return;
// Subscribe to HEZ balance
unsubscribeHez = await api.query.system.account(
selectedAccount.address,
({ data: balanceData }) => {
const free = balanceData.free.toString();
const reserved = balanceData.reserved.toString();
const decimals = 12;
const divisor = Math.pow(10, decimals);
const freeTokens = (parseInt(free) / divisor).toFixed(4);
const reservedTokens = (parseInt(reserved) / divisor).toFixed(4);
const totalTokens = ((parseInt(free) + parseInt(reserved)) / divisor).toFixed(4);
setBalance({
free: freeTokens,
reserved: reservedTokens,
total: totalTokens,
});
}
);
// Subscribe to PEZ balance (Asset ID: 1) from Asset Hub
if (assetHubApi && isAssetHubReady) {
try {
unsubscribePez = await assetHubApi.query.assets.account(
1,
selectedAccount.address,
(assetBalance) => {
if (assetBalance.isSome) {
const assetData = assetBalance.unwrap();
const pezAmount = assetData.balance.toString();
const decimals = 12;
const divisor = Math.pow(10, decimals);
const pezTokens = (parseInt(pezAmount) / divisor).toFixed(4);
setPezBalance(pezTokens);
} else {
setPezBalance('0.0000');
}
}
);
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to subscribe to PEZ balance from Asset Hub:', error);
}
}
// Subscribe to USDT balance (wUSDT - Asset ID: 1000) from Asset Hub
if (assetHubApi && isAssetHubReady) {
try {
unsubscribeUsdt = await assetHubApi.query.assets.account(
ASSET_IDS.WUSDT,
selectedAccount.address,
(assetBalance) => {
if (assetBalance.isSome) {
const assetData = assetBalance.unwrap();
const usdtAmount = assetData.balance.toString();
const decimals = 6; // wUSDT has 6 decimals
const divisor = Math.pow(10, decimals);
const usdtTokens = (parseInt(usdtAmount) / divisor).toFixed(2);
setUsdtBalance(usdtTokens);
} else {
setUsdtBalance('0');
}
}
);
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to subscribe to USDT balance:', error);
}
}
};
subscribeBalance();
return () => {
if (unsubscribeHez) unsubscribeHez();
if (unsubscribePez) unsubscribePez();
if (unsubscribeUsdt) unsubscribeUsdt();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount]);
if (!selectedAccount) {
return (
<Card className="bg-gray-900 border-gray-800">
<CardContent className="pt-6">
<div className="text-center text-gray-400">
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{t('balance.connectWallet')}</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{/* HEZ Balance Card - Multi-Chain */}
<Card className="bg-gradient-to-br from-green-900/30 to-yellow-900/30 border-green-500/30">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<img src="/tokens/HEZ.png" alt="HEZ" className="w-10 h-10 rounded-full" />
<div>
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.hezBalance')}
</CardTitle>
<div className="text-xs text-gray-500">{t('balance.multiChain')}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setIsXCMTeleportModalOpen(true)}
className="border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 group relative"
title="Send HEZ to teyrcahins for transaction fees"
>
<Fuel className="w-4 h-4 mr-1" />
{t('balance.addFee')}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-gray-800 text-gray-200 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
{t('balance.sendHezToTeyrcahins')}
</span>
</Button>
<Button
variant="ghost"
size="icon"
onClick={fetchBalance}
disabled={isLoading}
className="text-gray-400 hover:text-white"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Total HEZ */}
<div>
<div className="text-4xl font-bold text-white mb-1">
{isLoading ? '...' : (parseFloat(balance.total) + parseFloat(assetHubHezBalance) + parseFloat(peopleHezBalance)).toFixed(4)}
<span className="text-2xl text-gray-400 ml-2">HEZ</span>
</div>
<div className="text-sm text-gray-400">
{hezUsdPrice > 0
? t('balance.usdTotal', { amount: ((parseFloat(balance.total) + parseFloat(assetHubHezBalance) + parseFloat(peopleHezBalance)) * hezUsdPrice).toFixed(2) })
: t('balance.priceLoading')}
</div>
</div>
{/* Chain Balances */}
<div className="grid grid-cols-1 gap-3">
{/* Relay Chain (Main) */}
<div className="bg-gray-800/50 rounded-lg p-3 border border-green-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm text-gray-300">{t('balance.relayChain')}</span>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-white">{balance.free} HEZ</div>
<div className="text-xs text-gray-500">{t('balance.reserved', { amount: balance.reserved })}</div>
</div>
</div>
</div>
{/* Asset Hub */}
<div className="bg-gray-800/50 rounded-lg p-3 border border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<span className="text-sm text-gray-300">{t('balance.assetHub')}</span>
<span className="text-xs text-gray-500">{t('balance.pezFees')}</span>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-white">{assetHubHezBalance} HEZ</div>
{parseFloat(assetHubHezBalance) < 0.1 && (
<div className="text-xs text-yellow-400">{`⚠️ ${t('balance.lowForFees')}`}</div>
)}
</div>
</div>
</div>
{/* People Chain */}
<div className="bg-gray-800/50 rounded-lg p-3 border border-purple-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-500"></div>
<span className="text-sm text-gray-300">{t('balance.peopleChain')}</span>
<span className="text-xs text-gray-500">{t('balance.identityFees')}</span>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-white">{peopleHezBalance} HEZ</div>
{parseFloat(peopleHezBalance) < 0.1 && (
<div className="text-xs text-yellow-400">{`⚠️ ${t('balance.lowForFees')}`}</div>
)}
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* PEZ Balance Card */}
<Card className="bg-gradient-to-br from-blue-900/30 to-purple-900/30 border-blue-500/30">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<img src="/tokens/PEZ.png" alt="PEZ" className="w-10 h-10 rounded-full flex-shrink-0" />
<CardTitle className="text-lg font-medium text-gray-300 whitespace-nowrap">
{t('balance.pezBalance')}
</CardTitle>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setIsXCMTeleportModalOpen(true)}
className="border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 group relative"
title="Send HEZ to Asset Hub for transaction fees"
>
<Fuel className="w-4 h-4 mr-1" />
{t('balance.addFees')}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-gray-800 text-gray-200 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
{t('balance.sendHezForPezFees')}
</span>
</Button>
</div>
</CardHeader>
<CardContent>
<div>
<div className="text-4xl font-bold text-white mb-1">
{isLoading ? '...' : pezBalance}
<span className="text-2xl text-gray-400 ml-2">PEZ</span>
</div>
<div className="text-sm text-gray-400">
{pezUsdPrice > 0
? `$${(parseFloat(pezBalance) * pezUsdPrice).toFixed(2)} USD`
: t('balance.priceLoading')}
</div>
<div className="text-xs text-gray-500 mt-1">
{t('balance.govRewardsToken')}
</div>
</div>
</CardContent>
</Card>
{/* USDT Balance Card */}
<Card className="bg-gradient-to-br from-emerald-900/30 to-teal-900/30 border-emerald-500/30">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<img src="/tokens/USDT.png" alt="USDT" className="w-10 h-10 rounded-full" />
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.usdtBalance')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div>
<div className="text-4xl font-bold text-white mb-1">
{isLoading ? '...' : usdtBalance}
<span className="text-2xl text-gray-400 ml-2">USDT</span>
</div>
<div className="text-sm text-gray-400">
{t('balance.stablecoinOnAssetHub', { amount: usdtBalance })}
</div>
</div>
</CardContent>
</Card>
{/* wHEZ Balance Card */}
{parseFloat(whezBalance) > 0 && (
<Card className="bg-gradient-to-br from-yellow-900/30 to-orange-900/30 border-yellow-500/30">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<img src="/tokens/HEZ.png" alt="wHEZ" className="w-10 h-10 rounded-full" />
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.whezBalance')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div>
<div className="text-4xl font-bold text-white mb-1">
{isLoading ? '...' : whezBalance}
<span className="text-2xl text-gray-400 ml-2">wHEZ</span>
</div>
<div className="text-sm text-gray-400">
{hezUsdPrice > 0
? `$${(parseFloat(whezBalance) * hezUsdPrice).toFixed(2)} USD`
: t('balance.priceLoading')} {t('balance.wrappedHezOnAssetHub')}
</div>
</div>
</CardContent>
</Card>
)}
{/* LP Token Cards */}
{lpTokens.length > 0 && (
<Card className="bg-gradient-to-br from-purple-900/30 to-pink-900/30 border-purple-500/30">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<img src="/tokens/LP.png" alt="LP" className="w-10 h-10 rounded-full" />
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.lpTokenPositions')}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{lpTokens.map((lp) => (
<div key={lp.assetId} className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg group">
<div className="flex items-center gap-3">
<img src="/tokens/LP.png" alt={lp.symbol} className="w-8 h-8 rounded-full" />
<div>
<div className="text-sm font-medium text-white">{lp.symbol}</div>
<div className="text-xs text-gray-400">{lp.name}</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-lg font-semibold text-white">{lp.balance}</div>
<div className="text-xs text-gray-500">{t('balance.poolShare')}</div>
</div>
<Button
size="sm"
onClick={() => {
setSelectedLPForStake(lp);
setIsLPStakeModalOpen(true);
}}
className="bg-gradient-to-r from-purple-600 to-pink-600 text-white font-medium hover:from-purple-500 hover:to-pink-500 border-0"
>
<Lock className="w-3 h-3 mr-1" />
{t('balance.stake')}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Account Info & Scores */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.accountInfo')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Account Details */}
<div className="space-y-2 pb-4 border-b border-gray-800">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">{t('balance.account')}</span>
<span className="text-white font-mono">
{selectedAccount.meta.name || 'Unnamed'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400">{t('balance.address')}</span>
<span className="text-white font-mono text-xs">
{selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-8)}
</span>
</div>
</div>
{/* Scores from Blockchain */}
<div>
<div className="text-xs text-gray-400 mb-3">{t('balance.scoresFromBlockchain')}</div>
{loadingScores ? (
<div className="text-sm text-gray-400">{t('balance.loadingScores')}</div>
) : (
<div className="space-y-3">
{/* Score Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Shield className="h-3 w-3 text-purple-400" />
<span className="text-xs text-gray-400">{t('balance.trust')}</span>
</div>
<span className="text-base font-bold text-purple-400">{scores.trustScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Users className="h-3 w-3 text-cyan-400" />
<span className="text-xs text-gray-400">{t('balance.referral')}</span>
</div>
<span className="text-base font-bold text-cyan-400">{scores.referralScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<TrendingUp className="h-3 w-3 text-green-400" />
<span className="text-xs text-gray-400">{t('balance.staking')}</span>
</div>
<span className="text-base font-bold text-green-400">{scores.stakingScore}</span>
</div>
<div className="bg-gray-800/50 rounded-lg p-3">
<div className="flex items-center gap-1 mb-1">
<Award className="h-3 w-3 text-pink-400" />
<span className="text-xs text-gray-400">{t('balance.tiki')}</span>
</div>
<span className="text-base font-bold text-pink-400">{scores.tikiScore}</span>
</div>
</div>
{/* Total Score */}
<div className="pt-3 border-t border-gray-800">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">{t('balance.totalScore')}</span>
<span className="text-xl font-bold bg-gradient-to-r from-purple-400 to-cyan-400 bg-clip-text text-transparent">
{scores.totalScore}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Other Tokens */}
<Card className="bg-gray-900 border-gray-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Coins className="h-5 w-5 text-cyan-400" />
<CardTitle className="text-lg font-medium text-gray-300">
{t('balance.otherAssets')}
</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddTokenModalOpen(true)}
className="text-cyan-400 hover:text-cyan-300 hover:bg-cyan-400/10"
>
<Plus className="h-4 w-4 mr-1" />
{t('balance.addToken')}
</Button>
</div>
</CardHeader>
<CardContent>
{otherTokens.length === 0 ? (
<div className="text-center py-8">
<Coins className="w-12 h-12 text-gray-600 mx-auto mb-3 opacity-50" />
<p className="text-gray-500 text-sm">{t('balance.noCustomTokens')}</p>
<p className="text-gray-600 text-xs mt-1">
{t('balance.addCustomTokensDesc')}
</p>
</div>
) : (
<div className="space-y-2">
{otherTokens.map((token) => {
const tokenColor = getTokenColor(token.assetId);
return (
<div
key={token.assetId}
className="flex items-center justify-between p-4 bg-gray-800/50 rounded-lg hover:bg-gray-800/70 transition-all duration-200 group border border-transparent hover:border-gray-700"
>
<div className="flex items-center gap-4 flex-1">
{/* Token Logo */}
{getTokenLogo(token.symbol) ? (
<img
src={getTokenLogo(token.symbol)!}
alt={token.symbol}
className="w-12 h-12 rounded-full shadow-lg object-cover"
/>
) : (
<div className={`w-12 h-12 rounded-full bg-gradient-to-br ${tokenColor.bg} flex items-center justify-center border ${tokenColor.border} shadow-lg`}>
<span className={`text-base font-bold ${tokenColor.text}`}>
{token.symbol.slice(0, 2).toUpperCase()}
</span>
</div>
)}
{/* Token Info */}
<div className="flex-1">
<div className="flex items-baseline gap-2">
<span className="text-base font-semibold text-white">
{token.symbol}
</span>
<span className="text-xs text-gray-500">
#{token.assetId}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">
{token.name}
</div>
</div>
</div>
{/* Balance & Actions */}
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-base font-semibold text-white">
{parseFloat(token.balance).toFixed(4)}
</div>
<div className="text-xs text-gray-500">
${token.usdValue.toFixed(2)} USD
</div>
</div>
{/* Send Button */}
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedTokenForTransfer(token);
setIsTransferModalOpen(true);
}}
className={`${tokenColor.text} hover:${tokenColor.text} hover:bg-gray-700/50 opacity-0 group-hover:opacity-100 transition-opacity`}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Add Token Modal */}
<AddTokenModal
isOpen={isAddTokenModalOpen}
onClose={() => setIsAddTokenModalOpen(false)}
onAddToken={handleAddToken}
/>
{/* Transfer Modal */}
<TransferModal
isOpen={isTransferModalOpen}
onClose={() => {
setIsTransferModalOpen(false);
setSelectedTokenForTransfer(null);
}}
selectedAsset={selectedTokenForTransfer}
/>
{/* XCM Teleport Modal */}
<XCMTeleportModal
isOpen={isXCMTeleportModalOpen}
onClose={() => setIsXCMTeleportModalOpen(false)}
/>
{/* LP Stake Modal */}
<LPStakeModal
isOpen={isLPStakeModalOpen}
onClose={() => {
setIsLPStakeModalOpen(false);
setSelectedLPForStake(null);
}}
lpToken={selectedLPForStake}
onStakeSuccess={() => fetchBalance()}
/>
</div>
);
};