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 -); +|); diff --git a/package.json b/package.json index 009daf5..41f4183 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@hookform/resolvers": "^5.7.1", "@react-pdf/renderer": "^3.4.4", + "@ducanh2912/next-pwa": "^10.2.0", "@sentry/nextjs": "^10.62.0", "@stellar-split/sdk": "^0.1.0", "@stellar/freighter-api": "^3.0.0", @@ -63,8 +64,8 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/web-push": "^3.6.4", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@vitejs/plugin-react": "^6.0.3", + "@typescript-eslint/plugin": "^6.21.0", + "@vite/plugin-react": "^6.0.3", "autoprefixer": "^10.4.0", "axe-core": "^4.12.1", "eslint": "^8.57.0", @@ -76,4 +77,4 @@ "typescript": "^5.4.0", "vitest": "^4.1.9" } -} +} \ No newline at end of file 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 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 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}