Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 75 additions & 79 deletions wata-board-frontend/src/components/WalletBalance.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import type { WalletBalance as WalletBalanceType } from '../services/walletBalance';
import { balanceUtils } from '../services/walletBalance';
import { useWalletBalance } from '../hooks/useWalletBalance';
Expand All @@ -10,16 +9,34 @@ interface WalletBalanceProps {
isConnected?: boolean;
isLowBalance?: boolean;
lastUpdated?: Date | null;
refreshBalance?: () => void;
refreshBalance?: () => void | Promise<void>;
showDetails?: boolean;
showRefreshButton?: boolean;
className?: string;
}

export const WalletBalance: React.FC<WalletBalanceProps> = (props) => {
// Use internal hook if props are not provided
const formatUsd = (value?: number) =>
typeof value === 'number' && Number.isFinite(value)
? value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
: 'USD unavailable';

function BalanceSkeleton({ className = '' }: { className?: string }) {
return (
<div className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`} aria-busy="true">
<div className="flex items-center justify-between gap-4">
<div className="space-y-3">
<div className="h-3 w-28 rounded bg-slate-800" />
<div className="h-7 w-36 rounded bg-slate-800" />
<div className="h-3 w-24 rounded bg-slate-800" />
</div>
<div className="h-9 w-9 rounded-lg bg-slate-800" />
</div>
</div>
);
}

export function WalletBalance(props: WalletBalanceProps) {
const internal = useWalletBalance();

const {
balance = internal.balance,
isLoading = internal.isLoading,
Expand All @@ -28,129 +45,108 @@ export const WalletBalance: React.FC<WalletBalanceProps> = (props) => {
isLowBalance = internal.isLowBalance,
lastUpdated = internal.lastUpdated,
refreshBalance = internal.refreshBalance,
showDetails = false,
showDetails = true,
showRefreshButton = true,
className = ''
className = '',
} = props;

console.log('[WalletBalance Component] Render state:', { isConnected, isLoading, hasBalance: !!balance, hasError: !!error });

if (!isConnected) {
return (
<div className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`}>
<div className="text-sm text-slate-400">Wallet not connected</div>
</div>
<section className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`}>
<p className="text-sm font-medium text-slate-300">Wallet not connected</p>
<p className="mt-1 text-xs text-slate-500">Connect your Stellar wallet to view balances.</p>
</section>
);
}

if (error) {
return (
<div className={`rounded-xl border border-red-800/50 bg-red-950/20 p-4 ${className}`}>
<div className="flex items-center justify-between">
<section className={`rounded-xl border border-red-800/50 bg-red-950/20 p-4 ${className}`} role="alert">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium text-red-400">Balance Error</div>
<div className="text-xs text-red-300 mt-1">{error}</div>
<p className="text-sm font-semibold text-red-300">Balance unavailable</p>
<p className="mt-1 text-xs text-red-200">{error}</p>
</div>
{showRefreshButton && (
<button
onClick={refreshBalance}
className="px-3 py-1 text-xs bg-red-500/20 text-red-300 rounded-lg hover:bg-red-500/30 transition-colors"
type="button"
onClick={() => void refreshBalance()}
className="rounded-lg bg-red-500/20 px-3 py-2 text-xs font-semibold text-red-100 transition hover:bg-red-500/30"
>
Retry
</button>
)}
</div>
</div>
</section>
);
}

if (isLoading || !balance) {
return (
<div className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`}>
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-slate-600 border-t-sky-500"></div>
<div className="text-sm text-slate-400">Loading balance...</div>
</div>
</div>
);
return <BalanceSkeleton className={className} />;
}

const xlmBalance = balance.balances.find(b => b.isNative);
const xlmBalance = balance.balances.find((assetBalance) => assetBalance.isNative);
const balanceStatusColor = balanceUtils.getBalanceStatusColor(balance);
const balanceStatusText = balanceUtils.getBalanceStatusText(balance);

