diff --git a/src/App.jsx b/src/App.jsx index 3bfe35b..a988000 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import ErrorBoundary from './components/ErrorBoundary.jsx'; import Navbar from './components/Navbar.jsx'; import Sidebar from './components/Sidebar.jsx'; import Footer from './components/Footer.jsx'; +import ConnectionBanner from './components/ConnectionBanner.jsx'; import Home from './pages/Home.jsx'; import SendMoney from './pages/SendMoney.jsx'; import Transfers from './pages/Transfers.jsx'; @@ -25,6 +26,7 @@ export default function App() { {showSidebar && }
+
diff --git a/src/pages/SendMoney.css b/src/pages/SendMoney.css index b6dc31f..20fea24 100644 --- a/src/pages/SendMoney.css +++ b/src/pages/SendMoney.css @@ -83,3 +83,30 @@ padding: 2rem; text-align: center; } + +/* Offline and reconnection notices */ +.send-offline-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + margin-bottom: 1rem; + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 8px; + color: #fde68a; + font-size: 0.875rem; +} + +.send-reconnected-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + margin-bottom: 1rem; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 8px; + color: #86efac; + font-size: 0.875rem; +} diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 344b26c..34859c8 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import TextField from '../components/TextField.jsx'; import CurrencySelect from '../components/CurrencySelect.jsx'; @@ -14,6 +14,7 @@ import { } from '../utils/validate.js'; import { useWallet } from '../hooks/useWallet.js'; import { useTransfers } from '../hooks/useTransfers.js'; +import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; import { DEFAULT_SOURCE, DEFAULT_DEST } from '../constants/currencies.js'; @@ -27,6 +28,7 @@ export default function SendMoney() { const { wallet, isConnected, connect } = useWallet(); const { addTransfer } = useTransfers(); const { locale } = useApp(); + const isOnline = useOnlineStatus(); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState(''); @@ -36,6 +38,12 @@ export default function SendMoney() { const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const submissionLock = useRef(false); + const wasOffline = useRef(false); + + // True when the form just recovered from a disconnected state. Used to + // surface an honest, non-blocking "back online" notice after the browser + // regains connectivity (the transfer has NOT been submitted automatically). + const [justReconnected, setJustReconnected] = useState(false); // Debounce the amount so the quote isn't rebuilt on every keystroke. const debouncedAmount = useDebouncedValue(amount, 250); @@ -84,11 +92,37 @@ export default function SendMoney() { return isValid; } + /** + * Track connectivity transitions. When the browser comes back online we do + * NOT blindly resubmit the form (that would duplicate the transfer) — we + * only clear the stale "offline" error state and inform the user. + */ + useEffect(() => { + const recovered = wasOffline.current && isOnline; + wasOffline.current = !isOnline; + if (recovered) { + setSubmitError(null); + setJustReconnected(true); + setTimeout(() => setJustReconnected(false), 4000); + } + }, [isOnline]); + async function handleSubmit(e) { e.preventDefault(); if (submissionLock.current) return; setSubmitError(null); + setJustReconnected(false); + + // Never start a transfer while offline: submitting blind would either + // fail confusingly or, worse, appear to succeed while nothing happened. + if (!isOnline) { + setSubmitError( + "You're offline. Connect to the internet before sending money.", + ); + return; + } + if (!validate()) return; submissionLock.current = true; @@ -111,7 +145,18 @@ export default function SendMoney() { }); navigate('/transfers'); } catch (err) { - setSubmitError('Could not submit the transfer. Please try again.'); + // A transfer can be interrupted mid-signature by a connection drop. + // The honest message here is "unknown", not "failed": the backend may + // have accepted the transfer even though the response never arrived. + // The transfers page reconciles real status on reconnect. + // Read the current connectivity directly (not from the render closure) + // so that a mid-flight disconnect produces the correct message. + const connectedNow = typeof navigator !== 'undefined' && navigator.onLine; + setSubmitError( + connectedNow + ? 'Could not submit the transfer. Please try again.' + : 'Connection lost while sending. Reconnect to check your transfer status.', + ); } finally { submissionLock.current = false; setSubmitting(false); @@ -139,6 +184,28 @@ export default function SendMoney() {
)} + {!isOnline && ( +
+ ⚠️ No internet connection. Send Money is disabled until you + reconnect. +
+ )} + + {justReconnected && ( +
+ ✓ Back online. Your form was not submitted while you were + offline — review it and send when ready. +
+ )} + } - diff --git a/src/pages/Transfers.css b/src/pages/Transfers.css index 45a4151..fd86757 100644 --- a/src/pages/Transfers.css +++ b/src/pages/Transfers.css @@ -48,6 +48,20 @@ margin-top: 0.75rem; } +.transfers-sync-notice { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + margin-bottom: 1.25rem; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 8px; + color: #86efac; + font-size: 0.875rem; +} + @media (max-width: 720px) { .transfers-filters { flex-direction: column; diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 2967fe8..bb52b92 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -11,6 +11,7 @@ import Pagination from '../components/Pagination.jsx'; import PullToRefresh from '../components/PullToRefresh.jsx'; import SelectionToolbar from '../components/SelectionToolbar.jsx'; import { useTransfers } from '../hooks/useTransfers.js'; +import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; import './Transfers.css'; @@ -31,8 +32,15 @@ const PAGE_SIZE = 5; export default function Transfers() { const { transfers, loading, error, reload } = useTransfers(); const { locale } = useApp(); + const isOnline = useOnlineStatus(); const [searchParams, setSearchParams] = useSearchParams(); + // Becomes true while the browser is offline, then flips back to false the + // moment connectivity returns so we can reconcile transfers against the + // backend (a transfer may have completed while the response was lost). + const [wasOffline, setWasOffline] = useState(false); + const [syncingAfterReconnect, setSyncingAfterReconnect] = useState(false); + const search = searchParams.get('search') || ''; const status = searchParams.get('status') || ''; const range = searchParams.get('range') || ''; @@ -51,6 +59,20 @@ export default function Transfers() { setSelectAllAcross(false); }, [search, status, range]); + // Track connectivity so that a reconnect triggers an automatic reload. + // The reload reconciles the true status of transfers that may have been + // created or settled while the connection was down — without resubmitting + // anything. + useEffect(() => { + if (isOnline && wasOffline && !loading) { + setSyncingAfterReconnect(true); + reload().finally(() => { + setSyncingAfterReconnect(false); + }); + } + setWasOffline(!isOnline); + }, [isOnline, wasOffline, loading, reload]); + const filteredTransfers = useMemo(() => { return transfers.filter((t) => { if (status && t.status !== status) return false; @@ -239,6 +261,12 @@ export default function Transfers() { + {syncingAfterReconnect && ( +
+ ✓ Back online — refreshing your transfers to show the latest status. +
+ )} +
{ + window.dispatchEvent(new Event('offline')); + }); +} + +function goOnline() { + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true, + }); + act(() => { + window.dispatchEvent(new Event('online')); + }); +} + +async function fillValidForm(user) { + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.type(screen.getByLabelText(/amount/i), '15'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); +} + +describe('Offline and reconnect state for transfer mutations', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: true, + }); + window.history.pushState({}, '', '/send'); + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not create a transfer when submitting while offline', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillValidForm(user); + goOffline(); + + // The button is disabled and labelled "Offline — Reconnect to send" while offline. + const submitButton = screen.getByRole('button', { + name: /reconnect to send/i, + }); + expect(submitButton).toBeDisabled(); + + expect( + screen.getByText(/no internet connection\. send money is disabled/i), + ).toBeInTheDocument(); + + // A synthetic click on a disabled button must not start a transfer. + fireEvent.click(submitButton); + expect(createTransfer).not.toHaveBeenCalled(); + }); + + it('shows the ConnectionBanner while offline', async () => { + render(); + + expect(screen.queryByText(/you're offline/i)).not.toBeInTheDocument(); + + goOffline(); + + expect( + await screen.findByText( + /you're offline\. some features may not work/i, + ), + ).toBeInTheDocument(); + }); + + it('blocks submission via submit handler even if the button were not disabled', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillValidForm(user); + goOffline(); + + // Force a submit event on the form itself, bypassing the button's disabled attribute. + const form = screen.getByLabelText(/recipient/i).closest('form'); + fireEvent.submit(form); + + expect(createTransfer).not.toHaveBeenCalled(); + expect( + screen.getByText(/you're offline\. connect to the internet/i), + ).toBeInTheDocument(); + }); + + it('re-enables submission after coming back online', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillValidForm(user); + goOffline(); + + expect( + screen.getByRole('button', { name: /reconnect to send/i }), + ).toBeDisabled(); + + goOnline(); + + // After reconnecting, the button should change back to "Review & Send" and be enabled. + const submitButton = await screen.findByRole('button', { + name: /review & send/i, + }, { timeout: 5000 }); + expect(submitButton).toBeEnabled(); + + await user.click(submitButton); + + await screen.findByRole('heading', { name: /your transfers/i }, { timeout: 10000 }); + expect(createTransfer).toHaveBeenCalledTimes(1); + }); + + it('does not auto-resubmit the form on reconnect (no duplicate transfer)', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + render(); + + goOffline(); + + const user = userEvent.setup(); + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.type(screen.getByLabelText(/amount/i), '15'); + + goOnline(); + + // Coming back online must NOT resubmit the form automatically. + expect(createTransfer).not.toHaveBeenCalled(); + expect( + screen.getByText(/back online\. your form was not submitted/i), + ).toBeInTheDocument(); + }); + + it('shows an honest unknown-status message when a transfer fails mid-flight during a disconnect', async () => { + // Simulate a transfer that the backend accepted but whose response was + // lost to a mid-flight disconnect: mock createTransfer to go offline + // before rejecting, so the catch block reads the real-time offline state. + const createTransfer = vi + .spyOn(api, 'createTransfer') + .mockImplementation(async () => { + // Drop the connection before the rejection — the same thread that + // would handle the response swallows the connection. + Object.defineProperty(navigator, 'onLine', { + configurable: true, + value: false, + }); + window.dispatchEvent(new Event('offline')); + throw new Error('network lost'); + }); + const user = userEvent.setup(); + render(); + + await fillValidForm(user); + + // Start the submission. + const submitButton = screen.getByRole('button', { name: /review & send/i }); + fireEvent.click(submitButton); + + // After the catch block runs, the error message should be about + // connection loss, NOT a generic "could not submit" message. + await waitFor(() => { + expect( + screen.getByText(/connection lost while sending/i), + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText(/could not submit the transfer\./i), + ).not.toBeInTheDocument(); + }); +}); \ No newline at end of file