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
21 changes: 21 additions & 0 deletions docs/TRANSACTION_TIMELINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Transaction timeline

The transaction progress modal now renders a four-step timeline that mirrors the transaction lifecycle:

1. Build — prepare the transaction payload.
2. Sign — wait for wallet confirmation.
3. Submit — broadcast the transaction to the network.
4. Confirm — wait for ledger confirmation.

## Behavior

- The active step is highlighted while the transaction is in progress.
- Completed steps are marked done.
- Failed steps remain visible so users can understand where the flow stopped.
- The active step shows an elapsed-time indicator while the modal is in progress.
- A live region announces the current phase for assistive technology.
- The timeline respects `prefers-reduced-motion` by avoiding the elapsed timer animation.

## Error state affordances

When a transaction fails, the timeline remains visible and exposes the transaction hash with a copy action so the user can preserve the reference while retrying.
48 changes: 48 additions & 0 deletions src/app/TransactionProgressModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React from 'react';
import TransactionStepTimeline, { type TransactionTimelinePhase } from '@/components/transaction/TransactionStepTimeline';
import { buildExplorerUrl, openExplorerUrl } from '@/utils/explorerLinks';

export type TransactionState =
Expand Down Expand Up @@ -86,10 +87,50 @@ export default function TransactionProgressModal({
onRetry,
onSuccessAction,
}: TransactionProgressModalProps) {
const [timelinePhase, setTimelinePhase] = React.useState<TransactionTimelinePhase>('build');

React.useEffect(() => {
if (!isOpen || state === 'IDLE') {
setTimelinePhase('build');
return;
}

if (state === 'AWAITING_SIGNATURE') {
setTimelinePhase('sign');
return;
}

if (state === 'SUBMITTING') {
setTimelinePhase('submit');
return;
}

if (state === 'PROCESSING' || state === 'SUCCESS') {
setTimelinePhase('confirm');
return;
}

if (state === 'ERROR') {
setTimelinePhase((current) => (current === 'build' ? 'sign' : current));
}
}, [isOpen, state]);

if (!isOpen || state === 'IDLE') return null;

const txExplorerUrl = buildExplorerUrl('tx', txHash);

const handleCopyHash = async (hash: string) => {
if (!hash) return;

if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(hash);
} catch {
// Ignore clipboard failures and keep the affordance available.
}
}
};

// -- State Configuration Helpers --
const getHeader = () => {
switch (state) {
Expand Down Expand Up @@ -294,6 +335,13 @@ export default function TransactionProgressModal({
{getHelperText()}
</p>

<TransactionStepTimeline
currentPhase={timelinePhase}
state={state === 'SUCCESS' ? 'success' : state === 'ERROR' ? 'error' : 'in_progress'}
txHash={txHash}
onCopyHash={handleCopyHash}
/>

{/* Explorer Link Slot */}
{txExplorerUrl && (state === 'SUCCESS' || state === 'ERROR' || state === 'PROCESSING') && (
<div className="mt-6 p-3 w-full rounded-lg bg-white/5 border border-white/5 flex items-center justify-between">
Expand Down
60 changes: 60 additions & 0 deletions src/components/transaction/TransactionStepTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment happy-dom
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import TransactionStepTimeline from './TransactionStepTimeline';

describe('TransactionStepTimeline', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});

it('renders the stepped lifecycle and marks the active step', () => {
render(<TransactionStepTimeline currentPhase="sign" state="in_progress" />);

const list = screen.getByRole('list', { name: /transaction progress/i });
expect(list).toBeInTheDocument();

const signItem = screen.getByText('Sign').closest('li');
expect(signItem).toHaveAttribute('aria-current', 'step');
expect(signItem).toHaveTextContent(/Active/i);
});

it('updates elapsed time while the active step is running', () => {
render(<TransactionStepTimeline currentPhase="submit" state="in_progress" />);

expect(screen.getByText(/elapsed 0s/i)).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(3000);
});

expect(screen.getByText(/elapsed 3s/i)).toBeInTheDocument();
});

it('renders failed state and copy action when a step fails', () => {
const onCopyHash = vi.fn();

render(
<TransactionStepTimeline
currentPhase="submit"
state="error"
txHash="abc123"
onCopyHash={onCopyHash}
/>,
);

const submitItem = screen.getByText('Submit').closest('li');
expect(submitItem).toHaveTextContent(/Failed/i);

fireEvent.click(screen.getByRole('button', { name: /copy transaction hash/i }));

expect(onCopyHash).toHaveBeenCalledWith('abc123');
});
});
170 changes: 170 additions & 0 deletions src/components/transaction/TransactionStepTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import React, { useEffect, useMemo, useState } from 'react';

export type TransactionTimelinePhase = 'build' | 'sign' | 'submit' | 'confirm';
export type TransactionTimelineStatus = 'pending' | 'active' | 'done' | 'failed';

export interface TransactionStepTimelineProps {
currentPhase: TransactionTimelinePhase;
state: 'in_progress' | 'success' | 'error';
txHash?: string;
onCopyHash?: (hash: string) => void;
}

interface TimelineStep {
key: TransactionTimelinePhase;
label: string;
description: string;
}

const STEPS: TimelineStep[] = [
{ key: 'build', label: 'Build', description: 'Preparing the transaction payload' },
{ key: 'sign', label: 'Sign', description: 'Waiting for wallet approval' },
{ key: 'submit', label: 'Submit', description: 'Sending to the network' },
{ key: 'confirm', label: 'Confirm', description: 'Waiting for ledger confirmation' },
];

function getStatus(step: TimelineStep, currentPhase: TransactionTimelinePhase, state: TransactionTimelineStatus): TransactionTimelineStatus {
if (state === 'error') {
return step.key === currentPhase ? 'failed' : step.key < currentPhase ? 'done' : 'pending';
}

if (step.key === currentPhase) {
return state === 'success' ? 'done' : 'active';
}

return step.key < currentPhase ? 'done' : 'pending';
}

function formatElapsed(seconds: number) {
return `Elapsed ${seconds}s`;
}

export default function TransactionStepTimeline({
currentPhase,
state,
txHash,
onCopyHash,
}: TransactionStepTimelineProps) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [reducedMotion, setReducedMotion] = useState(false);

const phaseOrder = useMemo(() => ({ build: 0, sign: 1, submit: 2, confirm: 3 }), []);

const activeStep = STEPS.find((step) => step.key === currentPhase) ?? STEPS[0];

useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const updatePreference = () => setReducedMotion(mediaQuery.matches);
updatePreference();
mediaQuery.addEventListener('change', updatePreference);

return () => mediaQuery.removeEventListener('change', updatePreference);
}, []);

