diff --git a/frontend/next.config.js b/frontend/next.config.js
new file mode 100644
index 0000000..29c081a
--- /dev/null
+++ b/frontend/next.config.js
@@ -0,0 +1,17 @@
+const withPWA = require('next-pwa')({
+ dest: 'public',
+ register: true,
+ skipWaiting: true,
+ disable: process.env.NODE_ENV === 'development',
+});
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ swcMinify: true,
+ images: {
+ domains: ['localhost'],
+ },
+};
+
+module.exports = withPWA(nextConfig);
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..5d6ccb6
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "verinode-pwa",
+ "version": "1.0.0",
+ "description": "Verinode Progressive Web App for TradeFlow verification",
+ "main": "index.js",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "^14.0.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "typescript": "^5.0.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "tailwindcss": "^3.3.0",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.31",
+ "workbox-webpack-plugin": "^7.0.0",
+ "workbox-window": "^7.0.0"
+ },
+ "devDependencies": {
+ "eslint": "^8.0.0",
+ "eslint-config-next": "^14.0.0"
+ },
+ "keywords": ["pwa", "react", "nextjs", "tradeflow", "verification"],
+ "author": "TradeFlow Team",
+ "license": "MIT"
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..33ad091
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..0a670a9
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -0,0 +1,97 @@
+{
+ "name": "Verinode - TradeFlow Verification",
+ "short_name": "Verinode",
+ "description": "Progressive Web App for TradeFlow verification and proof management",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#3b82f6",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "icons": [
+ {
+ "src": "/icons/icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-128x128.png",
+ "sizes": "128x128",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-152x152.png",
+ "sizes": "152x152",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-384x384.png",
+ "sizes": "384x384",
+ "type": "image/png",
+ "purpose": "maskable any"
+ },
+ {
+ "src": "/icons/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable any"
+ }
+ ],
+ "categories": ["business", "finance", "productivity"],
+ "lang": "en-US",
+ "dir": "ltr",
+ "prefer_related_applications": false,
+ "shortcuts": [
+ {
+ "name": "View Proofs",
+ "short_name": "Proofs",
+ "description": "View and manage verification proofs",
+ "url": "/proofs",
+ "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
+ },
+ {
+ "name": "New Verification",
+ "short_name": "Verify",
+ "description": "Start a new verification process",
+ "url": "/verify",
+ "icons": [{ "src": "/icons/icon-96x96.png", "sizes": "96x96" }]
+ }
+ ],
+ "screenshots": [
+ {
+ "src": "/screenshots/desktop-1.png",
+ "sizes": "1280x720",
+ "type": "image/png",
+ "form_factor": "wide",
+ "label": "Verinode desktop view"
+ },
+ {
+ "src": "/screenshots/mobile-1.png",
+ "sizes": "375x667",
+ "type": "image/png",
+ "form_factor": "narrow",
+ "label": "Verinode mobile view"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..5ca97bf
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,324 @@
+const CACHE_NAME = 'verinode-v1';
+const STATIC_CACHE = 'verinode-static-v1';
+const DYNAMIC_CACHE = 'verinode-dynamic-v1';
+const PROOF_CACHE = 'verinode-proofs-v1';
+
+// Assets to cache immediately
+const STATIC_ASSETS = [
+ '/',
+ '/manifest.json',
+ '/_next/static/css/',
+ '/_next/static/chunks/',
+ '/icons/icon-192x192.png',
+ '/icons/icon-512x512.png',
+];
+
+// API endpoints to cache with network-first strategy
+const API_ENDPOINTS = [
+ '/api/proofs',
+ '/api/verification',
+];
+
+// Install event - cache static assets
+self.addEventListener('install', (event) => {
+ console.log('[SW] Installing service worker...');
+
+ event.waitUntil(
+ caches.open(STATIC_CACHE)
+ .then((cache) => {
+ console.log('[SW] Caching static assets');
+ return cache.addAll(STATIC_ASSETS);
+ })
+ .then(() => self.skipWaiting())
+ );
+});
+
+// Activate event - clean up old caches
+self.addEventListener('activate', (event) => {
+ console.log('[SW] Activating service worker...');
+
+ event.waitUntil(
+ caches.keys()
+ .then((cacheNames) => {
+ return Promise.all(
+ cacheNames.map((cacheName) => {
+ if (cacheName !== STATIC_CACHE &&
+ cacheName !== DYNAMIC_CACHE &&
+ cacheName !== PROOF_CACHE) {
+ console.log('[SW] Deleting old cache:', cacheName);
+ return caches.delete(cacheName);
+ }
+ })
+ );
+ })
+ .then(() => self.clients.claim())
+ );
+});
+
+// Fetch event - implement caching strategies
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET requests
+ if (request.method !== 'GET') return;
+
+ // Handle different types of requests
+ if (isAPIRequest(url)) {
+ event.respondWith(networkFirstStrategy(request));
+ } else if (isStaticAsset(url)) {
+ event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
+ } else if (isProofRequest(url)) {
+ event.respondWith(staleWhileRevalidate(request, PROOF_CACHE));
+ } else {
+ event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
+ }
+});
+
+// Background sync for offline actions
+self.addEventListener('sync', (event) => {
+ console.log('[SW] Background sync triggered:', event.tag);
+
+ if (event.tag === 'background-sync-proofs') {
+ event.waitUntil(syncProofs());
+ } else if (event.tag === 'background-sync-verification') {
+ event.waitUntil(syncVerifications());
+ }
+});
+
+// Push notification handler
+self.addEventListener('push', (event) => {
+ console.log('[SW] Push message received');
+
+ const options = {
+ body: event.data ? event.data.text() : 'New verification update available',
+ icon: '/icons/icon-192x192.png',
+ badge: '/icons/icon-96x96.png',
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ primaryKey: 1
+ },
+ actions: [
+ {
+ action: 'explore',
+ title: 'View Details',
+ icon: '/icons/icon-96x96.png'
+ },
+ {
+ action: 'close',
+ title: 'Close',
+ icon: '/icons/icon-96x96.png'
+ }
+ ]
+ };
+
+ event.waitUntil(
+ self.registration.showNotification('Verinode Update', options)
+ );
+});
+
+// Notification click handler
+self.addEventListener('notificationclick', (event) => {
+ console.log('[SW] Notification click received');
+
+ event.notification.close();
+
+ if (event.action === 'explore') {
+ event.waitUntil(
+ clients.openWindow('/proofs')
+ );
+ } else if (event.action === 'close') {
+ // Just close the notification
+ } else {
+ // Default action - open the app
+ event.waitUntil(
+ clients.matchAll().then((clientList) => {
+ for (const client of clientList) {
+ if (client.url === '/' && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ if (clients.openWindow) {
+ return clients.openWindow('/');
+ }
+ })
+ );
+ }
+});
+
+// Caching strategies
+async function cacheFirstStrategy(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cachedResponse = await cache.match(request);
+
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ try {
+ const networkResponse = await fetch(request);
+ if (networkResponse.ok) {
+ cache.put(request, networkResponse.clone());
+ }
+ return networkResponse;
+ } catch (error) {
+ console.log('[SW] Network request failed, returning cached or offline page');
+ return new Response('Offline - No cached data available', {
+ status: 503,
+ statusText: 'Service Unavailable'
+ });
+ }
+}
+
+async function networkFirstStrategy(request) {
+ try {
+ const networkResponse = await fetch(request);
+ if (networkResponse.ok) {
+ const cache = await caches.open(DYNAMIC_CACHE);
+ cache.put(request, networkResponse.clone());
+ }
+ return networkResponse;
+ } catch (error) {
+ console.log('[SW] Network failed, trying cache');
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ return new Response('Offline - No cached data available', {
+ status: 503,
+ statusText: 'Service Unavailable'
+ });
+ }
+}
+
+async function staleWhileRevalidate(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const cachedResponse = await cache.match(request);
+
+ const fetchPromise = fetch(request).then((networkResponse) => {
+ if (networkResponse.ok) {
+ cache.put(request, networkResponse.clone());
+ }
+ return networkResponse;
+ });
+
+ return cachedResponse || fetchPromise;
+}
+
+// Helper functions
+function isAPIRequest(url) {
+ return API_ENDPOINTS.some(endpoint => url.pathname.includes(endpoint));
+}
+
+function isStaticAsset(url) {
+ return STATIC_ASSETS.some(asset => url.pathname.includes(asset)) ||
+ url.pathname.includes('/_next/static/') ||
+ url.pathname.includes('/icons/');
+}
+
+function isProofRequest(url) {
+ return url.pathname.includes('/proofs') || url.pathname.includes('/verification');
+}
+
+// Background sync functions
+async function syncProofs() {
+ console.log('[SW] Syncing proofs...');
+
+ try {
+ const offlineProofs = await getOfflineData('proofs');
+
+ for (const proof of offlineProofs) {
+ try {
+ const response = await fetch('/api/proofs', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(proof)
+ });
+
+ if (response.ok) {
+ await removeOfflineData('proofs', proof.id);
+ console.log('[SW] Successfully synced proof:', proof.id);
+ }
+ } catch (error) {
+ console.error('[SW] Failed to sync proof:', proof.id, error);
+ }
+ }
+ } catch (error) {
+ console.error('[SW] Error syncing proofs:', error);
+ }
+}
+
+async function syncVerifications() {
+ console.log('[SW] Syncing verifications...');
+
+ try {
+ const offlineVerifications = await getOfflineData('verifications');
+
+ for (const verification of offlineVerifications) {
+ try {
+ const response = await fetch('/api/verification', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(verification)
+ });
+
+ if (response.ok) {
+ await removeOfflineData('verifications', verification.id);
+ console.log('[SW] Successfully synced verification:', verification.id);
+ }
+ } catch (error) {
+ console.error('[SW] Failed to sync verification:', verification.id, error);
+ }
+ }
+ } catch (error) {
+ console.error('[SW] Error syncing verifications:', error);
+ }
+}
+
+// IndexedDB helpers for offline storage
+async function getOfflineData(storeName) {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('verinode-offline', 1);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const db = request.result;
+ const transaction = db.transaction([storeName], 'readonly');
+ const store = transaction.objectStore(storeName);
+ const getAllRequest = store.getAll();
+
+ getAllRequest.onsuccess = () => resolve(getAllRequest.result);
+ getAllRequest.onerror = () => reject(getAllRequest.error);
+ };
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(storeName)) {
+ db.createObjectStore(storeName, { keyPath: 'id' });
+ }
+ };
+ });
+}
+
+async function removeOfflineData(storeName, id) {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('verinode-offline', 1);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ const db = request.result;
+ const transaction = db.transaction([storeName], 'readwrite');
+ const store = transaction.objectStore(storeName);
+ const deleteRequest = store.delete(id);
+
+ deleteRequest.onsuccess = () => resolve();
+ deleteRequest.onerror = () => reject(deleteRequest.error);
+ };
+ });
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
new file mode 100644
index 0000000..4140896
--- /dev/null
+++ b/frontend/src/app/globals.css
@@ -0,0 +1,361 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom CSS variables for PWA */
+:root {
+ --primary-color: #3b82f6;
+ --primary-hover: #2563eb;
+ --primary-active: #1d4ed8;
+ --success-color: #10b981;
+ --warning-color: #f59e0b;
+ --error-color: #ef4444;
+ --text-primary: #111827;
+ --text-secondary: #6b7280;
+ --bg-primary: #ffffff;
+ --bg-secondary: #f9fafb;
+ --border-color: #e5e7eb;
+}
+
+/* Dark mode support */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --text-primary: #f9fafb;
+ --text-secondary: #d1d5db;
+ --bg-primary: #111827;
+ --bg-secondary: #1f2937;
+ --border-color: #374151;
+ }
+}
+
+/* Base styles */
+* {
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+}
+
+body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+#root {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+/* PWA specific styles */
+.app-shell {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ position: relative;
+}
+
+.app-header {
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ background-color: var(--bg-primary);
+ border-bottom: 1px solid var(--border-color);
+ backdrop-filter: blur(8px);
+ background-color: rgba(255, 255, 255, 0.95);
+}
+
+@media (prefers-color-scheme: dark) {
+ .app-header {
+ background-color: rgba(17, 24, 39, 0.95);
+ }
+}
+
+.app-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.app-footer {
+ background-color: var(--bg-secondary);
+ border-top: 1px solid var(--border-color);
+ padding: 1rem;
+ text-align: center;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* Navigation styles */
+.nav-container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1rem;
+ height: 4rem;
+ max-width: 100%;
+}
+
+.nav-logo {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 600;
+ font-size: 1.125rem;
+ color: var(--primary-color);
+ text-decoration: none;
+}
+
+.nav-menu {
+ display: none;
+ gap: 1rem;
+ align-items: center;
+}
+
+@media (min-width: 768px) {
+ .nav-menu {
+ display: flex;
+ }
+}
+
+.nav-mobile-toggle {
+ display: block;
+ background: none;
+ border: none;
+ padding: 0.5rem;
+ cursor: pointer;
+ color: var(--text-primary);
+}
+
+@media (min-width: 768px) {
+ .nav-mobile-toggle {
+ display: none;
+ }
+}
+
+/* Mobile navigation drawer */
+.mobile-nav {
+ position: fixed;
+ top: 0;
+ left: -100%;
+ width: 80%;
+ max-width: 300px;
+ height: 100vh;
+ background-color: var(--bg-primary);
+ border-right: 1px solid var(--border-color);
+ z-index: 50;
+ transition: left 0.3s ease-in-out;
+ overflow-y: auto;
+}
+
+.mobile-nav.open {
+ left: 0;
+}
+
+.mobile-nav-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 45;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
+}
+
+.mobile-nav-overlay.open {
+ opacity: 1;
+ visibility: visible;
+}
+
+/* Content area styles */
+.content-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 1rem;
+ width: 100%;
+}
+
+@media (min-width: 640px) {
+ .content-container {
+ padding: 1.5rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .content-container {
+ padding: 2rem;
+ }
+}
+
+/* Card styles */
+.card {
+ background-color: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 0.75rem;
+ padding: 1.5rem;
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
+ transition: box-shadow 0.2s ease-in-out;
+}
+
+.card:hover {
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+/* Button styles */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: 1px solid transparent;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.25rem;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.2s ease-in-out;
+ white-space: nowrap;
+}
+
+.btn-primary {
+ background-color: var(--primary-color);
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: var(--primary-hover);
+}
+
+.btn-secondary {
+ background-color: transparent;
+ color: var(--text-primary);
+ border-color: var(--border-color);
+}
+
+.btn-secondary:hover {
+ background-color: var(--bg-secondary);
+}
+
+.btn-ghost {
+ background-color: transparent;
+ color: var(--text-secondary);
+}
+
+.btn-ghost:hover {
+ background-color: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
+/* Loading states */
+.loading-spinner {
+ width: 1.5rem;
+ height: 1.5rem;
+ border: 2px solid var(--border-color);
+ border-top: 2px solid var(--primary-color);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* Skeleton loading */
+.skeleton {
+ background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
+ background-size: 200% 100%;
+ animation: loading 1.5s infinite;
+}
+
+@keyframes loading {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* Responsive grid */
+.grid {
+ display: grid;
+ gap: 1rem;
+}
+
+.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+
+@media (min-width: 640px) {
+ .sm\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+ .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .sm\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+
+@media (min-width: 768px) {
+ .md\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+ .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+
+@media (min-width: 1024px) {
+ .lg\:grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+ .lg\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+ .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+}
+
+/* Utility classes */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Focus styles for accessibility */
+.focus-ring:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
+/* Print styles */
+@media print {
+ .no-print {
+ display: none !important;
+ }
+
+ .app-header,
+ .app-footer {
+ display: none !important;
+ }
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
new file mode 100644
index 0000000..8ec030f
--- /dev/null
+++ b/frontend/src/app/layout.tsx
@@ -0,0 +1,64 @@
+import type { Metadata } from 'next';
+import { Inter } from 'next/font/google';
+import './globals.css';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata: Metadata = {
+ title: 'Verinode - TradeFlow Verification',
+ description: 'Progressive Web App for TradeFlow verification and proof management',
+ manifest: '/manifest.json',
+ themeColor: '#3b82f6',
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: 'default',
+ title: 'Verinode',
+ },
+ formatDetection: {
+ telephone: false,
+ },
+ openGraph: {
+ type: 'website',
+ siteName: 'Verinode',
+ title: 'Verinode - TradeFlow Verification',
+ description: 'Progressive Web App for TradeFlow verification and proof management',
+ },
+ twitter: {
+ card: 'summary',
+ title: 'Verinode - TradeFlow Verification',
+ description: 'Progressive Web App for TradeFlow verification and proof management',
+ },
+ viewport: {
+ width: 'device-width',
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+ viewportFit: 'cover',
+ },
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
new file mode 100644
index 0000000..fd4460a
--- /dev/null
+++ b/frontend/src/app/page.tsx
@@ -0,0 +1,307 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import InstallPrompt from '@/components/PWA/InstallPrompt';
+import OfflineIndicator from '@/components/PWA/OfflineIndicator';
+import SyncStatus from '@/components/PWA/SyncStatus';
+import { useServiceWorker } from '@/hooks/useServiceWorker';
+import { useOfflineSync } from '@/hooks/useOfflineSync';
+
+export default function HomePage() {
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const { isReady, updateAvailable, applyUpdate } = useServiceWorker();
+ const { isOnline, pendingItems } = useOfflineSync();
+
+ useEffect(() => {
+ // Register service worker and initialize PWA features
+ if (isReady) {
+ console.log('PWA is ready');
+ }
+ }, [isReady]);
+
+ const navigationItems = [
+ { name: 'Dashboard', href: '/', icon: '🏠' },
+ { name: 'Proofs', href: '/proofs', icon: '📄' },
+ { name: 'Verify', href: '/verify', icon: '✅' },
+ { name: 'Settings', href: '/settings', icon: '⚙️' },
+ ];
+
+ return (
+
+ {/* Update Available Banner */}
+ {updateAvailable && (
+
+
+ A new version is available!
+
+ Update Now
+
+
+
+ )}
+
+ {/* Header */}
+
+
+
+
+ V
+
+ Verinode
+
+
+ {/* Desktop Navigation */}
+
+
+ {/* Mobile Menu Toggle */}
+ setIsMobileMenuOpen(!isMobileMenuOpen)}
+ className="nav-mobile-toggle"
+ aria-label="Toggle navigation"
+ >
+
+ {isMobileMenuOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Mobile Navigation Drawer */}
+
setIsMobileMenuOpen(false)} />
+
+
+
+
+
+
+
+
+
+ Status:
+
+ {isOnline ? 'Online' : 'Offline'}
+
+
+ {pendingItems.length > 0 && (
+
+ Pending:
+ {pendingItems.length} items
+
+ )}
+
+
+
+
+
+ {/* Main Content */}
+
+
+ {/* Hero Section */}
+
+
+ Welcome to Verinode
+
+
+ Your Progressive Web App for TradeFlow verification and proof management.
+ Work offline, sync automatically, and get instant notifications.
+
+
+
+
+
+ {/* Features Grid */}
+
+
+ PWA Features
+
+
+
+ {/* Offline Support */}
+
+
+
Offline Support
+
+ Access your proofs and verification data even without an internet connection.
+
+
+
+ {/* Push Notifications */}
+
+
+
Push Notifications
+
+ Get instant updates when verification status changes or new proofs are available.
+
+
+
+ {/* Background Sync */}
+
+
+
Background Sync
+
+ Automatically sync your data when you come back online, no manual intervention needed.
+
+
+
+ {/* App Installation */}
+
+
+
Install as App
+
+ Install Verinode on your device for a native app experience with quick access.
+
+
+
+ {/* Fast Loading */}
+
+
+
Instant Loading
+
+ App shell architecture ensures the app loads instantly even on slow connections.
+
+
+
+ {/* Responsive Design */}
+
+
+
Responsive Design
+
+ Optimized for all devices - mobile, tablet, and desktop with adaptive layouts.
+
+
+
+
+
+ {/* Status Section */}
+
+
+ Current Status
+
+
+
+
+
+
+ Connection Status:
+
+ {isOnline ? '🟢 Online' : '🔴 Offline'}
+
+
+
+
+ PWA Status:
+
+ {isReady ? '✅ Ready' : '⏳ Loading...'}
+
+
+
+
+ Pending Sync Items:
+ 0
+ ? 'bg-orange-100 text-orange-800'
+ : 'bg-green-100 text-green-800'
+ }`}>
+ {pendingItems.length} items
+
+
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
+ {/* PWA Components */}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/PWA/InstallPrompt.tsx b/frontend/src/components/PWA/InstallPrompt.tsx
new file mode 100644
index 0000000..9b90908
--- /dev/null
+++ b/frontend/src/components/PWA/InstallPrompt.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface BeforeInstallPromptEvent extends Event {
+ readonly platforms: string[];
+ readonly userChoice: Promise<{
+ outcome: 'accepted' | 'dismissed';
+ platform: string;
+ }>;
+ prompt(): Promise
;
+}
+
+export default function InstallPrompt() {
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [showInstallButton, setShowInstallButton] = useState(false);
+ const [isInstalled, setIsInstalled] = useState(false);
+
+ useEffect(() => {
+ // Check if app is already installed
+ const checkIfInstalled = () => {
+ const isInStandaloneMode =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (window.navigator as any).standalone ||
+ document.referrer.includes('android-app://');
+
+ setIsInstalled(isInStandaloneMode);
+ };
+
+ checkIfInstalled();
+
+ // Listen for beforeinstallprompt event
+ const handleBeforeInstallPrompt = (e: Event) => {
+ e.preventDefault();
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
+ setShowInstallButton(true);
+ };
+
+ // Listen for app installed event
+ const handleAppInstalled = () => {
+ setShowInstallButton(false);
+ setDeferredPrompt(null);
+ setIsInstalled(true);
+ };
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
+ window.addEventListener('appinstalled', handleAppInstalled);
+
+ return () => {
+ window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
+ window.removeEventListener('appinstalled', handleAppInstalled);
+ };
+ }, []);
+
+ const handleInstallClick = async () => {
+ if (!deferredPrompt) return;
+
+ try {
+ await deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+
+ if (outcome === 'accepted') {
+ console.log('User accepted the install prompt');
+ } else {
+ console.log('User dismissed the install prompt');
+ }
+
+ setDeferredPrompt(null);
+ setShowInstallButton(false);
+ } catch (error) {
+ console.error('Error during installation:', error);
+ }
+ };
+
+ const handleDismiss = () => {
+ setShowInstallButton(false);
+ // Store dismissal in localStorage to not show again for a while
+ localStorage.setItem('install-prompt-dismissed', Date.now().toString());
+ };
+
+ // Don't show if already installed or no install prompt available
+ if (isInstalled || !showInstallButton) return null;
+
+ // Check if user recently dismissed the prompt
+ const dismissedTime = localStorage.getItem('install-prompt-dismissed');
+ if (dismissedTime) {
+ const daysSinceDismissed = (Date.now() - parseInt(dismissedTime)) / (1000 * 60 * 60 * 24);
+ if (daysSinceDismissed < 7) return null; // Don't show for 7 days after dismissal
+ }
+
+ return (
+
+
+
+
+
+
+ Install Verinode
+
+
+ Install our app for faster access and offline capabilities. Get instant notifications and a native app experience.
+
+
+
+
+
+
+
+ Install App
+
+
+
+ Not now
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/PWA/OfflineIndicator.tsx b/frontend/src/components/PWA/OfflineIndicator.tsx
new file mode 100644
index 0000000..ffcd69b
--- /dev/null
+++ b/frontend/src/components/PWA/OfflineIndicator.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+export default function OfflineIndicator() {
+ const [isOnline, setIsOnline] = useState(true);
+ const [showOfflineMessage, setShowOfflineMessage] = useState(false);
+
+ useEffect(() => {
+ // Initialize online status
+ setIsOnline(navigator.onLine);
+
+ const handleOnline = () => {
+ setIsOnline(true);
+ setShowOfflineMessage(false);
+ };
+
+ const handleOffline = () => {
+ setIsOnline(false);
+ setShowOfflineMessage(true);
+ };
+
+ // Listen for online/offline events
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ // Auto-hide offline message after 5 seconds when coming back online
+ useEffect(() => {
+ if (isOnline && showOfflineMessage) {
+ const timer = setTimeout(() => {
+ setShowOfflineMessage(false);
+ }, 5000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isOnline, showOfflineMessage]);
+
+ if (isOnline && !showOfflineMessage) return null;
+
+ return (
+
+
+
+
+ {isOnline ? (
+ <>
+
+
+
+
+ You're back online! All data has been synced.
+
+ >
+ ) : (
+ <>
+
+
+
+
+ You're offline. Some features may be limited.
+
+ >
+ )}
+
+
+ {showOfflineMessage && (
+
setShowOfflineMessage(false)}
+ className="ml-4 text-white hover:text-gray-200 transition-colors"
+ >
+
+
+
+
+ )}
+
+
+
+ {/* Add spacing below the indicator */}
+ {!isOnline &&
}
+
+ );
+}
diff --git a/frontend/src/components/PWA/SyncStatus.tsx b/frontend/src/components/PWA/SyncStatus.tsx
new file mode 100644
index 0000000..e963bd0
--- /dev/null
+++ b/frontend/src/components/PWA/SyncStatus.tsx
@@ -0,0 +1,296 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface SyncStatus {
+ isOnline: boolean;
+ lastSyncTime: Date | null;
+ pendingSyncs: number;
+ isSyncing: boolean;
+ syncError: string | null;
+}
+
+export default function SyncStatus() {
+ const [syncStatus, setSyncStatus] = useState({
+ isOnline: navigator.onLine,
+ lastSyncTime: null,
+ pendingSyncs: 0,
+ isSyncing: false,
+ syncError: null,
+ });
+
+ const [showDetails, setShowDetails] = useState(false);
+
+ useEffect(() => {
+ const updateOnlineStatus = () => {
+ setSyncStatus(prev => ({
+ ...prev,
+ isOnline: navigator.onLine,
+ }));
+ };
+
+ const handleSyncEvent = (event: CustomEvent) => {
+ setSyncStatus(prev => ({
+ ...prev,
+ ...event.detail,
+ }));
+ };
+
+ // Listen for online/offline events
+ window.addEventListener('online', updateOnlineStatus);
+ window.addEventListener('offline', updateOnlineStatus);
+
+ // Listen for custom sync events from service worker
+ window.addEventListener('sync-status', handleSyncEvent as EventListener);
+
+ // Check for pending syncs on load
+ checkPendingSyncs();
+
+ return () => {
+ window.removeEventListener('online', updateOnlineStatus);
+ window.removeEventListener('offline', updateOnlineStatus);
+ window.removeEventListener('sync-status', handleSyncEvent as EventListener);
+ };
+ }, []);
+
+ const checkPendingSyncs = async () => {
+ try {
+ const db = await openDB();
+ const proofsStore = db.transaction('proofs', 'readonly').objectStore('proofs');
+ const verificationsStore = db.transaction('verifications', 'readonly').objectStore('verifications');
+
+ const pendingProofs = await proofsStore.count();
+ const pendingVerifications = await verificationsStore.count();
+
+ setSyncStatus(prev => ({
+ ...prev,
+ pendingSyncs: pendingProofs + pendingVerifications,
+ }));
+
+ db.close();
+ } catch (error) {
+ console.error('Error checking pending syncs:', error);
+ }
+ };
+
+ const openDB = (): Promise => {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('verinode-offline', 1);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('proofs')) {
+ db.createObjectStore('proofs', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('verifications')) {
+ db.createObjectStore('verifications', { keyPath: 'id' });
+ }
+ };
+ });
+ };
+
+ const triggerManualSync = async () => {
+ if (!syncStatus.isOnline || syncStatus.isSyncing) return;
+
+ setSyncStatus(prev => ({ ...prev, isSyncing: true, syncError: null }));
+
+ try {
+ // Register background sync if supported
+ if ('serviceWorker' in navigator && 'SyncManager' in window) {
+ const registration = await navigator.serviceWorker.ready;
+ await registration.sync.register('background-sync-proofs');
+ await registration.sync.register('background-sync-verification');
+ } else {
+ // Fallback: manual sync
+ await performManualSync();
+ }
+ } catch (error) {
+ console.error('Manual sync failed:', error);
+ setSyncStatus(prev => ({
+ ...prev,
+ syncError: 'Sync failed. Please try again.',
+ isSyncing: false,
+ }));
+ }
+ };
+
+ const performManualSync = async () => {
+ // This would implement the same logic as the service worker sync
+ // but executed in the main thread for browsers without background sync
+ try {
+ const db = await openDB();
+
+ // Sync proofs
+ const proofsStore = db.transaction('proofs', 'readwrite').objectStore('proofs');
+ const allProofs = await proofsStore.getAll();
+
+ for (const proof of allProofs) {
+ try {
+ const response = await fetch('/api/proofs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(proof),
+ });
+
+ if (response.ok) {
+ await proofsStore.delete(proof.id);
+ }
+ } catch (error) {
+ console.error('Failed to sync proof:', proof.id, error);
+ }
+ }
+
+ // Sync verifications
+ const verificationsStore = db.transaction('verifications', 'readwrite').objectStore('verifications');
+ const allVerifications = await verificationsStore.getAll();
+
+ for (const verification of allVerifications) {
+ try {
+ const response = await fetch('/api/verification', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(verification),
+ });
+
+ if (response.ok) {
+ await verificationsStore.delete(verification.id);
+ }
+ } catch (error) {
+ console.error('Failed to sync verification:', verification.id, error);
+ }
+ }
+
+ db.close();
+
+ setSyncStatus(prev => ({
+ ...prev,
+ isSyncing: false,
+ lastSyncTime: new Date(),
+ pendingSyncs: 0,
+ }));
+ } catch (error) {
+ setSyncStatus(prev => ({
+ ...prev,
+ isSyncing: false,
+ syncError: 'Manual sync failed',
+ }));
+ }
+ };
+
+ const getStatusColor = () => {
+ if (syncStatus.syncError) return 'text-red-600';
+ if (syncStatus.isSyncing) return 'text-yellow-600';
+ if (!syncStatus.isOnline) return 'text-gray-500';
+ if (syncStatus.pendingSyncs > 0) return 'text-orange-600';
+ return 'text-green-600';
+ };
+
+ const getStatusIcon = () => {
+ if (syncStatus.syncError) {
+ return (
+
+
+
+ );
+ }
+ if (syncStatus.isSyncing) {
+ return (
+
+
+
+ );
+ }
+ if (!syncStatus.isOnline) {
+ return (
+
+
+
+ );
+ }
+ if (syncStatus.pendingSyncs > 0) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ };
+
+ const getStatusText = () => {
+ if (syncStatus.syncError) return 'Sync Error';
+ if (syncStatus.isSyncing) return 'Syncing...';
+ if (!syncStatus.isOnline) return 'Offline';
+ if (syncStatus.pendingSyncs > 0) return `${syncStatus.pendingSyncs} Pending`;
+ return 'Synced';
+ };
+
+ return (
+
+
+
setShowDetails(!showDetails)}
+ className="flex items-center justify-between w-full text-left"
+ >
+
+
+ {getStatusIcon()}
+
+
+ {getStatusText()}
+
+
+
+
+
+
+
+
+ {showDetails && (
+
+ {syncStatus.lastSyncTime && (
+
+ Last sync: {syncStatus.lastSyncTime.toLocaleTimeString()}
+
+ )}
+
+ {syncStatus.pendingSyncs > 0 && (
+
+ {syncStatus.pendingSyncs} items waiting to sync
+
+ )}
+
+ {syncStatus.syncError && (
+
+ Error: {syncStatus.syncError}
+
+ )}
+
+ {syncStatus.isOnline && syncStatus.pendingSyncs > 0 && !syncStatus.isSyncing && (
+
+ Sync Now
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/hooks/useOfflineSync.ts b/frontend/src/hooks/useOfflineSync.ts
new file mode 100644
index 0000000..6c4f3e1
--- /dev/null
+++ b/frontend/src/hooks/useOfflineSync.ts
@@ -0,0 +1,282 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useServiceWorker } from './useServiceWorker';
+
+interface OfflineData {
+ proofs: any[];
+ verifications: any[];
+}
+
+interface SyncQueueItem {
+ id: string;
+ type: 'proof' | 'verification';
+ data: any;
+ timestamp: number;
+ retryCount: number;
+}
+
+export function useOfflineSync() {
+ const [isOnline, setIsOnline] = useState(true);
+ const [pendingItems, setPendingItems] = useState([]);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [lastSyncTime, setLastSyncTime] = useState(null);
+ const [syncError, setSyncError] = useState(null);
+
+ const { triggerBackgroundSync, isReady: swReady } = useServiceWorker();
+
+ useEffect(() => {
+ const updateOnlineStatus = () => {
+ const online = navigator.onLine;
+ setIsOnline(online);
+
+ if (online && pendingItems.length > 0) {
+ // Trigger sync when coming back online
+ syncPendingItems();
+ }
+ };
+
+ // Initialize online status
+ updateOnlineStatus();
+
+ // Listen for online/offline events
+ window.addEventListener('online', updateOnlineStatus);
+ window.addEventListener('offline', updateOnlineStatus);
+
+ // Load pending items from IndexedDB on mount
+ loadPendingItems();
+
+ return () => {
+ window.removeEventListener('online', updateOnlineStatus);
+ window.removeEventListener('offline', updateOnlineStatus);
+ };
+ }, [pendingItems.length]);
+
+ const openDB = useCallback((): Promise => {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('verinode-offline', 1);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('proofs')) {
+ db.createObjectStore('proofs', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('verifications')) {
+ db.createObjectStore('verifications', { keyPath: 'id' });
+ }
+ if (!db.objectStoreNames.contains('syncQueue')) {
+ const syncQueueStore = db.createObjectStore('syncQueue', { keyPath: 'id' });
+ syncQueueStore.createIndex('type', 'type', { unique: false });
+ syncQueueStore.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+ };
+ });
+ }, []);
+
+ const loadPendingItems = useCallback(async () => {
+ try {
+ const db = await openDB();
+ const transaction = db.transaction(['syncQueue'], 'readonly');
+ const store = transaction.objectStore('syncQueue');
+ const request = store.getAll();
+
+ request.onsuccess = () => {
+ setPendingItems(request.result || []);
+ };
+
+ db.close();
+ } catch (error) {
+ console.error('Error loading pending items:', error);
+ }
+ }, [openDB]);
+
+ const addToSyncQueue = useCallback(async (type: 'proof' | 'verification', data: any) => {
+ const item: SyncQueueItem = {
+ id: `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ type,
+ data,
+ timestamp: Date.now(),
+ retryCount: 0,
+ };
+
+ try {
+ const db = await openDB();
+ const transaction = db.transaction(['syncQueue'], 'readwrite');
+ const store = transaction.objectStore('syncQueue');
+ await store.add(item);
+
+ setPendingItems(prev => [...prev, item]);
+
+ // Also store in the appropriate store for offline access
+ const dataStore = db.transaction([type + 's'], 'readwrite').objectStore(type + 's');
+ await dataStore.add({ ...data, id: item.id });
+
+ db.close();
+
+ // Try to sync immediately if online
+ if (isOnline && swReady) {
+ triggerBackgroundSync(`background-sync-${type}s`);
+ }
+
+ } catch (error) {
+ console.error('Error adding to sync queue:', error);
+ throw error;
+ }
+ }, [openDB, isOnline, swReady, triggerBackgroundSync]);
+
+ const saveProofOffline = useCallback(async (proof: any) => {
+ return addToSyncQueue('proof', proof);
+ }, [addToSyncQueue]);
+
+ const saveVerificationOffline = useCallback(async (verification: any) => {
+ return addToSyncQueue('verification', verification);
+ }, [addToSyncQueue]);
+
+ const syncPendingItems = useCallback(async () => {
+ if (!isOnline || isSyncing || pendingItems.length === 0) return;
+
+ setIsSyncing(true);
+ setSyncError(null);
+
+ try {
+ const db = await openDB();
+ const transaction = db.transaction(['syncQueue'], 'readwrite');
+ const store = transaction.objectStore('syncQueue');
+
+ const itemsToSync = [...pendingItems];
+ const successfulSyncs: string[] = [];
+ const failedSyncs: SyncQueueItem[] = [];
+
+ for (const item of itemsToSync) {
+ try {
+ const endpoint = item.type === 'proof' ? '/api/proofs' : '/api/verification';
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(item.data),
+ });
+
+ if (response.ok) {
+ successfulSyncs.push(item.id);
+
+ // Remove from the specific data store
+ const dataStore = db.transaction([item.type + 's'], 'readwrite').objectStore(item.type + 's');
+ await dataStore.delete(item.id);
+ } else {
+ // Increment retry count and keep in queue if under max retries
+ const updatedItem = { ...item, retryCount: item.retryCount + 1 };
+ if (updatedItem.retryCount < 3) {
+ await store.put(updatedItem);
+ failedSyncs.push(updatedItem);
+ } else {
+ // Remove from queue after max retries
+ await store.delete(item.id);
+ console.error(`Max retries exceeded for item ${item.id}`);
+ }
+ }
+ } catch (error) {
+ console.error(`Failed to sync item ${item.id}:`, error);
+
+ // Increment retry count
+ const updatedItem = { ...item, retryCount: item.retryCount + 1 };
+ if (updatedItem.retryCount < 3) {
+ await store.put(updatedItem);
+ failedSyncs.push(updatedItem);
+ } else {
+ await store.delete(item.id);
+ }
+ }
+ }
+
+ setPendingItems(failedSyncs);
+ setLastSyncTime(new Date());
+
+ if (failedSyncs.length > 0) {
+ setSyncError(`${failedSyncs.length} items failed to sync`);
+ }
+
+ db.close();
+ } catch (error) {
+ console.error('Error during sync:', error);
+ setSyncError('Sync failed. Please try again.');
+ } finally {
+ setIsSyncing(false);
+ }
+ }, [isOnline, isSyncing, pendingItems, openDB]);
+
+ const getOfflineData = useCallback(async (): Promise => {
+ try {
+ const db = await openDB();
+ const proofsTransaction = db.transaction(['proofs'], 'readonly');
+ const verificationsTransaction = db.transaction(['verifications'], 'readonly');
+
+ const proofs = await new Promise((resolve) => {
+ const request = proofsTransaction.objectStore('proofs').getAll();
+ request.onsuccess = () => resolve(request.result || []);
+ });
+
+ const verifications = await new Promise((resolve) => {
+ const request = verificationsTransaction.objectStore('verifications').getAll();
+ request.onsuccess = () => resolve(request.result || []);
+ });
+
+ db.close();
+
+ return { proofs, verifications };
+ } catch (error) {
+ console.error('Error getting offline data:', error);
+ return { proofs: [], verifications: [] };
+ }
+ }, [openDB]);
+
+ const clearOfflineData = useCallback(async () => {
+ try {
+ const db = await openDB();
+ const stores = ['proofs', 'verifications', 'syncQueue'];
+
+ for (const storeName of stores) {
+ const transaction = db.transaction([storeName], 'readwrite');
+ const store = transaction.objectStore(storeName);
+ await store.clear();
+ }
+
+ setPendingItems([]);
+ setLastSyncTime(new Date());
+
+ db.close();
+ } catch (error) {
+ console.error('Error clearing offline data:', error);
+ throw error;
+ }
+ }, [openDB]);
+
+ const getOfflineProofs = useCallback(async () => {
+ const data = await getOfflineData();
+ return data.proofs;
+ }, [getOfflineData]);
+
+ const getOfflineVerifications = useCallback(async () => {
+ const data = await getOfflineData();
+ return data.verifications;
+ }, [getOfflineData]);
+
+ return {
+ isOnline,
+ pendingItems,
+ isSyncing,
+ lastSyncTime,
+ syncError,
+ saveProofOffline,
+ saveVerificationOffline,
+ syncPendingItems,
+ getOfflineData,
+ getOfflineProofs,
+ getOfflineVerifications,
+ clearOfflineData,
+ };
+}
diff --git a/frontend/src/hooks/useServiceWorker.ts b/frontend/src/hooks/useServiceWorker.ts
new file mode 100644
index 0000000..25b307a
--- /dev/null
+++ b/frontend/src/hooks/useServiceWorker.ts
@@ -0,0 +1,146 @@
+'use client';
+
+import { useEffect, useState, useCallback } from 'react';
+
+interface ServiceWorkerRegistration {
+ installing: ServiceWorker | null;
+ waiting: ServiceWorker | null;
+ active: ServiceWorker | null;
+}
+
+export function useServiceWorker() {
+ const [registration, setRegistration] = useState(null);
+ const [isSupported, setIsSupported] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+ const [updateAvailable, setUpdateAvailable] = useState(false);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
+ setIsSupported(true);
+
+ // Register service worker
+ registerServiceWorker();
+ }
+ }, []);
+
+ const registerServiceWorker = useCallback(async () => {
+ try {
+ const reg = await navigator.serviceWorker.register('/sw.js', {
+ scope: '/'
+ });
+
+ setRegistration(reg);
+ setIsReady(true);
+
+ console.log('Service Worker registered with scope:', reg.scope);
+
+ // Check for updates
+ reg.addEventListener('updatefound', () => {
+ const newWorker = reg.installing;
+ if (newWorker) {
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ setUpdateAvailable(true);
+ }
+ });
+ }
+ });
+
+ // Handle controller change (new service worker activated)
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ console.log('Controller changed - reloading page');
+ window.location.reload();
+ });
+
+ // Listen for messages from service worker
+ navigator.serviceWorker.addEventListener('message', (event) => {
+ console.log('Message from service worker:', event.data);
+
+ // Handle custom events
+ if (event.data.type === 'SYNC_STATUS') {
+ window.dispatchEvent(new CustomEvent('sync-status', {
+ detail: event.data.payload
+ }));
+ }
+ });
+
+ } catch (error) {
+ console.error('Service Worker registration failed:', error);
+ }
+ }, []);
+
+ const applyUpdate = useCallback(() => {
+ if (registration && registration.waiting) {
+ registration.waiting.postMessage({ type: 'SKIP_WAITING' });
+ }
+ }, [registration]);
+
+ const unregisterServiceWorker = useCallback(async () => {
+ if (registration) {
+ await registration.unregister();
+ setRegistration(null);
+ setIsReady(false);
+ console.log('Service Worker unregistered');
+ }
+ }, [registration]);
+
+ const sendMessageToSW = useCallback(async (message: any) => {
+ if (registration && registration.active) {
+ registration.active.postMessage(message);
+ }
+ }, [registration]);
+
+ const triggerBackgroundSync = useCallback(async (tag: string) => {
+ if (registration && 'sync' in registration) {
+ try {
+ await registration.sync.register(tag);
+ console.log(`Background sync registered for tag: ${tag}`);
+ } catch (error) {
+ console.error('Background sync registration failed:', error);
+ }
+ }
+ }, [registration]);
+
+ const getNotificationsPermission = useCallback(async () => {
+ if ('Notification' in window) {
+ return Notification.permission;
+ }
+ return 'denied';
+ }, []);
+
+ const requestNotificationsPermission = useCallback(async () => {
+ if ('Notification' in window) {
+ const permission = await Notification.requestPermission();
+ return permission;
+ }
+ return 'denied';
+ }, []);
+
+ const showNotification = useCallback(async (title: string, options?: NotificationOptions) => {
+ if (registration && 'showNotification' in registration) {
+ try {
+ await registration.showNotification(title, {
+ icon: '/icons/icon-192x192.png',
+ badge: '/icons/icon-96x96.png',
+ ...options
+ });
+ } catch (error) {
+ console.error('Failed to show notification:', error);
+ }
+ }
+ }, [registration]);
+
+ return {
+ isSupported,
+ isReady,
+ registration,
+ updateAvailable,
+ applyUpdate,
+ unregisterServiceWorker,
+ sendMessageToSW,
+ triggerBackgroundSync,
+ getNotificationsPermission,
+ requestNotificationsPermission,
+ showNotification,
+ };
+}
diff --git a/frontend/src/utils/cacheStrategy.ts b/frontend/src/utils/cacheStrategy.ts
new file mode 100644
index 0000000..a005197
--- /dev/null
+++ b/frontend/src/utils/cacheStrategy.ts
@@ -0,0 +1,320 @@
+// Cache strategy utilities for PWA implementation
+
+export interface CacheOptions {
+ cacheName: string;
+ maxAge?: number; // in milliseconds
+ maxEntries?: number;
+ networkTimeout?: number; // in milliseconds
+}
+
+export interface CacheEntry {
+ response: Response;
+ timestamp: number;
+ url: string;
+}
+
+export class CacheStrategy {
+ private cacheName: string;
+ private maxAge: number;
+ private maxEntries: number;
+ private networkTimeout: number;
+
+ constructor(options: CacheOptions) {
+ this.cacheName = options.cacheName;
+ this.maxAge = options.maxAge || 24 * 60 * 60 * 1000; // 24 hours default
+ this.maxEntries = options.maxEntries || 100;
+ this.networkTimeout = options.networkTimeout || 3000; // 3 seconds default
+ }
+
+ // Cache First Strategy - tries cache first, then network
+ async cacheFirst(request: Request): Promise {
+ const cache = await caches.open(this.cacheName);
+ const cachedResponse = await cache.match(request);
+
+ if (cachedResponse && !this.isExpired(cachedResponse)) {
+ return cachedResponse;
+ }
+
+ try {
+ const networkResponse = await this.fetchWithTimeout(request);
+ if (networkResponse.ok) {
+ await this.putInCache(cache, request, networkResponse);
+ }
+ return networkResponse;
+ } catch (error) {
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ throw error;
+ }
+ }
+
+ // Network First Strategy - tries network first, then cache
+ async networkFirst(request: Request): Promise {
+ const cache = await caches.open(this.cacheName);
+
+ try {
+ const networkResponse = await this.fetchWithTimeout(request);
+ if (networkResponse.ok) {
+ await this.putInCache(cache, request, networkResponse);
+ }
+ return networkResponse;
+ } catch (error) {
+ const cachedResponse = await cache.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ throw error;
+ }
+ }
+
+ // Stale While Revalidate Strategy - serves from cache, updates in background
+ async staleWhileRevalidate(request: Request): Promise {
+ const cache = await caches.open(this.cacheName);
+ const cachedResponse = await cache.match(request);
+
+ const networkPromise = this.fetchWithTimeout(request)
+ .then(async (networkResponse) => {
+ if (networkResponse.ok) {
+ await this.putInCache(cache, request, networkResponse);
+ }
+ return networkResponse;
+ })
+ .catch(() => null);
+
+ if (cachedResponse) {
+ // Trigger network update in background
+ networkPromise.catch(() => {}); // Ignore errors for background update
+ return cachedResponse;
+ }
+
+ // If no cache, wait for network
+ const networkResponse = await networkPromise;
+ if (networkResponse) {
+ return networkResponse;
+ }
+
+ throw new Error('No cache available and network request failed');
+ }
+
+ // Network Only Strategy - always tries network, never cache
+ async networkOnly(request: Request): Promise {
+ return this.fetchWithTimeout(request);
+ }
+
+ // Cache Only Strategy - always serves from cache, never network
+ async cacheOnly(request: Request): Promise {
+ const cache = await caches.open(this.cacheName);
+ const cachedResponse = await cache.match(request);
+
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ throw new Error('No cache entry found');
+ }
+
+ private async fetchWithTimeout(request: Request): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.networkTimeout);
+
+ try {
+ const response = await fetch(request, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ return response;
+ } catch (error) {
+ clearTimeout(timeoutId);
+ throw error;
+ }
+ }
+
+ private async putInCache(cache: Cache, request: Request, response: Response): Promise {
+ // Clone the response since it can only be consumed once
+ const responseClone = response.clone();
+
+ // Add timestamp to response headers for expiration checking
+ const headers = new Headers(responseClone.headers);
+ headers.set('sw-cached-at', Date.now().toString());
+
+ const modifiedResponse = new Response(responseClone.body, {
+ status: responseClone.status,
+ statusText: responseClone.statusText,
+ headers,
+ });
+
+ await cache.put(request, modifiedResponse);
+
+ // Clean up old entries if maxEntries is exceeded
+ await this.cleanupCache(cache);
+ }
+
+ private isExpired(response: Response): boolean {
+ const cachedAt = response.headers.get('sw-cached-at');
+ if (!cachedAt) return true;
+
+ const cacheAge = Date.now() - parseInt(cachedAt);
+ return cacheAge > this.maxAge;
+ }
+
+ private async cleanupCache(cache: Cache): Promise {
+ const requests = await cache.keys();
+
+ if (requests.length <= this.maxEntries) return;
+
+ // Get all entries with their timestamps
+ const entries: CacheEntry[] = [];
+
+ for (const request of requests) {
+ const response = await cache.match(request);
+ if (response) {
+ const cachedAt = response.headers.get('sw-cached-at');
+ entries.push({
+ response,
+ timestamp: cachedAt ? parseInt(cachedAt) : 0,
+ url: request.url,
+ });
+ }
+ }
+
+ // Sort by timestamp (oldest first)
+ entries.sort((a, b) => a.timestamp - b.timestamp);
+
+ // Remove oldest entries to maintain maxEntries
+ const entriesToRemove = entries.slice(0, entries.length - this.maxEntries);
+
+ for (const entry of entriesToRemove) {
+ await cache.delete(entry.url);
+ }
+ }
+
+ // Clear all cache entries
+ async clearCache(): Promise {
+ const cache = await caches.open(this.cacheName);
+ const requests = await cache.keys();
+
+ for (const request of requests) {
+ await cache.delete(request);
+ }
+ }
+
+ // Get cache size information
+ async getCacheInfo(): Promise<{
+ entries: number;
+ size: number;
+ oldestEntry: number | null;
+ newestEntry: number | null;
+ }> {
+ const cache = await caches.open(this.cacheName);
+ const requests = await cache.keys();
+
+ let totalSize = 0;
+ let oldestTimestamp = Date.now();
+ let newestTimestamp = 0;
+
+ for (const request of requests) {
+ const response = await cache.match(request);
+ if (response) {
+ const cachedAt = response.headers.get('sw-cached-at');
+ const timestamp = cachedAt ? parseInt(cachedAt) : 0;
+
+ oldestTimestamp = Math.min(oldestTimestamp, timestamp);
+ newestTimestamp = Math.max(newestTimestamp, timestamp);
+
+ // Estimate size (this is approximate)
+ const responseClone = response.clone();
+ const text = await responseClone.text();
+ totalSize += text.length;
+ }
+ }
+
+ return {
+ entries: requests.length,
+ size: totalSize,
+ oldestEntry: oldestTimestamp === Date.now() ? null : oldestTimestamp,
+ newestEntry: newestTimestamp === 0 ? null : newestTimestamp,
+ };
+ }
+}
+
+// Predefined cache strategies for different types of content
+export const cacheStrategies = {
+ // For static assets that rarely change
+ staticAssets: new CacheStrategy({
+ cacheName: 'static-assets-v1',
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ maxEntries: 200,
+ }),
+
+ // For API responses that change occasionally
+ apiResponses: new CacheStrategy({
+ cacheName: 'api-responses-v1',
+ maxAge: 5 * 60 * 1000, // 5 minutes
+ maxEntries: 50,
+ }),
+
+ // For user data that should be fresh
+ userData: new CacheStrategy({
+ cacheName: 'user-data-v1',
+ maxAge: 60 * 1000, // 1 minute
+ maxEntries: 20,
+ }),
+
+ // For proofs and verification data
+ proofsData: new CacheStrategy({
+ cacheName: 'proofs-data-v1',
+ maxAge: 30 * 60 * 1000, // 30 minutes
+ maxEntries: 100,
+ }),
+
+ // For images and media
+ media: new CacheStrategy({
+ cacheName: 'media-v1',
+ maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
+ maxEntries: 50,
+ }),
+};
+
+// Helper function to determine which strategy to use based on URL
+export function getCacheStrategyForUrl(url: string): CacheStrategy {
+ if (url.includes('/_next/static/') || url.includes('/icons/') || url.endsWith('.css') || url.endsWith('.js')) {
+ return cacheStrategies.staticAssets;
+ }
+
+ if (url.includes('/api/')) {
+ if (url.includes('/proofs') || url.includes('/verification')) {
+ return cacheStrategies.proofsData;
+ }
+ if (url.includes('/user') || url.includes('/profile')) {
+ return cacheStrategies.userData;
+ }
+ return cacheStrategies.apiResponses;
+ }
+
+ if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
+ return cacheStrategies.media;
+ }
+
+ // Default to stale-while-revalidate for HTML pages
+ return new CacheStrategy({
+ cacheName: 'pages-v1',
+ maxAge: 10 * 60 * 1000, // 10 minutes
+ maxEntries: 20,
+ });
+}
+
+// Utility function to warm up cache with critical resources
+export async function warmupCache(urls: string[]): Promise {
+ const cachePromises = urls.map(async (url) => {
+ try {
+ const strategy = getCacheStrategyForUrl(url);
+ const request = new Request(url);
+ await strategy.staleWhileRevalidate(request);
+ } catch (error) {
+ console.warn(`Failed to warm up cache for ${url}:`, error);
+ }
+ });
+
+ await Promise.allSettled(cachePromises);
+}
diff --git a/frontend/src/utils/pushNotifications.ts b/frontend/src/utils/pushNotifications.ts
new file mode 100644
index 0000000..1a749ea
--- /dev/null
+++ b/frontend/src/utils/pushNotifications.ts
@@ -0,0 +1,439 @@
+// Push notification utilities for PWA
+
+export interface NotificationPayload {
+ title: string;
+ body: string;
+ icon?: string;
+ badge?: string;
+ tag?: string;
+ data?: any;
+ actions?: NotificationAction[];
+ requireInteraction?: boolean;
+ silent?: boolean;
+ vibrate?: number[];
+}
+
+export interface NotificationAction {
+ action: string;
+ title: string;
+ icon?: string;
+}
+
+export interface PushSubscription {
+ endpoint: string;
+ keys: {
+ p256dh: string;
+ auth: string;
+ };
+}
+
+export class PushNotificationManager {
+ private registration: ServiceWorkerRegistration | null = null;
+ private subscription: PushSubscription | null = null;
+
+ constructor() {
+ this.initialize();
+ }
+
+ private async initialize() {
+ if ('serviceWorker' in navigator && 'PushManager' in window) {
+ try {
+ this.registration = await navigator.serviceWorker.ready;
+ await this.loadSubscription();
+ } catch (error) {
+ console.error('Failed to initialize push notifications:', error);
+ }
+ }
+ }
+
+ // Request permission for notifications
+ async requestPermission(): Promise {
+ if (!('Notification' in window)) {
+ throw new Error('This browser does not support notifications');
+ }
+
+ let permission = Notification.permission;
+
+ if (permission === 'default') {
+ permission = await Notification.requestPermission();
+ }
+
+ return permission;
+ }
+
+ // Check if notifications are supported and permitted
+ async isSupported(): Promise {
+ return 'serviceWorker' in navigator &&
+ 'PushManager' in window &&
+ 'Notification' in window &&
+ Notification.permission === 'granted';
+ }
+
+ // Subscribe to push notifications
+ async subscribe(): Promise {
+ if (!this.registration) {
+ throw new Error('Service worker not registered');
+ }
+
+ try {
+ const permission = await this.requestPermission();
+ if (permission !== 'granted') {
+ throw new Error('Notification permission not granted');
+ }
+
+ // In production, you would use your actual VAPID public key
+ const applicationServerKey = this.urlBase64ToUint8Array(
+ process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ||
+ 'BMxzFTmF3i9j9A5DhxGJ5g0Q7Sz8Z9fX7g8J7v8k9l0m1n2o3p4q5r6s7t8u9v0w'
+ );
+
+ const subscription = await this.registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey,
+ });
+
+ this.subscription = subscription;
+ await this.saveSubscription(subscription);
+
+ console.log('Push subscription successful:', subscription);
+ return subscription;
+ } catch (error) {
+ console.error('Failed to subscribe to push notifications:', error);
+ throw error;
+ }
+ }
+
+ // Unsubscribe from push notifications
+ async unsubscribe(): Promise {
+ if (!this.subscription) {
+ return true;
+ }
+
+ try {
+ const success = await this.subscription.unsubscribe();
+ if (success) {
+ this.subscription = null;
+ await this.removeSubscription();
+ console.log('Successfully unsubscribed from push notifications');
+ }
+ return success;
+ } catch (error) {
+ console.error('Failed to unsubscribe from push notifications:', error);
+ return false;
+ }
+ }
+
+ // Get current subscription
+ getSubscription(): PushSubscription | null {
+ return this.subscription;
+ }
+
+ // Show a local notification
+ async showLocalNotification(payload: NotificationPayload): Promise {
+ const permission = await this.requestPermission();
+ if (permission !== 'granted') {
+ throw new Error('Notification permission not granted');
+ }
+
+ const options: NotificationOptions = {
+ body: payload.body,
+ icon: payload.icon || '/icons/icon-192x192.png',
+ badge: payload.badge || '/icons/icon-96x96.png',
+ tag: payload.tag,
+ data: payload.data,
+ requireInteraction: payload.requireInteraction || false,
+ silent: payload.silent || false,
+ };
+
+ if (payload.actions) {
+ options.actions = payload.actions;
+ }
+
+ if (payload.vibrate) {
+ options.vibrate = payload.vibrate;
+ }
+
+ if (this.registration) {
+ await this.registration.showNotification(payload.title, options);
+ } else {
+ // Fallback to browser notification
+ new Notification(payload.title, options);
+ }
+ }
+
+ // Send a push notification (server-side simulation)
+ async sendPushNotification(payload: NotificationPayload): Promise {
+ if (!this.subscription) {
+ throw new Error('No active subscription');
+ }
+
+ // In a real implementation, this would send the payload to your server
+ // which would then send the push notification via web push protocol
+ console.log('Sending push notification:', payload);
+
+ // For demo purposes, show a local notification
+ await this.showLocalNotification(payload);
+ }
+
+ // Schedule a notification (using setTimeout for demo)
+ scheduleNotification(
+ payload: NotificationPayload,
+ delayMs: number
+ ): { id: number; cancel: () => void } {
+ const id = setTimeout(async () => {
+ try {
+ await this.showLocalNotification(payload);
+ } catch (error) {
+ console.error('Failed to show scheduled notification:', error);
+ }
+ }, delayMs);
+
+ return {
+ id,
+ cancel: () => clearTimeout(id),
+ };
+ }
+
+ // Handle notification click
+ static handleNotificationClick(event: NotificationEvent): void {
+ const notification = event.notification;
+ const action = event.action;
+
+ console.log('Notification clicked:', notification, 'Action:', action);
+
+ if (action === 'explore') {
+ // Open specific page
+ clients.openWindow('/proofs');
+ } else if (action === 'dismiss') {
+ // Just close the notification
+ notification.close();
+ } else {
+ // Default action - open the app
+ clients.openWindow('/');
+ }
+
+ notification.close();
+ }
+
+ // Handle notification close
+ static handleNotificationClose(event: NotificationEvent): void {
+ console.log('Notification closed:', event.notification);
+ }
+
+ // Convert URL base64 to Uint8Array (for VAPID key)
+ private urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/-/g, '+')
+ .replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+
+ return outputArray;
+ }
+
+ // Save subscription to IndexedDB
+ private async saveSubscription(subscription: PushSubscription): Promise {
+ try {
+ const db = await this.openDB();
+ const transaction = db.transaction(['pushSubscription'], 'readwrite');
+ const store = transaction.objectStore('pushSubscription');
+ await store.put({
+ id: 'current',
+ subscription: JSON.parse(JSON.stringify(subscription)),
+ timestamp: Date.now(),
+ });
+ db.close();
+ } catch (error) {
+ console.error('Failed to save subscription:', error);
+ }
+ }
+
+ // Load subscription from IndexedDB
+ private async loadSubscription(): Promise {
+ try {
+ const db = await this.openDB();
+ const transaction = db.transaction(['pushSubscription'], 'readonly');
+ const store = transaction.objectStore('pushSubscription');
+ const result = await store.get('current');
+
+ if (result) {
+ this.subscription = result.subscription;
+ }
+
+ db.close();
+ } catch (error) {
+ console.error('Failed to load subscription:', error);
+ }
+ }
+
+ // Remove subscription from IndexedDB
+ private async removeSubscription(): Promise {
+ try {
+ const db = await this.openDB();
+ const transaction = db.transaction(['pushSubscription'], 'readwrite');
+ const store = transaction.objectStore('pushSubscription');
+ await store.delete('current');
+ db.close();
+ } catch (error) {
+ console.error('Failed to remove subscription:', error);
+ }
+ }
+
+ // Open IndexedDB for subscription storage
+ private openDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open('verinode-push', 1);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains('pushSubscription')) {
+ db.createObjectStore('pushSubscription', { keyPath: 'id' });
+ }
+ };
+ });
+ }
+}
+
+// Predefined notification templates
+export const notificationTemplates = {
+ verificationComplete: (proofId: string): NotificationPayload => ({
+ title: 'Verification Complete',
+ body: `Your proof ${proofId} has been successfully verified.`,
+ icon: '/icons/icon-192x192.png',
+ tag: `verification-${proofId}`,
+ data: { proofId, type: 'verification' },
+ actions: [
+ { action: 'view', title: 'View Proof' },
+ { action: 'dismiss', title: 'Dismiss' },
+ ],
+ requireInteraction: true,
+ vibrate: [200, 100, 200],
+ }),
+
+ verificationFailed: (proofId: string, reason: string): NotificationPayload => ({
+ title: 'Verification Failed',
+ body: `Your proof ${proofId} failed verification: ${reason}`,
+ icon: '/icons/icon-192x192.png',
+ tag: `verification-failed-${proofId}`,
+ data: { proofId, type: 'verification-failed', reason },
+ actions: [
+ { action: 'retry', title: 'Retry' },
+ { action: 'dismiss', title: 'Dismiss' },
+ ],
+ requireInteraction: true,
+ vibrate: [200, 100, 200, 100, 200],
+ }),
+
+ newProofAvailable: (proofId: string): NotificationPayload => ({
+ title: 'New Proof Available',
+ body: `A new proof ${proofId} is available for review.`,
+ icon: '/icons/icon-192x192.png',
+ tag: `new-proof-${proofId}`,
+ data: { proofId, type: 'new-proof' },
+ actions: [
+ { action: 'view', title: 'View Proof' },
+ { action: 'dismiss', title: 'Dismiss' },
+ ],
+ vibrate: [100, 50, 100],
+ }),
+
+ syncComplete: (count: number): NotificationPayload => ({
+ title: 'Sync Complete',
+ body: `${count} items have been successfully synced.`,
+ icon: '/icons/icon-192x192.png',
+ tag: 'sync-complete',
+ data: { type: 'sync', count },
+ silent: true,
+ }),
+
+ offlineMode: (): NotificationPayload => ({
+ title: 'Offline Mode',
+ body: 'You are now offline. Changes will be synced when you reconnect.',
+ icon: '/icons/icon-192x192.png',
+ tag: 'offline-mode',
+ data: { type: 'offline' },
+ vibrate: [50],
+ }),
+
+ onlineMode: (): NotificationPayload => ({
+ title: 'Back Online',
+ body: 'You are back online. Syncing your changes...',
+ icon: '/icons/icon-192x192.png',
+ tag: 'online-mode',
+ data: { type: 'online' },
+ vibrate: [100],
+ }),
+};
+
+// Singleton instance
+export const pushNotificationManager = new PushNotificationManager();
+
+// Utility functions for common notification scenarios
+export const notifyVerificationComplete = async (proofId: string) => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.verificationComplete(proofId)
+ );
+ } catch (error) {
+ console.error('Failed to show verification complete notification:', error);
+ }
+};
+
+export const notifyVerificationFailed = async (proofId: string, reason: string) => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.verificationFailed(proofId, reason)
+ );
+ } catch (error) {
+ console.error('Failed to show verification failed notification:', error);
+ }
+};
+
+export const notifyNewProofAvailable = async (proofId: string) => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.newProofAvailable(proofId)
+ );
+ } catch (error) {
+ console.error('Failed to show new proof notification:', error);
+ }
+};
+
+export const notifySyncComplete = async (count: number) => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.syncComplete(count)
+ );
+ } catch (error) {
+ console.error('Failed to show sync complete notification:', error);
+ }
+};
+
+export const notifyOfflineMode = async () => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.offlineMode()
+ );
+ } catch (error) {
+ console.error('Failed to show offline mode notification:', error);
+ }
+};
+
+export const notifyOnlineMode = async () => {
+ try {
+ await pushNotificationManager.showLocalNotification(
+ notificationTemplates.onlineMode()
+ );
+ } catch (error) {
+ console.error('Failed to show online mode notification:', error);
+ }
+};
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..56b7766
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,21 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ primary: {
+ 50: '#eff6ff',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ },
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..abb59dc
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "es6"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}