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)}
+ />
);
}