diff --git a/.env.example b/.env.example index d2e830f..97f42b3 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/components/rewards/reward-history.test.tsx b/src/components/rewards/reward-history.test.tsx index df2a007..0d88191 100644 --- a/src/components/rewards/reward-history.test.tsx +++ b/src/components/rewards/reward-history.test.tsx @@ -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(); + expect(screen.getByText("Total earned")).toBeInTheDocument(); + expect(screen.getByText("Confirmed")).toBeInTheDocument(); + expect(screen.getByText("Pending")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + }); }); diff --git a/src/components/wallet/balance-display.tsx b/src/components/wallet/balance-display.tsx index 4400672..3a4cb54 100644 --- a/src/components/wallet/balance-display.tsx +++ b/src/components/wallet/balance-display.tsx @@ -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 { @@ -14,37 +15,49 @@ interface BalanceDisplayProps { } export function BalanceDisplay({ className, compact = false }: BalanceDisplayProps) { - const { balances, loading } = useRewards(); - const [usdRate, setUsdRate] = useState(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 (
@@ -65,6 +78,7 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro

Available {tokenCode} balance (${usdValue} USD)

+

{tokenCode} decimals: {decimals}

); @@ -99,6 +113,14 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro
≈ ${usdValue} USD
+
+ {tokenCode} decimals: {decimals} + {issuer && ( + + Issuer: {issuer.slice(0, 6)}… + + )} +
@@ -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 ? ( @@ -119,6 +142,14 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro : contractAddress} +
diff --git a/src/lib/hooks/use-token-price.ts b/src/lib/hooks/use-token-price.ts new file mode 100644 index 0000000..c93c991 --- /dev/null +++ b/src/lib/hooks/use-token-price.ts @@ -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 { + try { + const asset = issuer ? `${code}-${issuer}` : code; + const res = await fetch( + `https://api.stellarindex.io/v1/price?asset=${encodeURIComponent( + asset + )}"e=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(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 }; +} diff --git a/src/tests/wallet/balance-display.test.tsx b/src/tests/wallet/balance-display.test.tsx new file mode 100644 index 0000000..b49fe4a --- /dev/null +++ b/src/tests/wallet/balance-display.test.tsx @@ -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; +} + +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(); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("LEARN")).toBeInTheDocument(); + }); + + it("shows USD equivalent", () => { + render(); + // 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(); + expect(screen.getByText(/LEARN decimals: 7/)).toBeInTheDocument(); + }); + + it("links to transaction history", () => { + render(); + 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(); + await user.click(screen.getByTitle("Copy contract address")); + expect(writeText).toHaveBeenCalledWith(balances[0].tokenIssuer); + }); +});