From ef9bb81f82f3f369f8ad2a55ad7d96188b5d4e24 Mon Sep 17 00:00:00 2001 From: Saber1Y Date: Thu, 26 Mar 2026 16:35:23 +0100 Subject: [PATCH 1/3] feat(frontend): add transaction pending overlay for Stellar broadcasts - Created TransactionPendingOverlay component with pending/success/error states - Integrated overlay into PayrollScheduler for claimable balance broadcasts - Integrated overlay into CrossAssetPayment for cross-asset settlements - Added unit tests for the overlay component - Overlay is non-blocking with Stellar-themed design and tx hash explorer links Closes #162 --- .../TransactionPendingOverlay.test.tsx | 111 ++++++++++++ .../components/TransactionPendingOverlay.tsx | 167 ++++++++++++++++++ frontend/src/pages/CrossAssetPayment.tsx | 19 ++ frontend/src/pages/PayrollScheduler.tsx | 28 +++ 4 files changed, 325 insertions(+) create mode 100644 frontend/src/__tests__/TransactionPendingOverlay.test.tsx create mode 100644 frontend/src/components/TransactionPendingOverlay.tsx diff --git a/frontend/src/__tests__/TransactionPendingOverlay.test.tsx b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx new file mode 100644 index 00000000..c98f7e3b --- /dev/null +++ b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx @@ -0,0 +1,111 @@ +/** + * Unit Tests for TransactionPendingOverlay Component + */ + +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TransactionPendingOverlay } from '../components/TransactionPendingOverlay'; + +describe('TransactionPendingOverlay', () => { + test('renders nothing when isVisible is false', () => { + render(); + expect(screen.queryByText('Broadcasted to Stellar')).not.toBeInTheDocument(); + }); + + test('displays pending state with default message', () => { + render(); + + expect(screen.getByText('Broadcasted to Stellar')).toBeInTheDocument(); + expect( + screen.getByText('Your transaction is being processed on-chain. This may take a few seconds.') + ).toBeInTheDocument(); + expect(screen.getByText('Settling on Stellar network...')).toBeInTheDocument(); + }); + + test('displays pending state with custom message', () => { + render( + + ); + + expect(screen.getByText('Custom Pending Message')).toBeInTheDocument(); + expect(screen.getByText('Custom sub message')).toBeInTheDocument(); + }); + + test('displays success state with default message', () => { + render(); + + expect(screen.getByText('Transaction Confirmed')).toBeInTheDocument(); + expect( + screen.getByText('Your transaction has been successfully processed.') + ).toBeInTheDocument(); + }); + + test('displays success state with txHash and explorer link', () => { + const txHash = 'abc123def456789'; + render(); + + expect(screen.getByText('Transaction Confirmed')).toBeInTheDocument(); + expect(screen.getByText(/abc123def...456789/)).toBeInTheDocument(); + + const explorerLink = screen.getByLabelText('View transaction on explorer'); + expect(explorerLink).toBeInTheDocument(); + expect(explorerLink).toHaveAttribute('href'); + }); + + test('displays error state with default message', () => { + render(); + + expect(screen.getByText('Transaction Failed')).toBeInTheDocument(); + expect(screen.getByText('There was an issue processing your transaction.')).toBeInTheDocument(); + }); + + test('shows dismiss button on success state', () => { + const onDismiss = vi.fn(); + render(); + + const dismissButton = screen.getByText('Dismiss'); + expect(dismissButton).toBeInTheDocument(); + + fireEvent.click(dismissButton); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + test('shows dismiss button on error state', () => { + const onDismiss = vi.fn(); + render(); + + const dismissButton = screen.getByText('Dismiss'); + expect(dismissButton).toBeInTheDocument(); + + fireEvent.click(dismissButton); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + test('does not show dismiss button during pending state', () => { + const onDismiss = vi.fn(); + render(); + + expect(screen.queryByText('Dismiss')).not.toBeInTheDocument(); + }); + + test('has proper accessibility attributes', () => { + render(); + + const overlay = screen.getByRole('dialog'); + expect(overlay).toBeInTheDocument(); + expect(overlay).toHaveAttribute('aria-live', 'polite'); + }); + + test('truncates long txHash correctly', () => { + const longTxHash = 'a'.repeat(64); + render(); + + const truncatedHash = screen.getByText(/^aaaaaaaaaaaa/); + expect(truncatedHash).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/TransactionPendingOverlay.tsx b/frontend/src/components/TransactionPendingOverlay.tsx new file mode 100644 index 00000000..f32821f6 --- /dev/null +++ b/frontend/src/components/TransactionPendingOverlay.tsx @@ -0,0 +1,167 @@ +import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +type OverlayStatus = 'pending' | 'success' | 'error'; + +interface TransactionPendingOverlayProps { + isVisible: boolean; + status?: OverlayStatus; + txHash?: string; + message?: string; + subMessage?: string; + onDismiss?: () => void; +} + +const explorerBase = + (import.meta.env.VITE_STELLAR_EXPLORER_TX_URL as string | undefined) || + 'https://stellar.expert/explorer/testnet/tx/'; + +export function TransactionPendingOverlay({ + isVisible, + status = 'pending', + txHash, + message, + subMessage, + onDismiss, +}: TransactionPendingOverlayProps) { + const defaultMessages = { + pending: { + title: 'Broadcasted to Stellar', + subtitle: 'Your transaction is being processed on-chain. This may take a few seconds.', + }, + success: { + title: 'Transaction Confirmed', + subtitle: 'Your transaction has been successfully processed.', + }, + error: { + title: 'Transaction Failed', + subtitle: 'There was an issue processing your transaction.', + }, + }; + + const content = { + title: message || defaultMessages[status].title, + subtitle: subMessage || defaultMessages[status].subtitle, + }; + + return ( + + {isVisible && ( + + e.stopPropagation()} + > +
+ +
+
+ {status === 'pending' && ( +
+ +
+ )} + {status === 'success' && ( +
+ +
+ )} + {status === 'error' && ( +
+ +
+ )} + +
+ + + + +
+
+ +

