diff --git a/package.json b/package.json
index e3ae8da..dee346c 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
}
],
"dependencies": {
- "@creit.tech/stellar-wallets-kit": "^0.0.0-beta.0",
+ "@creit.tech/stellar-wallets-kit": "^2.5.0",
"@stellar/stellar-sdk": "^13.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -62,11 +62,18 @@
}
},
"devDependencies": {
+ "@radix-ui/react-slot": "^2.0.0",
"@size-limit/file": "^11.2.0",
+ "@testing-library/react": "^14.0.0",
+ "@testing-library/user-event": "^14.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
+ "@hugeicons/react": "^0.5.0",
+ "@hugeicons/core-free-icons": "^0.5.0",
+ "clsx": "^1.2.0",
"size-limit": "^11.2.0",
+ "tailwind-merge": "^2.0.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
diff --git a/src/components/ClaimableBalanceCard.tsx b/src/components/ClaimableBalanceCard.tsx
index 8e075e5..ad0e8d8 100644
--- a/src/components/ClaimableBalanceCard.tsx
+++ b/src/components/ClaimableBalanceCard.tsx
@@ -83,13 +83,14 @@ export function ClaimableBalanceCard() {
useEffect(() => {
if (!address) return;
- let active = true;
+ let cancelled = false;
const timerId = window.setTimeout(() => {
setLoading(true);
+ setError(null);
getClient()
.account.getClaimableBalances(address)
.then(({ data, error: err }) => {
- if (!active) return;
+ if (cancelled) return;
if (err) {
setError(err);
return;
@@ -97,12 +98,13 @@ export function ClaimableBalanceCard() {
setBalances(data ?? []);
})
.finally(() => {
- if (active) setLoading(false);
+ if (cancelled) return;
+ setLoading(false);
});
}, 0);
return () => {
- active = false;
+ cancelled = true;
window.clearTimeout(timerId);
};
}, [address]);
diff --git a/src/components/FeeEstimator.test.tsx b/src/components/FeeEstimator.test.tsx
index 6746943..9948acf 100644
--- a/src/components/FeeEstimator.test.tsx
+++ b/src/components/FeeEstimator.test.tsx
@@ -114,4 +114,76 @@ describe("FeeEstimator", () => {
await waitFor(() => expect(liveRegion).toHaveTextContent(/100/));
});
});
+
+ // ── XLM conversion and high-fee badge (#185) ───────────────────────────────
+ describe("XLM conversion and high-fee badge", () => {
+ it("converts stroops to XLM using 10_000_000 divisor", async () => {
+ mockEstimateFee({
+ data: { baseFee: "10000000", recommended: "20000000" },
+ error: null,
+ });
+ render();
+
+ await waitFor(() => {
+ // 10000000 stroops = 1 XLM
+ expect(screen.getByText(/1\.0000000 XLM/)).toBeInTheDocument();
+ // 20000000 stroops = 2 XLM
+ expect(screen.getByText(/2\.0000000 XLM/)).toBeInTheDocument();
+ });
+ });
+
+ it("shows high-fee badge when recommended > 2x base fee", async () => {
+ mockEstimateFee({
+ data: { baseFee: "100", recommended: "250" }, // 250 > 2*100
+ error: null,
+ });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("High fee")).toBeInTheDocument();
+ });
+ });
+
+ it("does not show high-fee badge when recommended <= 2x base fee", async () => {
+ mockEstimateFee({
+ data: { baseFee: "100", recommended: "200" }, // 200 = 2*100
+ error: null,
+ });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("100")).toBeInTheDocument();
+ });
+
+ // High fee badge should not be present
+ expect(screen.queryByText("High fee")).not.toBeInTheDocument();
+ });
+
+ it("shows high-fee badge only when recommended is strictly greater than 2x base", async () => {
+ mockEstimateFee({
+ data: { baseFee: "100", recommended: "201" }, // 201 > 2*100
+ error: null,
+ });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("High fee")).toBeInTheDocument();
+ });
+ });
+
+ it("handles fractional stroops correctly in XLM conversion", async () => {
+ mockEstimateFee({
+ data: { baseFee: "123456", recommended: "654321" },
+ error: null,
+ });
+ render();
+
+ await waitFor(() => {
+ // 123456 / 10000000 = 0.0123456 XLM
+ expect(screen.getByText(/0\.0123456 XLM/)).toBeInTheDocument();
+ // 654321 / 10000000 = 0.0654321 XLM
+ expect(screen.getByText(/0\.0654321 XLM/)).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/src/components/FeeEstimator.tsx b/src/components/FeeEstimator.tsx
index 13173d8..d954a34 100644
--- a/src/components/FeeEstimator.tsx
+++ b/src/components/FeeEstimator.tsx
@@ -1,51 +1,136 @@
-/**
- * FeeEstimator Component
- *
- * Calculates and displays estimated transaction fees based on network conditions,
- * operation complexity, and current Stellar network state.
- *
- * @component
- * @example
- * ```tsx
- * import { FeeEstimator } from 'sorokit-ui';
- *
- * export function TransactionForm() {
- * return (
- *
- *
- *
- * );
- * }
- * ```
- *
- * @param props - Component props
- * @param props.operations - Number of operations in transaction (default: 1)
- * @param props.network - Network to estimate fees for ('testnet' | 'public')
- * @param props.onEstimate - Callback when fee is calculated
- *
- * @returns The rendered FeeEstimator component
- *
- * @remarks
- * - Updates every 10 seconds with latest network fees
- * - Shows breakdown of base fee + operations fee
- * - Includes estimated stroops
- * - Requires SorokitProvider context
- *
- * @see {@link SorokitProvider} for setup
- */
-export function FeeEstimator({
- operations = 1,
- network,
- onEstimate
-}: FeeEstimatorProps) {
- // Component implementation
-}
+import { useEffect, useState } from "react";
+import { getClient } from "@/lib/client";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { cn } from "@/lib/utils";
+import { RefreshCwIcon } from "@hugeicons/react";
+
+const XLM_STROOPS = 10_000_000;
+const HIGH_FEE_THRESHOLD = 2;
export interface FeeEstimatorProps {
operations?: number;
- network: 'testnet' | 'public';
+ network?: string;
onEstimate?: (fee: string) => void;
}
+
+interface FeeData {
+ baseFee: string;
+ recommended: string;
+}
+
+export function FeeEstimator({ operations = 1, network, onEstimate }: FeeEstimatorProps) {
+ const [feeData, setFeeData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchFees = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const { data, error: err } = await getClient().transaction.estimateFee({
+ operations,
+ });
+ if (err) {
+ setError(err);
+ setFeeData(null);
+ } else if (data) {
+ setFeeData(data);
+ onEstimate?.(data.recommended);
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to estimate fee");
+ setFeeData(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchFees();
+ const interval = setInterval(fetchFees, 10000);
+ return () => clearInterval(interval);
+ }, [operations]);
+
+ const isHighFee =
+ feeData &&
+ parseFloat(feeData.recommended) > parseFloat(feeData.baseFee) * HIGH_FEE_THRESHOLD;
+
+ const recommendedXlm = feeData
+ ? (parseFloat(feeData.recommended) / XLM_STROOPS).toFixed(7)
+ : null;
+
+ const baseXlm = feeData
+ ? (parseFloat(feeData.baseFee) / XLM_STROOPS).toFixed(7)
+ : null;
+
+ return (
+
+
+
Network Fee
+
+
+
+ {loading && !feeData ? (
+
+ ) : error ? (
+
{error}
+ ) : feeData ? (
+
+
+
+
+ Base Fee
+
+
+
+ {feeData.baseFee}
+
+
+ ({baseXlm} XLM)
+
+
+
+
+
+
+ Recommended
+
+ {isHighFee && (
+
+ High fee
+
+ )}
+
+
+
+ {feeData.recommended}
+
+
+ ({recommendedXlm} XLM)
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/ui/Button.test.tsx b/src/components/ui/Button.test.tsx
index 65b89ef..3239e34 100644
--- a/src/components/ui/Button.test.tsx
+++ b/src/components/ui/Button.test.tsx
@@ -1,5 +1,5 @@
-import { render, screen } from "@testing-library/react";
-import { describe, it, expect } from "vitest";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
@@ -85,4 +85,107 @@ describe("Button", () => {
expect(link).toHaveAttribute("href", "/test");
expect(link?.className).toContain("bg-brand"); // variant styles are transferred
});
+
+ describe("requireConfirm pattern", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("changes label to confirmLabel on first click", () => {
+ const onClick = vi.fn();
+ render(
+
+ );
+
+ const button = screen.getByRole("button", { name: "Delete" });
+ fireEvent.click(button);
+
+ expect(screen.getByRole("button", { name: "Are you sure?" })).toBeInTheDocument();
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("fires onClick on second click", () => {
+ const onClick = vi.fn();
+ render(
+
+ );
+
+ const button = screen.getByRole("button", { name: "Delete" });
+
+ // First click
+ fireEvent.click(button);
+ expect(onClick).not.toHaveBeenCalled();
+
+ // Second click
+ const confirmButton = screen.getByRole("button", { name: "Confirm?" });
+ fireEvent.click(confirmButton);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("resets to original label after timeout without second click", () => {
+ const onClick = vi.fn();
+ render(
+
+ );
+
+ const button = screen.getByRole("button", { name: "Delete" });
+
+ // First click
+ fireEvent.click(button);
+ expect(screen.getByRole("button", { name: "Confirm?" })).toBeInTheDocument();
+
+ // Advance time past timeout
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ // Label should reset back to original
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("cancels timeout if second click happens before timeout", () => {
+ const onClick = vi.fn();
+ render(
+
+ );
+
+ const button = screen.getByRole("button", { name: "Delete" });
+
+ // First click
+ fireEvent.click(button);
+ expect(screen.getByRole("button", { name: "Confirm?" })).toBeInTheDocument();
+
+ // Second click before timeout
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ const confirmButton = screen.getByRole("button", { name: "Confirm?" });
+ fireEvent.click(confirmButton);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ // Advance time to see if timeout would have fired (it shouldn't)
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+
+ // Button should show original label after reset
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index faf171a..11cbc24 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -1,4 +1,4 @@
-import { forwardRef } from "react";
+import { forwardRef, useState, useEffect, useRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
@@ -10,6 +10,9 @@ interface ButtonProps extends React.ButtonHTMLAttributes {
size?: Size;
asChild?: boolean;
loading?: boolean;
+ requireConfirm?: boolean;
+ confirmLabel?: string;
+ confirmTimeout?: number;
}
const variants: Record = {
@@ -37,11 +40,24 @@ export const Button = forwardRef(
disabled,
children,
onClick,
+ requireConfirm = false,
+ confirmLabel,
+ confirmTimeout = 3000,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
+ const [isConfirming, setIsConfirming] = useState(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ };
+ }, []);
const handleClick = (e: React.MouseEvent) => {
if (disabled || loading) {
@@ -49,9 +65,32 @@ export const Button = forwardRef(
e.stopPropagation();
return;
}
- onClick?.(e);
+
+ if (requireConfirm) {
+ if (isConfirming) {
+ if (timeoutRef.current) {
+ window.clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ setIsConfirming(false);
+ onClick?.(e);
+ } else {
+ setIsConfirming(true);
+ if (timeoutRef.current) {
+ window.clearTimeout(timeoutRef.current);
+ }
+ timeoutRef.current = window.setTimeout(() => {
+ setIsConfirming(false);
+ }, confirmTimeout);
+ }
+ } else {
+ onClick?.(e);
+ }
};
+ const displayLabel =
+ requireConfirm && isConfirming && confirmLabel ? confirmLabel : children;
+
return (
(
{...props}
>
{asChild ? (
- children
+ displayLabel
) : (
<>
{loading && (
@@ -81,7 +120,7 @@ export const Button = forwardRef(
Loading
>
)}
- {children}
+ {displayLabel}
>
)}
diff --git a/src/context/SorokitProvider.tsx b/src/context/SorokitProvider.tsx
index 0047d85..92f0c32 100644
--- a/src/context/SorokitProvider.tsx
+++ b/src/context/SorokitProvider.tsx
@@ -39,6 +39,7 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) {
let active = true;
const timerId = window.setTimeout(() => {
setIsLoadingAccount(true);
+ setError(null);
Promise.all([
client.account.getAccount(address),
client.account.getBalances(address),
@@ -46,9 +47,13 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) {
.then(([accountRes, balancesRes]) => {
if (!active) return;
if (accountRes.data) setAccount(accountRes.data);
- if (accountRes.error) setError(accountRes.error);
if (balancesRes.data) setBalances(balancesRes.data);
- if (balancesRes.error) setError(balancesRes.error);
+ // Keep first non-null error instead of overwriting
+ if (accountRes.error) {
+ setError(accountRes.error);
+ } else if (balancesRes.error) {
+ setError(balancesRes.error);
+ }
})
.finally(() => {
if (active) setIsLoadingAccount(false);
@@ -106,25 +111,33 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) {
const refreshAccount = useCallback(async () => {
if (!address) return;
setIsLoadingAccount(true);
+ setError(null);
try {
const [accountRes, balancesRes] = await Promise.all([
client.account.getAccount(address),
client.account.getBalances(address),
]);
if (accountRes.data) setAccount(accountRes.data);
- if (accountRes.error) setError(accountRes.error);
if (balancesRes.data) setBalances(balancesRes.data);
- if (balancesRes.error) setError(balancesRes.error);
+ // Keep first non-null error instead of overwriting
+ if (accountRes.error) {
+ setError(accountRes.error);
+ } else if (balancesRes.error) {
+ setError(balancesRes.error);
+ }
} finally {
setIsLoadingAccount(false);
}
}, [address, client]);
+ const isLoading = isConnecting || isLoadingAccount;
+
const value = useMemo(
() => ({
address,
isConnected: !!address,
isConnecting,
+ isLoading,
connectWallet,
disconnectWallet,
account,
@@ -139,6 +152,7 @@ export function SorokitProvider({ client, children }: SorokitProviderProps) {
[
address,
isConnecting,
+ isLoading,
connectWallet,
disconnectWallet,
account,
diff --git a/src/context/sorokit-context.ts b/src/context/sorokit-context.ts
index a0fe8e2..2d2831b 100644
--- a/src/context/sorokit-context.ts
+++ b/src/context/sorokit-context.ts
@@ -11,6 +11,7 @@ export interface SorokitState {
address: string | null;
isConnected: boolean;
isConnecting: boolean;
+ isLoading: boolean;
connectWallet: () => Promise;
disconnectWallet: () => Promise;
account: AccountData | null;
diff --git a/src/screens/WalletScreen.test.tsx b/src/screens/WalletScreen.test.tsx
index 4d02c82..da3cf57 100644
--- a/src/screens/WalletScreen.test.tsx
+++ b/src/screens/WalletScreen.test.tsx
@@ -56,9 +56,9 @@ describe("WalletScreen", () => {
});
render();
-
+
const disconnectBtn = screen.getByRole("button", { name: "Disconnect" });
-
+
// First click
fireEvent.click(disconnectBtn);
expect(screen.getByRole("button", { name: "Disconnect?" })).toBeInTheDocument();
@@ -72,4 +72,94 @@ describe("WalletScreen", () => {
expect(screen.getByRole("button", { name: "Disconnect" })).toBeInTheDocument();
expect(mockDisconnect).not.toHaveBeenCalled();
});
+
+ it("renders network info cells with copyable passphrase and RPC URL", () => {
+ (useSorokit as any).mockReturnValue({
+ address: "GABC123456",
+ isConnected: true,
+ disconnectWallet: mockDisconnect,
+ network: {
+ name: "testnet",
+ rpcUrl: "https://rpc.testnet.example.com",
+ passphrase: "Test SDF Network ; September 2015",
+ },
+ });
+
+ render();
+
+ // Check network name is displayed
+ expect(screen.getByText("testnet")).toBeInTheDocument();
+
+ // Check RPC URL is displayed
+ expect(screen.getByText("https://rpc.testnet.example.com")).toBeInTheDocument();
+
+ // Check passphrase is displayed if it exists
+ expect(screen.getByText("Test SDF Network ; September 2015")).toBeInTheDocument();
+ });
+
+ it("has copyable buttons for network info", async () => {
+ const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
+ Object.assign(navigator, { clipboard });
+
+ (useSorokit as any).mockReturnValue({
+ address: "GABC123456",
+ isConnected: true,
+ disconnectWallet: mockDisconnect,
+ network: {
+ name: "testnet",
+ rpcUrl: "https://rpc.testnet.example.com",
+ },
+ });
+
+ const { container } = render();
+
+ // Find copy buttons (they have aria-label "Copy value")
+ const copyButtons = screen.getAllByLabelText("Copy value");
+ expect(copyButtons.length).toBeGreaterThan(0);
+
+ // Click a copy button
+ fireEvent.click(copyButtons[0]);
+
+ // Check clipboard was called
+ expect(clipboard.writeText).toHaveBeenCalled();
+ });
+
+ it("displays QR code for receiving funds when connected", () => {
+ (useSorokit as any).mockReturnValue({
+ address: "GABC123456",
+ isConnected: true,
+ disconnectWallet: mockDisconnect,
+ network: { name: "testnet", rpcUrl: "https://rpc.com" },
+ });
+
+ const { container } = render();
+
+ // Check "Receive Funds" section is visible
+ expect(screen.getByText("Receive Funds")).toBeInTheDocument();
+
+ // Check for QR code canvas
+ const canvas = container.querySelector("canvas");
+ expect(canvas).toBeInTheDocument();
+
+ // Check address is displayed
+ expect(screen.getByText(/GABC123456/)).toBeInTheDocument();
+ });
+
+ it("does not display QR code when not connected", () => {
+ (useSorokit as any).mockReturnValue({
+ address: null,
+ isConnected: false,
+ disconnectWallet: mockDisconnect,
+ network: null,
+ });
+
+ const { container } = render();
+
+ // Check "Receive Funds" section is not visible
+ expect(screen.queryByText("Receive Funds")).not.toBeInTheDocument();
+
+ // Check for QR code canvas
+ const canvas = container.querySelector("canvas");
+ expect(canvas).not.toBeInTheDocument();
+ });
});