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(); + }); });