Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions frontend/src/components/TransactionTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TransactionTimeline status="pending" />);
expect(screen.getByRole("status")).toBeInTheDocument();
});

it("shows Submitted step as active when pending", () => {
render(<TransactionTimeline status="pending" elapsedSeconds={5} />);
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(<TransactionTimeline status="confirming" elapsedSeconds={12} />);
expect(screen.getByText("Confirming")).toBeInTheDocument();
expect(screen.getByText("12s")).toBeInTheDocument();
});

it("shows Finalized step and explorer link when finalized", () => {
render(
<TransactionTimeline
status="finalized"
txHash="abc123"
/>,
);
// 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(<TransactionTimeline status="finalized" />);
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});

it("shows Failed step with default error description", () => {
render(<TransactionTimeline status="failed" />);
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(
screen.getByText("Transaction was not accepted by the network."),
).toBeInTheDocument();
});

it("shows custom errorMessage when failed", () => {
render(
<TransactionTimeline
status="failed"
errorMessage="Transaction timed out."
/>,
);
const matches = screen.getAllByText("Transaction timed out.");
expect(matches.length).toBeGreaterThanOrEqual(1);
});

it("does not show elapsed seconds when not provided", () => {
render(<TransactionTimeline status="pending" />);
// No "Xs" elapsed time text
expect(screen.queryByText(/^\d+s$/)).not.toBeInTheDocument();
});

it("marks Submitted as completed when status is confirming", () => {
const { container } = render(<TransactionTimeline status="confirming" />);
// 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();
});
});
216 changes: 216 additions & 0 deletions frontend/src/components/TransactionTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<TxTimelineStatus, number> = {
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<StepState, { dot: string; line: string; text: string }> = {
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 <Check size={14} />;
if (state === "failed") return <AlertCircle size={14} />;
if (state === "active") return <Loader2 size={14} className="spin" style={{ animation: "spin 0.9s linear infinite" }} />;
return null;
}

const TransactionTimeline: React.FC<TransactionTimelineProps> = ({
status,
txHash,
elapsedSeconds,
errorMessage,
}) => {
const { t } = useTranslation();

const steps = status === "failed"
? STEPS.slice(0, 2) // pending + confirming (failed at confirming)
: STEPS;

return (
<div
role="status"
aria-label={t("txTimeline.ariaLabel")}
aria-live="polite"
style={{ padding: "4px 0" }}
>
{steps.map((step, index) => {
const stepState = getStepState(step.key, status, index);
const colors = STEP_COLORS[stepState];
const isLast = index === steps.length - 1;

return (
<div key={step.key} style={{ display: "flex", gap: "14px" }}>
{/* Connector column */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
<div
style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: "var(--bg-surface)",
border: `2px solid ${colors.dot}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: colors.dot,
flexShrink: 0,
transition: "border-color 0.3s, color 0.3s",
}}
>
<StepIcon state={stepState} />
</div>
{!isLast && (
<div
style={{
width: "2px",
flex: 1,
minHeight: "20px",
background: colors.line,
transition: "background 0.3s",
}}
/>
)}
</div>

{/* Content */}
<div style={{ flex: 1, paddingBottom: isLast ? 0 : "20px" }}>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", marginBottom: "2px" }}>
<span
style={{
fontWeight: 600,
fontSize: "var(--text-sm)",
color: stepState === "upcoming" ? "var(--text-tertiary)" : "var(--text-primary)",
transition: "color 0.3s",
}}
>
{t(step.labelKey)}
</span>
{stepState === "active" && elapsedSeconds !== undefined && (
<span style={{ fontSize: "var(--text-xs)", color: "var(--text-tertiary)" }}>
{elapsedSeconds}s
</span>
)}
</div>
<p
style={{
margin: 0,
fontSize: "var(--text-xs)",
color: "var(--text-tertiary)",
lineHeight: "var(--leading-relaxed)",
}}
>
{stepState === "failed" && errorMessage
? errorMessage
: t(step.descKey)}
</p>
</div>
</div>
);
})}

{/* Final state row for finalized/failed */}
{(status === "finalized" || status === "failed") && (
<div style={{ display: "flex", gap: "14px" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
<div
style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: status === "finalized" ? "rgba(34, 197, 94, 0.15)" : "var(--bg-error)",
border: `2px solid ${status === "finalized" ? "var(--accent-green)" : "var(--text-error)"}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: status === "finalized" ? "var(--accent-green)" : "var(--text-error)",
}}
>
{status === "finalized" ? <Check size={14} /> : <AlertCircle size={14} />}
</div>
</div>
<div style={{ flex: 1 }}>
<span
style={{
fontWeight: 600,
fontSize: "var(--text-sm)",
color: status === "finalized" ? "var(--accent-green)" : "var(--text-error)",
}}
>
{t(status === "finalized" ? "txTimeline.steps.finalized.label" : "txTimeline.steps.failed.label")}
</span>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--text-tertiary)" }}>
{status === "failed" && errorMessage
? errorMessage
: t(status === "finalized" ? "txTimeline.steps.finalized.desc" : "txTimeline.steps.failed.desc")}
</p>
{status === "finalized" && txHash && (
<a
href={`https://stellar.expert/explorer/testnet/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: "6px",
fontSize: "var(--text-xs)",
color: "var(--accent-cyan)",
textDecoration: "none",
}}
>
{t("txTimeline.viewOnExplorer")} ↗
</a>
)}
</div>
</div>
)}
</div>
);
};

export default TransactionTimeline;
Loading