diff --git a/frontend/src/__tests__/TransactionPendingOverlay.test.tsx b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx new file mode 100644 index 0000000..fa55afe --- /dev/null +++ b/frontend/src/__tests__/TransactionPendingOverlay.test.tsx @@ -0,0 +1,113 @@ +/** + * 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(); + + const explorerLink = screen.getByLabelText('View transaction on explorer'); + expect(explorerLink).toBeInTheDocument(); + expect(explorerLink).toHaveAttribute('href', expect.stringContaining(txHash)); + + expect(screen.getByText('Transaction Hash')).toBeInTheDocument(); + }); + + 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'); + expect(overlay).toHaveAttribute('aria-label', 'Broadcasted to Stellar'); + }); + + test('truncates long txHash correctly', () => { + const longTxHash = 'a'.repeat(64); + render(); + + const truncatedHash = screen.getByText(/^aaaaaaaaaaaa/); + expect(truncatedHash).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index b078a75..05edd3a 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -14,6 +14,9 @@ import { import { ContractErrorPanel } from '../components/ContractErrorPanel'; import { IssuerMultisigBanner } from '../components/IssuerMultisigBanner'; 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 } = @@ -32,6 +35,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, @@ -90,7 +95,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); } }; @@ -123,6 +132,8 @@ export default function CrossAssetPayment() { setStatus('submitting'); setContractError(null); + setOverlayVisible(true); + setOverlayStatus('pending'); try { await contractService.initialize(); const contractId = @@ -149,6 +160,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.' @@ -452,6 +464,13 @@ export default function CrossAssetPayment() { + + setOverlayVisible(false)} + /> ); } diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 6621b5a..4ca0b7a 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -56,6 +56,8 @@ interface PendingClaim { status: string; } +type OverlayStatus = 'pending' | 'success' | 'error'; + // Mock employer secret key for simulation purposes const MOCK_EMPLOYER_SECRET = 'SD3X5K7G7XV4K5V3M2G5QXH434M3VX6O5P3QVQO3L2PQSQQQQQQQQQQQ'; @@ -83,6 +85,10 @@ export default function PayrollScheduler() { const [formErrors, setFormErrors] = useState({}); 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); @@ -234,6 +240,10 @@ export default function PayrollScheduler() { const handleBroadcast = async () => { setIsBroadcasting(true); setContractError(null); + setOverlayVisible(true); + setOverlayStatus('pending'); + setOverlayTxHash(undefined); + try { const mockRecipientPublicKey = generateWallet().publicKey; @@ -269,6 +279,16 @@ export default function PayrollScheduler() { // Subscribe to updates for this new claim subscribeToTransaction(newClaim.id); + // Show success overlay + setOverlayStatus('success'); + const mockTxHash = `broadcast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + setOverlayTxHash(mockTxHash); + + // Auto-dismiss after 3 seconds + setTimeout(() => { + setOverlayVisible(false); + }, 3000); + notifySuccess( 'Broadcast successful!', `Claimable balance created for ${formData.employeeName}` @@ -304,6 +324,7 @@ export default function PayrollScheduler() { ); setContractError(parsed); notifyPaymentFailure(parsed.message); + setOverlayStatus('error'); } finally { setIsBroadcasting(false); } @@ -628,6 +649,13 @@ export default function PayrollScheduler() {
+ + setOverlayVisible(false)} + /> ); }