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! + +
+
+ )} + + {/* Header */} +
+ +
+ + {/* Mobile Navigation Drawer */} +
setIsMobileMenuOpen(false)} /> + + + {/* 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 */} +
+

© 2024 Verinode - TradeFlow Verification. All rights reserved.

+
+ + {/* 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. +

+ +
+ + + +
+
+ + +
+
+ ); +} 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 && ( + + )} +
+
+ + {/* 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 ( +
+
+ + + {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 && ( + + )} +
+ )} +
+
+ ); +} 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"] +}