diff --git a/src/App.tsx b/src/App.tsx
index ddef64a1..7a619b21 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import Compare from './components/dashboard/AccountComparison'
import WalletConnect from './components/dashboard/WalletConnect'
import TransactionSigner from './components/dashboard/TransactionSigner'
import PriceTicker from './components/dashboard/PriceTicker'
+import OfflineStatus from './components/layout/OfflineStatus'
import PortfolioValue from './components/dashboard/PortfolioValue'
import NetworkMetricsChart from './components/charts/NetworkMetricsChart'
import AccountActivityChart from './components/charts/AccountActivityChart'
@@ -349,6 +350,7 @@ function DashboardLayout() {
+
{!connectedAddress ? : }
diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx
index 5212b9b3..1f1ae86c 100644
--- a/src/components/layout/Sidebar.jsx
+++ b/src/components/layout/Sidebar.jsx
@@ -1,7 +1,7 @@
-import React from 'react'
+import React, { useEffect, useState } from 'react'
import { useStore } from '../../lib/store'
import CopyableValue from '../dashboard/CopyableValue'
-import { NETWORKS, updateCustomNetworkConfig, switchToCustomProfile, loadCustomNetworkProfiles } from '../../lib/stellar'
+import { NETWORKS, getCustomNetworkAuthHeaders, updateCustomNetworkConfig, switchToCustomProfile, loadCustomNetworkProfiles } from '../../lib/stellar'
import { getActiveProfile } from '../../lib/userPreferences'
const SESSION_API_KEY = 'stellar_custom_api_key'
diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts
index ec000cd6..2e94706d 100644
--- a/src/lib/stellar.ts
+++ b/src/lib/stellar.ts
@@ -109,13 +109,13 @@ function saveCustomNetworkAuthHeaders(headers: Record) {
}
}
-function getNetworkHeaders(network: NetworkName): Record {
+function getNetworkHeadersForRequest(network: NetworkName): Record {
if (network === 'custom') return getCustomNetworkAuthHeaders()
return NETWORKS[network].headers || {}
}
function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): RequestInit {
- const headers = getNetworkHeaders(network)
+ const headers = getNetworkHeadersForRequest(network)
if (!Object.keys(headers).length) return options
return {
@@ -128,7 +128,7 @@ function withNetworkHeaders(options: RequestInit = {}, network: NetworkName): Re
}
function getServerOptions(network: NetworkName) {
- const headers = getNetworkHeaders(network)
+ const headers = getNetworkHeadersForRequest(network)
return Object.keys(headers).length ? { headers } : undefined
}
@@ -235,6 +235,10 @@ export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon.
)
}
+export function ee(network: NetworkName = 'testnet'): StellarSdk.Horizon.Server {
+ return getServer(network)
+}
+
export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server {
const config = NETWORKS[network]
if (network === 'custom' && !config.sorobanUrl) {
diff --git a/src/main.jsx b/src/main.jsx
index 53055c3a..c21a0bf0 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -12,3 +12,24 @@ ReactDOM.createRoot(document.getElementById("root")).render(
,
);
+
+// Register service worker and forward messages to window
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', async () => {
+ try {
+ const reg = await navigator.serviceWorker.register('/sw.js');
+ // forward messages from SW to window-level event
+ if (navigator.serviceWorker) {
+ navigator.serviceWorker.addEventListener('message', (e) => {
+ window.dispatchEvent(new CustomEvent('sw-message', { detail: e.data }));
+ });
+ }
+ // request initial sync registration when coming online
+ window.addEventListener('online', () => {
+ if (reg && reg.sync) reg.sync.register('sync-queue').catch(() => {});
+ });
+ } catch (err) {
+ // registration failed
+ }
+ });
+}
diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts
new file mode 100644
index 00000000..cdd34fcb
--- /dev/null
+++ b/tests/e2e/offline.spec.ts
@@ -0,0 +1,121 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('offline queue and conflict resolution', () => {
+ test('caches page, queues offline request, and syncs on reconnect', async ({ page }) => {
+ let requestCount = 0;
+ await page.route('**/api/test-offline', async (route) => {
+ requestCount += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ok: true }),
+ });
+ });
+
+ await page.goto('/');
+ await page.locator('button[title="User Preferences"]').waitFor({ state: 'visible', timeout: 10000 });
+ const swResponse = await page.request.get('/sw.js');
+ expect(swResponse.status()).toBe(200);
+
+ const queueLength = await page.evaluate(async () => {
+ const { queueRequest, getQueue } = await import('/src/lib/offlineQueue.js');
+ await queueRequest({
+ url: '/api/test-offline',
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: { value: 'local', updatedAt: Date.now() },
+ version: 'v1',
+ });
+ const queue = await getQueue();
+ return queue.length;
+ });
+ expect(queueLength).toBe(1);
+
+ await page.context().setOffline(true);
+ await page.waitForFunction(() => navigator.onLine === false);
+ await page.evaluate(() => window.dispatchEvent(new Event('offline')));
+ await expect(page.locator('text=You are offline')).toBeVisible();
+ expect(requestCount).toBe(0);
+
+ const syncRequestPromise = page.waitForRequest((request) => request.url().endsWith('/api/test-offline'), { timeout: 15000 });
+ await page.context().setOffline(false);
+ await page.waitForFunction(() => navigator.onLine === true);
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+
+ await syncRequestPromise;
+ const remaining = await page.evaluate(async () => {
+ const { getQueue } = await import('/src/lib/offlineQueue.js');
+ const queue = await getQueue();
+ return queue.length;
+ });
+ expect(remaining).toBe(0);
+ });
+
+ test('detects conflict and allows user resolution', async ({ page }) => {
+ let requestCount = 0;
+ let resolveHeaders = {};
+
+ await page.route('**/api/conflict', async (route, request) => {
+ requestCount += 1;
+ if (requestCount === 1) {
+ await route.fulfill({
+ status: 409,
+ contentType: 'application/json',
+ body: JSON.stringify('remote'),
+ });
+ return;
+ }
+ resolveHeaders = request.headers();
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ ok: true }),
+ });
+ });
+
+ await page.goto('/');
+ await page.locator('button[title="User Preferences"]').waitFor({ state: 'visible', timeout: 10000 });
+ const swResponse = await page.request.get('/sw.js');
+ expect(swResponse.status()).toBe(200);
+
+ await page.evaluate(async () => {
+ window.__conflicts = [];
+ const { onQueueEvent, queueRequest } = await import('/src/lib/offlineQueue.js');
+ onQueueEvent((detail) => window.__conflicts.push(detail));
+ await queueRequest({
+ url: '/api/conflict',
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: 'local',
+ version: 'v1',
+ });
+ });
+
+ await page.context().setOffline(true);
+ await page.waitForFunction(() => navigator.onLine === false);
+ await page.evaluate(() => window.dispatchEvent(new Event('offline')));
+ await expect(page.locator('text=You are offline')).toBeVisible();
+
+ const conflictRequestPromise = page.waitForRequest((request) => request.url().endsWith('/api/conflict'), { timeout: 15000 });
+ await page.context().setOffline(false);
+ await page.waitForFunction(() => navigator.onLine === true);
+ await conflictRequestPromise;
+
+ await page.waitForFunction(
+ () => window.__conflicts && window.__conflicts.some((detail) => detail.type === 'conflict'),
+ null,
+ { timeout: 15000 }
+ );
+ await expect(page.locator('text=Open Resolver')).toBeVisible();
+ await page.click('text=Open Resolver');
+ await expect(page.locator('text=Conflict Resolver')).toBeVisible();
+
+ await page.evaluate(async () => {
+ const conflicts = window.__conflicts;
+ const { resolveConflict } = await import('/src/lib/offlineQueue.js');
+ await resolveConflict(conflicts[0].id, { type: 'accept-local' });
+ });
+
+ expect(resolveHeaders['x-conflict-resolution']).toBe('accept-local');
+ });
+});