From f979031d26a982a0f5ce291a271aea5e0c7fbc73 Mon Sep 17 00:00:00 2001 From: faveteamz Date: Mon, 1 Jun 2026 11:57:27 +0000 Subject: [PATCH] feat: add transaction timeline component for pending-to-finalized transitions (#543) - Add TransactionTimeline component with step-based state machine - Add useTransactionTimeline hook polling Horizon API every 3s - Integrate PendingTimelinePanel into TransactionHistory page - Add i18n keys (en + es) for all timeline strings - Add 9 unit tests (all passing) --- .../components/TransactionTimeline.test.tsx | 82 +++++++ .../src/components/TransactionTimeline.tsx | 216 ++++++++++++++++++ frontend/src/hooks/useTransactionTimeline.ts | 138 +++++++++++ frontend/src/i18n/locales/en.ts | 22 ++ frontend/src/i18n/locales/es.ts | 22 ++ frontend/src/pages/TransactionHistory.tsx | 200 +++++++++++----- 6 files changed, 617 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/TransactionTimeline.test.tsx create mode 100644 frontend/src/components/TransactionTimeline.tsx create mode 100644 frontend/src/hooks/useTransactionTimeline.ts diff --git a/frontend/src/components/TransactionTimeline.test.tsx b/frontend/src/components/TransactionTimeline.test.tsx new file mode 100644 index 00000000..58eebe82 --- /dev/null +++ b/frontend/src/components/TransactionTimeline.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import TransactionTimeline from "./TransactionTimeline"; + +describe("TransactionTimeline", () => { + it("renders aria label", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("shows Submitted step as active when pending", () => { + render(); + expect(screen.getByText("Submitted")).toBeInTheDocument(); + // elapsed seconds shown next to active step + expect(screen.getByText("5s")).toBeInTheDocument(); + }); + + it("shows Confirming step as active when confirming", () => { + render(); + expect(screen.getByText("Confirming")).toBeInTheDocument(); + expect(screen.getByText("12s")).toBeInTheDocument(); + }); + + it("shows Finalized step and explorer link when finalized", () => { + render( + , + ); + // Two "Finalized" labels: one in the steps list (completed) and one in the final row + const finalizedLabels = screen.getAllByText("Finalized"); + expect(finalizedLabels.length).toBeGreaterThanOrEqual(1); + + const explorerLink = screen.getByRole("link", { name: /View on Stellar Explorer/i }); + expect(explorerLink).toHaveAttribute("href", expect.stringContaining("abc123")); + expect(explorerLink).toHaveAttribute("target", "_blank"); + expect(explorerLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("does not render explorer link when finalized without txHash", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("shows Failed step with default error description", () => { + render(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect( + screen.getByText("Transaction was not accepted by the network."), + ).toBeInTheDocument(); + }); + + it("shows custom errorMessage when failed", () => { + render( + , + ); + const matches = screen.getAllByText("Transaction timed out."); + expect(matches.length).toBeGreaterThanOrEqual(1); + }); + + it("does not show elapsed seconds when not provided", () => { + render(); + // No "Xs" elapsed time text + expect(screen.queryByText(/^\d+s$/)).not.toBeInTheDocument(); + }); + + it("marks Submitted as completed when status is confirming", () => { + const { container } = render(); + // The Submitted step dot should use the green completed color + const dots = container.querySelectorAll('[style*="border"]'); + // At least one dot should have the green accent color + const greenDot = Array.from(dots).find((el) => + (el as HTMLElement).style.borderColor?.includes("var(--accent-green)") || + (el as HTMLElement).getAttribute("style")?.includes("var(--accent-green)"), + ); + expect(greenDot).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/TransactionTimeline.tsx b/frontend/src/components/TransactionTimeline.tsx new file mode 100644 index 00000000..9f699902 --- /dev/null +++ b/frontend/src/components/TransactionTimeline.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import { Check, AlertCircle, Loader2 } from "./icons"; +import { useTranslation } from "../i18n"; + +export type TxTimelineStatus = "pending" | "confirming" | "finalized" | "failed"; + +export interface TransactionTimelineProps { + status: TxTimelineStatus; + txHash?: string; + /** Elapsed seconds since the transaction was submitted */ + elapsedSeconds?: number; + /** Error message shown when status is "failed" */ + errorMessage?: string; +} + +interface Step { + key: TxTimelineStatus; + labelKey: string; + descKey: string; +} + +const STEPS: Step[] = [ + { key: "pending", labelKey: "txTimeline.steps.pending.label", descKey: "txTimeline.steps.pending.desc" }, + { key: "confirming", labelKey: "txTimeline.steps.confirming.label", descKey: "txTimeline.steps.confirming.desc" }, + { key: "finalized", labelKey: "txTimeline.steps.finalized.label", descKey: "txTimeline.steps.finalized.desc" }, +]; + +const STATUS_ORDER: Record = { + pending: 0, + confirming: 1, + finalized: 2, + failed: 2, +}; + +type StepState = "completed" | "active" | "failed" | "upcoming"; + +function getStepState( + stepKey: TxTimelineStatus, + currentStatus: TxTimelineStatus, + stepIndex: number, +): StepState { + if (currentStatus === "failed" && stepIndex === STATUS_ORDER.confirming) return "failed"; + const currentOrder = STATUS_ORDER[currentStatus]; + const stepOrder = STATUS_ORDER[stepKey]; + if (stepOrder < currentOrder) return "completed"; + if (stepOrder === currentOrder) return "active"; + return "upcoming"; +} + +const STEP_COLORS: Record = { + completed: { dot: "var(--accent-green)", line: "rgba(34, 197, 94, 0.4)", text: "var(--accent-green)" }, + active: { dot: "var(--accent-cyan)", line: "rgba(0, 240, 255, 0.2)", text: "var(--accent-cyan)" }, + failed: { dot: "var(--text-error)", line: "rgba(255, 107, 107, 0.3)", text: "var(--text-error)" }, + upcoming: { dot: "var(--text-tertiary)", line: "var(--border-glass)", text: "var(--text-tertiary)" }, +}; + +function StepIcon({ state }: { state: StepState }) { + if (state === "completed") return ; + if (state === "failed") return ; + if (state === "active") return ; + return null; +} + +const TransactionTimeline: React.FC = ({ + status, + txHash, + elapsedSeconds, + errorMessage, +}) => { + const { t } = useTranslation(); + + const steps = status === "failed" + ? STEPS.slice(0, 2) // pending + confirming (failed at confirming) + : STEPS; + + return ( +
+ {steps.map((step, index) => { + const stepState = getStepState(step.key, status, index); + const colors = STEP_COLORS[stepState]; + const isLast = index === steps.length - 1; + + return ( +
+ {/* Connector column */} +
+
+ +
+ {!isLast && ( +
+ )} +
+ + {/* Content */} +
+
+ + {t(step.labelKey)} + + {stepState === "active" && elapsedSeconds !== undefined && ( + + {elapsedSeconds}s + + )} +
+

+ {stepState === "failed" && errorMessage + ? errorMessage + : t(step.descKey)} +

+
+
+ ); + })} + + {/* Final state row for finalized/failed */} + {(status === "finalized" || status === "failed") && ( +
+
+
+ {status === "finalized" ? : } +
+
+
+ + {t(status === "finalized" ? "txTimeline.steps.finalized.label" : "txTimeline.steps.failed.label")} + +

+ {status === "failed" && errorMessage + ? errorMessage + : t(status === "finalized" ? "txTimeline.steps.finalized.desc" : "txTimeline.steps.failed.desc")} +

+ {status === "finalized" && txHash && ( + + {t("txTimeline.viewOnExplorer")} ↗ + + )} +
+
+ )} +
+ ); +}; + +export default TransactionTimeline; diff --git a/frontend/src/hooks/useTransactionTimeline.ts b/frontend/src/hooks/useTransactionTimeline.ts new file mode 100644 index 00000000..61667be1 --- /dev/null +++ b/frontend/src/hooks/useTransactionTimeline.ts @@ -0,0 +1,138 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import type { TxTimelineStatus } from "../components/TransactionTimeline"; + +const HORIZON_BASE = "https://horizon-testnet.stellar.org"; +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_ATTEMPTS = 40; // ~2 minutes + +interface HorizonTxResponse { + hash: string; + successful: boolean; +} + +interface UseTransactionTimelineOptions { + /** Stellar transaction hash to track. Pass null/undefined to disable. */ + txHash: string | null | undefined; + /** Called when the transaction reaches a terminal state. */ + onFinalized?: (success: boolean) => void; +} + +interface UseTransactionTimelineResult { + status: TxTimelineStatus; + elapsedSeconds: number; + errorMessage: string | undefined; + /** Manually reset to re-track a new transaction */ + reset: () => void; +} + +async function fetchTxStatus(hash: string): Promise<"finalized" | "failed" | "pending"> { + const res = await fetch(`${HORIZON_BASE}/transactions/${hash}`); + if (res.status === 404) return "pending"; + if (!res.ok) throw new Error(`Horizon error: ${res.status}`); + const data = (await res.json()) as HorizonTxResponse; + return data.successful ? "finalized" : "failed"; +} + +export function useTransactionTimeline({ + txHash, + onFinalized, +}: UseTransactionTimelineOptions): UseTransactionTimelineResult { + const [status, setStatus] = useState("pending"); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [errorMessage, setErrorMessage] = useState(); + + const attemptsRef = useRef(0); + const startTimeRef = useRef(Date.now()); + const onFinalizedRef = useRef(onFinalized); + const isTerminalRef = useRef(false); + + useEffect(() => { + onFinalizedRef.current = onFinalized; + }, [onFinalized]); + + const reset = useCallback(() => { + setStatus("pending"); + setElapsedSeconds(0); + setErrorMessage(undefined); + attemptsRef.current = 0; + startTimeRef.current = Date.now(); + isTerminalRef.current = false; + }, []); + + // Elapsed seconds ticker + useEffect(() => { + if (!txHash || isTerminalRef.current) return; + + startTimeRef.current = Date.now(); + const ticker = setInterval(() => { + setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000)); + }, 1000); + + return () => clearInterval(ticker); + }, [txHash]); + + // Polling loop + useEffect(() => { + if (!txHash) return; + + isTerminalRef.current = false; + attemptsRef.current = 0; + setStatus("pending"); + setErrorMessage(undefined); + + let timeoutId: ReturnType; + + async function poll() { + if (isTerminalRef.current) return; + + attemptsRef.current += 1; + + // Transition to "confirming" after first attempt + if (attemptsRef.current === 2) { + setStatus("confirming"); + } + + if (attemptsRef.current > MAX_POLL_ATTEMPTS) { + isTerminalRef.current = true; + setStatus("failed"); + setErrorMessage("Transaction timed out. It may still confirm on-chain."); + onFinalizedRef.current?.(false); + return; + } + + try { + const result = await fetchTxStatus(txHash as string); + + if (result === "finalized") { + isTerminalRef.current = true; + setStatus("finalized"); + onFinalizedRef.current?.(true); + return; + } + + if (result === "failed") { + isTerminalRef.current = true; + setStatus("failed"); + setErrorMessage("Transaction was rejected by the network."); + onFinalizedRef.current?.(false); + return; + } + + // Still pending — schedule next poll + timeoutId = setTimeout(() => { void poll(); }, POLL_INTERVAL_MS); + } catch { + // Network error — keep polling + timeoutId = setTimeout(() => { void poll(); }, POLL_INTERVAL_MS); + } + } + + void poll(); + + return () => { + clearTimeout(timeoutId); + isTerminalRef.current = true; + }; + }, [txHash]); + + return { status, elapsedSeconds, errorMessage, reset }; +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e418b2c6..1c422a0d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -132,6 +132,28 @@ export const en = { common: { dismiss: "Dismiss", }, + txTimeline: { + ariaLabel: "Transaction status timeline", + viewOnExplorer: "View on Stellar Explorer", + steps: { + pending: { + label: "Submitted", + desc: "Transaction sent to the Stellar network.", + }, + confirming: { + label: "Confirming", + desc: "Waiting for ledger inclusion…", + }, + finalized: { + label: "Finalized", + desc: "Transaction confirmed on-chain.", + }, + failed: { + label: "Failed", + desc: "Transaction was not accepted by the network.", + }, + }, + }, commands: { goToVaults: "Go to Vaults", goToPortfolio: "Go to Portfolio", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 3f6925f8..a88e1dc7 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -130,6 +130,28 @@ export const es = { common: { dismiss: "Descartar", }, + txTimeline: { + ariaLabel: "Línea de tiempo del estado de la transacción", + viewOnExplorer: "Ver en Stellar Explorer", + steps: { + pending: { + label: "Enviada", + desc: "Transacción enviada a la red Stellar.", + }, + confirming: { + label: "Confirmando", + desc: "Esperando inclusión en el ledger…", + }, + finalized: { + label: "Finalizada", + desc: "Transacción confirmada en la cadena.", + }, + failed: { + label: "Fallida", + desc: "La red no aceptó la transacción.", + }, + }, + }, commands: { goToVaults: "Ir a Bóvedas", goToPortfolio: "Ir a Portafolio", diff --git a/frontend/src/pages/TransactionHistory.tsx b/frontend/src/pages/TransactionHistory.tsx index 14e5d0ca..9f86c749 100644 --- a/frontend/src/pages/TransactionHistory.tsx +++ b/frontend/src/pages/TransactionHistory.tsx @@ -5,8 +5,10 @@ import Badge from "../components/Badge"; import { DataTable, type DataTableColumn } from "../components/DataTable"; import PageHeader from "../components/PageHeader"; import TransactionFilterPanel from "../components/TransactionFilterPanel"; +import TransactionTimeline from "../components/TransactionTimeline"; import EmptyState from "../components/ui/EmptyState"; import { Activity, Loader2 } from "../components/icons"; +import { useTransactionTimeline } from "../hooks/useTransactionTimeline"; import { normalizeApiError, isValidationError, @@ -94,69 +96,52 @@ const STATUS_COLOR_MAP: Record[] = [ - { - id: "type", - header: "Type", - sortable: true, - cell: (row) => ( - - {row.type} - - ), - }, - { - id: "status", - header: "Status", - sortable: true, - cell: (row) => ( - : undefined} - > - {row.status} - - ), - }, - { - id: "amount", - header: "Amount", - sortable: true, - cell: (row) => {formatAmount(row.amount, row.asset)}, - }, - { - id: "asset", - header: "Asset", - sortable: false, - cell: (row) => {row.asset ?? "—"}, - }, - { - id: "date", - header: "Date", - sortable: true, - cell: (row) => {formatTimestamp(row.timestamp)}, - }, - { - id: "hash", - header: "Transaction Hash", - sortable: false, - cell: (row) => ( - - {truncateHash(row.transactionHash)} - - ), - }, -]; +/** Inline panel shown below a pending transaction row to track its live state. */ +const PendingTimelinePanel: React.FC<{ txHash: string; onDismiss: () => void }> = ({ + txHash, + onDismiss, +}) => { + const { status, elapsedSeconds, errorMessage } = useTransactionTimeline({ txHash }); + + return ( +
+
+ + Live Status + + +
+ +
+ ); +}; const TransactionHistory: React.FC = ({ walletAddress, @@ -165,6 +150,87 @@ const TransactionHistory: React.FC = ({ const delayedLoading = useDelayedLoading(isLoading); const transactions = queryTransactions ?? []; + const [selectedPendingHash, setSelectedPendingHash] = useState(null); + + const columns: DataTableColumn[] = React.useMemo(() => [ + { + id: "type", + header: "Type", + sortable: true, + cell: (row) => ( + + {row.type} + + ), + }, + { + id: "status", + header: "Status", + sortable: true, + cell: (row) => ( + + ), + }, + { + id: "amount", + header: "Amount", + sortable: true, + cell: (row) => {formatAmount(row.amount, row.asset)}, + }, + { + id: "asset", + header: "Asset", + sortable: false, + cell: (row) => {row.asset ?? "—"}, + }, + { + id: "date", + header: "Date", + sortable: true, + cell: (row) => {formatTimestamp(row.timestamp)}, + }, + { + id: "hash", + header: "Transaction Hash", + sortable: false, + cell: (row) => ( + + {truncateHash(row.transactionHash)} + + ), + }, + ], [selectedPendingHash]); + const error = queryError ? (isValidationError(queryError) ? queryError : normalizeApiError(queryError)) : null; @@ -642,6 +708,14 @@ const TransactionHistory: React.FC = ({ onPageChange={setPage} /> )} + + {/* Live timeline for selected pending transaction */} + {selectedPendingHash && ( + setSelectedPendingHash(null)} + /> + )}
)}