Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +26,7 @@ export default function App() {
</a>
{showSidebar && <Sidebar />}
<div className="app-content">
<ConnectionBanner />
<Navbar />
<main id="main-content" className="app-main">
<ErrorBoundary>
Expand Down
27 changes: 27 additions & 0 deletions src/pages/SendMoney.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
82 changes: 78 additions & 4 deletions src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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('');
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -139,6 +184,28 @@ export default function SendMoney() {
</div>
)}

{!isOnline && (
<div
className="send-offline-notice"
role="status"
aria-live="polite"
>
⚠️ No internet connection. Send Money is disabled until you
reconnect.
</div>
)}

{justReconnected && (
<div
className="send-reconnected-notice"
role="status"
aria-live="polite"
>
✓ Back online. Your form was not submitted while you were
offline — review it and send when ready.
</div>
)}

<TextField
id="recipient"
label="Recipient (email or Stellar address)"
Expand Down Expand Up @@ -186,8 +253,15 @@ export default function SendMoney() {

{submitError && <ErrorMessage message={submitError} />}

<Button type="submit" disabled={submitting}>
{submitting ? 'Sending...' : 'Review & Send'}
<Button
type="submit"
disabled={submitting || !isOnline}
>
{!isOnline
? 'Offline — Reconnect to send'
: submitting
? 'Sending...'
: 'Review & Send'}
</Button>
</form>

Expand Down
14 changes: 14 additions & 0 deletions src/pages/Transfers.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions src/pages/Transfers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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') || '';
Expand All @@ -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;
Expand Down Expand Up @@ -239,6 +261,12 @@ export default function Transfers() {
<Button to="/send">New Transfer</Button>
</div>

{syncingAfterReconnect && (
<div className="transfers-sync-notice" role="status" aria-live="polite">
✓ Back online — refreshing your transfers to show the latest status.
</div>
)}

<div className="transfers-filters">
<input
type="search"
Expand Down
Loading