diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6834544..2d449be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,20 +22,32 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- cache: 'npm'
- - name: Cache node_modules
+ - name: Cache npm dependencies
id: cache-node-modules
uses: actions/cache@v4
with:
- path: node_modules
+ path: |
+ node_modules
+ ~/.npm
key: node-modules-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
node-modules-${{ runner.os }}-node${{ matrix.node-version }}-
+ node-modules-${{ runner.os }}-
- name: Install dependencies
run: npm ci
+ - name: Cache Next.js build output
+ uses: actions/cache@v4
+ with:
+ path: |
+ .next/cache
+ key: nextjs-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('pages/**/*.tsx', 'pages/**/*.ts', 'components/**/*.tsx', 'components/**/*.ts') }}
+ restore-keys: |
+ nextjs-${{ runner.os }}-node${{ matrix.node-version }}-
+ nextjs-${{ runner.os }}-
+
- name: Verify contract bindings are up-to-date
run: npm run codegen:validate
diff --git a/components/atoms/index.tsx b/components/atoms/index.tsx
index 19b60bc..61ff211 100644
--- a/components/atoms/index.tsx
+++ b/components/atoms/index.tsx
@@ -10,3 +10,4 @@ export * from './toast'
export * from './theme-toggle'
export * from './spacer'
export * from './markdown-renderer'
+export * from './offline-banner'
diff --git a/components/atoms/offline-banner/index.tsx b/components/atoms/offline-banner/index.tsx
new file mode 100644
index 0000000..d372920
--- /dev/null
+++ b/components/atoms/offline-banner/index.tsx
@@ -0,0 +1,40 @@
+import React from 'react'
+import styles from './style.module.css'
+import { useOnlineStatus } from '../../../hooks/useOnlineStatus'
+
+interface OfflineBannerProps {
+ /** Number of pending transactions in the queue. */
+ pendingCount?: number
+}
+
+/**
+ * A prominent banner that slides in when the browser goes offline,
+ * showing the current connectivity status and pending transaction count.
+ *
+ * When the user comes back online, the banner slides out (via CSS transition).
+ */
+export function OfflineBanner({ pendingCount = 0 }: OfflineBannerProps) {
+ const isOnline = useOnlineStatus()
+
+ if (isOnline) return null
+
+ return (
+
+
+ π‘
+
+ You are offline
+
+ {pendingCount > 0
+ ? `${pendingCount} transaction(s) queued β they will be processed once you reconnect.`
+ : 'Some features may be unavailable until you reconnect.'}
+
+
+ Test the offline transaction queue by queuing sample transactions.
+ Transactions persist in IndexedDB and auto-process when connectivity is restored.
+
+
+
+
+
+
+
+
+ Transactions are stored in IndexedDB and survive page reloads.{' '}
+ Go offline (in DevTools β Network β Offline), queue a transaction,{' '}
+ then come back online to see it auto-process.
+
+
+
{/* Empty state */}
πΌ
diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg
new file mode 100644
index 0000000..3770d0d
--- /dev/null
+++ b/public/icons/icon-192.svg
@@ -0,0 +1,24 @@
+
diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg
new file mode 100644
index 0000000..0af226e
--- /dev/null
+++ b/public/icons/icon-512.svg
@@ -0,0 +1,24 @@
+
diff --git a/public/icons/icon.svg b/public/icons/icon.svg
new file mode 100644
index 0000000..9cd3cd6
--- /dev/null
+++ b/public/icons/icon.svg
@@ -0,0 +1,24 @@
+
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..dad7fb0
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,55 @@
+{
+ "name": "TrustFlow β Decentralized Escrow on Stellar",
+ "short_name": "TrustFlow",
+ "description": "TrustFlow is a next-generation gig-economy protocol built on Stellar & Soroban. Trustless escrow, milestone payments, and community dispute resolution.",
+ "start_url": "/dashboard",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#030712",
+ "theme_color": "#3e63dd",
+ "categories": ["finance", "productivity", "business"],
+ "lang": "en",
+ "dir": "ltr",
+ "icons": [
+ {
+ "src": "/icons/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-512.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ],
+ "screenshots": [],
+ "shortcuts": [
+ {
+ "name": "Dashboard",
+ "short_name": "Dashboard",
+ "description": "View your gigs and manage escrows",
+ "url": "/dashboard"
+ },
+ {
+ "name": "Explore Gigs",
+ "short_name": "Explore",
+ "description": "Browse available gigs",
+ "url": "/explore"
+ },
+ {
+ "name": "Create Gig",
+ "short_name": "New Gig",
+ "description": "Create a new milestone-based gig",
+ "url": "/create-gig"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..db90703
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,202 @@
+/* βββ TrustFlow Service Worker ββββββββββββββββββββββββββββββββ
+ * Cache-first for static assets, network-first for API calls,
+ * offline fallback, and background transaction queue support.
+ * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+const CACHE_NAME = 'trustflow-v1'
+const STATIC_CACHE = 'trustflow-static-v1'
+const API_CACHE = 'trustflow-api-v1'
+
+// Assets to pre-cache on install
+const PRECACHE_URLS = [
+ '/',
+ '/dashboard',
+ '/explore',
+ '/create-gig',
+ '/leaderboard',
+ '/manifest.json',
+ '/icons/icon.svg',
+ '/icons/icon-192.svg',
+ '/icons/icon-512.svg',
+]
+
+// ββ Install ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(STATIC_CACHE).then((cache) => {
+ return cache.addAll(PRECACHE_URLS)
+ })
+ )
+ // Force the waiting service worker to become the active one
+ self.skipWaiting()
+})
+
+// ββ Activate βββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+self.addEventListener('activate', (event) => {
+ // Clean up old caches
+ event.waitUntil(
+ caches.keys().then((cacheNames) => {
+ return Promise.all(
+ cacheNames
+ .filter((name) => name !== STATIC_CACHE && name !== API_CACHE && name !== CACHE_NAME)
+ .map((name) => caches.delete(name))
+ )
+ })
+ )
+ // Start controlling all clients immediately
+ self.clients.claim()
+})
+
+// ββ Fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event
+ const url = new URL(request.url)
+
+ // Skip non-GET requests (POST, PUT, etc.) β don't cache mutations
+ if (request.method !== 'GET') return
+
+ // Skip browser extensions and non-http(s) requests
+ if (!url.protocol.startsWith('http')) return
+
+ // API / data requests β network-first with cache fallback
+ if (
+ url.pathname.startsWith('/api/') ||
+ url.hostname.includes('stellar') ||
+ url.hostname.includes('soroban')
+ ) {
+ event.respondWith(networkFirstWithFallback(request))
+ return
+ }
+
+ // Navigation requests β network-first for fresh HTML, cache as fallback
+ if (request.mode === 'navigate') {
+ event.respondWith(networkFirstWithFallback(request))
+ return
+ }
+
+ // Static assets (JS, CSS, fonts, images, icons) β cache-first
+ if (
+ url.pathname.match(/\.(js|css|woff2?|ttf|svg|png|jpg|jpeg|webp|avif|ico)$/) ||
+ url.pathname.startsWith('/_next/static/')
+ ) {
+ event.respondWith(cacheFirstWithRefresh(request))
+ return
+ }
+
+ // Everything else β network-first
+ event.respondWith(networkFirstWithFallback(request))
+})
+
+// ββ Cache Strategies βββββββββββββββββββββββββββββββββββββββββββ
+
+/**
+ * Cache-first: serve from cache immediately, update cache in background.
+ */
+async function cacheFirstWithRefresh(request) {
+ const cached = await caches.match(request)
+ if (cached) {
+ // Fire-and-forget: update cache in background
+ fetch(request).then((response) => {
+ if (response.ok) {
+ caches.open(STATIC_CACHE).then((cache) => cache.put(request, response))
+ }
+ }).catch(() => { /* offline, ignore */ })
+ return cached
+ }
+
+ try {
+ const response = await fetch(request)
+ if (response.ok) {
+ const cache = await caches.open(STATIC_CACHE)
+ cache.put(request, response.clone())
+ }
+ return response
+ } catch {
+ return new Response('Offline', { status: 503, statusText: 'Service Unavailable' })
+ }
+}
+
+/**
+ * Network-first: try the network, fall back to cache, then offline page.
+ */
+async function networkFirstWithFallback(request) {
+ try {
+ const response = await fetch(request)
+ if (response.ok && response.type === 'basic') {
+ const cache = await caches.open(API_CACHE)
+ cache.put(request, response.clone())
+ }
+ return response
+ } catch {
+ const cached = await caches.match(request)
+ if (cached) return cached
+
+ // For navigation requests, serve the cached dashboard as SPA fallback
+ if (request.mode === 'navigate') {
+ const fallback = await caches.match('/dashboard')
+ if (fallback) return fallback
+ }
+
+ return new Response(
+ JSON.stringify({ error: 'You are offline. Some features may be unavailable.' }),
+ {
+ status: 503,
+ statusText: 'Service Unavailable',
+ headers: { 'Content-Type': 'application/json' },
+ }
+ )
+ }
+}
+
+// ββ Message Relay (for transaction queue sync) βββββββββββββββββ
+
+self.addEventListener('message', (event) => {
+ if (!event.data) return
+
+ const { type, payload } = event.data
+
+ switch (type) {
+ case 'SKIP_WAITING':
+ self.skipWaiting()
+ break
+
+ case 'QUEUE_SYNC':
+ // Notify all clients about a queued transaction
+ self.clients.matchAll().then((clients) => {
+ clients.forEach((client) => {
+ client.postMessage({ type: 'QUEUE_SYNC', payload })
+ })
+ })
+ break
+
+ case 'CLEAR_CACHE':
+ caches.delete(STATIC_CACHE)
+ caches.delete(API_CACHE)
+ caches.delete(CACHE_NAME)
+ break
+
+ default:
+ break
+ }
+})
+
+// ββ Periodic Background Sync (if available) ββββββββββββββββββββ
+
+self.addEventListener('periodicsync', (event) => {
+ if (event.tag === 'sync-transaction-queue') {
+ event.waitUntil(syncTransactionQueue())
+ }
+})
+
+async function syncTransactionQueue() {
+ // The actual queue processing logic runs in the client via the
+ // useTransactionQueue hook. This periodic sync tag is a trigger
+ // that wakes the page to process the queue.
+ const clients = await self.clients.matchAll({ type: 'window' })
+ clients.forEach((client) => {
+ client.postMessage({ type: 'PERIODIC_SYNC_TRIGGER' })
+ })
+}