diff --git a/components/OfflineIndicator.tsx b/components/OfflineIndicator.tsx
index 26b37ea..b9d1bc4 100644
--- a/components/OfflineIndicator.tsx
+++ b/components/OfflineIndicator.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
+import { queuedTransactions } from '@/lib/offline-transactions';
interface OfflineIndicatorProps {
className?: string;
@@ -8,19 +9,26 @@ interface OfflineIndicatorProps {
export function OfflineIndicator({ className = '' }: OfflineIndicatorProps) {
const [isOnline, setIsOnline] = useState(true);
+ const [queuedCount, setQueuedCount] = useState(0);
useEffect(() => {
setIsOnline(navigator.onLine);
+ setQueuedCount(queuedTransactions().length);
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
+ const handleQueueChange = () => setQueuedCount(queuedTransactions().length);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
+ window.addEventListener('offline-transactions-changed', handleQueueChange);
+ window.addEventListener('storage', handleQueueChange);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
+ window.removeEventListener('offline-transactions-changed', handleQueueChange);
+ window.removeEventListener('storage', handleQueueChange);
};
}, []);
@@ -28,6 +36,11 @@ export function OfflineIndicator({ className = '' }: OfflineIndicatorProps) {
return null;
}
+ const queueMessage =
+ queuedCount === 0
+ ? 'transactions will be queued.'
+ : `${queuedCount} transaction${queuedCount === 1 ? '' : 's'} queued for sync.`;
+
return (
- You are currently offline. Cached data is shown; transactions will be queued.
+ You are currently offline. Cached data is shown; {queueMessage}
);
}
diff --git a/components/__tests__/OfflineIndicator.test.tsx b/components/__tests__/OfflineIndicator.test.tsx
index 7693d7d..6864efe 100644
--- a/components/__tests__/OfflineIndicator.test.tsx
+++ b/components/__tests__/OfflineIndicator.test.tsx
@@ -2,10 +2,12 @@ import React, { act } from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createRoot } from 'react-dom/client';
import { OfflineIndicator } from '../OfflineIndicator';
+import { queueTransaction, queuedTransactions } from '@/lib/offline-transactions';
describe('OfflineIndicator', () => {
beforeEach(() => {
vi.clearAllMocks();
+ localStorage.clear();
});
it('should not render when online', () => {
@@ -58,4 +60,99 @@ describe('OfflineIndicator', () => {
const testClass = 'custom-class';
expect(testClass).toBe('custom-class');
});
+
+ it('should display queued transaction count when offline', () => {
+ Object.defineProperty(navigator, 'onLine', {
+ writable: true,
+ value: false,
+ configurable: true,
+ });
+
+ queueTransaction({
+ kind: 'withdraw',
+ publicKey: 'GTEST',
+ streamAddress: 'STEST',
+ amount: '100',
+ });
+ queueTransaction({
+ kind: 'cancel',
+ publicKey: 'GTEST',
+ streamAddress: 'STEST',
+ });
+
+ expect(queuedTransactions()).toHaveLength(2);
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render();
+ });
+
+ const banner = container.querySelector('[role="status"]');
+ expect(banner).toBeTruthy();
+ expect(banner?.textContent).toContain('2 transactions queued for sync');
+
+ act(() => {
+ root.unmount();
+ });
+ document.body.removeChild(container);
+ });
+
+ it('should show default message when offline with no queued transactions', () => {
+ Object.defineProperty(navigator, 'onLine', {
+ writable: true,
+ value: false,
+ configurable: true,
+ });
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render();
+ });
+
+ const banner = container.querySelector('[role="status"]');
+ expect(banner).toBeTruthy();
+ expect(banner?.textContent).toContain('transactions will be queued');
+
+ act(() => {
+ root.unmount();
+ });
+ document.body.removeChild(container);
+ });
+
+ it('should use singular form for a single queued transaction', () => {
+ Object.defineProperty(navigator, 'onLine', {
+ writable: true,
+ value: false,
+ configurable: true,
+ });
+
+ queueTransaction({
+ kind: 'topup',
+ publicKey: 'GTEST',
+ streamAddress: 'STEST',
+ amount: '50',
+ });
+
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ act(() => {
+ root.render();
+ });
+
+ const banner = container.querySelector('[role="status"]');
+ expect(banner?.textContent).toContain('1 transaction queued for sync');
+
+ act(() => {
+ root.unmount();
+ });
+ document.body.removeChild(container);
+ });
});
diff --git a/lib/offline-transactions.ts b/lib/offline-transactions.ts
index 0808829..2da5215 100644
--- a/lib/offline-transactions.ts
+++ b/lib/offline-transactions.ts
@@ -20,9 +20,16 @@ function read(): OfflineTransaction[] {
function write(items: OfflineTransaction[]) { localStorage.setItem(KEY, JSON.stringify(items)); }
+function notifyQueueChanged() {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(new Event('offline-transactions-changed'));
+ }
+}
+
export function queueTransaction(transaction: NewOfflineTransaction): string {
const id = crypto.randomUUID();
write([...read(), { ...transaction, id } as OfflineTransaction]);
+ notifyQueueChanged();
navigator.serviceWorker?.controller?.postMessage({ type: 'REGISTER_BACKGROUND_SYNC' });
return id;
}
@@ -37,6 +44,7 @@ export async function flushQueuedTransactions(signTx: SignTransaction): Promise<
if (item.kind === 'cancel') await cancel(item.publicKey, item.streamAddress, signTx);
if (item.kind === 'topup') await topUp(item.publicKey, item.streamAddress, BigInt(item.amount), signTx);
write(read().filter((queued) => queued.id !== item.id));
+ notifyQueueChanged();
} catch (error) {
console.error('Queued transaction failed:', error);
break;