+ {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