{content.title}

+

{content.subtitle}

+ + {txHash && ( +
+

+ Transaction Hash +

+
+ + {txHash.slice(0, 12)}...{txHash.slice(-8)} + + + + + + + + +
+
+ )} + + {status === 'pending' && ( +
+
+ Settling on Stellar network... +
+ )} + + {(status === 'success' || status === 'error') && onDismiss && ( + + )} +
+ + + )} + + ); +} + +export default TransactionPendingOverlay; diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index ba2579a3..deba325a 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -13,6 +13,9 @@ import { } from '../services/crossAssetPayment'; import { ContractErrorPanel } from '../components/ContractErrorPanel'; import { parseContractError, type ContractErrorDetail } from '../utils/contractErrorParser'; +import { TransactionPendingOverlay } from '../components/TransactionPendingOverlay'; + +type OverlayStatus = 'pending' | 'success' | 'error'; export default function CrossAssetPayment() { const { notifyError, notifyPaymentSuccess, notifyPaymentFailure, notifyApiError } = @@ -31,6 +34,8 @@ export default function CrossAssetPayment() { const [liveStatusMessage, setLiveStatusMessage] = useState('Waiting for submission...'); const [status, setStatus] = useState('idle'); const [contractError, setContractError] = useState(null); + const [overlayVisible, setOverlayVisible] = useState(false); + const [overlayStatus, setOverlayStatus] = useState('pending'); const selectedPath = useMemo( () => paths.find((path) => path.id === selectedPathId) || null, @@ -89,7 +94,11 @@ export default function CrossAssetPayment() { setStatus(nextStatus); setLiveStatusMessage(`Live update: ${nextStatus}`); if (nextStatus === 'completed' || nextStatus === 'confirmed') { + setOverlayStatus('success'); notifyPaymentSuccess(txHash, 'Cross-asset payment completed'); + setTimeout(() => { + setOverlayVisible(false); + }, 3000); } }; @@ -122,6 +131,8 @@ export default function CrossAssetPayment() { setStatus('submitting'); setContractError(null); + setOverlayVisible(true); + setOverlayStatus('pending'); try { await contractService.initialize(); const contractId = @@ -148,6 +159,7 @@ export default function CrossAssetPayment() { notifyPaymentSuccess(result.txHash, 'Payment submitted'); } catch (error) { setStatus('error'); + setOverlayStatus('error'); const parsed = parseContractError( undefined, error instanceof Error ? error.message : 'An unexpected error occurred.' @@ -418,6 +430,13 @@ export default function CrossAssetPayment() {
+ + setOverlayVisible(false)} + /> ); } diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 1452a7bb..56fe0e44 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -11,6 +11,7 @@ import { Card, Heading, Text, Button, Input, Select } from '@stellar/design-syst import { SchedulingWizard } from '../components/SchedulingWizard'; import { CountdownTimer } from '../components/CountdownTimer'; import { BulkPaymentStatusTracker } from '../components/BulkPaymentStatusTracker'; +import { TransactionPendingOverlay } from '../components/TransactionPendingOverlay'; import { ContractErrorPanel } from '../components/ContractErrorPanel'; import { parseContractError, type ContractErrorDetail } from '../utils/contractErrorParser'; @@ -126,6 +127,8 @@ interface PendingClaim { status: string; } +type OverlayStatus = 'pending' | 'success' | 'error'; + // Mock employer secret key for simulation purposes const MOCK_EMPLOYER_SECRET = 'SD3X5K7G7XV4K5V3M2G5QXH434M3VX6O5P3QVQO3L2PQSQQQQQQQQQQQ'; @@ -145,6 +148,10 @@ export default function PayrollScheduler() { const [formData, setFormData] = useState(initialFormState); const [isBroadcasting, setIsBroadcasting] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); + + const [overlayVisible, setOverlayVisible] = useState(false); + const [overlayStatus, setOverlayStatus] = useState('pending'); + const [overlayTxHash, setOverlayTxHash] = useState(); const [activeSchedule, setActiveSchedule] = useState(null); const [nextRunDate, setNextRunDate] = useState(null); const [contractError, setContractError] = useState(null); @@ -280,6 +287,10 @@ export default function PayrollScheduler() { const handleBroadcast = async () => { setIsBroadcasting(true); setContractError(null); + setOverlayVisible(true); + setOverlayStatus('pending'); + setOverlayTxHash(undefined); + try { const mockRecipientPublicKey = generateWallet().publicKey; @@ -315,6 +326,15 @@ export default function PayrollScheduler() { // Subscribe to updates for this new claim subscribeToTransaction(newClaim.id); + // Show success overlay + setOverlayStatus('success'); + setOverlayTxHash(result.txHash); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + setOverlayVisible(false); + }, 3000); + notifySuccess( 'Broadcast successful!', `Claimable balance created for ${formData.employeeName}` @@ -353,6 +373,7 @@ export default function PayrollScheduler() { ); setContractError(parsed); notifyPaymentFailure(parsed.message); + setOverlayStatus('error'); } finally { setIsBroadcasting(false); } @@ -646,6 +667,13 @@ export default function PayrollScheduler() {
+ + setOverlayVisible(false)} + /> ); } From 655ae93ae3fce604d82f507a1522a00e4f5be9f0 Mon Sep 17 00:00:00 2001 From: Saber1Y Date: Fri, 27 Mar 2026 09:21:48 +0100 Subject: [PATCH 2/3] fix(frontend): generate mock txHash for overlay since service returns simulated data --- frontend/src/pages/PayrollScheduler.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 56fe0e44..33c6a2ea 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -328,7 +328,8 @@ export default function PayrollScheduler() { // Show success overlay setOverlayStatus('success'); - setOverlayTxHash(result.txHash); + const mockTxHash = `broadcast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + setOverlayTxHash(mockTxHash); // Auto-dismiss after 3 seconds setTimeout(() => { From 91ce0b01cf1cda89075a56bad117d96f55def6e4 Mon Sep 17 00:00:00 2001 From: Saber1Y Date: Mon, 30 Mar 2026 12:55:53 +0100 Subject: [PATCH 3/3] fix(tests): fix TransactionPendingOverlay tests for txHash and accessibility - Updated test to check explorer link href contains txHash - Added role="dialog" for proper accessibility - Fixed txHash display test to work with CSS break-all --- frontend/src/__tests__/TransactionPendingOverlay.test.tsx | 6 ++++-- frontend/src/components/TransactionPendingOverlay.tsx | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/TransactionPendingOverlay.test.tsx b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx index c98f7e3b..fa55afec 100644 --- a/frontend/src/__tests__/TransactionPendingOverlay.test.tsx +++ b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx @@ -50,11 +50,12 @@ describe('TransactionPendingOverlay', () => { render(); expect(screen.getByText('Transaction Confirmed')).toBeInTheDocument(); - expect(screen.getByText(/abc123def...456789/)).toBeInTheDocument(); const explorerLink = screen.getByLabelText('View transaction on explorer'); expect(explorerLink).toBeInTheDocument(); - expect(explorerLink).toHaveAttribute('href'); + expect(explorerLink).toHaveAttribute('href', expect.stringContaining(txHash)); + + expect(screen.getByText('Transaction Hash')).toBeInTheDocument(); }); test('displays error state with default message', () => { @@ -99,6 +100,7 @@ describe('TransactionPendingOverlay', () => { const overlay = screen.getByRole('dialog'); expect(overlay).toBeInTheDocument(); expect(overlay).toHaveAttribute('aria-live', 'polite'); + expect(overlay).toHaveAttribute('aria-label', 'Broadcasted to Stellar'); }); test('truncates long txHash correctly', () => { diff --git a/frontend/src/components/TransactionPendingOverlay.tsx b/frontend/src/components/TransactionPendingOverlay.tsx index f32821f6..60485c55 100644 --- a/frontend/src/components/TransactionPendingOverlay.tsx +++ b/frontend/src/components/TransactionPendingOverlay.tsx @@ -53,6 +53,7 @@ export function TransactionPendingOverlay({ exit={{ opacity: 0 }} transition={{ duration: 0.2 }} className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm pointer-events-none" + role="dialog" aria-live="polite" aria-label={content.title} >