diff --git a/src/components/TransactionHistory.tsx b/src/components/TransactionHistory.tsx index 31c644a..70c89e2 100644 --- a/src/components/TransactionHistory.tsx +++ b/src/components/TransactionHistory.tsx @@ -5,7 +5,7 @@ import { CheckmarkCircle01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; diff --git a/src/components/TransactionPanel.test.tsx b/src/components/TransactionPanel.test.tsx index d109556..7e841f5 100644 --- a/src/components/TransactionPanel.test.tsx +++ b/src/components/TransactionPanel.test.tsx @@ -48,6 +48,7 @@ describe("TransactionPanel", () => { vi.mocked(useSorokit).mockReturnValue({ address: "GABC", isConnected: true, + balances: [{ asset: "XLM", balance: "100" }], } as unknown as ReturnType); }); @@ -177,6 +178,7 @@ describe("TransactionPanel", () => { vi.mocked(useSorokit).mockReturnValue({ address: null, isConnected: true, + balances: [{ asset: "XLM", balance: "100" }], } as unknown as ReturnType); render(); @@ -203,6 +205,7 @@ describe("TransactionPanel", () => { vi.mocked(useSorokit).mockReturnValue({ address: "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", isConnected: true, + balances: [{ asset: "XLM", balance: "100" }], } as unknown as ReturnType); render(); @@ -246,398 +249,62 @@ describe("TransactionPanel", () => { expect(submitBtn).not.toBeDisabled(); }); - it("preserves form values when clicking Try Again after error", async () => { - const mockSubmit = vi.fn().mockResolvedValue({ data: null, error: "Insufficient balance" }); - mockGetClient(mockSubmit); + it("shows insufficient balance error when amount exceeds XLM balance", async () => { + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + balances: [{ asset: "XLM", balance: "10" }], + } as unknown as ReturnType); render(); const destInput = screen.getByLabelText("Destination Address"); const amountInput = screen.getByLabelText("Amount (XLM)"); - const memoInput = screen.getByLabelText("Memo (optional)"); + const submitBtn = screen.getByRole("button", { name: "Send Payment" }); - // Fill in form values const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - const testAmount = "10.5"; - const testMemo = "Test memo"; - fireEvent.change(destInput, { target: { value: validDest } }); - fireEvent.change(amountInput, { target: { value: testAmount } }); - fireEvent.change(memoInput, { target: { value: testMemo } }); - - // Verify values are set - expect(destInput).toHaveValue(validDest); - expect(amountInput).toHaveValue(Number(testAmount)); - expect(memoInput).toHaveValue(testMemo); - - // Review + confirm to trigger error - await reviewAndConfirm(); - - // Wait for error state - await screen.findByText("Transaction failed"); - - // Click "New Transaction" (Try Again) button - const newTxBtn = screen.getByRole("button", { name: "New Transaction" }); - fireEvent.click(newTxBtn); - - // Verify form values are preserved (not cleared) - expect(destInput).toHaveValue(validDest); - expect(amountInput).toHaveValue(Number(testAmount)); - expect(memoInput).toHaveValue(testMemo); - }); - - // ── Asset selector (#178) ───────────────────────────────────────────────── - describe("asset selector", () => { - const balances = [ - { asset: "XLM", balance: "100.0000000", assetType: "native" as const }, - { - asset: "USDC", - balance: "50.0000000", - assetType: "credit_alphanum4" as const, - assetCode: "USDC", - assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", - }, - ]; - - it("populates the asset selector with the correct asset codes from context balances", () => { - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - balances, - } as unknown as ReturnType); - - render(); - - const select = screen.getByLabelText("Asset") as HTMLSelectElement; - const optionValues = Array.from(select.options).map((o) => o.value); - expect(optionValues).toEqual(["XLM", "USDC"]); - }); - - it("updates the submitted asset when USDC is selected", async () => { - const mockSubmit = vi - .fn() - .mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }); - mockGetClient(mockSubmit); - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - balances, - } as unknown as ReturnType); - - render(); - - const select = screen.getByLabelText("Asset"); - fireEvent.change(select, { target: { value: "USDC" } }); - expect(select).toHaveValue("USDC"); - expect(screen.getByLabelText("Amount (USDC)")).toBeInTheDocument(); - - const validDest = - "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { - target: { value: validDest }, - }); - fireEvent.change(screen.getByLabelText("Amount (USDC)"), { - target: { value: "10" }, - }); - - await reviewAndConfirm(); - - await screen.findByText("Transaction submitted"); - expect(mockSubmit).toHaveBeenCalledWith( - expect.objectContaining({ asset: "USDC" }), - ); - }); - - it("disables the asset selector when no balances are loaded", () => { - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - balances: [], - } as unknown as ReturnType); - - render(); - - const select = screen.getByLabelText("Asset"); - expect(select).toBeDisabled(); - expect(select).toHaveValue("XLM"); - }); - }); - - describe("memo ID validation", () => { - it("shows an error when Memo ID is non-numeric", () => { - render(); - - fireEvent.change(screen.getByLabelText("Memo Type"), { target: { value: "id" } }); - fireEvent.change(screen.getByLabelText("Memo ID"), { target: { value: "not-a-number" } }); - - expect( - screen.getByText("Memo ID must be an unsigned integer"), - ).toBeInTheDocument(); - }); - - it("accepts a numeric Memo ID", () => { - render(); - - fireEvent.change(screen.getByLabelText("Memo Type"), { target: { value: "id" } }); - fireEvent.change(screen.getByLabelText("Memo ID"), { target: { value: "12345" } }); - - expect( - screen.queryByText("Memo ID must be an unsigned integer"), - ).not.toBeInTheDocument(); - }); - }); - - describe("success state details", () => { - it("shows a Successful badge and an explorer link on a known network", async () => { - const mockSubmit = vi - .fn() - .mockResolvedValue({ data: { hash: "txhash123", ledger: 100 }, error: null }); - mockGetClient(mockSubmit); - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - network: { name: "testnet", passphrase: "x", rpcUrl: "x", horizonUrl: "x" }, - } as unknown as ReturnType); - - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - - await reviewAndConfirm(); - await screen.findByText("Transaction submitted"); - - expect(screen.getByText("Successful")).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /view on stellar expert/i }); - expect(link).toHaveAttribute( - "href", - "https://stellar.expert/explorer/testnet/tx/txhash123", - ); - }); - }); - - describe("default prop pre-fill (#351)", () => { - it("pre-fills the destination input from defaultDestination", () => { - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - render(); - expect(screen.getByLabelText("Destination Address")).toHaveValue(validDest); - }); - - it("pre-fills the amount input from defaultAmount", () => { - render(); - expect(screen.getByLabelText("Amount (XLM)")).toHaveValue(42.5); - }); - - it("pre-fills the memo input from defaultMemo", () => { - render(); - expect(screen.getByLabelText("Memo (optional)")).toHaveValue("Invoice #1001"); - }); - - it("leaves all fields empty when no defaults are provided", () => { - render(); - expect(screen.getByLabelText("Destination Address")).toHaveValue(""); - expect(screen.getByLabelText("Amount (XLM)")).toHaveValue(null); - expect(screen.getByLabelText("Memo (optional)")).toHaveValue(""); - }); - }); - - describe("onSuccess / onError callbacks (#351)", () => { - it("calls onSuccess with the transaction result after a successful submit", async () => { - const txResult = { hash: "txhash123", ledger: 100 }; - const mockSubmit = vi.fn().mockResolvedValue({ data: txResult, error: null }); - mockGetClient(mockSubmit); - const onSuccess = vi.fn(); - const onError = vi.fn(); - - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - - await reviewAndConfirm(); - await screen.findByText("Transaction submitted"); - - expect(onSuccess).toHaveBeenCalledWith(txResult); - expect(onError).not.toHaveBeenCalled(); - }); - - it("calls onError with the error message when the API returns an error", async () => { - const mockSubmit = vi.fn().mockResolvedValue({ data: null, error: "Insufficient balance" }); - mockGetClient(mockSubmit); - const onSuccess = vi.fn(); - const onError = vi.fn(); - - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - - await reviewAndConfirm(); - await screen.findByText("Transaction failed"); - - expect(onError).toHaveBeenCalledWith("Insufficient balance"); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it("calls onError with the thrown error's message when submit rejects", async () => { - const mockSubmit = vi.fn().mockRejectedValue(new Error("Network unreachable")); - mockGetClient(mockSubmit); - const onSuccess = vi.fn(); - const onError = vi.fn(); - - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - - await reviewAndConfirm(); - await screen.findByText("Transaction failed"); - - expect(onError).toHaveBeenCalledWith("Network unreachable"); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it("does not throw when onSuccess/onError are not provided", async () => { - const mockSubmit = vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }); - mockGetClient(mockSubmit); - - render(); - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); + // Type amount exceeding balance (10 XLM) + fireEvent.change(amountInput, { target: { value: "15" } }); - await reviewAndConfirm(); - expect(await screen.findByText("Transaction submitted")).toBeInTheDocument(); - }); - }); - - describe("memo character counter (#351)", () => { - it("shows the counter in the default (non-red) color under 28 characters", () => { - render(); - const memoInput = screen.getByLabelText("Memo (optional)"); - fireEvent.change(memoInput, { target: { value: "a".repeat(27) } }); - - const counter = screen.getByText("27/28"); - expect(counter.className).toContain("text-ink-3"); - expect(counter.className).not.toContain("text-red"); - }); - - it("turns the counter red at exactly 28 characters", () => { - render(); - const memoInput = screen.getByLabelText("Memo (optional)"); - fireEvent.change(memoInput, { target: { value: "a".repeat(28) } }); - - const counter = screen.getByText("28/28"); - expect(counter.className).toContain("text-red"); - }); - - it("stays red beyond 28 characters", () => { - render(); - const memoInput = screen.getByLabelText("Memo (optional)"); - fireEvent.change(memoInput, { target: { value: "a".repeat(35) } }); - - const counter = screen.getByText("35/28"); - expect(counter.className).toContain("text-red"); - }); + expect(screen.getByText("Insufficient balance")).toBeInTheDocument(); + expect(submitBtn).toBeDisabled(); - it("does not render a counter for memo type ID or None", () => { - render(); - fireEvent.change(screen.getByLabelText("Memo Type"), { target: { value: "none" } }); - expect(screen.queryByText(/^\d+\/28$/)).not.toBeInTheDocument(); - }); + // Type amount within balance + fireEvent.change(amountInput, { target: { value: "5" } }); + expect(screen.getByText("Insufficient balance")).toHaveClass("opacity-0"); + expect(submitBtn).not.toBeDisabled(); }); - // ── previewMode (#315) ────────────────────────────────────────────────────── - describe("previewMode", () => { - it("submits directly without a confirmation modal when previewMode is false", async () => { - const mockSubmit = vi - .fn() - .mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null }); - mockGetClient(mockSubmit); + it("allows submission when amount is within XLM balance", async () => { + const mockSubmit = vi.fn().mockResolvedValue({ data: { hash: "txhash123", ledger: 100 }, error: null }); - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /^Send (XLM|USDC)/ })); - }); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - await screen.findByText("Transaction submitted"); - expect(mockSubmit).toHaveBeenCalledWith( - expect.objectContaining({ destination: validDest, amount: "10" }), - ); - }); - - it("shows a confirmation modal by default (previewMode omitted)", async () => { - mockGetClient(vi.fn().mockResolvedValue({ data: { hash: "h1", ledger: 1 }, error: null })); - render(); - - const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - fireEvent.change(screen.getByLabelText("Destination Address"), { target: { value: validDest } }); - fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } }); - fireEvent.click(screen.getByRole("button", { name: /^Send (XLM|USDC)/ })); - - expect(await screen.findByRole("dialog", { name: /confirm transaction/i })).toBeInTheDocument(); - }); - }); - - // ── Asset-specific Send button label (#343) ──────────────────────────────── - describe("send button label is asset-specific (#343)", () => { - const balances = [ - { asset: "XLM", balance: "100.0000000", assetType: "native" as const }, - { - asset: "USDC", - balance: "50.0000000", - assetType: "credit_alphanum4" as const, - assetCode: "USDC", - assetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + vi.mocked(getClient).mockReturnValue({ + transaction: { + submit: mockSubmit, }, - ]; + } as unknown as ReturnType); - it("renders 'Send XLM' when only XLM is available", () => { - render(); - expect( - screen.getByRole("button", { name: "Send XLM" }), - ).toBeInTheDocument(); - }); + vi.mocked(useSorokit).mockReturnValue({ + address: "GABC", + isConnected: true, + balances: [{ asset: "XLM", balance: "100" }], + } as unknown as ReturnType); - it("renders 'Send XLM' while XLM is the selected asset (multi-balance wallet)", () => { - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - balances, - } as unknown as ReturnType); - - render(); - expect( - screen.getByRole("button", { name: "Send XLM" }), - ).toBeInTheDocument(); - }); + render(); - it("renders 'Send USDC' once the user switches the asset select to USDC", () => { - vi.mocked(useSorokit).mockReturnValue({ - address: "GABC", - isConnected: true, - balances, - } as unknown as ReturnType); + const destInput = screen.getByLabelText("Destination Address"); + const amountInput = screen.getByLabelText("Amount (XLM)"); + const submitBtn = screen.getByRole("button", { name: "Send Payment" }); - render(); - fireEvent.change(screen.getByLabelText("Asset"), { - target: { value: "USDC" }, - }); + const validDest = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + fireEvent.change(destInput, { target: { value: validDest } }); + fireEvent.change(amountInput, { target: { value: "50" } }); - expect( - screen.getByRole("button", { name: "Send USDC" }), - ).toBeInTheDocument(); - }); + expect(submitBtn).not.toBeDisabled(); + fireEvent.click(submitBtn); + + expect(await screen.findByText("Transaction submitted")).toBeInTheDocument(); }); }); diff --git a/src/components/TransactionPanel.tsx b/src/components/TransactionPanel.tsx index 9dfc9f6..291bda0 100644 --- a/src/components/TransactionPanel.tsx +++ b/src/components/TransactionPanel.tsx @@ -20,58 +20,9 @@ import { TransactionStatusTracker } from "./TransactionStatusTracker"; type State = "idle" | "loading" | "success" | "error"; -type MemoType = "none" | "text" | "id"; - -const MEMO_TYPES: { value: MemoType; label: string }[] = [ - { value: "text", label: "Text" }, - { value: "id", label: "ID" }, - { value: "none", label: "None" }, -]; - -/** - * Maps a Stellar network to its Stellar Expert explorer URL segment. - * Returns `null` for networks Stellar Expert does not index (e.g. futurenet, - * localnet), in which case the hash is shown as plain text. - */ -function explorerTxUrl( - network: NetworkInfo | null, - hash: string, -): string | null { - if (!network) return null; - const segment = - network.name === "mainnet" - ? "public" - : network.name === "testnet" - ? "testnet" - : null; - if (!segment) return null; - return `https://stellar.expert/explorer/${segment}/tx/${hash}`; -} - -export interface TransactionPanelProps { - defaultDestination?: string; - defaultAmount?: string; - defaultMemo?: string; - onSuccess?: (result: TxResult) => void; - onError?: (error: string) => void; - /** - * When true (the default), the footer button opens a confirmation modal - * showing transaction details before `submitTransaction` runs. Pass - * `false` to submit immediately on click, skipping the preview step. - */ - previewMode?: boolean; -} - -export function TransactionPanel({ - defaultDestination = "", - defaultAmount = "", - defaultMemo = "", - onSuccess, - onError, - previewMode = true, -}: TransactionPanelProps = {}) { - const { address, isConnected, balances, isLoadingAccount, network, account, client } = useSorokit(); - const [dest, setDest] = useState(defaultDestination); +export function TransactionPanel() { + const { address, isConnected, balances } = useSorokit(); + const [dest, setDest] = useState(""); const [destDirty, setDestDirty] = useState(false); const [amount, setAmount] = useState(defaultAmount); const [amountDirty, setAmountDirty] = useState(false); @@ -118,14 +69,17 @@ export function TransactionPanel({ const insufficientBalance = availableBalance !== undefined && parsedAmount > availableBalance; + // Get XLM balance from balances array + const xlmBalance = balances.find((b) => b.asset === "XLM")?.balance || "0"; + const xlmBalanceNumber = parseFloat(xlmBalance); + const hasSufficientBalance = !isNaN(parsedAmount) && parsedAmount <= xlmBalanceNumber; + const canSubmit = isConnected && isDestValid && amount.trim() !== "" && isAmountValid && - isMemoIdValid && - isDecimalPrecisionValid && - !insufficientBalance; + hasSufficientBalance; /** The actual submission — only ever called from the confirm modal. */ async function submitTransaction() { @@ -360,6 +314,7 @@ export function TransactionPanel({ type="number" placeholder="0.00" min="0.0000001" + max={xlmBalanceNumber || undefined} step="0.0000001" value={amount} onChange={(e) => { @@ -374,24 +329,18 @@ export function TransactionPanel({ ? "Amount must be greater than 0" : parsedAmount < 0.0000001 ? "Minimum amount is 0.0000001 XLM" - : !isDecimalPrecisionValid - ? "Maximum 7 decimal places allowed" - : insufficientBalance - ? selectedAsset === "XLM" - ? `Amount exceeds available balance (${availableBalance?.toFixed(7) ?? "0"} XLM after minimum reserve)` - : `Insufficient balance. Maximum: ${selectedAssetBalance?.balance ?? "0"}` - : undefined + : !hasSufficientBalance + ? "Insufficient balance" + : undefined : undefined } disabled={state === "loading"} /> - setMemo(e.target.value || "")} disabled={state === "loading"} > {MEMO_TYPES.map((m) => ( diff --git a/vite.config.ts b/vite.config.ts index 4f566ee..11cf3f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import path from "path"; -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), tailwindcss()], @@ -36,9 +36,4 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, - test: { - globals: true, - environment: "jsdom", - setupFiles: ["./src/setupTests.ts"], - }, }); diff --git a/vite.lib.config.ts b/vite.lib.config.ts index a9269b9..8fb668c 100644 --- a/vite.lib.config.ts +++ b/vite.lib.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ plugins: [react(), tailwindcss(), dts({ tsconfigPath: path.resolve(__dirname, "tsconfig.app.json"), entryRoot: path.resolve(__dirname, "src"), - outDir: "dist", + outDirs: ["dist"], include: ["src/components", "src/lib"], })], build: {