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'); + }); +});