useEffect(() => {
if (state !== 'in_progress' || reducedMotion) {
setElapsedSeconds(0);
return;
}

const interval = window.setInterval(() => {
setElapsedSeconds((value) => value + 1);
}, 1000);

return () => window.clearInterval(interval);
}, [currentPhase, state, reducedMotion]);

const statusForStep = (step: TimelineStep) => {
const currentIndex = phaseOrder[currentPhase];
const stepIndex = phaseOrder[step.key];

if (state === 'error') {
return step.key === currentPhase ? 'failed' : step.key < currentPhase ? 'done' : 'pending';
}

if (state === 'success') {
return stepIndex <= currentIndex ? 'done' : 'pending';
}

return stepIndex < currentIndex ? 'done' : stepIndex === currentIndex ? 'active' : 'pending';
};

const visibleSteps = STEPS.map((step) => ({ ...step, status: statusForStep(step) }));

const activeLabel = state === 'error' ? 'Failed' : state === 'success' ? 'Completed' : 'Active';

return (
<section className="mt-6 w-full" aria-label="Transaction progress">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">Transaction timeline</p>
<p className="text-xs text-white/60">{activeStep.label} • {activeLabel}</p>
</div>
{state === 'in_progress' && !reducedMotion && (
<span className="text-xs text-[#00C950]">{formatElapsed(elapsedSeconds)}</span>
)}
</div>

<ol className="mt-4 space-y-3" aria-label="Transaction progress">
{visibleSteps.map((step) => {
const isActive = step.status === 'active';
const isFailed = step.status === 'failed';
const isDone = step.status === 'done';
const baseClass = 'flex items-start gap-3 rounded-lg border px-3 py-3';
const statusClass = isFailed
? 'border-[#FF4757]/20 bg-[#FF4757]/10'
: isActive
? 'border-[#00C950]/25 bg-[#00C950]/10'
: isDone
? 'border-white/10 bg-white/[0.04]'
: 'border-white/10 bg-transparent';

return (
<li
key={step.key}
className={`${baseClass} ${statusClass}`}
aria-current={isActive ? 'step' : undefined}
>
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#121212] text-sm font-semibold text-white">
{isFailed ? '!' : isDone ? '✓' : isActive ? '•' : '•'}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-white">{step.label}</p>
<span className="text-[11px] uppercase tracking-[0.2em] text-white/60">
{isFailed ? 'Failed' : isDone ? 'Done' : isActive ? 'Active' : 'Pending'}
</span>
</div>
<p className="mt-1 text-xs text-white/60">{step.description}</p>
</div>
</li>
);
})}
</ol>

{state === 'error' && txHash && (
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2">
<span className="truncate text-xs font-mono text-white/60">{txHash}</span>
<button
type="button"
onClick={() => onCopyHash?.(txHash)}
className="text-xs font-medium text-[#00C950]"
aria-label="Copy transaction hash"
>
Copy hash
</button>
</div>
)}

<p className="sr-only" aria-live="polite">
{state === 'error'
? `${activeStep.label} failed.`
: state === 'success'
? `${activeStep.label} completed.`
: `${activeStep.label} is ${activeLabel.toLowerCase()}.`}
</p>
</div>
</section>
);
}
Loading