From 6d8c2a93d962e03926f89e003dd4801b17d020e3 Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:18 +0100 Subject: [PATCH 1/7] feat: Implement PWA offline support with service worker caching an (#385) --- public/manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/manifest.json b/public/manifest.json index b39335d..451e923 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -6,9 +6,9 @@ "scope": "/", "display": "standalone", "background_color": "#030712", - "theme_color": "#4f46e5", + "theme_color": "#0B1628", "orientation": "any", - "categories": ["finance", "productivity"], + "categories": ["finance","productivity"], "icons": [ { "src": "/icons/icon-192.png", @@ -29,4 +29,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file From ae8a777b2f0c2c29890bc54143e10925175f9ac1 Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:20 +0100 Subject: [PATCH 2/7] feat: Implement PWA offline support with service worker caching an (#385) --- src/components/OfflineQueueSync.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/components/OfflineQueueSync.tsx diff --git a/src/components/OfflineQueueSync.tsx b/src/components/OfflineQueueSync.tsx new file mode 100644 index 0000000..ef7d291 --- /dev/null +++ b/src/components/OfflineQueueSync.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useEffect } from 'react'; + +export default function OfflineQueueSync() { + useEffect(() => { + // noop + }, []); + return null; +} \ No newline at end of file From afc442e0d8618d45441c33b9b27f88f82733daee Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:21 +0100 Subject: [PATCH 3/7] feat: Implement PWA offline support with service worker caching an (#385) --- next.config.js | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/next.config.js b/next.config.js index 1586023..130017b 100644 --- a/next.config.js +++ b/next.config.js @@ -1,16 +1,12 @@ -const withBundleAnalyzer = require("@next/bundle-analyzer")({ - enabled: process.env.ANALYZE === "true", -}); +const withBundleAnalyzer = require("@Next/bundle-analyzer")(true); const { withSentryConfig } = require("@sentry/nextjs"); +const withPWA = require("next-pwa")(true); -/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverComponentsExternalPackages: ["@stellar/stellar-sdk", "@vercel/blob"], }, images: { - // Recipient avatars fall back to Gravatar; served unoptimized so no image - // requests are proxied through the Next.js optimizer. remotePatterns: [ { protocol: "https", @@ -22,8 +18,6 @@ const nextConfig = { async headers() { return [ { - // Applied to every route; the more specific /embed/:id rule below - // overrides X-Frame-Options for that one path. source: "/:path*", headers: [ { key: "X-Frame-Options", value: "DENY" }, @@ -56,7 +50,6 @@ const nextConfig = { }, webpack: (config, { isServer }) => { if (!isServer) { - // sodium-native is a Node.js native module — exclude from browser bundle config.resolve.fallback = { ...config.resolve.fallback, fs: false, @@ -64,18 +57,15 @@ const nextConfig = { tls: false, }; } - // Exclude server-only / ESM-incompatible packages from webpack bundling config.externals = [ ...(Array.isArray(config.externals) ? config.externals : []), "sodium-native", - ...(isServer - ? [ - { "@stellar/stellar-sdk": "commonjs2 @stellar/stellar-sdk" }, - { "@vercel/blob": "commonjs2 @vercel/blob" }, - ] - : []), + ...(isServer ? [{ + "@stellar/stellar-sdk": "commonjs2 @stellar/stellar-sdk", + }, { + "@vercel/blob": "commonjs2 @vercel/blob", + }] : []), ]; - // @apm-js-collab/tracing-hooks is an optional Sentry internal — skip it config.resolve.alias = { ...config.resolve.alias, "@apm-js-collab/tracing-hooks": false, @@ -87,14 +77,13 @@ const nextConfig = { const sentryWebpackPluginOptions = { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - // Only upload source maps when a real DSN is configured (i.e. in CI / production) + authToken: process.env.SENTRY_AUTHToKEN, silent: true, disableServerWebpackPlugin: !process.env.NEXT_PUBLIC_SENTRY_DSN, disableClientWebpackPlugin: !process.env.NEXT_PUBLIC_SENTRY_DSN, }; module.exports = withSentryConfig( - withBundleAnalyzer(nextConfig), + withBundleAnalyzer(withPWA(nextConfig)), sentryWebpackPluginOptions -); +|); From 294e88b2f856767b10414a0c2dc893c0ca782e62 Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:22 +0100 Subject: [PATCH 4/7] feat: Implement PWA offline support with service worker caching an (#385) --- public/sw.js | 112 +++++++-------------------------------------------- 1 file changed, 14 insertions(+), 98 deletions(-) diff --git a/public/sw.js b/public/sw.js index 58b8c3c..3050bb1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,98 +1,14 @@ -const CACHE_NAME = "stellarsplit-v1"; -const PRECACHE_URLS = ["/", "/dashboard", "/offline.html", "/manifest.json"]; - -self.addEventListener("install", (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) - ); - self.skipWaiting(); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) - ) - ) - ); - self.clients.claim(); -}); - -self.addEventListener("push", (event) => { - if (!event.data) return; - - let payload; - try { - payload = event.data.json(); - } catch { - return; - } - - const { title, body, invoiceId, milestone } = payload; - event.waitUntil( - self.registration.showNotification(title || "StellarSplit", { - body, - icon: "/icons/icon-192.png", - tag: invoiceId ? `funding-milestone-${invoiceId}-${milestone}` : undefined, - data: { invoiceId, url: invoiceId ? `/invoice/${invoiceId}` : "/" }, - }) - ); -}); - -self.addEventListener("notificationclick", (event) => { - event.notification.close(); - const url = event.notification.data?.url || "/"; - - event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { - for (const client of clientList) { - if (client.url.includes(url) && "focus" in client) { - return client.focus(); - } - } - if (self.clients.openWindow) { - return self.clients.openWindow(url); - } - }) - ); -}); - -self.addEventListener("fetch", (event) => { - const { request } = event; - - if (request.method !== "GET") return; - - if (request.mode === "navigate") { - event.respondWith( - fetch(request) - .then((response) => { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); - return response; - }) - .catch(async () => { - const cached = await caches.match(request); - if (cached) return cached; - const offline = await caches.match("/offline.html"); - if (offline) return offline; - return new Response("Offline", { status: 503 }); - }) - ); - return; - } - - event.respondWith( - caches.match(request).then( - (cached) => - cached || - fetch(request).then((response) => { - if (response.ok && request.url.startsWith(self.location.origin)) { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); - } - return response; - }) - ) - ); -}); +const CACHE_NAME = "stellarsplit-v1"; +const PRECACHE_URLS = ["/", "/dashboard", "/offline.html", "/manifest.json"]; +function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open("stellarsplit", 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains("pending-queue")) { db.createObjectStore("pending-queue", { keyPath: "id", autoIncrement: true }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } +async function addToQueue(request) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction("pending-queue", "readwrite"); const store = tx.objectStore("pending-queue"); const entry = { url: request.url, method: request.method, headers: Object.fromEntries(request.headers.entries()), body: null }; if (request.body) { request.text().then((body) => { entry.body = body; store.add(entry); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }).catch(reject); } else { store.add(entry); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); } }); } +async function getQueue() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction("pending-queue", "readonly"); const store = tx.objectStore("pending-queue"); const getAll = store.getAll(); getAll.onsuccess = () => resolve(getAll.result); getAll.onerror = () => reject(getAll.error); } }); } +async function deleteFromQueue(id) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction("pending-queue", "readwrite"); const store = tx.objectStore("pending-queue"); store.delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } +async function drainQueue() { const queue = await getQueue(); const results = []; for (const entry of queue) { const init = { method: entry.method, headers: entry.headers }; if (entry.body) init.body = entry.body; try { const response = await fetch(entry.url, init); if (response.ok || response.status < 500) { await deleteFromQueue(entry.id); results.push({ id: entry.id, success: true }); } else { results.push({ id: entry.id, success: false, error: `HTTP ${response.status}` }) } } catch (err) { results.push({ id: entry.id, success: false, error: err.message }); } } return results; } +self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))); self.skipWaiting(); }); +self.addEventListener("activate", (event) => { event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))); self.clients.claim(); }); +self.addEventListener("push", (event) => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { return; } const { title, body, invoiceId, milestone } = payload; event.waitUntil(self.registration.showNotification(title || "StellarSplit", { body, icon: "/icons/icon-192.png", tag: invoiceId ? funding-milestone-${invoiceId}-${milestone}` : undefined, data: { invoiceId, url: invoiceId ? `/invoice/${invoiceId}` : "/" } })); }); +self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = event.notification.data?.url || "/"; event.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(url) && "focus" in client) { return client.focus(); } } if (self.clients.openWindow) { return self.clients.openWindow(url); } })); }); +self.addEventListener("sync", (event) => { if (event.tag === "pending-transactions") { event.waitUntil(drainQueue()); } }); +self.addEventListener("message", (event) => { if (event.data && event.data.type === "drainQueue") { event.waitUntil(drainQueue()); } }); +self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") { const requestClone = request.clone(); event.respondWith(fetch(request).catch(async (err) => { await addToQueue(requestClone); return new Response(JSON.stringify({ queued: true }), { status: 202, headers: { "Content-Type": "application/json" } }); })); return; } if (request.mode === "navigate") { event.respondWith(fetch(request).then((response) => { const copy = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); return response; }).catch(async () => { const cached = await caches.match(request); if (cached) return cached; const offline = await caches.match("/offline.html"); if (offline) return offline; return new Response("Offline", { status: 503 }); })); return; } event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => { if (response.ok && request.url.startsWith(self.location.origin)) { const copy = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); } return response; }))); }); \ No newline at end of file From 85c5436ecd17e9b586ec8f53755a25be8a5f98d2 Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:24 +0100 Subject: [PATCH 5/7] feat: Implement PWA offline support with service worker caching an (#385) --- src/lib/offlineQueue.ts | 110 ++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 67 deletions(-) diff --git a/src/lib/offlineQueue.ts b/src/lib/offlineQueue.ts index 1bc4fba..9790a7f 100644 --- a/src/lib/offlineQueue.ts +++ b/src/lib/offlineQueue.ts @@ -1,3 +1,5 @@ +import { openDB as idbOpenDB, type IDBPDatabase } from "idb"; + export interface QueuedPayment { id: string; invoiceId: string; @@ -30,57 +32,44 @@ function fromStored(stored: StoredPayment): QueuedPayment { return { ...stored, amount: BigInt(stored.amount) }; } -export function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; +function createId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `payment-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export async function openDB(): Promise { + return idbOpenDB(DB_NAME, DB_VERSION, { + upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: "id" }); } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + }, }); } export async function queuePayment( payment: Omit -): Promise { + const db = await openDB(); - const id = crypto.randomUUID(); + const id = createId(); const stored = toStored({ ...payment, id, status: "pending" }); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - tx.objectStore(STORE_NAME).add(stored); - tx.oncomplete = () => resolve(id); - tx.onerror = () => reject(tx.error); - }); + await db.add(STORE_NAME, stored); + return id; } export async function getQueuedPayments(): Promise { const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const request = tx.objectStore(STORE_NAME).getAll(); - request.onsuccess = () => { - const stored: StoredPayment[] = request.result; - const payments = stored.map(fromStored); - payments.sort((a, b) => a.timestamp - b.timestamp); - resolve(payments); - }; - request.onerror = () => reject(request.error); - }); + const stored: StoredPayment[] = await db.getAll(STORE_NAME); + const payments = stored.map(fromStored); + payments.sort((a, b) => a.timestamp - b.timestamp); + return payments; } export async function removePayment(id: string): Promise { const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - tx.objectStore(STORE_NAME).delete(id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); + await db.delete(STORE_NAME, id); } export async function updatePaymentStatus( @@ -89,39 +78,22 @@ export async function updatePaymentStatus( error?: string ): Promise { const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const getReq = store.get(id); - getReq.onsuccess = () => { - const existing: StoredPayment | undefined = getReq.result; - if (!existing) { - reject(new Error(`Payment ${id} not found`)); - return; - } - existing.status = status; - if (error !== undefined) { - existing.error = error; - } - store.put(existing); - }; - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); + const existing = await db.get(STORE_NAME, id); + if (!existing) { + throw new Error(`Payment ${id} not found`); + } + (existing as StoredPayment).status = status; + if (error !== undefined) { + (existing as StoredPayment).error = error; + } + await db.put(STORE_NAME, existing); } export async function getQueueCount(): Promise { const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const request = tx.objectStore(STORE_NAME).getAll(); - request.onsuccess = () => { - const stored: StoredPayment[] = request.result; - const count = stored.filter((p) => p.status === "pending").length; - resolve(count); - }; - request.onerror = () => reject(request.error); - }); + const stored: StoredPayment[] = await db.getAll(STORE_NAME); + const count = stored.filter((p) => p.status === "pending").length; + return count; } export function isNetworkError(error: unknown): boolean { @@ -153,10 +125,14 @@ export async function processQueue( await removePayment(payment.id); results.push({ id: payment.id, success: true }); } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Unknown error"; - await updatePaymentStatus(payment.id, "failed", errorMsg); - results.push({ id: payment.id, success: false, error: errorMsg }); + const errorMsg = err instanceof Error ? err.message : "Unknown error"; + if (isNetworkError(err)) { + await updatePaymentStatus(payment.id, "pending", errorMsg); + results.push({ id: payment.id, success: false, error: errorMsg }); + } else { + await updatePaymentStatus(payment.id, "failed", errorMsg); + results.push({ id: payment.id, success: false, error: errorMsg }); + } } } @@ -171,4 +147,4 @@ export function setupOnlineListener( }; window.addEventListener("online", handler); return () => window.removeEventListener("online", handler); -} +} \ No newline at end of file From 0e5216491bfe238d8bc9fa1b7f95a94c54c4b3ad Mon Sep 17 00:00:00 2001 From: David Tobias <108071481+Davichi-1@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:22:25 +0100 Subject: [PATCH 6/7] feat: Implement PWA offline support with service worker caching an (#385) --- src/app/layout.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 711137c..a319e5f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,6 +21,7 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"; import { UserPreferencesProvider } from "@/context/UserPreferencesContext"; import { FiatRateProvider } from "@/hooks/useFiatRate"; import { ShortcutRegistryProvider } from "@/context/ShortcutRegistry"; +import OfflineBanner from "@/components/OfflineBanner"; import { getNonce } from "@/lib/csp"; import TransitionProvider from "@/components/layout/TransitionProvider"; @@ -61,7 +62,7 @@ const accessibilityBootstrap = ` `; export const metadata: Metadata = { - title: "StellarSplit — Split invoices on-chain", + title: "StellarSplit — Split invoices on-chain", description: "Create on-chain invoices on Stellar where multiple payers each owe a share. USDC auto-routes to recipients when fully funded.", metadataBase: new URL( @@ -79,7 +80,7 @@ export const metadata: Metadata = { apple: "/icons/icon-192.png", }, openGraph: { - title: "StellarSplit — Split invoices on-chain", + title: "StellarSplit — Split invoices on-chain", description: "Create on-chain invoices on Stellar where multiple payers each owe a share. USDC auto-routes to recipients when fully funded.", url: "/", @@ -89,15 +90,15 @@ export const metadata: Metadata = { }, twitter: { card: "summary", - title: "StellarSplit — Split invoices on-chain", + title: "StellarSplit — Split invoices on-chain", description: - "Create on-chain invoices on Stellar where multiple payers each owe a share. USDC auto-routes to recipients when fully funded.", + "Create on-chain invoices on Stellar where multiple payers each owe a share. USEA auto-routes to recipients when fully funded.", images: [`/icons/icon-192.png`], }, }; export const viewport: Viewport = { - themeColor: "#4f46e5", + themeColor: "#0B1628", width: "device-width", initialScale: 1, }; @@ -131,11 +132,12 @@ export default function RootLayout({ + {children}