From ca859980ba8e0e3ba94bc47179dbeef4d0b75c6f Mon Sep 17 00:00:00 2001 From: Wilfred007 Date: Fri, 16 Oct 2026 22:09:38 +0100 Subject: [PATCH] feat: add confirmation modal before submitting transactions Introduce a reusable TransactionConfirmModal that shows transaction details (pool, amount, lock period, fee estimate) and a caution note before handing off to Freighter for signing. Focus starts on Cancel so a stray Enter can't submit by accident. Wired into every transaction entry point: - Deposit (farm list DepositModal) - Lock (pool detail page, replacing the inline Alert confirm) - Unlock (UnlockModal) - Set boost (BoostModal) Co-Authored-By: Claude Opus 5.5 --- src/app/farm/[poolId]/PoolDetailClient.tsx | 64 +++------ src/app/farm/page.tsx | 28 +++- src/components/BoostModal/BoostModal.tsx | 25 +++- .../TransactionConfirmModal.test.tsx | 63 ++++++++ .../TransactionConfirmModal.tsx | 135 ++++++++++++++++++ .../UnlockModal/UnlockModal.test.tsx | 19 +++ src/components/UnlockModal/UnlockModal.tsx | 34 ++++- 7 files changed, 320 insertions(+), 48 deletions(-) create mode 100644 src/components/TransactionConfirmModal/TransactionConfirmModal.test.tsx create mode 100644 src/components/TransactionConfirmModal/TransactionConfirmModal.tsx diff --git a/src/app/farm/[poolId]/PoolDetailClient.tsx b/src/app/farm/[poolId]/PoolDetailClient.tsx index b1d4f22..7be5552 100644 --- a/src/app/farm/[poolId]/PoolDetailClient.tsx +++ b/src/app/farm/[poolId]/PoolDetailClient.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import NextLink from "next/link"; +import TransactionConfirmModal from "@/components/TransactionConfirmModal/TransactionConfirmModal"; import { Alert, AlertIcon, @@ -209,6 +210,11 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { const displayAmount = parseFloat(rawAmount); const amountValid = Number.isFinite(displayAmount) && displayAmount > 0; const isPending = isDepositPending(flow.step); + const lockPeriodLabel = pool + ? pool.minLockPeriod >= 86400 + ? `${Math.floor(pool.minLockPeriod / 86400)} day${Math.floor(pool.minLockPeriod / 86400) > 1 ? "s" : ""}` + : `${Math.floor(pool.minLockPeriod / 3600)} hour${Math.floor(pool.minLockPeriod / 3600) > 1 ? "s" : ""}` + : ""; if (error) { return ( @@ -507,50 +513,6 @@ export default function PoolDetailClient({ poolId }: { poolId: string }) { )} - {showConfirmation && ( - - - - - - - Confirm Lock Transaction - - - You are about to lock {displayAmount} {pool?.asset.code ?? ""} for a minimum period of{" "} - {pool - ? pool.minLockPeriod >= 86400 - ? `${Math.floor(pool.minLockPeriod / 86400)} day${Math.floor(pool.minLockPeriod / 86400) > 1 ? "s" : ""}` - : `${Math.floor(pool.minLockPeriod / 3600)} hour${Math.floor(pool.minLockPeriod / 3600) > 1 ? "s" : ""}` - : ""} - . This action cannot be reversed until the lock period expires. - - - - - - - - - - )} - {flow.step === "error" && ( + + + + + ); +} diff --git a/src/components/UnlockModal/UnlockModal.test.tsx b/src/components/UnlockModal/UnlockModal.test.tsx index 01138e7..980983f 100644 --- a/src/components/UnlockModal/UnlockModal.test.tsx +++ b/src/components/UnlockModal/UnlockModal.test.tsx @@ -219,6 +219,7 @@ describe('UnlockModal', () => { const button = screen.getByRole('button', { name: /Unlock with Freighter/i }); fireEvent.click(button); + fireEvent.click(await screen.findByRole('button', { name: /Confirm Unlock/i })); // Wait for the modal to show success state — the Badge text await waitFor(() => { @@ -248,6 +249,7 @@ describe('UnlockModal', () => { const button = screen.getByRole('button', { name: /Unlock with Freighter/i }); fireEvent.click(button); + fireEvent.click(await screen.findByRole('button', { name: /Confirm Unlock/i })); await waitFor(() => { expect(screen.getByText(/Insufficient balance/i)).toBeTruthy(); @@ -260,6 +262,23 @@ describe('UnlockModal', () => { }); }); + it('requires confirmation before submitting the unlock', async () => { + renderWithProviders(createElement(UnlockModal)); + fireEvent.change(screen.getByPlaceholderText('Amount'), { target: { value: '50' } }); + + fireEvent.click(screen.getByRole('button', { name: /Unlock with Freighter/i })); + + expect(await screen.findByText('Confirm Unlock', { selector: 'header' })).toBeTruthy(); + expect(screen.getByText('50 XLM')).toBeTruthy(); + expect(unlockAssets).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /^Cancel$/i })); + await waitFor(() => { + expect(screen.queryByRole('button', { name: /Confirm Unlock/i })).toBeNull(); + }); + expect(unlockAssets).not.toHaveBeenCalled(); + }); + describe('Escape key (#516)', () => { function mockStore(close: () => void) { vi.mocked(useFarmStore).mockImplementation((selector: (state: FarmStore) => unknown) => diff --git a/src/components/UnlockModal/UnlockModal.tsx b/src/components/UnlockModal/UnlockModal.tsx index 3d46641..3c75bfc 100644 --- a/src/components/UnlockModal/UnlockModal.tsx +++ b/src/components/UnlockModal/UnlockModal.tsx @@ -4,6 +4,7 @@ import { poolContractId, stellarNetwork, } from "@/config"; +import TransactionConfirmModal from "@/components/TransactionConfirmModal/TransactionConfirmModal"; import { useErrorHandler } from "@/context/ErrorContext"; import { useStellarWallet } from "@/context/StellarWalletContext"; import { useCountdown } from "@/hooks/useCountdown"; @@ -72,6 +73,7 @@ export default function UnlockModal() { const [step, setStep] = useState("idle"); const [error, setError] = useState(null); const [txHash, setTxHash] = useState(null); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const amountInputRef = useRef(null); const selectedPoolContractId = position?.contractAddress || poolContractId; @@ -101,7 +103,7 @@ export default function UnlockModal() { setStep("idle"); setError(null); setTxHash(null); - + setIsConfirmOpen(false); } }, [isUnlock, position]); @@ -127,6 +129,11 @@ export default function UnlockModal() { numericDailyRate, ); + const handleConfirmUnlock = () => { + setIsConfirmOpen(false); + void handleUnlock(); + }; + const handleUnlock = async () => { if (!publicKey || !walletApi) { setError("Connect your Freighter wallet to unlock."); @@ -549,7 +556,7 @@ export default function UnlockModal() { color="app.onAccent" _hover={{ opacity: 0.9 }} isDisabled={!canUnlock || !amountValid || isProcessing || isNetworkMismatch} - onClick={() => void handleUnlock()} + onClick={() => setIsConfirmOpen(true)} w="full" > {isProcessing ? ( @@ -565,6 +572,29 @@ export default function UnlockModal() { )} + setIsConfirmOpen(false)} + onConfirm={handleConfirmUnlock} + title="Confirm Unlock" + description="Review the details below. You'll be asked to sign this transaction in Freighter." + details={[ + { label: "Pool", value: position.name }, + { label: "Unlock amount", value: `${numericAmount} ${position.symbol}` }, + { label: "Remaining stake", value: `${remainingStake.toFixed(4)} ${position.symbol}` }, + { label: "New daily rate", value: `${newDailyRate.toFixed(6)} credits/day` }, + { + label: "Estimated Soroban fee", + value: feePreview.data ? `${feePreview.data.feePreview} stroops` : "Unavailable", + }, + ]} + warning={ + numericAmount < position.lockedAmount + ? "Unlocked assets stop earning credits once the transaction confirms." + : "This closes your entire position. Unlocked assets stop earning credits once the transaction confirms." + } + confirmLabel="Confirm Unlock" + /> ); }