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;