return (
<div className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`}>
{/* Main Balance Display */}
<div className="flex items-center justify-between mb-3">
<section className={`rounded-xl border border-slate-800 bg-slate-950/40 p-4 ${className}`}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-1">
Wallet Balance
</div>
<div className={`text-lg font-semibold ${balanceStatusColor}`}>
{xlmBalance ? balanceUtils.formatXLM(xlmBalance.balance) : '0 XLM'}
</div>
<div className={`text-xs ${balanceStatusColor} mt-1`}>
{balanceStatusText}
{isLowBalance && (
<span className="ms-2 text-amber-400">⚠️ Low Balance</span>
)}
</div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Wallet Balance</p>
<p className={`mt-1 text-2xl font-semibold ${balanceStatusColor}`}>
{xlmBalance ? balanceUtils.formatXLM(xlmBalance.balance) : '0.00 XLM'}
</p>
<p className="mt-1 text-sm font-medium text-slate-300">{formatUsd(balance.totalBalanceUSD)}</p>
<p className={`mt-1 text-xs ${balanceStatusColor}`}>{balanceStatusText}</p>
</div>

{showRefreshButton && (
<button
onClick={refreshBalance}
type="button"
onClick={() => void refreshBalance()}
disabled={isLoading}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg transition-colors disabled:opacity-50"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-800 hover:text-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
title="Refresh balance"
aria-label="Refresh wallet balance"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<svg className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h5M20 20v-5h-5M5.6 16.5A8 8 0 0 0 18.4 18M18.4 7.5A8 8 0 0 0 5.6 6"
/>
</svg>
</button>
)}
</div>

{/* Low Balance Warning */}
{isLowBalance && (
<div className="mb-3 p-2 bg-amber-500/10 border border-amber-500/20 rounded-lg">
<div className="text-xs text-amber-300">
⚠️ Low balance detected. You may need additional XLM for transaction fees.
</div>
<div className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3">
<p className="text-xs text-amber-200">Low balance detected. Add XLM to cover payments and network fees.</p>
</div>
)}

{/* Last Updated */}
{lastUpdated && (
<div className="text-xs text-slate-500 mb-3">
Last updated: {lastUpdated.toLocaleTimeString()}
</div>
)}
{lastUpdated && <p className="mt-3 text-xs text-slate-500">Last updated: {lastUpdated.toLocaleTimeString()}</p>}

{/* Detailed Balance Information */}
{showDetails && (
<div className="space-y-2 pt-3 border-t border-slate-800">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-2">
All Balances
</div>
{balance.balances.map((assetBalance, index) => (
<div key={index} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
assetBalance.isNative ? 'bg-sky-500' : 'bg-purple-500'
}`}></div>
<span className="text-slate-300">
{balanceUtils.getAssetDisplayName(assetBalance)}
</span>
{showDetails && balance.balances.length > 0 && (
<div className="mt-4 space-y-2 border-t border-slate-800 pt-4">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Assets</p>
{balance.balances.map((assetBalance) => (
<div
key={`${assetBalance.assetCode}-${assetBalance.assetIssuer ?? 'native'}`}
className="flex items-center justify-between gap-3 text-sm"
>
<div className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 rounded-full ${assetBalance.isNative ? 'bg-sky-500' : 'bg-violet-500'}`} />
<span className="truncate text-slate-300">{balanceUtils.getAssetDisplayName(assetBalance)}</span>
</div>
<span className="text-slate-100 font-medium">
{balanceUtils.formatBalance(assetBalance.balance)}
</span>
<span className="shrink-0 font-medium text-slate-100">{balanceUtils.formatBalance(assetBalance.balance)}</span>
</div>
))}
</div>
)}
</div>
</section>
);
};
}
65 changes: 54 additions & 11 deletions wata-board-frontend/src/services/walletBalance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Horizon, Asset, Networks, StrKey } from '@stellar/stellar-sdk';
import { Horizon, Networks, StrKey } from '@stellar/stellar-sdk';
import { requestAccess, isConnected } from '../utils/wallet-bridge';
import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT } from '../utils/network-config';
import { getCurrentNetworkConfig, NETWORK_CHANGE_EVENT, type NetworkConfig } from '../utils/network-config';

export interface BalanceInfo {
assetCode: string;
Expand All @@ -14,6 +14,7 @@ export interface WalletBalance {
publicKey: string;
balances: BalanceInfo[];
nativeBalance: number;
xlmPriceUSD?: number;
totalBalanceUSD?: number;
lastUpdated: Date;
network: string;
Expand All @@ -23,6 +24,22 @@ export interface BalanceUpdateCallback {
(balance: WalletBalance): void;
}

interface StellarBalanceLine {
asset_type: BalanceInfo['assetType'];
balance: string;
asset_code?: string;
asset_issuer?: string;
}

interface StellarAccountLike {
id: string;
balances: StellarBalanceLine[];
}

interface WalletTestWindow extends Window {
__MOCK_STELLAR_ACCOUNT__?: (publicKey: string) => StellarAccountLike;
}

export const balanceUtils = {
formatBalance: (balance: string, decimals: number = 7): string => {
const num = parseFloat(balance);
Expand Down Expand Up @@ -57,11 +74,13 @@ export const balanceUtils = {

export class WalletBalanceService {
private server: Horizon.Server;
private networkConfig: any;
private networkConfig: NetworkConfig;
private balanceCache: Map<string, { balance: WalletBalance; timestamp: number }> = new Map();
private updateCallbacks: Set<BalanceUpdateCallback> = new Set();
private refreshInterval: NodeJS.Timeout | null = null;
private priceCache: { price: number; timestamp: number } | null = null;
private readonly CACHE_DURATION = 30000;
private readonly PRICE_CACHE_DURATION = 60000;
private networkChangeHandler: (() => void) | null = null;

constructor() {
Expand All @@ -77,7 +96,7 @@ export class WalletBalanceService {
this.server = new Horizon.Server(horizonUrl);
this.clearCache();
};
window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler as any);
window.addEventListener(NETWORK_CHANGE_EVENT, this.networkChangeHandler);
}
}

Expand All @@ -97,6 +116,26 @@ export class WalletBalanceService {
return this.getWalletBalance();
}

private async getXlmPriceUSD(): Promise<number | undefined> {
if (this.priceCache && Date.now() - this.priceCache.timestamp < this.PRICE_CACHE_DURATION) {
return this.priceCache.price;
}

try {
const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd');
if (!response.ok) return undefined;

const data = (await response.json()) as { stellar?: { usd?: number } };
const price = data.stellar?.usd;
if (typeof price !== 'number' || !Number.isFinite(price)) return undefined;

this.priceCache = { price, timestamp: Date.now() };
return price;
} catch {
return undefined;
}
}

async getWalletBalance(): Promise<WalletBalance | null> {
try {
const connectResult = await isConnected();
Expand All @@ -122,27 +161,31 @@ export class WalletBalanceService {
console.log(`[WalletBalanceService] Loading account for ${pubKeyString} from ${this.server.serverURL.toString()}`);

// FOR TESTS: Bypass loadAccount if mock is provided
let account;
if ((window as any).__MOCK_STELLAR_ACCOUNT__) {
let account: StellarAccountLike;
const testWindow = window as WalletTestWindow;
if (testWindow.__MOCK_STELLAR_ACCOUNT__) {
console.log('[WalletBalanceService] Using mock stellar account');
const mockData = (window as any).__MOCK_STELLAR_ACCOUNT__(pubKeyString);
const mockData = testWindow.__MOCK_STELLAR_ACCOUNT__(pubKeyString);
account = {
id: pubKeyString,
sequence: '100',
balances: mockData.balances || [{ asset_type: 'native', balance: '1000.00' }]
};
} as StellarAccountLike;
} else {
account = await this.server.loadAccount(pubKeyString);
account = await this.server.loadAccount(pubKeyString) as StellarAccountLike;
console.log(`[WalletBalanceService] Account loaded:`, account.id);
}

const balances = this.parseBalances(account.balances);
const nativeBalance = this.getNativeBalance(balances);
const xlmPriceUSD = await this.getXlmPriceUSD();

const walletBalance: WalletBalance = {
publicKey: pubKeyString,
balances,
nativeBalance,
xlmPriceUSD,
totalBalanceUSD: xlmPriceUSD ? nativeBalance * xlmPriceUSD : undefined,
lastUpdated: new Date(),
network: this.networkConfig.networkPassphrase === Networks.PUBLIC ? 'mainnet' : 'testnet'
};
Expand All @@ -163,9 +206,9 @@ export class WalletBalanceService {
}
}

private parseBalances(stellarBalances: any[]): BalanceInfo[] {
private parseBalances(stellarBalances: StellarBalanceLine[]): BalanceInfo[] {
return stellarBalances.map(balance => ({
assetCode: balance.asset_type === 'native' ? 'XLM' : balance.asset_code,
assetCode: balance.asset_type === 'native' ? 'XLM' : (balance.asset_code ?? 'UNKNOWN'),
assetIssuer: balance.asset_issuer,
balance: balance.balance,
assetType: balance.asset_type,
Expand Down
Loading