From d1eb7f95c9a4002a46a849ab009124ac5959190c Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 20:36:53 +0100 Subject: [PATCH 01/22] Restore and finalize inline transaction conflict resolver for issue #726. Re-add conflict detection modules, resolver UI, VaultDashboard integration, and idempotency-aware vault API calls. Co-authored-by: Cursor --- .../TransactionConflictResolver.test.tsx | 30 +++ .../TransactionConflictResolver.tsx | 206 +++++++++++++++ frontend/src/components/VaultDashboard.tsx | 235 +++++++++++++----- frontend/src/hooks/useStaleSubmissionGuard.ts | 69 +++++ frontend/src/hooks/useTransactionIntent.ts | 124 +++++++++ frontend/src/hooks/useVaultMutations.ts | 37 +-- .../src/lib/staleSubmissionDetection.test.ts | 42 ++++ frontend/src/lib/staleSubmissionDetection.ts | 126 ++++++++++ frontend/src/lib/transactionConflict.test.ts | 39 +++ frontend/src/lib/transactionConflict.ts | 114 +++++++++ frontend/src/lib/transactionIntent.test.ts | 59 +++++ frontend/src/lib/transactionIntent.ts | 112 +++++++++ frontend/src/lib/vaultApi.ts | 62 ++++- 13 files changed, 1174 insertions(+), 81 deletions(-) create mode 100644 frontend/src/components/TransactionConflictResolver.test.tsx create mode 100644 frontend/src/components/TransactionConflictResolver.tsx create mode 100644 frontend/src/hooks/useStaleSubmissionGuard.ts create mode 100644 frontend/src/hooks/useTransactionIntent.ts create mode 100644 frontend/src/lib/staleSubmissionDetection.test.ts create mode 100644 frontend/src/lib/staleSubmissionDetection.ts create mode 100644 frontend/src/lib/transactionConflict.test.ts create mode 100644 frontend/src/lib/transactionConflict.ts create mode 100644 frontend/src/lib/transactionIntent.test.ts create mode 100644 frontend/src/lib/transactionIntent.ts diff --git a/frontend/src/components/TransactionConflictResolver.test.tsx b/frontend/src/components/TransactionConflictResolver.test.tsx new file mode 100644 index 00000000..212f0f0b --- /dev/null +++ b/frontend/src/components/TransactionConflictResolver.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import TransactionConflictResolver from "./TransactionConflictResolver"; + +describe("TransactionConflictResolver", () => { + it("renders stale form resolution actions", () => { + const onResolve = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Use updated values" })); + expect(onResolve).toHaveBeenCalledWith("update-values"); + }); +}); diff --git a/frontend/src/components/TransactionConflictResolver.tsx b/frontend/src/components/TransactionConflictResolver.tsx new file mode 100644 index 00000000..fdaffdf9 --- /dev/null +++ b/frontend/src/components/TransactionConflictResolver.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import { AlertTriangle } from "./icons"; +import type { + TransactionConflictDetails, + TransactionConflictResolution, +} from "../lib/transactionConflict"; +import type { StaleFieldChange } from "../lib/staleSubmissionDetection"; + +export interface TransactionConflictResolverProps { + conflict: TransactionConflictDetails; + staleChanges?: StaleFieldChange[]; + onResolve: (resolution: TransactionConflictResolution) => void; + isResolving?: boolean; +} + +const CONFLICT_COPY: Record< + TransactionConflictDetails["type"], + { title: string; description: string } +> = { + "stale-form": { + title: "Transaction details changed", + description: + "Market conditions or your wallet balance changed while you were reviewing. Resolve the conflict before submitting.", + }, + "wallet-in-progress": { + title: "Wallet operation in progress", + description: + "Another deposit or withdrawal is already in progress for this wallet. Wait for it to finish or retry shortly.", + }, + "idempotency-conflict": { + title: "Duplicate transaction intent", + description: + "This submission conflicts with a previous request. Choose how to proceed without creating a duplicate on-chain transaction.", + }, +}; + +const TransactionConflictResolver: React.FC = ({ + conflict, + staleChanges = [], + onResolve, + isResolving = false, +}) => { + const copy = CONFLICT_COPY[conflict.type]; + + return ( +
+
+ +
+

+ {copy.title} +

+

+ {conflict.message || copy.description} +

