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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ NEXT_PUBLIC_CREDENTIALS_CONTRACT_TESTNET=
NEXT_PUBLIC_REWARDS_CONTRACT_MAINNET=
NEXT_PUBLIC_REWARDS_CONTRACT_TESTNET=
NEXT_PUBLIC_SITE_URL=
NEXT_PUBLIC_FALLBACK_TOKEN_USD=
20 changes: 20 additions & 0 deletions src/components/rewards/reward-history.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,24 @@ describe("RewardHistory", () => {
"https://stellar.expert/explorer/testnet/tx/abc123"
);
});

it("shows summary stats for total earned, confirmed, pending and failed", () => {
const mixed = [
...claims,
{
id: "r3",
txHash: "",
amount: "10000000",
tokenCode: "LEARN",
claimedAt: "2024-03-03T00:00:00Z",
status: "failed",
courseTitle: "Failed Quiz",
},
] as RewardClaim[];
render(<RewardHistory claims={mixed} />);
expect(screen.getByText("Total earned")).toBeInTheDocument();
expect(screen.getByText("Confirmed")).toBeInTheDocument();
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
});
});
77 changes: 54 additions & 23 deletions src/components/wallet/balance-display.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

import { useRewards } from "@/lib/hooks/use-rewards";
import { useTokenPrice } from "@/lib/hooks/use-token-price";
import { formatTokenBalance } from "@/lib/utils/format";
import { Coins, ExternalLink, Copy, Check } from "lucide-react";
import { Coins, ExternalLink, Copy, Check, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useState, useEffect } from "react";
import { useState, useCallback } from "react";
import Link from "next/link";

