Skip to content
Open
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
17 changes: 12 additions & 5 deletions components/navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { SearchInput } from "./SearchInput";
import { NetworkSwitcher } from "./NetworkSwitcher";
import { WalletMenu } from "./WalletMenu";
import { WalletBalance } from "./WalletBalance";
import { ConnectWalletAction } from "./ConnectWalletAction";
import { ConnectWalletModal } from "@/components/connect-wallet-modal";
import { useWalletContext } from "@/context/WalletContext";
Expand Down Expand Up @@ -101,7 +102,10 @@ export function Navbar() {
Loading...
</button>
) : connected ? (
<WalletMenu network={network} />
<div className="flex items-center gap-2">
<WalletBalance />
<WalletMenu network={network} />
</div>
) : (
<button
onClick={() => setIsWalletModalOpen(true)}
Expand Down Expand Up @@ -138,10 +142,13 @@ export function Navbar() {
<span aria-hidden="true">...</span>
</button>
) : connected ? (
<Avatar className="h-8 w-8 border border-slate-600">
<AvatarImage src={user?.avatarUrl} alt={user?.name} />
<AvatarFallback>{user?.name?.[0] || "U"}</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<WalletBalance className="hidden sm:flex" />
<Avatar className="h-8 w-8 border border-slate-600">
<AvatarImage src={user?.avatarUrl} alt={user?.name} />
<AvatarFallback>{user?.name?.[0] || "U"}</AvatarFallback>
</Avatar>
</div>
) : (
<button
onClick={() => setIsWalletModalOpen(true)}
Expand Down
61 changes: 61 additions & 0 deletions components/navbar/WalletBalance.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import React from "react";
import { useWalletContext } from "@/context/WalletContext";
import { useStellarBalance } from "@/hooks/useStellarBalance.hook";
import { usePrivacy } from "@/context/PrivacyContext";
import { maskAmount } from "@/utils/maskAmount";
import { Wallet, EyeOff } from "lucide-react";

function formatBalance(balance: string): string {
const num = parseFloat(balance);
if (isNaN(num)) return "0.00";
return num.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}

export function WalletBalance({ className = "" }: { className?: string }) {
const { address, connected } = useWalletContext();
const { balance, isLoading } = useStellarBalance(connected ? address : null);
const { hideBalances } = usePrivacy();

if (!connected || !address) return null;

if (isLoading && !balance) {
return (
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800/50 animate-pulse ${className}`}
aria-label="Loading wallet balance"
>
<div className="h-3 w-16 bg-slate-700 rounded" />
</div>
);
}

if (hideBalances) {
return (
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800/50 text-slate-400 text-sm font-mono ${className}`}
aria-label="Wallet balance hidden"
>
<Wallet className="h-3.5 w-3.5 text-cyan-400" aria-hidden="true" />
<span>{maskAmount(balance ?? "")}</span>
<EyeOff className="h-3 w-3 text-slate-500" aria-hidden="true" />
</div>
);
}

if (!balance) return null;

return (
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800/50 text-slate-300 text-sm font-mono ${className}`}
aria-label={`Wallet balance: ${formatBalance(balance)} XLM`}
>
<Wallet className="h-3.5 w-3.5 text-cyan-400" aria-hidden="true" />
<span>{formatBalance(balance)} <span className="text-slate-500">XLM</span></span>
</div>
);
}
131 changes: 131 additions & 0 deletions components/navbar/__tests__/WalletBalance.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from "react";
import { render, screen } from "@testing-library/react";

jest.mock("@/context/WalletContext", () => ({
useWalletContext: jest.fn(),
}));

jest.mock("@/hooks/useStellarBalance.hook", () => ({
useStellarBalance: jest.fn(),
}));

jest.mock("@/context/PrivacyContext", () => ({
usePrivacy: jest.fn(),
}));

jest.mock("@/utils/maskAmount", () => ({
maskAmount: jest.fn(() => "••••"),
}));

import { WalletBalance } from "../WalletBalance";
import { useWalletContext } from "@/context/WalletContext";
import { useStellarBalance } from "@/hooks/useStellarBalance.hook";
import { usePrivacy } from "@/context/PrivacyContext";

const mockUseWalletContext = useWalletContext as jest.Mock;
const mockUseStellarBalance = useStellarBalance as jest.Mock;
const mockUsePrivacy = usePrivacy as jest.Mock;

function mockWalletConnected(address = "GA5C3TNI3KQY3Y3X3X3X3X3X3X3X3X3X3X3X3X3X3X") {
mockUseWalletContext.mockReturnValue({
address,
connected: true,
isLoading: false,
});
}

function mockWalletDisconnected() {
mockUseWalletContext.mockReturnValue({
address: null,
connected: false,
isLoading: false,
});
}

function mockBalance(balance: string | null, isLoading = false) {
mockUseStellarBalance.mockReturnValue({ balance, isLoading, error: null });
}

function mockLoadingBalance() {
mockUseStellarBalance.mockReturnValue({ balance: null, isLoading: true, error: null });
}

function mockPrivacy(hideBalances = false) {
mockUsePrivacy.mockReturnValue({ hideBalances, setHideBalances: jest.fn() });
}

describe("WalletBalance", () => {
beforeEach(() => {
jest.clearAllMocks();
mockPrivacy(false);
});

it("renders nothing when wallet is not connected", () => {
mockWalletDisconnected();
mockBalance(null);
const { container } = render(<WalletBalance />);
expect(container.firstChild).toBeNull();
});

it("renders loading skeleton when fetching initial balance", () => {
mockWalletConnected();
mockLoadingBalance();
render(<WalletBalance />);
expect(screen.getByLabelText("Loading wallet balance")).toBeInTheDocument();
});

it("renders formatted balance when data is available", () => {
mockWalletConnected();
mockBalance("1250.5000000");
render(<WalletBalance />);
expect(screen.getByLabelText("Wallet balance: 1,250.50 XLM")).toBeInTheDocument();
expect(screen.getByText("1,250.50")).toBeInTheDocument();
expect(screen.getByText("XLM")).toBeInTheDocument();
});

it("renders zero balance when account returns 0", () => {
mockWalletConnected();
mockBalance("0.0000000");
render(<WalletBalance />);
expect(screen.getByLabelText("Wallet balance: 0.00 XLM")).toBeInTheDocument();
expect(screen.getByText("0.00")).toBeInTheDocument();
});

it("hides balance when hideBalances is true", () => {
mockWalletConnected();
mockPrivacy(true);
mockBalance("5000.0000000");
render(<WalletBalance />);
expect(screen.getByLabelText("Wallet balance hidden")).toBeInTheDocument();
expect(screen.getByText("••••")).toBeInTheDocument();
});

it("renders nothing when balance is null after loading completes", () => {
mockWalletConnected();
mockBalance(null);
const { container } = render(<WalletBalance />);
expect(container.firstChild).toBeNull();
});

it("applies custom className", () => {
mockWalletConnected();
mockBalance("100.0000000");
const { container } = render(<WalletBalance className="custom-class" />);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain("custom-class");
});

it("handles very large balance formatting", () => {
mockWalletConnected();
mockBalance("1234567.8900000");
render(<WalletBalance />);
expect(screen.getByText("1,234,567.89")).toBeInTheDocument();
});

it("handles tiny balance formatting", () => {
mockWalletConnected();
mockBalance("0.0000100");
render(<WalletBalance />);
expect(screen.getByText("0.00")).toBeInTheDocument();
});
});
72 changes: 72 additions & 0 deletions hooks/useStellarBalance.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { getHorizonUrl } from "@/lib/stellar/transaction";

const POLL_INTERVAL_MS = 15_000;

interface StellarBalanceResult {
balance: string | null;
isLoading: boolean;
error: string | null;
}

export function useStellarBalance(address: string | null): StellarBalanceResult {
const [balance, setBalance] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

const fetchBalance = useCallback(async (addr: string) => {
try {
setIsLoading(true);
setError(null);
const horizonUrl = getHorizonUrl();
const response = await fetch(`${horizonUrl}/accounts/${addr}`);

if (!response.ok) {
if (response.status === 404) {
setBalance("0.0000000");
return;
}
throw new Error(`Horizon error: ${response.status}`);
}

const data = await response.json();
const nativeBalance = data.balances?.find(
(b: { asset_type: string }) => b.asset_type === "native",
);

setBalance(nativeBalance ? nativeBalance.balance : "0.0000000");
} catch (err) {
const message = (err as Error)?.message || "Failed to fetch balance";
setError(message);
} finally {
setIsLoading(false);
}
}, []);

useEffect(() => {
if (!address) {
setBalance(null);
setError(null);
setIsLoading(false);
return;
}

fetchBalance(address);

intervalRef.current = setInterval(() => {
fetchBalance(address);
}, POLL_INTERVAL_MS);

return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [address, fetchBalance]);

return { balance, isLoading, error };
}