+
+
+ + {staleChanges.length > 0 && ( +
+
+ Changed fields +
+
    + {staleChanges.map((change) => ( +
  • + {change.label}: {change.previous} → {change.current} +
  • + ))} +
+
+ )} + +
+ {conflict.type === "stale-form" && ( + <> + + + + + )} + + {conflict.type === "wallet-in-progress" && ( + <> + + + + )} + + {conflict.type === "idempotency-conflict" && ( + <> + + + + + )} +
+
+ ); +}; + +export default TransactionConflictResolver; diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 216ede76..aa00c3cd 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import confetti from "canvas-confetti"; import { Activity, AlertCircle, @@ -41,13 +42,16 @@ import { useStaleIndicator } from "../hooks/useStaleIndicator"; import { useNetworkStatus } from "../hooks/useNetworkStatus"; import { useTransactionConfirmation } from "../hooks/useTransactionConfirmation"; import { useOfflineRetryCountdown } from "../hooks/useOfflineRetryCountdown"; -import { - clearVaultFormDraft, - saveVaultFormDraft, -} from "../lib/formDraftStorage"; +import { useStaleSubmissionGuard } from "../hooks/useStaleSubmissionGuard"; +import { useTransactionIntent } from "../hooks/useTransactionIntent"; import { buildDepositSummary, buildWithdrawalSummary } from "../lib/transactionConfirmationBuilder"; -import { useOfflineRetryCountdown } from "../hooks/useOfflineRetryCountdown"; -import confetti from "canvas-confetti"; +import TransactionConflictResolver from "./TransactionConflictResolver"; +import { + isTransactionConflict, + type TransactionConflictDetails, + type TransactionConflictResolution, +} from "../lib/transactionConflict"; +import type { StaleFieldChange } from "../lib/staleSubmissionDetection"; /** * Visual indicator for the 3-step transaction wizard. @@ -182,6 +186,11 @@ const VaultDashboard: React.FC = ({ message: string; txHash?: string } | null>(null); + const [activeConflict, setActiveConflict] = useState<{ + conflict: TransactionConflictDetails; + staleChanges?: StaleFieldChange[]; + } | null>(null); + const [isResolvingConflict, setIsResolvingConflict] = useState(false); const { isOffline, countdown } = useOfflineRetryCountdown(); @@ -225,22 +234,6 @@ const VaultDashboard: React.FC = ({ const amount = values.amount; - useEffect(() => { - if (!walletAddress) return; - if (!amount.trim() && dashboardUrl.state.step === "amount") return; - - saveVaultFormDraft({ - tab: dashboardUrl.state.tab, - step: dashboardUrl.state.step, - amount, - }); - }, [ - walletAddress, - dashboardUrl.state.tab, - dashboardUrl.state.step, - amount, - ]); - // Handle deep link parameters useEffect(() => { const action = dashboardUrl.state.tab; @@ -262,26 +255,6 @@ const VaultDashboard: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [amount]); - const resetWizard = () => { - setValues({ amount: "" }); - dashboardUrl.setStep("amount"); - dashboardUrl.setAmount(""); - setTransactionResult(null); - clearVaultFormDraft(); - }; - - const goToReview = () => { - if (Object.keys(errors).length > 0) { - toast.warning({ - title: "Please fix validation errors", - description: errors.amount || "Please enter a valid amount", - }); - return; - } - - dashboardUrl.setStep("review"); - }; - useEffect(() => { const handleDeposit = () => { dashboardUrl.setTab("deposit"); @@ -331,10 +304,53 @@ const VaultDashboard: React.FC = ({ !amount || (dashboardUrl.state.tab === "deposit" && isCapReached); + const staleGuard = useStaleSubmissionGuard({ + action: dashboardUrl.state.tab, + amount: enteredAmount, + availableBalance, + feeXlm, + isCapReached, + slippage, + }); - const handleTransaction = async (actionType: TransactionTab) => { + const transactionIntent = useTransactionIntent({ + walletAddress, + action: dashboardUrl.state.tab, + amount: enteredAmount, + snapshotHash: staleGuard.snapshotHash, + }); + + const resetWizard = () => { + setValues({ amount: "" }); + dashboardUrl.setStep("amount"); + dashboardUrl.setAmount(""); + setTransactionResult(null); + setActiveConflict(null); + staleGuard.clearReviewSnapshot(); + if (walletAddress) { + transactionIntent.clearIntent(); + } + }; + + const goToReview = () => { + if (Object.keys(errors).length > 0) { + toast.warning({ + title: "Please fix validation errors", + description: errors.amount || "Please enter a valid amount", + }); + return; + } + + staleGuard.captureReviewSnapshot(); + dashboardUrl.setStep("review"); + }; + + const executeTransaction = async ( + actionType: TransactionTab, + options: { skipStaleCheck?: boolean } = {}, + ) => { const value = Number(amount); - + if (!walletAddress) { toast.warning({ title: "Wallet required", @@ -343,11 +359,38 @@ const VaultDashboard: React.FC = ({ return; } + if (!options.skipStaleCheck) { + const staleResult = staleGuard.checkStaleSubmission(); + if (staleResult.isStale) { + setActiveConflict({ + conflict: { + type: "stale-form", + message: + "Transaction details changed since you started reviewing this submission.", + }, + staleChanges: staleResult.changes, + }); + return; + } + + if (transactionIntent.intentIsStale) { + setActiveConflict({ + conflict: { + type: "stale-form", + message: + "Your transaction intent is stale. Refresh the review details or start a new intent.", + }, + }); + return; + } + } + + setActiveConflict(null); + try { - // Build transaction summary and request user confirmation before signing const contractAddress = networkConfig.contractId; let summary; - + if (actionType === "deposit") { summary = buildDepositSummary({ amount: value, @@ -362,17 +405,21 @@ const VaultDashboard: React.FC = ({ }); } - // Show confirmation modal and wait for user response const confirmed = await confirmation.requestConfirmation(summary); if (!confirmed) { - // User cancelled the confirmation return; } - // Proceed with the transaction after user confirmed + const intent = transactionIntent.ensureIntent(); + const mutationParams = { + walletAddress, + amount: value, + idempotencyKey: intent?.idempotencyKey, + }; + if (actionType === "deposit") { - await depositMutation.mutateAsync({ walletAddress, amount: value }); - + await depositMutation.mutateAsync(mutationParams); + try { const depositKey = `has_deposited_${walletAddress}`; const alreadyDeposited = localStorage.getItem(depositKey); @@ -399,9 +446,12 @@ const VaultDashboard: React.FC = ({ } } } else { - await withdrawMutation.mutateAsync({ walletAddress, amount: value }); + await withdrawMutation.mutateAsync(mutationParams); } + transactionIntent.clearIntent(); + staleGuard.refreshSnapshot(); + setTransactionResult({ success: true, message: actionType === "deposit" @@ -409,7 +459,7 @@ const VaultDashboard: React.FC = ({ : `${value.toFixed(2)} USDC has been withdrawn from the vault.`, }); dashboardUrl.setStep("result"); - + toast.success({ title: actionType === "deposit" ? "Deposit Successful" : "Withdrawal Successful", description: @@ -418,20 +468,25 @@ const VaultDashboard: React.FC = ({ : `${value.toFixed(2)} USDC has been withdrawn from the vault.`, }); } catch (err: unknown) { - // Map server errors to form field errors + if (isTransactionConflict(err)) { + setActiveConflict({ + conflict: err.conflict, + }); + dashboardUrl.setStep("review"); + return; + } + const mappedError = mapServerError(err); - + if (mappedError.fieldErrors.length > 0) { - // Set field-level errors mappedError.fieldErrors.forEach(({ fieldName, message }) => { setFieldError(fieldName as keyof { amount: string }, message); }); dashboardUrl.setStep("amount"); } - // Get error message for display let errorMessage = "An error occurred during the transaction."; - + if (isValidationError(err)) { errorMessage = err.details?.[0]?.message || errorMessage; } else if (err instanceof Error) { @@ -445,7 +500,7 @@ const VaultDashboard: React.FC = ({ message: errorMessage, }); dashboardUrl.setStep("result"); - + toast.error({ title: "Transaction Failed", description: errorMessage, @@ -453,6 +508,52 @@ const VaultDashboard: React.FC = ({ } }; + const handleConflictResolution = async ( + resolution: TransactionConflictResolution, + ) => { + if (!activeConflict) { + return; + } + + setIsResolvingConflict(true); + + try { + if (resolution === "dismiss") { + setActiveConflict(null); + return; + } + + if (resolution === "update-values") { + staleGuard.refreshSnapshot(); + transactionIntent.refreshIntent(); + setActiveConflict(null); + return; + } + + if (resolution === "new-intent") { + transactionIntent.rotateIntent(); + setActiveConflict(null); + await executeTransaction(dashboardUrl.state.tab, { skipStaleCheck: true }); + return; + } + + if ( + resolution === "proceed-anyway" || + resolution === "retry" || + resolution === "retry-same" + ) { + setActiveConflict(null); + await executeTransaction(dashboardUrl.state.tab, { skipStaleCheck: true }); + } + } finally { + setIsResolvingConflict(false); + } + }; + + const handleTransaction = async (actionType: TransactionTab) => { + await executeTransaction(actionType); + }; + return (
{/* Transaction Confirmation Modal - shown for all sensitive actions */} @@ -1121,6 +1222,17 @@ const VaultDashboard: React.FC = ({ )}
)} + + {activeConflict && ( + { + void handleConflictResolution(resolution); + }} + isResolving={isResolvingConflict} + /> + )}
@@ -1128,7 +1240,10 @@ const VaultDashboard: React.FC = ({ type="button" className="btn btn-outline" style={{ flex: 1 }} - onClick={() => dashboardUrl.setStep("amount")} + onClick={() => { + staleGuard.clearReviewSnapshot(); + dashboardUrl.setStep("amount"); + }} disabled={isBusy} > Back diff --git a/frontend/src/hooks/useStaleSubmissionGuard.ts b/frontend/src/hooks/useStaleSubmissionGuard.ts new file mode 100644 index 00000000..cab9f76c --- /dev/null +++ b/frontend/src/hooks/useStaleSubmissionGuard.ts @@ -0,0 +1,69 @@ +import { useCallback, useState } from "react"; +import { + captureFormSnapshot, + detectStaleSubmission, + snapshotHashFromForm, + type FormSubmissionSnapshot, + type StaleSubmissionResult, +} from "../lib/staleSubmissionDetection"; +import type { TransactionAction } from "../lib/transactionIntent"; + +interface UseStaleSubmissionGuardParams { + action: TransactionAction; + amount: number; + availableBalance: number; + feeXlm: number; + isCapReached: boolean; + slippage: number; +} + +export function useStaleSubmissionGuard({ + action, + amount, + availableBalance, + feeXlm, + isCapReached, + slippage, +}: UseStaleSubmissionGuardParams) { + const [reviewSnapshot, setReviewSnapshot] = useState(null); + + const buildCurrentSnapshot = useCallback((): FormSubmissionSnapshot => { + return captureFormSnapshot({ + action, + amount, + availableBalance, + feeXlm, + isCapReached, + slippage, + }); + }, [action, amount, availableBalance, feeXlm, isCapReached, slippage]); + + const captureReviewSnapshot = useCallback(() => { + const snapshot = buildCurrentSnapshot(); + setReviewSnapshot(snapshot); + return snapshot; + }, [buildCurrentSnapshot]); + + const clearReviewSnapshot = useCallback(() => { + setReviewSnapshot(null); + }, []); + + const checkStaleSubmission = useCallback((): StaleSubmissionResult => { + return detectStaleSubmission(reviewSnapshot, buildCurrentSnapshot()); + }, [buildCurrentSnapshot, reviewSnapshot]); + + const refreshSnapshot = useCallback(() => { + return captureReviewSnapshot(); + }, [captureReviewSnapshot]); + + const snapshotHash = snapshotHashFromForm(buildCurrentSnapshot()); + + return { + reviewSnapshot, + snapshotHash, + captureReviewSnapshot, + clearReviewSnapshot, + checkStaleSubmission, + refreshSnapshot, + }; +} diff --git a/frontend/src/hooks/useTransactionIntent.ts b/frontend/src/hooks/useTransactionIntent.ts new file mode 100644 index 00000000..1bfb7914 --- /dev/null +++ b/frontend/src/hooks/useTransactionIntent.ts @@ -0,0 +1,124 @@ +import { useCallback, useMemo, useState } from "react"; +import { + clearTransactionIntent, + createTransactionIntent, + getStoredTransactionIntent, + hashSnapshot, + isIntentStale, + rotateIdempotencyKey, + storeTransactionIntent, + type TransactionAction, + type TransactionIntent, +} from "../lib/transactionIntent"; + +interface UseTransactionIntentParams { + walletAddress: string | null; + action: TransactionAction; + amount: number; + snapshotHash: string; +} + +export function useTransactionIntent({ + walletAddress, + action, + amount, + snapshotHash, +}: UseTransactionIntentParams) { + const [intent, setIntent] = useState(() => { + if (!walletAddress) { + return null; + } + return getStoredTransactionIntent(walletAddress, action); + }); + + const ensureIntent = useCallback(() => { + if (!walletAddress) { + return null; + } + + const existing = intent ?? getStoredTransactionIntent(walletAddress, action); + if (existing && !isIntentStale(existing, snapshotHash)) { + setIntent(existing); + return existing; + } + + const next = createTransactionIntent({ + action, + amount, + walletAddress, + snapshotHash, + idempotencyKey: existing?.idempotencyKey, + }); + + storeTransactionIntent(next); + setIntent(next); + return next; + }, [action, amount, intent, snapshotHash, walletAddress]); + + const refreshIntent = useCallback(() => { + if (!walletAddress) { + return null; + } + + const next = createTransactionIntent({ + action, + amount, + walletAddress, + snapshotHash, + }); + + storeTransactionIntent(next); + setIntent(next); + return next; + }, [action, amount, snapshotHash, walletAddress]); + + const rotateIntent = useCallback(() => { + if (!walletAddress) { + return null; + } + + const base = + intent ?? + createTransactionIntent({ + action, + amount, + walletAddress, + snapshotHash, + }); + + const rotated = rotateIdempotencyKey(base); + setIntent(rotated); + return rotated; + }, [action, amount, intent, snapshotHash, walletAddress]); + + const clearIntent = useCallback(() => { + if (!walletAddress) { + return; + } + + clearTransactionIntent(walletAddress, action); + setIntent(null); + }, [action, walletAddress]); + + const intentIsStale = useMemo(() => { + if (!intent) { + return false; + } + return isIntentStale(intent, snapshotHash); + }, [intent, snapshotHash]); + + const currentSnapshotHash = useMemo( + () => hashSnapshot([action, amount, snapshotHash]), + [action, amount, snapshotHash], + ); + + return { + intent, + intentIsStale, + currentSnapshotHash, + ensureIntent, + refreshIntent, + rotateIntent, + clearIntent, + }; +} diff --git a/frontend/src/hooks/useVaultMutations.ts b/frontend/src/hooks/useVaultMutations.ts index 0fda9b60..2805c565 100644 --- a/frontend/src/hooks/useVaultMutations.ts +++ b/frontend/src/hooks/useVaultMutations.ts @@ -9,6 +9,7 @@ interface MutationParams { walletAddress: string; amount: number; referralCode?: string; + idempotencyKey?: string; } interface OptimisticSnapshot { @@ -59,14 +60,17 @@ export function useDepositMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ walletAddress, amount, referralCode }: MutationParams) => { - await submitDeposit({ - walletAddress, - amount: amount.toString(), - asset: "USDC", - referralCode, - }); - return { walletAddress, amount, referralCode }; + mutationFn: async ({ walletAddress, amount, referralCode, idempotencyKey }: MutationParams) => { + await submitDeposit( + { + walletAddress, + amount: amount.toString(), + asset: "USDC", + referralCode, + }, + { idempotencyKey }, + ); + return { walletAddress, amount, referralCode, idempotencyKey }; }, onMutate: async ({ walletAddress, amount }) => { const balanceKey = queryKeys.balance.usdc(walletAddress); @@ -150,14 +154,17 @@ export function useWithdrawMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ walletAddress, amount }: MutationParams) => { + mutationFn: async ({ walletAddress, amount, idempotencyKey }: MutationParams) => { const shares = Math.max(1, Math.round(amount)); - await submitWithdrawal({ - walletAddress, - shares, - asset: "USDC", - }); - return { walletAddress, amount }; + await submitWithdrawal( + { + walletAddress, + shares, + asset: "USDC", + }, + { idempotencyKey }, + ); + return { walletAddress, amount, idempotencyKey }; }, onMutate: async ({ walletAddress, amount }) => { const balanceKey = queryKeys.balance.usdc(walletAddress); diff --git a/frontend/src/lib/staleSubmissionDetection.test.ts b/frontend/src/lib/staleSubmissionDetection.test.ts new file mode 100644 index 00000000..60123f04 --- /dev/null +++ b/frontend/src/lib/staleSubmissionDetection.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + captureFormSnapshot, + detectStaleSubmission, + snapshotHashFromForm, +} from "./staleSubmissionDetection"; + +describe("staleSubmissionDetection", () => { + const baseSnapshot = captureFormSnapshot({ + action: "deposit", + amount: 100, + availableBalance: 500, + feeXlm: 0.05, + isCapReached: false, + slippage: 0.5, + }); + + it("returns not stale when snapshots match", () => { + const result = detectStaleSubmission(baseSnapshot, { ...baseSnapshot }); + expect(result.isStale).toBe(false); + expect(result.changes).toHaveLength(0); + }); + + it("detects amount and balance changes", () => { + const current = captureFormSnapshot({ + action: "deposit", + amount: 90, + availableBalance: 450, + feeXlm: 0.05, + isCapReached: false, + slippage: 0.5, + }); + + const result = detectStaleSubmission(baseSnapshot, current); + + expect(result.isStale).toBe(true); + expect(result.changes.map((change) => change.field)).toEqual([ + "amount", + "availableBalance", + ]); + }); +}); diff --git a/frontend/src/lib/staleSubmissionDetection.ts b/frontend/src/lib/staleSubmissionDetection.ts new file mode 100644 index 00000000..f76ad866 --- /dev/null +++ b/frontend/src/lib/staleSubmissionDetection.ts @@ -0,0 +1,126 @@ +/** + * Detects when vault form state has changed since the user entered review. + */ + +import type { TransactionAction } from "./transactionIntent"; + +export interface FormSubmissionSnapshot { + action: TransactionAction; + amount: number; + availableBalance: number; + feeXlm: number; + isCapReached: boolean; + slippage: number; + capturedAt: number; +} + +export interface StaleFieldChange { + field: keyof FormSubmissionSnapshot | "intent"; + label: string; + previous: string; + current: string; +} + +export interface StaleSubmissionResult { + isStale: boolean; + changes: StaleFieldChange[]; +} + +function formatAmount(value: number): string { + return `${value.toFixed(2)} USDC`; +} + +function formatFee(value: number): string { + return `${value.toFixed(4)} XLM`; +} + +export function captureFormSnapshot(params: { + action: TransactionAction; + amount: number; + availableBalance: number; + feeXlm: number; + isCapReached: boolean; + slippage: number; +}): FormSubmissionSnapshot { + return { + action: params.action, + amount: params.amount, + availableBalance: params.availableBalance, + feeXlm: params.feeXlm, + isCapReached: params.isCapReached, + slippage: params.slippage, + capturedAt: Date.now(), + }; +} + +export function detectStaleSubmission( + snapshot: FormSubmissionSnapshot | null, + current: FormSubmissionSnapshot, +): StaleSubmissionResult { + if (!snapshot) { + return { isStale: false, changes: [] }; + } + + const changes: StaleFieldChange[] = []; + + if (snapshot.amount !== current.amount) { + changes.push({ + field: "amount", + label: "Amount", + previous: formatAmount(snapshot.amount), + current: formatAmount(current.amount), + }); + } + + if (snapshot.availableBalance !== current.availableBalance) { + changes.push({ + field: "availableBalance", + label: "Available balance", + previous: formatAmount(snapshot.availableBalance), + current: formatAmount(current.availableBalance), + }); + } + + if (Math.abs(snapshot.feeXlm - current.feeXlm) > 0.0001) { + changes.push({ + field: "feeXlm", + label: "Network fee", + previous: formatFee(snapshot.feeXlm), + current: formatFee(current.feeXlm), + }); + } + + if (snapshot.isCapReached !== current.isCapReached) { + changes.push({ + field: "isCapReached", + label: "Vault capacity", + previous: snapshot.isCapReached ? "Reached" : "Available", + current: current.isCapReached ? "Reached" : "Available", + }); + } + + if (snapshot.action === "withdraw" && snapshot.slippage !== current.slippage) { + changes.push({ + field: "slippage", + label: "Slippage tolerance", + previous: `${snapshot.slippage}%`, + current: `${current.slippage}%`, + }); + } + + return { + isStale: changes.length > 0, + changes, + }; +} + +export function snapshotHashFromForm(snapshot: FormSubmissionSnapshot): string { + return [ + snapshot.action, + snapshot.amount, + snapshot.availableBalance, + snapshot.feeXlm, + snapshot.isCapReached, + snapshot.slippage, + ].join("|"); +} diff --git a/frontend/src/lib/transactionConflict.test.ts b/frontend/src/lib/transactionConflict.test.ts new file mode 100644 index 00000000..ad065c7f --- /dev/null +++ b/frontend/src/lib/transactionConflict.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { + parseTransactionConflict, + TransactionConflictError, +} from "./transactionConflict"; + +describe("transactionConflict", () => { + it("parses wallet operation conflicts", () => { + const conflict = parseTransactionConflict({ + status: 409, + details: { + code: "WALLET_OPERATION_IN_PROGRESS", + message: "Another operation is already in progress for this wallet", + }, + }); + + expect(conflict?.conflict.type).toBe("wallet-in-progress"); + }); + + it("parses idempotency conflicts", () => { + const conflict = parseTransactionConflict({ + status: 409, + details: { + message: "Idempotency key already used for a different request body", + }, + }); + + expect(conflict?.conflict.type).toBe("idempotency-conflict"); + }); + + it("preserves TransactionConflictError instances", () => { + const original = new TransactionConflictError({ + type: "stale-form", + message: "Stale form", + }); + + expect(parseTransactionConflict(original)).toBe(original); + }); +}); diff --git a/frontend/src/lib/transactionConflict.ts b/frontend/src/lib/transactionConflict.ts new file mode 100644 index 00000000..e20676d8 --- /dev/null +++ b/frontend/src/lib/transactionConflict.ts @@ -0,0 +1,114 @@ +/** + * Transaction conflict types and parsing for vault deposit/withdraw operations. + */ + +export type TransactionConflictType = + | "stale-form" + | "wallet-in-progress" + | "idempotency-conflict"; + +export type TransactionConflictResolution = + | "update-values" + | "proceed-anyway" + | "retry" + | "new-intent" + | "retry-same" + | "dismiss"; + +export interface TransactionConflictDetails { + type: TransactionConflictType; + code?: string; + message: string; + walletAddress?: string; +} + +export class TransactionConflictError extends Error { + readonly conflict: TransactionConflictDetails; + + constructor(conflict: TransactionConflictDetails) { + super(conflict.message); + this.name = "TransactionConflictError"; + this.conflict = conflict; + } +} + +interface ConflictResponseBody { + error?: string; + status?: number; + code?: string; + message?: string; + walletAddress?: string; +} + +function isConflictResponseBody(value: unknown): value is ConflictResponseBody { + return value !== null && typeof value === "object"; +} + +export function parseTransactionConflict(error: unknown): TransactionConflictError | null { + if (error instanceof TransactionConflictError) { + return error; + } + + if (!error || typeof error !== "object") { + return null; + } + + const candidate = error as { + status?: number; + code?: string; + message?: string; + details?: unknown; + conflict?: TransactionConflictDetails; + }; + + if (candidate.conflict) { + return new TransactionConflictError(candidate.conflict); + } + + const status = candidate.status; + const details = isConflictResponseBody(candidate.details) + ? candidate.details + : isConflictResponseBody(error) + ? (error as ConflictResponseBody) + : null; + + if (status !== 409 && details?.status !== 409) { + return null; + } + + const code = details?.code ?? candidate.code; + const message = + details?.message ?? + candidate.message ?? + "A transaction conflict occurred. Please resolve before continuing."; + + if (code === "WALLET_OPERATION_IN_PROGRESS") { + return new TransactionConflictError({ + type: "wallet-in-progress", + code, + message, + walletAddress: details?.walletAddress, + }); + } + + if ( + code === "API_409_IDEMPOTENCY" || + message.toLowerCase().includes("idempotency") + ) { + return new TransactionConflictError({ + type: "idempotency-conflict", + code: code ?? "API_409_IDEMPOTENCY", + message, + }); + } + + return new TransactionConflictError({ + type: "idempotency-conflict", + code: code ?? "CONFLICT", + message, + }); +} + +export function isTransactionConflict(error: unknown): error is TransactionConflictError { + return error instanceof TransactionConflictError; +} diff --git a/frontend/src/lib/transactionIntent.test.ts b/frontend/src/lib/transactionIntent.test.ts new file mode 100644 index 00000000..f25affbe --- /dev/null +++ b/frontend/src/lib/transactionIntent.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + createTransactionIntent, + generateIdempotencyKey, + getStoredTransactionIntent, + isIntentStale, + rotateIdempotencyKey, + storeTransactionIntent, +} from "./transactionIntent"; + +describe("transactionIntent", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it("generates unique idempotency keys", () => { + expect(generateIdempotencyKey()).not.toBe(generateIdempotencyKey()); + }); + + it("stores and retrieves intents from session storage", () => { + const intent = createTransactionIntent({ + action: "deposit", + amount: 25, + walletAddress: "GABC123", + snapshotHash: "deposit|25|500", + }); + + storeTransactionIntent(intent); + expect(getStoredTransactionIntent("GABC123", "deposit")).toEqual(intent); + }); + + it("marks intents stale when snapshot hash changes", () => { + const intent = createTransactionIntent({ + action: "withdraw", + amount: 10, + walletAddress: "GABC123", + snapshotHash: "withdraw|10|100", + }); + + expect(isIntentStale(intent, "withdraw|10|100")).toBe(false); + expect(isIntentStale(intent, "withdraw|10|90")).toBe(true); + }); + + it("rotates idempotency keys while preserving intent metadata", () => { + const intent = createTransactionIntent({ + action: "deposit", + amount: 50, + walletAddress: "GABC123", + snapshotHash: "deposit|50|500", + }); + + const rotated = rotateIdempotencyKey(intent); + expect(rotated.idempotencyKey).not.toBe(intent.idempotencyKey); + expect(getStoredTransactionIntent("GABC123", "deposit")?.idempotencyKey).toBe( + rotated.idempotencyKey, + ); + }); +}); diff --git a/frontend/src/lib/transactionIntent.ts b/frontend/src/lib/transactionIntent.ts new file mode 100644 index 00000000..faff8c4a --- /dev/null +++ b/frontend/src/lib/transactionIntent.ts @@ -0,0 +1,112 @@ +/** + * Transaction intent tracking with idempotency keys for vault operations. + */ + +export type TransactionAction = "deposit" | "withdraw"; + +export interface TransactionIntent { + idempotencyKey: string; + action: TransactionAction; + amount: number; + walletAddress: string; + createdAt: number; + snapshotHash: string; +} + +const STORAGE_PREFIX = "yv-transaction-intent"; + +function storageKey(walletAddress: string, action: TransactionAction): string { + return `${STORAGE_PREFIX}:${walletAddress}:${action}`; +} + +export function generateIdempotencyKey(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `intent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +export function hashSnapshot(parts: Array): string { + return parts.map(String).join("|"); +} + +export function createTransactionIntent(params: { + action: TransactionAction; + amount: number; + walletAddress: string; + snapshotHash: string; + idempotencyKey?: string; +}): TransactionIntent { + return { + idempotencyKey: params.idempotencyKey ?? generateIdempotencyKey(), + action: params.action, + amount: params.amount, + walletAddress: params.walletAddress, + createdAt: Date.now(), + snapshotHash: params.snapshotHash, + }; +} + +export function storeTransactionIntent(intent: TransactionIntent): void { + if (typeof sessionStorage === "undefined") { + return; + } + + sessionStorage.setItem( + storageKey(intent.walletAddress, intent.action), + JSON.stringify(intent), + ); +} + +export function getStoredTransactionIntent( + walletAddress: string, + action: TransactionAction, +): TransactionIntent | null { + if (typeof sessionStorage === "undefined") { + return null; + } + + const raw = sessionStorage.getItem(storageKey(walletAddress, action)); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as TransactionIntent; + } catch { + return null; + } +} + +export function clearTransactionIntent( + walletAddress: string, + action: TransactionAction, +): void { + if (typeof sessionStorage === "undefined") { + return; + } + + sessionStorage.removeItem(storageKey(walletAddress, action)); +} + +export function rotateIdempotencyKey(intent: TransactionIntent): TransactionIntent { + const rotated: TransactionIntent = { + ...intent, + idempotencyKey: generateIdempotencyKey(), + createdAt: Date.now(), + }; + + storeTransactionIntent(rotated); + return rotated; +} + +export function isIntentStale( + intent: TransactionIntent, + snapshotHash: string, + maxAgeMs = 15 * 60 * 1000, +): boolean { + const expired = Date.now() - intent.createdAt > maxAgeMs; + const snapshotChanged = intent.snapshotHash !== snapshotHash; + return expired || snapshotChanged; +} diff --git a/frontend/src/lib/vaultApi.ts b/frontend/src/lib/vaultApi.ts index 2caf4acb..18fd567c 100644 --- a/frontend/src/lib/vaultApi.ts +++ b/frontend/src/lib/vaultApi.ts @@ -2,6 +2,8 @@ import { Contract, rpc, TransactionBuilder, BASE_FEE } from "@stellar/stellar-sd import { networkConfig } from "../config/network"; import { apiClient } from "./apiClient"; import { validate, VaultHistoryQuerySchema, DepositRequestSchema, WithdrawalRequestSchema } from "./api"; +import { isApiError } from "./api/error"; +import { parseTransactionConflict } from "./transactionConflict"; // ─── Share Price Error ──────────────────────────────────────────────────────── @@ -174,14 +176,62 @@ export async function getVaultHistory(params?: unknown): Promise((resolve) => setTimeout(resolve, 2000)); +export interface VaultSubmitOptions { + idempotencyKey?: string; } -export async function submitWithdrawal(params: unknown) { - validate(WithdrawalRequestSchema, params, "WithdrawalRequest"); - return new Promise((resolve) => setTimeout(resolve, 2000)); +async function submitVaultOperation( + path: string, + body: object, + options: VaultSubmitOptions = {}, +): Promise { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL; + + if (!apiBaseUrl) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return; + } + + const headers: Record = {}; + if (options.idempotencyKey) { + headers["Idempotency-Key"] = options.idempotencyKey; + } + + try { + await apiClient.post(path, { + body, + headers, + retry: false, + }); + } catch (error) { + const conflict = parseTransactionConflict( + isApiError(error) + ? { status: error.status, message: error.message, details: error.details } + : error, + ); + + if (conflict) { + throw conflict; + } + + throw error; + } +} + +export async function submitDeposit( + params: unknown, + options: VaultSubmitOptions = {}, +) { + const payload = validate(DepositRequestSchema, params, "DepositRequest"); + await submitVaultOperation("/api/v1/vault/deposits", payload, options); +} + +export async function submitWithdrawal( + params: unknown, + options: VaultSubmitOptions = {}, +) { + const payload = validate(WithdrawalRequestSchema, params, "WithdrawalRequest"); + await submitVaultOperation("/api/v1/vault/withdrawals", payload, options); } export async function getXlmPrice(): Promise { From 091769cb7d5e6aafe3ff07fc52e8323cc3178cf1 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 21:03:27 +0100 Subject: [PATCH 02/22] Add responsive transaction detail drawer with keyboard accessibility. Introduce an accessible Drawer primitive and TransactionDetailDrawer, wire row selection into TransactionHistory, and add responsive styles plus unit tests. Closes #729 Co-authored-by: Cursor --- frontend/src/components/DataTable.tsx | 51 ++- frontend/src/components/Drawer.test.tsx | 97 ++++++ frontend/src/components/Drawer.tsx | 167 ++++++++++ .../TransactionDetailDrawer.test.tsx | 104 ++++++ .../components/TransactionDetailDrawer.tsx | 168 ++++++++++ frontend/src/components/icons.ts | 1 + frontend/src/i18n/locales/en.ts | 9 + frontend/src/i18n/locales/es.ts | 9 + frontend/src/index.css | 161 +++++++++ .../src/pages/TransactionHistory.test.tsx | 313 ++++++++++++++---- frontend/src/pages/TransactionHistory.tsx | 107 ++---- 11 files changed, 1037 insertions(+), 150 deletions(-) create mode 100644 frontend/src/components/Drawer.test.tsx create mode 100644 frontend/src/components/Drawer.tsx create mode 100644 frontend/src/components/TransactionDetailDrawer.test.tsx create mode 100644 frontend/src/components/TransactionDetailDrawer.tsx diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index 4220f818..dce7be09 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -38,6 +38,8 @@ interface DataTableProps { renderRowDetails?: (row: T) => ReactNode; isLoading?: boolean; skeletonRows?: number; + onRowClick?: (row: T) => void; + selectedRowKey?: string; } function getCellAlignment(align: DataTableColumn["align"]) { @@ -67,6 +69,8 @@ export function DataTable({ renderRowDetails, isLoading = false, skeletonRows = 5, + onRowClick, + selectedRowKey, }: DataTableProps) { const { t } = useTranslation(); const delayedLoading = useDelayedLoading(isLoading); @@ -81,6 +85,20 @@ export function DataTable({ } }; + const handleRowKeyDown = ( + event: KeyboardEvent, + row: T, + ) => { + if (!onRowClick) return; + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onRowClick(row); + } + }; + + const isInteractive = Boolean(onRowClick); + return (
@@ -141,8 +159,34 @@ export function DataTable({ ) : ( - rows.map((row) => ( - + rows.map((row) => { + const key = rowKey(row); + const isSelected = selectedRowKey === key; + const rowClassName = [ + "data-table-row", + isInteractive && "data-table-row--interactive", + isSelected && "data-table-row--selected", + ] + .filter(Boolean) + .join(" "); + + return ( + onRowClick?.(row) : undefined} + onKeyDown={ + isInteractive + ? (event) => handleRowKeyDown(event, row) + : undefined + } + aria-selected={isInteractive ? isSelected : undefined} + aria-label={ + isInteractive ? t("dataTable.viewRowDetails") : undefined + } + role={isInteractive ? "button" : undefined} + > {columns.map((column, columnIndex) => { const content = column.cell ? column.cell(row) @@ -166,7 +210,8 @@ export function DataTable({ ); })} - )) + ); + }) )} diff --git a/frontend/src/components/Drawer.test.tsx b/frontend/src/components/Drawer.test.tsx new file mode 100644 index 00000000..a21a7610 --- /dev/null +++ b/frontend/src/components/Drawer.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Drawer } from "./Drawer"; + +describe("Drawer", () => { + const onClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function renderDrawer(isOpen = true) { + return render( + + + , + ); + } + + it("renders nothing when closed", () => { + renderDrawer(false); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders dialog when open", () => { + renderDrawer(true); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Drawer Title")).toBeInTheDocument(); + expect(screen.getByText("Drawer description")).toBeInTheDocument(); + }); + + it("closes on Escape key press", async () => { + renderDrawer(true); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + it("closes on backdrop click", async () => { + renderDrawer(true); + + const backdrop = screen.getByRole("dialog"); + fireEvent.click(backdrop); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + it("does not close when clicking inside the panel", () => { + renderDrawer(true); + + fireEvent.click(screen.getByRole("button", { name: "Inside button" })); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes when close button is clicked", () => { + renderDrawer(true); + + fireEvent.click(screen.getByRole("button", { name: "Close drawer" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("has aria-modal=true on the dialog", () => { + renderDrawer(true); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("traps focus with Tab key", async () => { + renderDrawer(true); + + const dialog = screen.getByRole("dialog"); + const focusableElements = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const lastElement = focusableElements[focusableElements.length - 1]; + lastElement.focus(); + + fireEvent.keyDown(document, { key: "Tab" }); + + expect(document.activeElement).toBe(focusableElements[0]); + }); +}); diff --git a/frontend/src/components/Drawer.tsx b/frontend/src/components/Drawer.tsx new file mode 100644 index 00000000..4260fe5e --- /dev/null +++ b/frontend/src/components/Drawer.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; + +export interface DrawerProps { + isOpen: boolean; + onClose: () => void; + title?: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + showCloseButton?: boolean; + closeOnBackdropClick?: boolean; + closeOnEscape?: boolean; + footer?: React.ReactNode; + "aria-labelledby"?: string; + "aria-describedby"?: string; +} + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export const Drawer: React.FC = ({ + isOpen, + onClose, + title, + description, + children, + showCloseButton = true, + closeOnBackdropClick = true, + closeOnEscape = true, + footer, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, +}) => { + const panelRef = useRef(null); + const previousFocusRef = useRef(null); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isOpen) return; + + if (e.key === "Escape" && closeOnEscape) { + onClose(); + return; + } + + if (e.key === "Tab" && panelRef.current) { + const focusableElements = + panelRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + + if (focusableElements.length === 0) { + e.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if ( + document.activeElement === firstElement || + document.activeElement === panelRef.current + ) { + lastElement.focus(); + e.preventDefault(); + } + } else if (document.activeElement === lastElement) { + firstElement.focus(); + e.preventDefault(); + } + } + }, + [isOpen, onClose, closeOnEscape], + ); + + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement; + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + + if (panelRef.current) { + const focusableElements = + panelRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } else { + panelRef.current.focus(); + } + } + } else { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + if (previousFocusRef.current) { + previousFocusRef.current.focus(); + } + } + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + }; + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && closeOnBackdropClick) { + onClose(); + } + }; + + const drawerId = ariaLabelledBy || (title ? "drawer-title" : undefined); + const descId = ariaDescribedBy || (description ? "drawer-desc" : undefined); + + return createPortal( +
+
e.stopPropagation()} + > + {(title || showCloseButton) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ {showCloseButton && ( + + )} +
+ )} + +
{children}
+ + {footer &&
{footer}
} +
+
, + document.body, + ); +}; + +export default Drawer; diff --git a/frontend/src/components/TransactionDetailDrawer.test.tsx b/frontend/src/components/TransactionDetailDrawer.test.tsx new file mode 100644 index 00000000..8921a10a --- /dev/null +++ b/frontend/src/components/TransactionDetailDrawer.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { TransactionDetailDrawer } from "./TransactionDetailDrawer"; +import { ToastProvider } from "../context/ToastContext"; +import type { Transaction } from "../lib/transactionApi"; + +const VALID_HASH = "a".repeat(64); + +vi.mock("../hooks/useTransactionTimeline", () => ({ + useTransactionTimeline: () => ({ + status: "pending", + elapsedSeconds: 3, + errorMessage: undefined, + reset: vi.fn(), + }), +})); + +const mockTransaction: Transaction = { + id: "tx-1", + type: "deposit", + status: "pending", + amount: "100", + asset: "USDC", + timestamp: "2025-01-15T10:30:00Z", + transactionHash: VALID_HASH, +}; + +function renderDrawer( + transaction: Transaction | null = mockTransaction, + isOpen = true, +) { + const onClose = vi.fn(); + render( + + + + + , + ); + return { onClose }; +} + +describe("TransactionDetailDrawer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders transaction details when open", () => { + renderDrawer(); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Transaction Details")).toBeInTheDocument(); + expect(screen.getByText("deposit")).toBeInTheDocument(); + expect(screen.getByText("pending")).toBeInTheDocument(); + expect(screen.getByText("100 USDC")).toBeInTheDocument(); + expect(screen.getByText(VALID_HASH)).toBeInTheDocument(); + }); + + it("renders explorer link with valid hash", () => { + renderDrawer(); + + const explorerLink = screen.getByRole("link", { + name: /View on Stellar Explorer/i, + }); + expect(explorerLink).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/tx/${VALID_HASH}`, + ); + expect(explorerLink).toHaveAttribute("target", "_blank"); + expect(explorerLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders live status section for pending transactions", () => { + renderDrawer(); + + expect(screen.getByText("Live Status")).toBeInTheDocument(); + expect(screen.getByText(/Submitted/i)).toBeInTheDocument(); + }); + + it("renders view receipt link", () => { + renderDrawer(); + + const receiptLink = screen.getByRole("link", { name: /View Receipt/i }); + expect(receiptLink).toHaveAttribute("href", `/receipt/${VALID_HASH}`); + }); + + it("calls onClose when close button is clicked", () => { + const { onClose } = renderDrawer(); + + fireEvent.click(screen.getByRole("button", { name: "Close drawer" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders nothing when transaction is null", () => { + renderDrawer(null, true); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/TransactionDetailDrawer.tsx b/frontend/src/components/TransactionDetailDrawer.tsx new file mode 100644 index 00000000..59bcca63 --- /dev/null +++ b/frontend/src/components/TransactionDetailDrawer.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import Badge from "./Badge"; +import CopyButton from "./CopyButton"; +import Drawer from "./Drawer"; +import TransactionTimeline from "./TransactionTimeline"; +import { ExternalLink, Loader2 } from "./icons"; +import { useTransactionTimeline } from "../hooks/useTransactionTimeline"; +import { useTranslation } from "../i18n"; +import { + formatAmount, + formatTimestamp, + type Transaction, +} from "../lib/transactionApi"; +import { getStellarExplorerUrl } from "../lib/security"; +import { networkConfig } from "../config/network"; + +const STATUS_COLOR_MAP: Record< + Transaction["status"], + "success" | "warning" | "error" +> = { + completed: "success", + pending: "warning", + failed: "error", +}; + +interface TransactionDetailDrawerProps { + transaction: Transaction | null; + isOpen: boolean; + onClose: () => void; +} + +function DetailRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +const PendingTimelineSection: React.FC<{ txHash: string }> = ({ txHash }) => { + const { status, elapsedSeconds, errorMessage } = useTransactionTimeline({ + txHash, + }); + const { t } = useTranslation(); + + return ( +
+

{t("txDetail.liveStatusSection")}

+ +
+ ); +}; + +export const TransactionDetailDrawer: React.FC = ({ + transaction, + isOpen, + onClose, +}) => { + const { t } = useTranslation(); + + if (!transaction) return null; + + const explorerUrl = getStellarExplorerUrl( + transaction.transactionHash, + networkConfig.isTestnet ? "testnet" : "mainnet", + ); + + return ( + + {t("txDetail.viewReceipt")} + + } + > +
+ + + {transaction.type} + + + + + + ) : undefined + } + > + {transaction.status} + + + + + {formatAmount(transaction.amount, transaction.asset)} + + + + {transaction.asset ?? "—"} + + + + {formatTimestamp(transaction.timestamp)} + + + + + {transaction.transactionHash} + + + +
+ + + + {transaction.status === "pending" && ( + + )} +
+ ); +}; + +export default TransactionDetailDrawer; diff --git a/frontend/src/components/icons.ts b/frontend/src/components/icons.ts index 4a28a049..3c9f2327 100644 --- a/frontend/src/components/icons.ts +++ b/frontend/src/components/icons.ts @@ -23,6 +23,7 @@ export { Wallet, X, DollarSign, + ExternalLink, Percent, Briefcase, Share2, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c4e02fe9..91f46a00 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -83,6 +83,7 @@ export const en = { previous: "Previous", next: "Next", sortBy: "Sort by", + viewRowDetails: "View row details", }, shortcuts: { title: "Keyboard Shortcuts", @@ -428,6 +429,14 @@ export const en = { dismissTimeline: "Dismiss timeline", tableCaption: "Transaction history", }, + txDetail: { + title: "Transaction Details", + description: "Review the full details of this transaction.", + viewReceipt: "View Receipt", + hashLabel: "transaction hash", + viewOnExplorer: "View on Stellar Explorer", + liveStatusSection: "Live Status", + }, portfolio: { pageTitle: "Portfolio", pageDesc: "Overview of your deposited real-world assets.", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index e3860184..791a8ed5 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -83,6 +83,7 @@ export const es = { previous: "Anterior", next: "Siguiente", sortBy: "Ordenar por", + viewRowDetails: "Ver detalles de la fila", }, shortcuts: { title: "Atajos de teclado", @@ -414,6 +415,14 @@ export const es = { dismissTimeline: "Descartar línea de tiempo", tableCaption: "Historial de transacciones", }, + txDetail: { + title: "Detalles de la transacción", + description: "Revisa los detalles completos de esta transacción.", + viewReceipt: "Ver recibo", + hashLabel: "hash de transacción", + viewOnExplorer: "Ver en Stellar Explorer", + liveStatusSection: "Estado en vivo", + }, portfolio: { pageTitle: "Portafolio", pageDesc: "Resumen de tus activos del mundo real depositados.", diff --git a/frontend/src/index.css b/frontend/src/index.css index 9b91f3c2..991cc009 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3124,3 +3124,164 @@ button { gap: 16px; } } + +/* ── Drawer ─────────────────────────────────────────────────────────────── */ + +.drawer-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + justify-content: flex-end; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); +} + +.drawer-panel { + display: flex; + flex-direction: column; + width: min(100%, 420px); + height: 100%; + max-height: 100vh; + margin: 0; + border-radius: 0; + border-left: 1px solid var(--border-glass); + overflow: hidden; + animation: drawer-slide-in 0.25s ease-out; +} + +@keyframes drawer-slide-in { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +.drawer-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 24px 24px 0; + flex-shrink: 0; +} + +.drawer-title { + margin: 0 0 8px; + font-size: var(--text-xl); + color: var(--text-primary); +} + +.drawer-description { + margin: 0; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.drawer-close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + flex-shrink: 0; +} + +.drawer-close-btn:hover { + color: var(--text-primary); + border-color: var(--border-glass-glow); +} + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.drawer-footer { + padding: 16px 24px 24px; + border-top: 1px solid var(--border-glass); +} + +.drawer-section-title { + margin: 0 0 12px; + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-secondary); +} + +.drawer-timeline-section { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-glass); +} + +.drawer-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.drawer-hash-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.drawer-explorer-link { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.data-table-row--interactive { + cursor: pointer; +} + +.data-table-row--interactive:hover { + background: rgba(255, 255, 255, 0.04); +} + +.data-table-row--interactive:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: -2px; +} + +.data-table-row--selected { + background: rgba(2, 132, 199, 0.12); +} + +@media (max-width: 768px) { + .drawer-backdrop { + align-items: flex-end; + } + + .drawer-panel { + width: 100%; + height: auto; + max-height: 90vh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-left: none; + animation: drawer-sheet-in 0.25s ease-out; + } + + @keyframes drawer-sheet-in { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } + } +} diff --git a/frontend/src/pages/TransactionHistory.test.tsx b/frontend/src/pages/TransactionHistory.test.tsx index 27d1a9cd..6515ffd4 100644 --- a/frontend/src/pages/TransactionHistory.test.tsx +++ b/frontend/src/pages/TransactionHistory.test.tsx @@ -1,10 +1,20 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import TransactionHistory from "./TransactionHistory"; import * as transactionApi from "../lib/transactionApi"; import type { Transaction } from "../lib/transactionApi"; +import { ToastProvider } from "../context/ToastContext"; + +vi.mock("../hooks/useTransactionTimeline", () => ({ + useTransactionTimeline: () => ({ + status: "pending", + elapsedSeconds: 0, + errorMessage: undefined, + reset: vi.fn(), + }), +})); // Hoisted so it can be referenced inside vi.mock factories const mockNetworkConfig = vi.hoisted(() => ({ @@ -63,7 +73,9 @@ function renderPage(walletAddress: string | null, initialEntries = ["/"]) { return render( - + + + , ); @@ -86,7 +98,7 @@ describe("TransactionHistory", () => { it("renders connect-wallet prompt when walletAddress is null", () => { renderPage(null); - expect(screen.getByText(/Connect your wallet/i)).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /Connect your wallet/i })).toBeInTheDocument(); expect(mockGetTransactions).not.toHaveBeenCalled(); }); @@ -101,16 +113,12 @@ describe("TransactionHistory", () => { renderPage(WALLET); - expect( - screen.getAllByText(/Loading transactions\.\.\./i).length, - ).toBeGreaterThan(0); + expect(screen.getAllByText(/Loading\.\.\./i).length).toBeGreaterThan(0); // Resolve to avoid act() warnings resolvePromise([]); await waitFor(() => - expect( - screen.queryByText(/Loading transactions\.\.\./i), - ).not.toBeInTheDocument(), + expect(screen.queryAllByText(/Loading\.\.\./i).length).toBe(0), ); }); @@ -123,9 +131,7 @@ describe("TransactionHistory", () => { await waitFor(() => expect(mockGetTransactions).toHaveBeenCalledWith({ walletAddress: WALLET, - limit: 10, - order: "desc", - type: "all", + limit: 200, }), ); }); @@ -287,24 +293,20 @@ describe("TransactionHistory", () => { ).toBeNull(); }); - // Req 5.1 — filter control renders All / Deposit / Withdrawal options - it("renders filter control with All, Deposit, and Withdrawal options", async () => { + // Req 5.1 — filter control renders Deposit / Withdrawal checkboxes + it("renders type filter checkboxes for Deposit and Withdrawal", async () => { mockGetTransactions.mockResolvedValue([]); renderPage(WALLET); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); - const filterSelect = screen.getByRole("combobox", { - name: /Filter by type/i, - }); - const options = Array.from(filterSelect.querySelectorAll("option")).map( - (o) => o.textContent, - ); - - expect(options).toContain("All"); - expect(options).toContain("Deposit"); - expect(options).toContain("Withdrawal"); + expect( + screen.getByRole("checkbox", { name: /Filter by Type Deposit/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { name: /Filter by Type Withdrawal/i }), + ).toBeInTheDocument(); }); it("filters transactions with a debounced client-side search input", async () => { @@ -320,7 +322,10 @@ describe("TransactionHistory", () => { renderPage(WALLET); - await waitFor(() => expect(screen.getByText("USDC")).toBeInTheDocument()); + const table = await screen.findByRole("table"); + await waitFor(() => + expect(within(table).getByText("USDC")).toBeInTheDocument(), + ); const searchInput = screen.getByRole("searchbox", { name: /Search transactions/i, @@ -330,19 +335,22 @@ describe("TransactionHistory", () => { fireEvent.change(searchInput, { target: { value: "EURC" } }); expect(mockGetTransactions).toHaveBeenCalledTimes(1); - expect(screen.getByText("USDC")).toBeInTheDocument(); + expect(within(table).getByText("USDC")).toBeInTheDocument(); await waitFor( - () => expect(screen.queryByText("USDC")).not.toBeInTheDocument(), + () => + expect(within(table).queryByText("USDC")).not.toBeInTheDocument(), { timeout: 2000 }, ); - expect(screen.getByText("EURC")).toBeInTheDocument(); + expect(within(table).getByText("EURC")).toBeInTheDocument(); expect(mockGetTransactions).toHaveBeenCalledTimes(1); fireEvent.change(searchInput, { target: { value: "" } }); - await waitFor(() => expect(screen.getByText("USDC")).toBeInTheDocument()); - expect(screen.getByText("EURC")).toBeInTheDocument(); + await waitFor(() => + expect(within(table).getByText("USDC")).toBeInTheDocument(), + ); + expect(within(table).getByText("EURC")).toBeInTheDocument(); expect(mockGetTransactions).toHaveBeenCalledTimes(1); }); @@ -368,10 +376,9 @@ describe("TransactionHistory", () => { ); // Apply a filter — should reset to page 1 - const filterSelect = screen.getByRole("combobox", { - name: /Filter by type/i, - }); - fireEvent.change(filterSelect, { target: { value: "deposit" } }); + fireEvent.click( + screen.getByRole("checkbox", { name: /Filter by Type Deposit/i }), + ); await waitFor(() => expect( @@ -413,25 +420,21 @@ describe("TransactionHistory", () => { // Req 7.2 — filtered empty state message it("shows filtered empty state message when filter yields no results", async () => { - // Only deposits — filtering by withdrawal should show filtered empty message - mockGetTransactions.mockImplementation(async (params: unknown) => { - const p = params as { type?: string }; - if (p.type === "withdrawal") return []; - return [makeTransaction({ id: "1", type: "deposit", status: "completed" })]; - }); + mockGetTransactions.mockResolvedValue([ + makeTransaction({ id: "1", type: "deposit", status: "completed" }), + ]); renderPage(WALLET); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); - const filterSelect = screen.getByRole("combobox", { - name: /Filter by type/i, - }); - fireEvent.change(filterSelect, { target: { value: "withdrawal" } }); + fireEvent.click( + screen.getByRole("checkbox", { name: /Filter by Type Withdrawal/i }), + ); await waitFor(() => expect( - screen.getByText("No matches found"), + screen.getByText("No transactions found"), ).toBeInTheDocument(), ); }); @@ -442,7 +445,17 @@ describe("TransactionHistory", () => { render( - + + + + + , ); @@ -472,7 +485,17 @@ describe("TransactionHistory", () => { render( - + + + + + , ); @@ -565,16 +588,16 @@ describe("TransactionHistory — amount range filter", () => { it("hides rows below amountMin when amountMin param is set in URL", async () => { mockGetTransactions.mockResolvedValue([ - makeTransaction({ id: "1", amount: "50.00", asset: "USDC" }), + makeTransaction({ id: "1", amount: "50", asset: "USDC" }), makeTransaction({ id: "2", - amount: "200.00", + amount: "200", asset: "USDC", transactionHash: "hash200000000000000000000000000000000000000", }), makeTransaction({ id: "3", - amount: "500.00", + amount: "500", asset: "USDC", transactionHash: "hash500000000000000000000000000000000000000", }), @@ -582,32 +605,43 @@ describe("TransactionHistory — amount range filter", () => { render( - + + + + + , ); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); + const table = screen.getByRole("table"); // 50 should be hidden; 200 and 500 should be visible await waitFor(() => - expect(screen.queryAllByText(/50\.00 USDC/).length).toBe(0), + expect(within(table).queryAllByText(/50 USDC/).length).toBe(0), ); - expect(screen.getByText("200.00 USDC")).toBeInTheDocument(); - expect(screen.getByText("500.00 USDC")).toBeInTheDocument(); + expect(within(table).getByText("200 USDC")).toBeInTheDocument(); + expect(within(table).getByText("500 USDC")).toBeInTheDocument(); }); it("hides rows above amountMax when amountMax param is set in URL", async () => { mockGetTransactions.mockResolvedValue([ - makeTransaction({ id: "1", amount: "50.00", asset: "USDC" }), + makeTransaction({ id: "1", amount: "50", asset: "USDC" }), makeTransaction({ id: "2", - amount: "200.00", + amount: "200", asset: "USDC", transactionHash: "hash200000000000000000000000000000000000000", }), makeTransaction({ id: "3", - amount: "500.00", + amount: "500", asset: "USDC", transactionHash: "hash500000000000000000000000000000000000000", }), @@ -615,18 +649,29 @@ describe("TransactionHistory — amount range filter", () => { render( - + + + + + , ); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); + const table = screen.getByRole("table"); // Only 50 should be visible await waitFor(() => - expect(screen.queryAllByText(/500\.00 USDC/).length).toBe(0), + expect(within(table).queryAllByText(/500 USDC/).length).toBe(0), ); - expect(screen.getByText("50.00 USDC")).toBeInTheDocument(); - expect(screen.queryAllByText(/200\.00 USDC/).length).toBe(0); + expect(within(table).getByText("50 USDC")).toBeInTheDocument(); + expect(within(table).queryAllByText(/200 USDC/).length).toBe(0); }); }); @@ -665,18 +710,29 @@ describe("TransactionHistory — status filter", () => { render( - + + + + + , ); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); + const table = screen.getByRole("table"); // Only EURC (pending) should survive the filter await waitFor(() => - expect(screen.queryAllByText("USDC").length).toBe(0), + expect(within(table).queryAllByText("USDC").length).toBe(0), ); - expect(screen.getByText("EURC")).toBeInTheDocument(); - expect(screen.queryAllByText("XLM").length).toBe(0); + expect(within(table).getByText("EURC")).toBeInTheDocument(); + expect(within(table).queryAllByText("XLM").length).toBe(0); }); }); @@ -701,7 +757,17 @@ describe("TransactionHistory — URL shareability", () => { render( - + + + + + , ); @@ -719,7 +785,17 @@ describe("TransactionHistory — URL shareability", () => { render( - + + + + + , ); @@ -732,3 +808,106 @@ describe("TransactionHistory — URL shareability", () => { expect(amountMaxInput).toHaveValue(500); }); }); + +// --------------------------------------------------------------------------- +// Transaction detail drawer +// --------------------------------------------------------------------------- + +const DRAWER_HASH = "b".repeat(64); + +describe("TransactionHistory — detail drawer", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mockNetworkConfig.isTestnet = true; + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("opens the detail drawer when a table row is clicked", async () => { + mockGetTransactions.mockResolvedValue([ + makeTransaction({ + id: "drawer-tx", + transactionHash: DRAWER_HASH, + status: "completed", + }), + ]); + + renderPage(WALLET); + + const table = await screen.findByRole("table"); + const row = within(table).getByRole("button", { + name: /View row details/i, + }); + fireEvent.click(row); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Transaction Details")).toBeInTheDocument(); + expect(screen.getByText(DRAWER_HASH)).toBeInTheDocument(); + }); + + it("does not open the drawer when the explorer hash link is clicked", async () => { + mockGetTransactions.mockResolvedValue([ + makeTransaction({ + id: "drawer-tx", + transactionHash: DRAWER_HASH, + status: "completed", + }), + ]); + + renderPage(WALLET); + + const link = await screen.findByTitle(DRAWER_HASH); + fireEvent.click(link); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes the drawer when Escape is pressed", async () => { + mockGetTransactions.mockResolvedValue([ + makeTransaction({ + id: "drawer-tx", + transactionHash: DRAWER_HASH, + status: "completed", + }), + ]); + + renderPage(WALLET); + + const table = await screen.findByRole("table"); + fireEvent.click( + within(table).getByRole("button", { name: /View row details/i }), + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("marks the selected row with the selected class", async () => { + mockGetTransactions.mockResolvedValue([ + makeTransaction({ + id: "drawer-tx", + transactionHash: DRAWER_HASH, + status: "completed", + }), + ]); + + renderPage(WALLET); + + const table = await screen.findByRole("table"); + const row = within(table).getByRole("button", { + name: /View row details/i, + }); + fireEvent.click(row); + + expect(row).toHaveClass("data-table-row--selected"); + }); +}); diff --git a/frontend/src/pages/TransactionHistory.tsx b/frontend/src/pages/TransactionHistory.tsx index b5729332..af9b2dbc 100644 --- a/frontend/src/pages/TransactionHistory.tsx +++ b/frontend/src/pages/TransactionHistory.tsx @@ -6,10 +6,9 @@ import { DataTable, type DataTableColumn } from "../components/DataTable"; import PageHeader from "../components/PageHeader"; import { SkeletonText } from "../components/Skeleton"; import TransactionFilterPanel from "../components/TransactionFilterPanel"; -import TransactionTimeline from "../components/TransactionTimeline"; +import TransactionDetailDrawer from "../components/TransactionDetailDrawer"; import EmptyState from "../components/ui/EmptyState"; import { Activity, Loader2, Wallet } from "../components/icons"; -import { useTransactionTimeline } from "../hooks/useTransactionTimeline"; import { normalizeApiError, isValidationError, @@ -94,54 +93,6 @@ const STATUS_COLOR_MAP: Record void }> = ({ - txHash, - onDismiss, -}) => { - const { status, elapsedSeconds, errorMessage } = useTransactionTimeline({ txHash }); - const { t } = useTranslation(); - - return ( -
-
- - {t("txHistory.liveStatus")} - - -
- -
- ); -}; - const TransactionHistory: React.FC = ({ walletAddress, }) => { @@ -153,7 +104,15 @@ const TransactionHistory: React.FC = ({ [queryTransactions], ); - const [selectedPendingHash, setSelectedPendingHash] = useState(null); + const [selectedTransaction, setSelectedTransaction] = useState(null); + + const handleRowSelect = useCallback((row: Transaction) => { + setSelectedTransaction(row); + }, []); + + const handleDrawerClose = useCallback(() => { + setSelectedTransaction(null); + }, []); const columns: DataTableColumn[] = React.useMemo(() => [ { @@ -171,28 +130,13 @@ const TransactionHistory: React.FC = ({ header: t("txHistory.statusHeader"), sortable: true, cell: (row) => ( - + {row.status} + ), }, { @@ -225,6 +169,7 @@ const TransactionHistory: React.FC = ({ )} target="_blank" rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} style={{ color: "var(--accent-cyan)", textDecoration: "none" }} title={row.transactionHash} > @@ -232,7 +177,7 @@ const TransactionHistory: React.FC = ({ ), }, - ], [selectedPendingHash, t]); + ], [t]); const error = queryError ? (isValidationError(queryError) ? queryError : normalizeApiError(queryError)) @@ -650,6 +595,8 @@ const TransactionHistory: React.FC = ({ sortBy={state.sortBy} sortDirection={state.sortDirection} onSortChange={setSort} + onRowClick={handleRowSelect} + selectedRowKey={selectedTransaction?.id} /> {/* Infinite scroll sentinel & status */} @@ -707,6 +654,8 @@ const TransactionHistory: React.FC = ({ sortBy={state.sortBy} sortDirection={state.sortDirection} onSortChange={setSort} + onRowClick={handleRowSelect} + selectedRowKey={selectedTransaction?.id} pagination={{ page, pageSize: state.pageSize, @@ -717,13 +666,11 @@ const TransactionHistory: React.FC = ({ /> )} - {/* Live timeline for selected pending transaction */} - {selectedPendingHash && ( - setSelectedPendingHash(null)} - /> - )} +
)} From 6129029eddc5a3d9b702142762479c92fc3a4072 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 21:39:59 +0100 Subject: [PATCH 03/22] Fix lint error: remove unused subscribe callback parameter. Co-authored-by: Cursor --- frontend/src/hooks/useRetryState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useRetryState.ts b/frontend/src/hooks/useRetryState.ts index 0fced7d4..6ada7361 100644 --- a/frontend/src/hooks/useRetryState.ts +++ b/frontend/src/hooks/useRetryState.ts @@ -31,7 +31,7 @@ export function useRetryState() { useEffect(() => { // Subscribe to query cache updates to detect retry cycles - const unsubscribe = queryClient.getQueryCache().subscribe((_event) => { + const unsubscribe = queryClient.getQueryCache().subscribe(() => { // Check if any query is in an error state (will trigger retry if not exhausted) const cache = queryClient.getQueryCache(); let anyRetrying = false; From 6eb53dd282fea3df34ed4d3e1cd322e3af8c9fe8 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 21:45:54 +0100 Subject: [PATCH 04/22] Fix TypeScript errors in hook test files for CI build. Co-authored-by: Cursor --- frontend/src/hooks/useDashboardUrlState.test.ts | 1 + frontend/src/hooks/useFormFocusFlow.test.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useDashboardUrlState.test.ts b/frontend/src/hooks/useDashboardUrlState.test.ts index 0a142475..34f363cf 100644 --- a/frontend/src/hooks/useDashboardUrlState.test.ts +++ b/frontend/src/hooks/useDashboardUrlState.test.ts @@ -1,4 +1,5 @@ import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; import { BrowserRouter } from "react-router-dom"; import { useDashboardUrlState } from "./useDashboardUrlState"; import React from "react"; diff --git a/frontend/src/hooks/useFormFocusFlow.test.ts b/frontend/src/hooks/useFormFocusFlow.test.ts index a0d820a2..3f06a800 100644 --- a/frontend/src/hooks/useFormFocusFlow.test.ts +++ b/frontend/src/hooks/useFormFocusFlow.test.ts @@ -22,7 +22,9 @@ describe("useFormFocusFlow", () => { ); act(() => { - result.current.containerRef.current = document.getElementById("container"); + result.current.containerRef.current = document.getElementById( + "container", + ) as HTMLDivElement | null; result.current.focusFirstError(); }); From fd18e376140373720b7283e31ccec6450c54ba34 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 21:53:55 +0100 Subject: [PATCH 05/22] Fix VaultDashboard merge duplicates and remove user-event from modal test. Co-authored-by: Cursor --- .../TransactionConfirmationModal.test.tsx | 5 +--- frontend/src/components/VaultDashboard.tsx | 26 +------------------ 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/TransactionConfirmationModal.test.tsx b/frontend/src/components/TransactionConfirmationModal.test.tsx index a02c9eda..df86683d 100644 --- a/frontend/src/components/TransactionConfirmationModal.test.tsx +++ b/frontend/src/components/TransactionConfirmationModal.test.tsx @@ -4,7 +4,6 @@ */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi } from 'vitest'; import { TransactionConfirmationModal } from './TransactionConfirmationModal'; import type { TransactionSummary } from '../types/transaction'; @@ -240,8 +239,6 @@ describe('TransactionConfirmationModal', () => { }); it('allows copying contract address', async () => { - const user = userEvent.setup(); - // Mock clipboard API Object.assign(navigator, { clipboard: { @@ -251,7 +248,7 @@ describe('TransactionConfirmationModal', () => { render(); const copyBtn = screen.getByRole('button', { name: /Copy contract address/ }); - await user.click(copyBtn); + fireEvent.click(copyBtn); expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockSummary.contractAddress); }); diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 5cdaf496..d7fedff9 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -50,7 +50,6 @@ import { saveVaultFormDraft, } from "../lib/formDraftStorage"; import { buildDepositSummary, buildWithdrawalSummary } from "../lib/transactionConfirmationBuilder"; -import confetti from "canvas-confetti"; import TransactionConflictResolver from "./TransactionConflictResolver"; import { isTransactionConflict, @@ -290,30 +289,6 @@ const VaultDashboard: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [amount]); - const resetWizard = () => { - setValues({ amount: "" }); - dashboardUrl.setStep("amount"); - dashboardUrl.setAmount(""); - setTransactionResult(null); - clearVaultFormDraft(); - }; - - const goToReview = () => { - if (Object.keys(errors).length > 0) { - toast.warning({ - title: "Please fix validation errors", - description: errors.amount || "Please enter a valid amount", - }); - formFocus.focusFirstError(); - return; - } - - dashboardUrl.setStep("review"); - window.setTimeout(() => { - document.getElementById(`vault-${activeTab}-confirm`)?.focus(); - }, 0); - }; - useEffect(() => { const handleDeposit = () => { dashboardUrl.setTab("deposit"); @@ -397,6 +372,7 @@ const VaultDashboard: React.FC = ({ title: "Please fix validation errors", description: errors.amount || "Please enter a valid amount", }); + formFocus.focusFirstError(); return; } From fcb14188b2eb9d1ba53f6765df5bc545893c8c1d Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 22:07:22 +0100 Subject: [PATCH 06/22] Fix VaultDashboard draft clear and repair merged index.css block. Co-authored-by: Cursor --- frontend/src/components/VaultDashboard.tsx | 1 + frontend/src/index.css | 3 +++ 2 files changed, 4 insertions(+) diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index d7fedff9..56e73079 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -358,6 +358,7 @@ const VaultDashboard: React.FC = ({ setValues({ amount: "" }); dashboardUrl.setStep("amount"); dashboardUrl.setAmount(""); + clearVaultFormDraft(); setTransactionResult(null); setActiveConflict(null); staleGuard.clearReviewSnapshot(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 9964776e..991cc009 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2752,6 +2752,9 @@ button { .infinite-scroll-progress { max-width: 100%; + } +} + /* ── TransactionFilterPanel ──────────────────────────────────────────────── */ .tx-filter-panel { From 579c1f6efa27ec978c25b67dd3d3ee9f5bc46c43 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Tue, 23 Jun 2026 23:17:30 +0100 Subject: [PATCH 07/22] Fix VaultDashboard merge issues, E2E fixtures, and stabilize frontend tests. Co-authored-by: Cursor --- frontend/bundle-stats.html | 2 +- frontend/e2e/fixtures.ts | 64 ++++++++++- .../src/components/OfflineBanner.test.tsx | 101 ++++++++++++------ .../TransactionConfirmationModal.test.tsx | 4 +- .../src/components/VaultDashboard.test.tsx | 75 +++++++++---- frontend/src/components/VaultDashboard.tsx | 3 + .../src/components/WalletConnect.test.tsx | 28 +++-- .../hooks/useTransactionConfirmation.test.ts | 2 +- .../src/hooks/useTransactionFilters.test.ts | 2 + frontend/src/hooks/useTransactionFilters.ts | 12 ++- frontend/src/lib/chartFormatters.test.ts | 10 +- frontend/src/lib/errorMappers.ts | 10 +- .../transactionConfirmationBuilder.test.ts | 4 +- .../src/pages/Analytics.emptystate.test.tsx | 24 +++-- frontend/src/pages/Portfolio.test.tsx | 14 +-- .../src/tests/VaultDashboardWizard.test.tsx | 81 ++++++++++++-- 16 files changed, 327 insertions(+), 109 deletions(-) diff --git a/frontend/bundle-stats.html b/frontend/bundle-stats.html index 10c10556..f21f1996 100644 --- a/frontend/bundle-stats.html +++ b/frontend/bundle-stats.html @@ -4930,7 +4930,7 @@