interface BalanceDisplayProps {
Expand All @@ -14,37 +15,49 @@ interface BalanceDisplayProps {
}

export function BalanceDisplay({ className, compact = false }: BalanceDisplayProps) {
const { balances, loading } = useRewards();
const [usdRate, setUsdRate] = useState<number | null>(null);
const [priceLoading, setPriceLoading] = useState(true);
const { balances, loading, refetch } = useRewards();
const [copied, setCopied] = useState(false);

const primaryBalance = balances[0];
const tokenCode = primaryBalance?.tokenCode || "LEARN";
const contractAddress = primaryBalance?.contractAddress || "CD5...LEARNCONTRACT";
const balanceVal = primaryBalance ? primaryBalance.balance : 0;
const balanceStr = formatTokenBalance(balanceVal, primaryBalance?.decimals || 7);
const decimals = primaryBalance?.decimals ?? 7;
const issuer = primaryBalance?.tokenIssuer;
const rawBalance = primaryBalance ? parseFloat(primaryBalance.balance) : 0;
const balanceVal = isNaN(rawBalance) ? 0 : rawBalance / Math.pow(10, decimals);
const balanceStr = formatTokenBalance(balanceVal, 0);

useEffect(() => {
// Simulate/fetch USD price conversion rate
setPriceLoading(true);
const timer = setTimeout(() => {
setUsdRate(0.15); // 1 LEARN = $0.15 USD
setPriceLoading(false);
}, 500);
return () => clearTimeout(timer);
}, [primaryBalance]);
// Derive the deployed address for the current network from the balance's
// issuer when available, so users always have a copyable asset address.
const contractAddress = issuer ?? "";

const handleCopyContract = (e: React.MouseEvent) => {
const { usdRate, usdLoading } = useTokenPrice(
tokenCode,
issuer,
primaryBalance?.balance ? 1 : 0
);

const handleCopyContract = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!contractAddress) return;
navigator.clipboard.writeText(contractAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
},
[contractAddress]
);

const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(contractAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
refetch();
};

const usdValue = usdRate !== null ? (balanceVal * usdRate).toFixed(2) : "0.00";
const usdValue =
usdRate !== null
? (balanceVal * usdRate).toFixed(2)
: "0.00";

if (loading || priceLoading) {
if (loading || usdLoading) {
return (
<div className={cn("animate-pulse", className)}>
<div className="h-20 w-full rounded-xl bg-gray-200 dark:bg-gray-800" />
Expand All @@ -65,6 +78,7 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
</TooltipTrigger>
<TooltipContent>
<p>Available {tokenCode} balance (${usdValue} USD)</p>
<p className="text-gray-300">{tokenCode} decimals: {decimals}</p>
</TooltipContent>
</Tooltip>
);
Expand Down Expand Up @@ -99,6 +113,14 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mt-0.5">
≈ ${usdValue} USD
</div>
<div className="flex items-center gap-2 text-[11px] text-gray-400 mt-1">
<span>{tokenCode} decimals: {decimals}</span>
{issuer && (
<span className="font-mono truncate max-w-[12rem]" title={`Issuer: ${issuer}`}>
Issuer: {issuer.slice(0, 6)}…
</span>
)}
</div>
</div>

<div className="flex flex-col items-end gap-1 text-xs text-gray-400">
Expand All @@ -107,6 +129,7 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
onClick={handleCopyContract}
className="flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
title="Copy contract address"
disabled={!contractAddress}
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
Expand All @@ -119,6 +142,14 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
: contractAddress}
</span>
</button>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
title="Refresh balance"
>
<RefreshCw className="h-3 w-3" /> Refresh
</button>
</div>
</div>
</div>
Expand Down
79 changes: 79 additions & 0 deletions src/lib/hooks/use-token-price.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { useState, useEffect } from "react";

/**
* Fetch a live USD price for a token from Stellar Index's public pricing API
* (https://api.stellarindex.io). The quote asset is USD so the returned value
* is already a USD-equivalent.
*
* Falls back to a configurable static rate (via NEXT_PUBLIC_FALLBACK_TOKEN_USD)
* when the network call fails so the UI always has a value to render.
*/
async function fetchTokenUsdRate(
code: string,
issuer?: string
): Promise<number | null> {
try {
const asset = issuer ? `${code}-${issuer}` : code;
const res = await fetch(
`https://api.stellarindex.io/v1/price?asset=${encodeURIComponent(
asset
)}&quote=fiat:USD`
);
if (!res.ok) return null;
const data = (await res.json()) as {
data?: { price?: number | string };
};
if (data?.data?.price === undefined) return null;
const parsed = parseFloat(String(data.data.price));
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch {
return null;
}
}

function fallbackUsdRate(): number | null {
const raw = process.env.NEXT_PUBLIC_FALLBACK_TOKEN_USD;
if (!raw) return null;
const parsed = parseFloat(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}

export interface UseTokenPriceResult {
usdRate: number | null;
usdLoading: boolean;
error: boolean;
}

export function useTokenPrice(
code: string,
issuer?: string,
nonce = 0
): UseTokenPriceResult {
const [usdRate, setUsdRate] = useState<number | null>(null);
const [usdLoading, setUsdLoading] = useState(true);
const [error, setError] = useState(false);

useEffect(() => {
let active = true;
setUsdLoading(true);
setError(false);

fetchTokenUsdRate(code, issuer)
.catch(() => null)
.then((rate) => {
if (!active) return;
const resolved = rate ?? fallbackUsdRate();
setUsdRate(resolved);
setError(rate === null);
setUsdLoading(false);
});

return () => {
active = false;
};
}, [code, issuer, nonce]);

return { usdRate, usdLoading, error };
}
79 changes: 79 additions & 0 deletions src/tests/wallet/balance-display.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BalanceDisplay } from "@/components/wallet/balance-display";
import type { TokenBalance } from "@/types/stellar";

const balances: TokenBalance[] = [
{
tokenCode: "LEARN",
tokenIssuer: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMN",
balance: "50000000",
decimals: 7,
},
];

interface RewardsMock {
balances: TokenBalance[];
loading: boolean;
refetch: ReturnType<typeof vi.fn>;
}

const rewardsMock: RewardsMock = {
balances,
loading: false,
refetch: vi.fn(),
};

vi.mock("@/lib/hooks/use-rewards", () => ({
useRewards: () => rewardsMock,
}));

vi.mock("@/lib/hooks/use-token-price", () => ({
useTokenPrice: () => ({ usdRate: 0.15, usdLoading: false, error: false }),
}));

describe("BalanceDisplay", () => {
beforeEach(() => {
rewardsMock.balances = balances;
rewardsMock.loading = false;
});

it("shows token balance and code", () => {
render(<BalanceDisplay />);
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText("LEARN")).toBeInTheDocument();
});

it("shows USD equivalent", () => {
render(<BalanceDisplay />);
// 5 LEARN * $0.15 = $0.75
const matches = screen.getAllByText((_, el) =>
(el?.textContent ?? "").includes("≈ $0.75")
);
expect(matches.length).toBeGreaterThan(0);
});

it("shows token decimals", () => {
render(<BalanceDisplay />);
expect(screen.getByText(/LEARN decimals: 7/)).toBeInTheDocument();
});

it("links to transaction history", () => {
render(<BalanceDisplay />);
const link = screen.getByRole("link", { name: /view transactions/i });
expect(link).toHaveAttribute("href", "/transactions");
});

it("copies the issuer address on click", async () => {
const user = userEvent.setup();
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
render(<BalanceDisplay />);
await user.click(screen.getByTitle("Copy contract address"));
expect(writeText).toHaveBeenCalledWith(balances[0].tokenIssuer);
});
});