diff --git a/docs/TRANSACTION_TIMELINE.md b/docs/TRANSACTION_TIMELINE.md new file mode 100644 index 00000000..1a4371df --- /dev/null +++ b/docs/TRANSACTION_TIMELINE.md @@ -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. diff --git a/src/app/TransactionProgressModal.tsx b/src/app/TransactionProgressModal.tsx index 2d471096..e4f559af 100644 --- a/src/app/TransactionProgressModal.tsx +++ b/src/app/TransactionProgressModal.tsx @@ -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 = @@ -86,10 +87,50 @@ export default function TransactionProgressModal({ onRetry, onSuccessAction, }: TransactionProgressModalProps) { + const [timelinePhase, setTimelinePhase] = React.useState('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) { @@ -294,6 +335,13 @@ export default function TransactionProgressModal({ {getHelperText()}

+ + {/* Explorer Link Slot */} {txExplorerUrl && (state === 'SUCCESS' || state === 'ERROR' || state === 'PROCESSING') && (
diff --git a/src/components/transaction/TransactionStepTimeline.test.tsx b/src/components/transaction/TransactionStepTimeline.test.tsx new file mode 100644 index 00000000..0779bce4 --- /dev/null +++ b/src/components/transaction/TransactionStepTimeline.test.tsx @@ -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(); + + 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(); + + 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( + , + ); + + 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'); + }); +}); diff --git a/src/components/transaction/TransactionStepTimeline.tsx b/src/components/transaction/TransactionStepTimeline.tsx new file mode 100644 index 00000000..43bd4320 --- /dev/null +++ b/src/components/transaction/TransactionStepTimeline.tsx @@ -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 ( +
+
+
+
+

Transaction timeline

+

{activeStep.label} • {activeLabel}

+
+ {state === 'in_progress' && !reducedMotion && ( + {formatElapsed(elapsedSeconds)} + )} +
+ +
    + {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 ( +
  1. +
    + {isFailed ? '!' : isDone ? '✓' : isActive ? '•' : '•'} +
    +
    +
    +

    {step.label}

    + + {isFailed ? 'Failed' : isDone ? 'Done' : isActive ? 'Active' : 'Pending'} + +
    +

    {step.description}

    +
    +
  2. + ); + })} +
+ + {state === 'error' && txHash && ( +
+ {txHash} + +
+ )} + +

+ {state === 'error' + ? `${activeStep.label} failed.` + : state === 'success' + ? `${activeStep.label} completed.` + : `${activeStep.label} is ${activeLabel.toLowerCase()}.`} +

+
+
+ ); +}