From ad408e86bb1a1743c049b55016499c74ede873e2 Mon Sep 17 00:00:00 2001 From: iyanumajekodunmi756 Date: Sun, 21 Jun 2026 02:49:35 +0000 Subject: [PATCH 001/152] feat(frontend): implement Workbox service worker & offline fallback (issue #7) Rewrites frontend/public/sw.js to use Workbox 6.4.1 with the caching strategies the issue Definition of Done calls for: * NetworkOnly + BackgroundSyncPlugin (24h retention) for mutating /api/* requests (POST/PUT/PATCH/DELETE) so offline progress and quiz submissions replay automatically when connectivity returns. * StaleWhileRevalidate for read-only /api/(courses|lessons|progress| analytics) GETs so previously viewed courses stay available offline. * NetworkFirst (3s timeout) for the remaining /api GET requests so user / auth / profile endpoints stay fresh. * CacheFirst for static assets (images, scripts, styles, fonts, manifest) with a 30-day expiration. * CacheFirst (1y, immutable) for /_next/static/* chunks. * StaleWhileRevalidate for full-page navigations. * setCatchHandler distinguishes /_next/data/* JSON (503 sentinel) from HTML navigations (precached /offline shell) and serves a 504 for images that miss the static cache. * precacheAndRoute pre-populates the /offline shell on install so the app boots offline on first-ever visit (acceptance criterion). * clients.claim() on activate and cleanup of any aethermint-* cache whose suffix does not match the current SW_VERSION (bump-from-v3). * skipWaiting is strictly opt-in via postMessage -- no surprise activation that would tear down in-flight requests. Adds a custom `_document.tsx` with the Lighthouse-PWA meta tag set (theme-color, apple-mobile-web-app-*, manifest link, apple-touch-icon) and a brand-new `/offline` page (`_offline.tsx`) that auto-redirects home when connectivity returns. Wires both into `_app.tsx` via a client-only ServiceWorkerBootstrap so SSR never sees navigator APIs. Updates `next.config.js` to send Service-Worker-Allowed: / plus a content-type header and no-cache header on /sw.js so proxy layers do not silently strip them. Refs https://github.com/AetherEdu/AetherMint/issues/7 --- frontend/next.config.js | 20 ++ frontend/public/sw.js | 449 +++++++++++++++++++++++++------ frontend/src/pages/_app.tsx | 47 ++++ frontend/src/pages/_document.tsx | 44 +++ frontend/src/pages/_offline.tsx | 159 +++++++++++ 5 files changed, 637 insertions(+), 82 deletions(-) create mode 100644 frontend/src/pages/_document.tsx create mode 100644 frontend/src/pages/_offline.tsx diff --git a/frontend/next.config.js b/frontend/next.config.js index ee74fdd4..1af5e251 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -121,6 +121,26 @@ const nextConfig = { }, ], }, + // Service-worker headers — ensure the browser accepts `/sw.js` as a + // top-level worker (Service-Worker-Allowed: '/') and never serves it + // from an HTTP cache (penalising immediate SW updates). + { + source: '/sw.js', + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + { + key: 'Service-Worker-Allowed', + value: '/', + }, + { + key: 'Content-Type', + value: 'application/javascript; charset=utf-8', + }, + ], + }, ]; }, }; diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 8eb9a086..6e79af2d 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,97 +1,382 @@ /** - * Service Worker for AetherMint PWA - * Built with Workbox for robust offline caching and background sync + * AetherMint Service Worker + * ----------------------------------------------------------------------------- + * Owns the runtime caching layer for the AetherMint PWA. + * + * Strategies (in registration order — most-specific to most-generic): + * 1. POST/PUT/PATCH/DELETE /api/* → NetworkOnly + BackgroundSync + * 2. GET /api/(courses|lessons|progress|analytics)/* → StaleWhileRevalidate + * 3. GET /api/* → NetworkFirst (3 s timeout) + * 4. Scripts/Styles/Images/Fonts/Manifest → CacheFirst (30 d) + * 5. /_next/static/* → CacheFirst (1 y, immutable) + * 6. Navigations (HTML) → StaleWhileRevalidate (7 d) + * 7. setCatchHandler → /offline shell for docs & prefetches, + * 503 JSON sentinel for `/_next/data/*`. + * + * Update flow: + * • `skipWaiting()` is NEVER invoked automatically. The page must post + * `{ type: 'SKIP_WAITING' }` to the waiting worker so in-flight requests + * are not torn down mid-flight. + * • `clients.claim()` runs unconditionally on activate so the first + * interaction after a refresh sees the new version. + * + * Workbox runtime is loaded from Google's CDN and pinned to v6.4.1 so the + * code below compiles against a known API surface. */ -importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'); +importScripts( + 'https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js' +); -if (workbox) { - console.log('✅ Workbox loaded successfully'); +/** Current cache key suffix. Bumped to evict stale caches during activate. */ +const SW_VERSION = 'v4'; - const { routing, strategies, backgroundSync, expiration } = workbox; +const isLocalHost = + self.location.hostname === 'localhost' || + self.location.hostname === '127.0.0.1'; - // Setup Background Sync for offline interactions (POST, PUT, DELETE) - const bgSyncPlugin = new backgroundSync.BackgroundSyncPlugin('aethermint-offline-queue', { - maxRetentionTime: 24 * 60, // Retry for up to 24 hours (specified in minutes) - onSync: async ({ queue }) => { - console.log('🔄 Replaying offline queued requests via Background Sync...'); - await queue.replayRequests(); +/** + * Only register the routes & set up state management when Workbox actually + * loaded. Older browsers / corporate proxies will leave `self.workbox` + * undefined and we degrade to a passthrough service worker. + */ +if (self.workbox) { + const { routing, strategies, backgroundSync, expiration, precaching } = + self.workbox; + + // ------------------------------------------------------------------------- + // Dev / live toggle. + // ------------------------------------------------------------------------- + // Skip *all* caching in dev so HMR stays snappy and stale chunks never + // confuse the developer. Production builds log a single banner so we can + // confirm the SW activated in DevTools → Application → Service Workers. + if (isLocalHost) { + // eslint-disable-next-line no-console + console.info( + '[AetherMint SW] local development detected — caching disabled.' + ); + } else { + /* eslint-disable no-console */ + console.info(`[AetherMint SW] AetherMint SW ${SW_VERSION} activating.`); + /* eslint-enable no-console */ + + // Opt-in verbose Workbox logging via querystring, useful for debugging. + if (self.location.search.includes('workbox-debug')) { + self.workbox.setConfig({ debug: true }); + } + + // ------------------------------------------------------------------------- + // Precache the offline fallback shell. + // ------------------------------------------------------------------------- + // Must correspond to a real navigable route — see + // `frontend/src/pages/_offline.tsx`. Revision is bumped with SW_VERSION so + // a deploy automatically replaces the precached shell. + precaching.precacheAndRoute([ + { url: '/offline', revision: SW_VERSION }, + ]); + + // ------------------------------------------------------------------------- + // Background-sync plugin for mutating requests. + // ------------------------------------------------------------------------- + const bgSyncPlugin = new backgroundSync.BackgroundSyncPlugin( + 'aethermint-offline-queue', + { + // Workbox uses minutes here; 24h retries ensure mutating calls + // (progress, enrollment, payments) eventually settle. + maxRetentionTime: 24 * 60, + onSync: async ({ queue }) => { + try { + await queue.replayRequests(); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + '[AetherMint SW] background-sync replay failed:', + error + ); + } + }, + } + ); + + // ------------------------------------------------------------------------- + // 1. Mutating API requests → queue + replay. + // ------------------------------------------------------------------------- + routing.registerRoute( + ({ url, request }) => + url.pathname.startsWith('/api/') && + ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method), + new strategies.NetworkOnly({ plugins: [bgSyncPlugin] }), + 'POST' + ); + + // ------------------------------------------------------------------------- + // 2. Read-only course/lesson/progress/analytics payload → SWR. + // ------------------------------------------------------------------------- + // Registered BEFORE the broader /api GET handler so Workbox matches the + // narrow pattern first. This is the entry that satisfies the acceptance + // criterion: "Previously viewed courses available offline". + const READ_ONLY_API_PATTERN = /^\/api\/(courses|lessons|progress|analytics)(\/|$|\?)/; + + routing.registerRoute( + ({ url, request }) => + request.method === 'GET' && READ_ONLY_API_PATTERN.test(url.pathname), + new strategies.StaleWhileRevalidate({ + cacheName: `aethermint-api-readonly-${SW_VERSION}`, + plugins: [ + new expiration.ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 7 * 24 * 60 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ); + + // ------------------------------------------------------------------------- + // 3. All other GET /api/* → NetworkFirst with 3 s timeout. + // ------------------------------------------------------------------------- + // User/profile/auth endpoints should stay as fresh as possible, but we + // still degrade gracefully to cache so the app stays interactive offline. + routing.registerRoute( + ({ url, request }) => + url.pathname.startsWith('/api/') && request.method === 'GET', + new strategies.NetworkFirst({ + cacheName: `aethermint-api-${SW_VERSION}`, + networkTimeoutSeconds: 3, + plugins: [ + new expiration.ExpirationPlugin({ + maxEntries: 60, + maxAgeSeconds: 24 * 60 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ); + + // ------------------------------------------------------------------------- + // 4. Static assets (images, scripts, styles, fonts, manifest). + // ------------------------------------------------------------------------- + routing.registerRoute( + ({ request }) => + request.destination === 'image' || + request.destination === 'script' || + request.destination === 'style' || + request.destination === 'font' || + request.destination === 'manifest', + new strategies.CacheFirst({ + cacheName: `aethermint-static-${SW_VERSION}`, + plugins: [ + new expiration.ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 30 * 24 * 60 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ); + + // ------------------------------------------------------------------------- + // 5. Next.js static chunks — already fingerprinted, cache-first forever. + // ------------------------------------------------------------------------- + routing.registerRoute( + ({ url }) => url.pathname.startsWith('/_next/static/'), + new strategies.CacheFirst({ + cacheName: `aethermint-next-static-${SW_VERSION}`, + plugins: [ + new expiration.ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 365 * 24 * 60 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ); + + // ------------------------------------------------------------------------- + // 6. Navigations (top-level HTML). + // ------------------------------------------------------------------------- + routing.registerRoute( + ({ request }) => request.mode === 'navigate', + new strategies.StaleWhileRevalidate({ + cacheName: `aethermint-pages-${SW_VERSION}`, + plugins: [ + new expiration.ExpirationPlugin({ + maxEntries: 50, + maxAgeSeconds: 7 * 24 * 60 * 60, + purgeOnQuotaError: true, + }), + ], + }) + ); + + // ------------------------------------------------------------------------- + // 7. Catch handler — final fallback when EVERYTHING above misses. + // ------------------------------------------------------------------------- + const OFFLINE_FALLBACK_URL = '/offline'; + + self.workbox.routing.setCatchHandler(async ({ event, request }) => { + // Never cache or proxy cross-origin errors — just return a generic + // network error so the browser handles it normally. + if (!request.url.startsWith(self.location.origin)) { + return Response.error(); + } + + const accept = request.headers.get('accept') || ''; + const { pathname } = new URL(request.url); + + // 7a. Document / navigation → precached /offline shell. + if ( + event.request.destination === 'document' || + request.mode === 'navigate' + ) { + const cached = await caches.match(OFFLINE_FALLBACK_URL); + if (cached) return cached; + return Response.error(); + } + + // 7b. Next.js pages-router payload (`/_next/data//.json`) + // or any explicit JSON accept header. + // + // Returning the HTML shell here would crash any caller that does + // `await res.json()` — issue a 503 with a JSON body so data-fetching + // helpers (React Query, SWR, fetchJson) short-circuit cleanly. + if ( + pathname.startsWith('/_next/data/') || + accept.includes('application/json') + ) { + const cached = await caches.match(event.request); + if (cached) return cached; + return new Response( + JSON.stringify({ + offline: true, + message: 'Network unavailable', + }), + { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 7c. HTML prefetch hints — keep layout intact with the shell. + if (accept.includes('text/html')) { + const cached = await caches.match(OFFLINE_FALLBACK_URL); + if (cached) return cached; + } + + // 7d. Image requests — fail soft; most icons are already precached. + if (request.destination === 'image') { + return new Response('', { status: 504 }); + } + + return Response.error(); + }); + } + + // --------------------------------------------------------------------------- + // Activate hook — evict predecessor caches & claim clients. + // --------------------------------------------------------------------------- + self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const cacheNames = await caches.keys(); + await Promise.all( + cacheNames + .filter( + (name) => + name.startsWith('aethermint-') && + !name.endsWith(`-${SW_VERSION}`) + ) + .map((name) => { + // eslint-disable-next-line no-console + console.info('[AetherMint SW] deleting outdated cache:', name); + return caches.delete(name); + }) + ); + + if (self.clients && typeof self.clients.claim === 'function') { + await self.clients.claim(); + } + })() + ); + }); + + // --------------------------------------------------------------------------- + // User-prompted update flow. + // --------------------------------------------------------------------------- + self.addEventListener('message', (event) => { + if (event && event.data && event.data.type === 'SKIP_WAITING') { + // eslint-disable-next-line no-console + console.info('[AetherMint SW] SKIP_WAITING — activating new version.'); + self.skipWaiting(); } }); - routing.registerRoute( - ({ url, request }) => url.pathname.startsWith('/api/') && ['POST', 'PUT', 'DELETE'].includes(request.method), - new strategies.NetworkOnly({ - plugins: [bgSyncPlugin] - }) - ); + // --------------------------------------------------------------------------- + // IndexedDB-driven background sync trigger. + // --------------------------------------------------------------------------- + self.addEventListener('sync', (event) => { + if (event.tag === 'sync-progress' || event.tag === 'background-sync') { + // The Workbox BackgroundSyncPlugin transparently handles queued + // network requests above. Hooks for custom IndexedDB replay can be + // added here without breaking the plugin contract. + // eslint-disable-next-line no-console + console.info('[AetherMint SW] sync event fired:', event.tag); + } + }); + + // --------------------------------------------------------------------------- + // Push notifications. + // --------------------------------------------------------------------------- + self.addEventListener('push', (event) => { + if (!event.data) return; + + let payload: { title?: string; body?: string } = {}; + try { + payload = event.data.json(); + } catch { + payload = { title: 'AetherMint', body: event.data.text() }; + } - // Cache-First strategy for static assets (Images, Scripts, Styles) - routing.registerRoute( - ({ request }) => request.destination === 'image' || request.destination === 'script' || request.destination === 'style', - new strategies.CacheFirst({ - cacheName: 'aethermint-static-v2', - plugins: [ - new expiration.ExpirationPlugin({ - maxEntries: 100, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days - }), + const options: NotificationOptions = { + body: payload.body || 'You have a new notification from AetherMint', + icon: '/icons/icon-192x192.png', + badge: '/icons/badge-72x72.png', + data: payload, + vibrate: [200, 100, 200], + actions: [ + { action: 'open', title: 'Open App' }, + { action: 'dismiss', title: 'Dismiss' }, ], - }) - ); + }; - // Network-First strategy for GET APIs (Courses, Profiles, Analytics) - routing.registerRoute( - ({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'GET', - new strategies.NetworkFirst({ - cacheName: 'aethermint-api-v2', - networkTimeoutSeconds: 3, // Fall back to cache if network is slow - }) - ); + event.waitUntil( + self.registration.showNotification( + payload.title || 'AetherMint', + options + ) + ); + }); - // Stale-While-Revalidate for Documents/HTML - routing.registerRoute( - ({ request }) => request.destination === 'document', - new strategies.StaleWhileRevalidate({ - cacheName: 'aethermint-dynamic-v2', - }) - ); + self.addEventListener('notificationclick', (event) => { + event.notification.close(); + if (event.action === 'dismiss') return; + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if ('focus' in client) return client.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow('/'); + return null; + }) + ); + }); } else { - console.log('❌ Workbox failed to load'); -} - -// Background sync for offline actions -self.addEventListener('sync', (event) => { - if (event.tag === 'sync-progress') { - console.log('🔄 Custom Background sync triggered:', event.tag); - // Let Workbox handle queued networks automatically, - // or trigger custom IndexedDB sync processes here - } -}); - -// Push notification handler -self.addEventListener('push', (event) => { - console.log('📬 Push notification received:', event); - - const options = { - body: event.data?.text() || 'You have a new notification from AetherMint', - icon: '/icons/icon-192x192.png', - badge: '/icons/badge-72x72.png', - vibrate: [200, 100, 200], - data: event.data?.json() || {}, - actions: [ - { - action: 'open', - title: 'Open App' - }, - { - action: 'dismiss', - title: 'Dismiss' - } - ] - }; - - event.waitUntil( - self.registration.showNotification('AetherMint', options) + // eslint-disable-next-line no-console + console.warn( + '[AetherMint SW] Workbox failed to load — falling back to passthrough.' ); -}); +} diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index e2ad8214..d77a1051 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -1,12 +1,59 @@ +import { useEffect } from 'react'; +import dynamic from 'next/dynamic'; import type { AppProps } from 'next/app'; import { WalletProvider } from '../context/WalletContext'; import { Toaster } from 'react-hot-toast'; import '../styles/globals.css'; +// The OfflineIndicator uses `useNetworkStatus`, which reads +// `navigator.onLine` on first render — that produces a different value on +// the server. Skip SSR for it. +const OfflineIndicator = dynamic( + () => + import('../components/PWA/OfflineIndicator').then( + (mod) => mod.OfflineIndicator + ), + { ssr: false } +); + +/** + * Invisible client-only component that registers the Workbox service worker. + * Kept out of the React tree (returns null) so it has no SSR footprint and + * never blocks rendering. Errors are swallowed: a failed registration must + * never bring the app down. + */ +function ServiceWorkerBootstrap() { + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { registerServiceWorker } = await import( + '../components/PWA/serviceWorkerRegistration' + ); + if (cancelled) return; + await registerServiceWorker(); + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + '[AetherMint] service worker registration failed:', + error + ); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return null; +} + function MyApp({ Component, pageProps }: AppProps) { return ( + + ); diff --git a/frontend/src/pages/_document.tsx b/frontend/src/pages/_document.tsx new file mode 100644 index 00000000..d06dab3d --- /dev/null +++ b/frontend/src/pages/_document.tsx @@ -0,0 +1,44 @@ +/** + * Custom `_document.tsx` + * ------------------------------------------------------------------------- + * Supplies the document-level `` tags and assets the Lighthouse PWA + * audit expects to find at the HTML root, rather than per-page: + * + * • theme-color — paints the address bar on mobile. + * • apple-touch-icon — home-screen icon on iOS. + * • manifest link — installable PWA bootstrap. + * • apple-mobile-web-app-* — full-screen / status-bar styling on iOS. + * • format-detection — disable phone-number auto-linking. + * + * Keep this file minimal — anything dynamic belongs in `_app.tsx` or in + * per-page `` exports. + */ + +import { Html, Head, Main, NextScript } from 'next/document'; + +const THEME_COLOR = '#3b82f6'; + +export default function Document() { + return ( + + + + + + + + + + + + + +
+ + + + ); +} diff --git a/frontend/src/pages/_offline.tsx b/frontend/src/pages/_offline.tsx new file mode 100644 index 00000000..83e9098f --- /dev/null +++ b/frontend/src/pages/_offline.tsx @@ -0,0 +1,159 @@ +/** + * Offline Fallback Page + * ------------------------------------------------------------------------- + * Served by the Workbox `setCatchHandler` in `public/sw.js` whenever a + * navigation request fails (no network AND no cached page). It is also + * precached on SW install so it is available on the very first offline + * load — covering the acceptance criterion: + * + * "Load app, go offline, refresh: app still loads". + * + * The page is intentionally context-free: it must NOT depend on `WalletProvider`, + * theme context, or any client-side data so it can render in the most + * constrained scenario. + */ + +import { useEffect } from 'react'; +import Head from 'next/head'; + +interface OfflineFeature { + title: string; + description: string; +} + +const OFFLINE_FEATURES: ReadonlyArray = [ + { + title: 'Cached courses', + description: + 'Any course you opened at least once stays available, including lessons and reading material.', + }, + { + title: 'Background sync', + description: + 'Quizzes, progress updates, and payments are queued locally and replay automatically when you reconnect.', + }, + { + title: 'Pinned install', + description: + 'Install AetherMint to your home screen to launch offline even with no prior visits.', + }, +]; + +const STATUS_CHECK_INTERVAL_MS = 5000; + +const redirectHome = (): void => { + // Use `href` (rather than `router.push`) so we bounce through the SW + // and pick up the newly-cached home route on retry. + window.location.href = '/'; +}; + +export default function OfflinePage() { + useEffect(() => { + // 1. Redirect as soon as the browser emits an `online` event. + window.addEventListener('online', redirectHome); + + // 2. Belt-and-braces: poll `navigator.onLine` for up-to 5 s in case + // the event fires before our listener attaches. This catches + // the common "already online when the SW fell back" case. + const intervalId = window.setInterval(() => { + if (navigator.onLine) { + window.clearInterval(intervalId); + redirectHome(); + } + }, STATUS_CHECK_INTERVAL_MS); + + return () => { + window.removeEventListener('online', redirectHome); + window.clearInterval(intervalId); + }; + }, []); + + return ( + <> + + Offline · AetherMint + + + + + +
+
+
+ +
+ +

+ You’re offline +

+ +

+ AetherMint can’t reach the network right now. Don’t + worry — cached courses and queued progress will continue to + work, and we’ll resync everything as soon as you’re + back online. +

+ +
+ + + Go to home + +
+ +
+ {OFFLINE_FEATURES.map((feature) => ( +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+ +

+ Connection status is monitored automatically. You’ll be + redirected as soon as the network comes back — no need to + refresh. +

+
+
+ + ); +} From 07c84bd706e512b2f74ab6e6bbaace1554a2443c Mon Sep 17 00:00:00 2001 From: iyanumajekodunmi756 Date: Sun, 21 Jun 2026 02:49:47 +0000 Subject: [PATCH 002/152] feat(frontend): add typed offline indicator, SW registration helper & offline IndexedDB (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the SW work in the previous commit by adding the user-facing pieces: * frontend/src/components/PWA/OfflineIndicator.tsx — fixed bottom banner that surfaces when useNetworkStatus reports offline. Dismissible via a keyboard-accessible button; choice persists in localStorage so it does not return on every refresh. Loaded via next/dynamic({ ssr: false }) from _app.tsx so the first paint runs client-side only. * frontend/src/components/PWA/serviceWorkerRegistration.ts — typed helper that registers /sw.js, exposes onUpdate / onOfflineReady callbacks and an applyUpdate() that posts SKIP_WAITING to the waiting worker. Dev-mode-aware (no-op unless force option is set). * frontend/src/utils/offlineDB.ts — typed re-write of the existing IndexedDB wrapper. Removes all `any` usages in favour of generic OfflineCourseRecord / OfflineProgressRecord / OfflineQueuedAction interfaces plus an explicit SyncCapableRegistration interface for the optional Background Sync API. Same public surface — drop-in replacement. * frontend/public/OfflineIndicator.tsx — deleted; the duplicate React component under public/ is replaced by the typed copy under src/components/PWA/. * Jest suites (both @jest-environment jsdom): - PWA/__tests__/OfflineIndicator.test.tsx covers hidden-when-online, alert-role rendering when offline, dismissal & persistence, custom storageKey, and the localStorage-blocked-from-throw scenario. - PWA/__tests__/serviceWorkerRegistration.test.ts covers unsupported, dev-mode guard, force flag, registration/updatefound statechange, onUpdate / onOfflineReady wiring, error path, and SKIP_WAITING postMessage. Closes https://github.com/AetherEdu/AetherMint/issues/7 --- frontend/public/OfflineIndicator.tsx | 21 -- .../src/components/PWA/OfflineIndicator.tsx | 119 ++++++++ .../PWA/__tests__/OfflineIndicator.test.tsx | 110 +++++++ .../serviceWorkerRegistration.test.ts | 178 ++++++++++++ .../PWA/serviceWorkerRegistration.ts | 153 ++++++++++ frontend/src/utils/offlineDB.ts | 271 +++++++++++++++--- 6 files changed, 788 insertions(+), 64 deletions(-) delete mode 100644 frontend/public/OfflineIndicator.tsx create mode 100644 frontend/src/components/PWA/OfflineIndicator.tsx create mode 100644 frontend/src/components/PWA/__tests__/OfflineIndicator.test.tsx create mode 100644 frontend/src/components/PWA/__tests__/serviceWorkerRegistration.test.ts create mode 100644 frontend/src/components/PWA/serviceWorkerRegistration.ts diff --git a/frontend/public/OfflineIndicator.tsx b/frontend/public/OfflineIndicator.tsx deleted file mode 100644 index 4d38fe26..00000000 --- a/frontend/public/OfflineIndicator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { useNetworkStatus } from '../../hooks/useNetworkStatus'; - -export const OfflineIndicator: React.FC = () => { - const { isOnline } = useNetworkStatus(); - - if (isOnline) return null; - - return ( -
-
- - - - You are offline. Content will be loaded from cache and progress will sync automatically when reconnected. -
-
- ); -}; - -export default OfflineIndicator; \ No newline at end of file diff --git a/frontend/src/components/PWA/OfflineIndicator.tsx b/frontend/src/components/PWA/OfflineIndicator.tsx new file mode 100644 index 00000000..40198435 --- /dev/null +++ b/frontend/src/components/PWA/OfflineIndicator.tsx @@ -0,0 +1,119 @@ +/** + * OfflineIndicator + * ------------------------------------------------------------------------- + * Fixed bottom banner rendered whenever `useNetworkStatus` reports an offline + * state. The component is loaded from `_app.tsx` via + * `next/dynamic(..., { ssr: false })` so the first paint always happens on + * the client — that lets `useNetworkStatus` read `navigator.onLine` without + * producing a hydration mismatch. + * + * UX notes: + * • Users can dismiss the banner; the choice persists in `localStorage` + * under `storageKey` so we don't annoy them every refresh. + * • The dismiss button is keyboard-reachable and labelled for screen + * readers. + * • Color tokens match the application's Tailwind palette (amber). + */ + +import { useState } from 'react'; +import { useNetworkStatus } from '../../hooks/useNetworkStatus'; + +const DEFAULT_STORAGE_KEY = 'aethermint-offline-banner-dismissed'; + +interface OfflineIndicatorProps { + /** + * Override the localStorage key used to remember that the user dismissed + * the banner. Mostly useful for tests / storybooks. + */ + storageKey?: string; +} + +const safeGetItem = (key: string): string | null => { + try { + return window.localStorage.getItem(key); + } catch { + // localStorage can throw in private mode / sandboxed iframes. + return null; + } +}; + +const safeSetItem = (key: string, value: string): void => { + try { + window.localStorage.setItem(key, value); + } catch { + /* intentionally ignored */ + } +}; + +export function OfflineIndicator({ + storageKey = DEFAULT_STORAGE_KEY, +}: OfflineIndicatorProps) { + const { isOnline } = useNetworkStatus(); + const [dismissed, setDismissed] = useState( + () => safeGetItem(storageKey) === 'true' + ); + + const handleDismiss = () => { + safeSetItem(storageKey, 'true'); + setDismissed(true); + }; + + if (isOnline || dismissed) return null; + + return ( +
+
+
+ + + You’re offline — cached content remains available and + progress will sync automatically once you’re back online. + +
+ +
+
+ ); +} + +export default OfflineIndicator; diff --git a/frontend/src/components/PWA/__tests__/OfflineIndicator.test.tsx b/frontend/src/components/PWA/__tests__/OfflineIndicator.test.tsx new file mode 100644 index 00000000..16f5525b --- /dev/null +++ b/frontend/src/components/PWA/__tests__/OfflineIndicator.test.tsx @@ -0,0 +1,110 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; + +import { OfflineIndicator } from '../OfflineIndicator'; + +// `useNetworkStatus` reads `navigator.onLine` and subscribes to `window` +// online/offline events — fine in jsdom but we mock it so each test can +// flip the value deterministically. +// Note: keeps the cast on a single expression so the TSX parser does not +// confuse `` with JSX. +jest.mock('../../../hooks/useNetworkStatus', () => ({ + useNetworkStatus: jest.fn(), +})); + +import { useNetworkStatus } from '../../../hooks/useNetworkStatus'; + +// `jest.Mock` keeps this file free of generic type expressions — the TSX +// transformer otherwise confuses `` with JSX. +const mockUseNetworkStatus = useNetworkStatus as unknown as jest.Mock; + +describe('OfflineIndicator', () => { + beforeEach(() => { + window.localStorage.clear(); + mockUseNetworkStatus.mockReturnValue({ isOnline: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing when online', () => { + mockUseNetworkStatus.mockReturnValue({ isOnline: true }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the banner with role="alert" when offline', async () => { + mockUseNetworkStatus.mockReturnValue({ isOnline: false }); + render(); + + const banner = await screen.findByRole('alert'); + expect(banner).toBeInTheDocument(); + expect(banner.textContent).toMatch(/offline/i); + }); + + it('hides the banner once dismissed and persists the choice', async () => { + mockUseNetworkStatus.mockReturnValue({ isOnline: false }); + render(); + + const dismiss = await screen.findByRole('button', { + name: /dismiss offline banner/i, + }); + + await act(async () => { + fireEvent.click(dismiss); + }); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + expect(window.localStorage.getItem('aethermint-offline-banner-dismissed')).toBe( + 'true' + ); + }); + + it('honours a custom storage key', async () => { + mockUseNetworkStatus.mockReturnValue({ isOnline: false }); + render(); + + const dismiss = await screen.findByRole('button', { + name: /dismiss offline banner/i, + }); + + await act(async () => { + fireEvent.click(dismiss); + }); + + await waitFor(() => { + expect(window.localStorage.getItem('custom-dismiss')).toBe('true'); + }); + }); + + it('does not throw when localStorage is unavailable', () => { + mockUseNetworkStatus.mockReturnValue({ isOnline: false }); + const original = window.localStorage; + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new Error('blocked'); + }, + }); + + expect(() => render()).not.toThrow(); + + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: original, + }); + }); +}); diff --git a/frontend/src/components/PWA/__tests__/serviceWorkerRegistration.test.ts b/frontend/src/components/PWA/__tests__/serviceWorkerRegistration.test.ts new file mode 100644 index 00000000..a89dfb77 --- /dev/null +++ b/frontend/src/components/PWA/__tests__/serviceWorkerRegistration.test.ts @@ -0,0 +1,178 @@ +/** + * @jest-environment jsdom + */ +import { + __resetServiceWorkerRegistrationForTests, + applyUpdate, + registerServiceWorker, +} from '../serviceWorkerRegistration'; + +type Listener = (event: Event) => void; + +interface FakeWorker { + state: ServiceWorkerState; + postMessage: jest.Mock; +} + +const makeFakeWorker = ( + initialState: ServiceWorkerState = 'installed' +): FakeWorker => ({ + state: initialState, + postMessage: jest.fn(), +}); + +interface FakeRegistration extends ServiceWorkerRegistration { + _fireUpdateFound: () => void; +} + +function installFakeServiceWorkerApi( + options: { + waiting?: FakeWorker | null; + active?: FakeWorker | null; + } = {} +) { + const waiting = options.waiting ?? makeFakeWorker('installed'); + const active = options.active ?? makeFakeWorker('activated'); + + const updateFoundListeners: Listener[] = []; + const controllerListeners: Listener[] = []; + + const registration = { + active, + installing: null, + waiting, + scope: '/', + updateViaCache: 'none', + addEventListener: jest.fn((event: string, cb: Listener) => { + if (event === 'updatefound') updateFoundListeners.push(cb); + }), + removeEventListener: jest.fn(), + _fireUpdateFound: () => { + updateFoundListeners.forEach((cb) => cb({} as Event)); + }, + } as unknown as FakeRegistration; + + const controller = makeFakeWorker('activated'); + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller, + register: jest.fn().mockResolvedValue(registration), + addEventListener: jest.fn((event: string, cb: Listener) => { + if (event === 'controllerchange') controllerListeners.push(cb); + }), + removeEventListener: jest.fn(), + _fireControllerChange: () => { + controllerListeners.forEach((cb) => cb({} as Event)); + }, + }, + }); + + return { + registration, + waiting, + fireControllerChange: () => { + controllerListeners.forEach((cb) => cb({} as Event)); + }, + }; +} + +describe('registerServiceWorker', () => { + beforeEach(() => { + __resetServiceWorkerRegistrationForTests(); + jest.resetModules(); + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: undefined, + }); + }); + + it('returns "unsupported" when the API is unavailable', async () => { + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: undefined, + }); + const status = await registerServiceWorker({}); + expect(status).toBe('unsupported'); + }); + + it('returns "unsupported" in development unless force=true', async () => { + installFakeServiceWorkerApi(); + process.env.NODE_ENV = 'development'; + const status = await registerServiceWorker({}); + expect(status).toBe('unsupported'); + + const forced = await registerServiceWorker({}, { force: true }); + expect(forced).toBe('registered'); + }); + + it('registers the worker and invokes onUpdate when a worker is waiting', async () => { + const api = installFakeServiceWorkerApi(); + const onUpdate = jest.fn(); + const onOfflineReady = jest.fn(); + + const status = await registerServiceWorker({ onUpdate, onOfflineReady }); + + expect(status).toBe('registered'); + expect(api.registration.addEventListener).toHaveBeenCalledWith( + 'updatefound', + expect.any(Function) + ); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onOfflineReady).not.toHaveBeenCalled(); + + // First controllerchange fires onOfflineReady once. + api.fireControllerChange(); + expect(onOfflineReady).toHaveBeenCalledTimes(1); + // Subsequent changes are no-ops (idempotent guard). + api.fireControllerChange(); + expect(onOfflineReady).toHaveBeenCalledTimes(1); + }); + + it('captures errors via onError and returns "error"', async () => { + installFakeServiceWorkerApi(); + (navigator.serviceWorker.register as jest.Mock).mockRejectedValueOnce( + new Error('boom') + ); + const onError = jest.fn(); + const status = await registerServiceWorker({ onError }); + expect(status).toBe('error'); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('fires onUpdate when a new worker installs after registration', async () => { + // Start with no waiting worker — becomes the "up to date" case. + const api = installFakeServiceWorkerApi({ waiting: null }); + const onUpdate = jest.fn(); + await registerServiceWorker({ onUpdate }); + + expect(onUpdate).not.toHaveBeenCalled(); + + // Simulate a new worker appearing via the updatefound event chain. + api.registration.waiting = makeFakeWorker('installed'); + api.registration._fireUpdateFound(); + + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + it('posts SKIP_WAITING when applyUpdate() is called', async () => { + const api = installFakeServiceWorkerApi(); + await registerServiceWorker({}); + const posted = applyUpdate(); + expect(posted).toBe(true); + expect(api.waiting.postMessage).toHaveBeenCalledWith({ + type: 'SKIP_WAITING', + }); + }); + + it('returns false from applyUpdate() when no worker is waiting', async () => { + installFakeServiceWorkerApi({ waiting: null }); + await registerServiceWorker({}); + expect(applyUpdate()).toBe(false); + }); +}); diff --git a/frontend/src/components/PWA/serviceWorkerRegistration.ts b/frontend/src/components/PWA/serviceWorkerRegistration.ts new file mode 100644 index 00000000..fe8c00a8 --- /dev/null +++ b/frontend/src/components/PWA/serviceWorkerRegistration.ts @@ -0,0 +1,153 @@ +/** + * Service Worker Registration Helper + * ------------------------------------------------------------------------- + * Tiny client-side wrapper that registers `/sw.js` once the page has loaded + * and exposes a user-prompted update flow: + * + * 1. Wait for `load` to avoid competing with critical-render fetches. + * 2. Register with a `/` scope so the SW covers every page route. + * 3. Detect new workers waiting to activate and call `onUpdate` so the + * UI can show a "Reload to update" prompt. + * 4. Only ON user consent does the page call `applyUpdate()`, which posts + * `{ type: 'SKIP_WAITING' }` to the waiting worker. Nothing happens + * automatically on install — that would surprise users mid-edit. + * + * The whole module is exported as a plain function so it is straightforward + * to unit-test against a fake `navigator.serviceWorker`. + */ + +export type ServiceWorkerStatus = + | 'unsupported' + | 'registering' + | 'registered' + | 'activated' + | 'error'; + +export interface ServiceWorkerCallbacks { + /** Fired when a new worker is installed and waiting to activate. */ + onUpdate?: (registration: ServiceWorkerRegistration) => void; + /** Fired the first time a worker successfully takes control of the page. */ + onOfflineReady?: (registration: ServiceWorkerRegistration) => void; + /** Called for any registration error — UI must degrade gracefully. */ + onError?: (error: unknown) => void; +} + +export interface RegisterServiceWorkerOptions { + /** Bypass the production-only guard (used by integration tests). */ + force?: boolean; + /** Override the script URL (defaults to `/sw.js`). */ + scriptUrl?: string; + /** Override scope (defaults to `/`). */ + scope?: string; +} + +// Module-local state so `applyUpdate()` can find the current registration +// without us threading it through React every render. +let activatedOnce = false; +let currentRegistration: ServiceWorkerRegistration | null = null; + +const isProduction = (): boolean => { + if (typeof process !== 'undefined' && process.env) { + return process.env.NODE_ENV === 'production'; + } + return true; +}; + +const isSupported = (): boolean => + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + typeof window.ServiceWorkerRegistration !== 'undefined'; + +/** + * Register the Workbox-based service worker. Resolves with the resulting + * status — never throws. In development (or when the API is unavailable) + * the function is a no-op that resolves to `'unsupported'`. + */ +export async function registerServiceWorker( + callbacks: ServiceWorkerCallbacks = {}, + options: RegisterServiceWorkerOptions = {} +): Promise { + const { onUpdate, onOfflineReady, onError } = callbacks; + const { + force = false, + scriptUrl = '/sw.js', + scope = '/', + } = options; + + if (!isSupported()) { + return 'unsupported'; + } + + if (!force && !isProduction()) { + // Don't pollute local dev caches. + return 'unsupported'; + } + + try { + const registration = await navigator.serviceWorker.register(scriptUrl, { + scope, + updateViaCache: 'none', + }); + + currentRegistration = registration; + + const fireUpdateIfWaiting = () => { + const waiting = + registration.waiting ?? registration.installing ?? null; + if ( + waiting && + waiting.state === 'installed' && + navigator.serviceWorker.controller + ) { + onUpdate?.(registration); + } + }; + + // Inspect any worker that is already waiting (e.g. user refreshed after + // the update was registered but not yet activated). + fireUpdateIfWaiting(); + + // Listen for the next install/activation cycle. + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (!newWorker) return; + newWorker.addEventListener('statechange', fireUpdateIfWaiting); + }); + + // First controller change = a worker took over = the user can use the + // app offline. Subsequent changes mean a *future* update and we + // surface them through `onUpdate` above. + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!activatedOnce) { + activatedOnce = true; + onOfflineReady?.(registration); + } + }); + + return 'registered'; + } catch (error) { + onError?.(error); + return 'error'; + } +} + +/** + * Prompt the waiting worker to take control. The page must call this AFTER + * the user has accepted an update prompt — typically followed by `location.reload()`. + * + * Returns `true` if a `SKIP_WAITING` message was posted, `false` if there is + * no pending update. + */ +export function applyUpdate(): boolean { + if (!currentRegistration) return false; + const waiting = currentRegistration.waiting; + if (!waiting) return false; + waiting.postMessage({ type: 'SKIP_WAITING' }); + return true; +} + +/** Test-only helper — exposed so jest suites can reset module state. */ +export function __resetServiceWorkerRegistrationForTests(): void { + activatedOnce = false; + currentRegistration = null; +} diff --git a/frontend/src/utils/offlineDB.ts b/frontend/src/utils/offlineDB.ts index 6c39b592..845d701d 100644 --- a/frontend/src/utils/offlineDB.ts +++ b/frontend/src/utils/offlineDB.ts @@ -1,12 +1,76 @@ +/** + * AetherMint Offline IndexedDB Utility + * -------------------------------------------------------------------- + * A tiny typed wrapper around IndexedDB for offline-first stores: + * + * • `courses` — course bodies downloaded for offline study + * • `progress` — learner progress snapshots flushed while offline + * • `syncQueue` — outbox for actions queued while offline + * + * Every API is fully typed so callers cannot accidentally pass the wrong + * shape — a common source of corruption in offline stores. + */ + const DB_NAME = 'AetherMintOfflineDB'; const DB_VERSION = 1; +export type OfflineStoreName = 'courses' | 'progress' | 'syncQueue'; + +/** + * Anything stored inside `courses`. Callers can extend this in their own + * modules; the runtime doesn't care, the types do. + */ +export interface OfflineCourseRecord { + id: string; + data: T; + downloadedAt: number; +} + +/** A learner progress snapshot — payload shape is platform-defined. */ +export interface OfflineProgressRecord { + id: string; + data: T; + savedAt: number; +} + +/** A pending action queued while offline (enrollments, quiz submits, etc.). */ +export interface OfflineQueuedAction { + id?: number; + payload: T; + queuedAt: number; +} + +/** + * Narrow interface for `ServiceWorkerRegistration` augmented with the + * optional Background Sync API. Used instead of an inline structural cast + * so callers can grep `SyncCapableRegistration` consistently. + */ +interface SyncCapableRegistration extends ServiceWorkerRegistration { + readonly sync: { register(tag: string): Promise }; +} + +/** + * Open (and if necessary create) the offline database. + * + * `initDB()` is idempotent and cached per-process: the same Promise is + * returned until the connection closes. On failure the cached promise is + * invalidated so the next call retries cleanly. + */ +let cachedDbPromise: Promise | null = null; + export const initDB = (): Promise => { - return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + return Promise.reject( + new Error('IndexedDB is unavailable in this environment.') + ); + } + if (cachedDbPromise) return cachedDbPromise; + + const promise = new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = (e) => { - const db = (e.target as IDBOpenDBRequest).result; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('courses')) { db.createObjectStore('courses', { keyPath: 'id' }); } @@ -14,64 +78,185 @@ export const initDB = (): Promise => { db.createObjectStore('progress', { keyPath: 'id' }); } if (!db.objectStoreNames.contains('syncQueue')) { - db.createObjectStore('syncQueue', { keyPath: 'id', autoIncrement: true }); + db.createObjectStore('syncQueue', { + keyPath: 'id', + autoIncrement: true, + }); } }; request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + request.onerror = () => + reject(request.error ?? new Error('IndexedDB open failed')); + request.onblocked = () => + reject(new Error('IndexedDB upgrade blocked by other tab.')); }); -}; -export const saveCourseOffline = async (courseId: string, courseData: any): Promise => { - const db = await initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction('courses', 'readwrite'); - const store = transaction.objectStore('courses'); - const request = store.put({ id: courseId, data: courseData, downloadedAt: Date.now() }); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + cachedDbPromise = promise.catch((error) => { + cachedDbPromise = null; + throw error; }); + + return cachedDbPromise; }; -export const getOfflineCourse = async (courseId: string): Promise => { +/** + * Lower-level helper that runs an async transaction against a single store + * and resolves with the resulting IDBRequest result. + */ +async function runRequest( + store: S, + mode: IDBTransactionMode, + action: (objectStore: IDBObjectStore) => IDBRequest +): Promise { const db = await initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction('courses', 'readonly'); - const store = transaction.objectStore('courses'); - const request = store.get(courseId); - request.onsuccess = () => resolve(request.result?.data || null); - request.onerror = () => reject(request.error); + return new Promise((resolve, reject) => { + const transaction = db.transaction(store, mode); + const objectStore = transaction.objectStore(store); + const request = action(objectStore); + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error(`IDB ${store} request failed`)); }); +} + +// --------------------------------------------------------------------------- +// Public API — `courses`. + +/** + * Persist a course body to the offline store, overwriting any prior copy. + * `downloadedAt` is stamped automatically. + */ +export const saveCourseOffline = ( + courseId: string, + courseData: T +): Promise => { + const record: OfflineCourseRecord = { + id: courseId, + data: courseData, + downloadedAt: Date.now(), + }; + return runRequest('courses', 'readwrite', (store) => store.put(record)); }; -export const saveOfflineProgress = async (id: string, progressData: any): Promise => { - const db = await initDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction('progress', 'readwrite'); - const store = transaction.objectStore('progress'); - const request = store.put({ id, data: progressData, savedAt: Date.now() }); - - request.onsuccess = () => { - // Auto-register background sync via service worker if available - if ('serviceWorker' in navigator && 'SyncManager' in window) { - navigator.serviceWorker.ready.then(registration => { - (registration as any).sync.register('sync-progress').catch(console.error); - }); +/** Read a single course from offline storage. Returns `null` if absent. */ +export const getOfflineCourse = async ( + courseId: string +): Promise => { + const record = await runRequest | undefined>( + 'courses', + 'readonly', + (store) => store.get(courseId) + ); + return record ? (record.data as T) : null; +}; + +/** List every course currently in offline storage, newest first. */ +export const listOfflineCourses = async (): Promise< + Array> +> => { + const records = await runRequest[]>( + 'courses', + 'readonly', + (store) => store.getAll() + ); + return (records ?? []).sort( + (a, b) => b.downloadedAt - a.downloadedAt + ); +}; + +// --------------------------------------------------------------------------- +// Public API — `progress`. + +/** + * Record a learner-progress snapshot. If the platform's Service Worker is + * registered and supports the Background Sync API, the snapshot is mirrored + * to the SW outbox so it can be replayed when connectivity returns. + */ +export const saveOfflineProgress = async ( + id: string, + progressData: T +): Promise => { + const record: OfflineProgressRecord = { + id, + data: progressData, + savedAt: Date.now(), + }; + await runRequest('progress', 'readwrite', (store) => store.put(record)); + + if ( + typeof navigator !== 'undefined' && + 'serviceWorker' in navigator && + 'SyncManager' in window + ) { + try { + const registration = await navigator.serviceWorker.ready; + if ('sync' in registration) { + await (registration as SyncCapableRegistration).sync.register( + 'sync-progress' + ); } - resolve(); - }; - request.onerror = () => reject(request.error); - }); + } catch (error) { + // Background Sync is best-effort — surface, don't throw. + // eslint-disable-next-line no-console + console.warn('[offlineDB] background-sync registration failed:', error); + } + } +}; + +/** Read a single progress record. */ +export const getOfflineProgress = async ( + id: string +): Promise => { + const record = await runRequest | undefined>( + 'progress', + 'readonly', + (store) => store.get(id) + ); + return record ? (record.data as T) : null; }; -export const queueOfflineAction = async (action: any): Promise => { +// --------------------------------------------------------------------------- +// Public API — `syncQueue` outbox. + +/** Enqueue an action to be retried when the network returns. */ +export const queueOfflineAction = async ( + action: T +): Promise => { + const record: OfflineQueuedAction = { + payload: action, + queuedAt: Date.now(), + }; + return runRequest('syncQueue', 'readwrite', (store) => + store.add(record as unknown as Omit, 'id'>) + ); +}; + +/** Drain the outbox — returns the queued actions and clears the store. */ +export const drainOfflineActions = async (): Promise< + Array & { id: number }> +> => { const db = await initDB(); return new Promise((resolve, reject) => { const transaction = db.transaction('syncQueue', 'readwrite'); const store = transaction.objectStore('syncQueue'); - const request = store.add({ payload: action, queuedAt: Date.now() }); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + const request = store.getAll(); + request.onsuccess = () => { + const records = (request.result || []) as OfflineQueuedAction[]; + const clearRequest = store.clear(); + clearRequest.onerror = () => + reject( + clearRequest.error ?? new Error('IDB syncQueue clear failed') + ); + resolve( + records.map((record) => ({ + id: record.id as number, + payload: record.payload, + queuedAt: record.queuedAt, + })) + ); + }; + request.onerror = () => + reject(request.error ?? new Error('IDB syncQueue read failed')); }); -}; \ No newline at end of file +}; From 1a0b48c2778463864207df4243835976d15f44eb Mon Sep 17 00:00:00 2001 From: Topmatrix Mor Date: Sun, 21 Jun 2026 03:55:22 +0000 Subject: [PATCH 003/152] fix(ui): implement mobile-responsive design for enrollment pages - Add custom responsive breakpoints (xs/375px, 2xl/1440px) to tailwind.config.js - Add mobile-first base styles: 16px body font, overflow prevention, touch targets >=44px - Add responsive utility classes: touch-target, modal-mobile, grid-responsive, form-grid, flex-responsive - Update WalletConnector: responsive flex layout, touch targets, mobile-friendly spacing - Update PaymentProcessor: single-column mobile layout, touch targets, responsive text - Update EnrollmentForm: responsive progress steps, single-column forms on mobile - Update test-enrollment page: responsive grid, touch targets - Update enroll page: responsive layout, touch targets - Closes #5 --- frontend/src/app/enroll/[courseId]/page.tsx | 134 +++++++++---------- frontend/src/app/globals.css | 93 ++++++++++++- frontend/src/app/test-enrollment/page.tsx | 46 +++---- frontend/src/components/EnrollmentForm.tsx | 97 +++++++------- frontend/src/components/PaymentProcessor.tsx | 78 +++++------ frontend/src/components/WalletConnector.tsx | 90 +++++++------ frontend/src/styles/globals.css | 39 ++++++ frontend/tailwind.config.js | 42 ++++++ 8 files changed, 396 insertions(+), 223 deletions(-) diff --git a/frontend/src/app/enroll/[courseId]/page.tsx b/frontend/src/app/enroll/[courseId]/page.tsx index f649f2e8..fe7915d6 100644 --- a/frontend/src/app/enroll/[courseId]/page.tsx +++ b/frontend/src/app/enroll/[courseId]/page.tsx @@ -159,50 +159,50 @@ const EnrollmentPage: React.FC = () => { if (enrollmentComplete && enrollmentData && course) { return ( -
-
+
+
{/* Success Header */} -
- -

Enrollment Successful!

-

You're now enrolled in {course.title}

+
+ +

Enrollment Successful!

+

You're now enrolled in {course.title}

{/* Confirmation Details */} -
-
+
+
{/* Course Information */}
-

Course Information

-
+

Course Information

+
- -
-

{course.title}

-

with {course.instructor}

+ +
+

{course.title}

+

with {course.instructor}

- +
-

{course.duration}

+

{course.duration}

Duration

-
- +
+
-

+

{new Date(course.startDate || '').toLocaleDateString()} - {new Date(course.endDate || '').toLocaleDateString()}

Course Period

- +
-

{course.currentEnrollments}/{course.maxStudents} students

+

{course.currentEnrollments}/{course.maxStudents} students

Class Size

@@ -211,38 +211,38 @@ const EnrollmentPage: React.FC = () => { {/* Enrollment Details */}
-

Enrollment Details

-
+

Enrollment Details

+
-

Enrollment ID

-

{enrollmentData.id}

+

Enrollment ID

+

{enrollmentData.id}

-

Student

-

+

Student

+

{enrollmentData.personalInfo?.firstName} {enrollmentData.personalInfo?.lastName}

-

Email

-

{enrollmentData.personalInfo?.email}

+

Email

+

{enrollmentData.personalInfo?.email}

-

Payment Amount

-

{course.price} {course.currency}

+

Payment Amount

+

{course.price} {course.currency}

-

Transaction

-

{enrollmentData.paymentDetails.transactionHash}

+

Transaction

+

{enrollmentData.paymentDetails.transactionHash}

{/* Next Steps */} -
-

What's Next?

-
    +
    +

    What's Next?

    +
    • • Check your email for course access credentials
    • • Join the course Discord community
    • • Download the course materials
    • @@ -251,24 +251,24 @@ const EnrollmentPage: React.FC = () => {
    {/* Action Buttons */} -
    +
    @@ -288,62 +288,62 @@ const EnrollmentPage: React.FC = () => {
    {/* Course Header */}
    -
    -
    +
    +
    -
    +
    -

    {course.title}

    -

    {course.description}

    +

    {course.title}

    +

    {course.description}

    -
    +
    - + 4.8 (234 reviews)
    - + {course.currentEnrollments} students
    - + {course.duration}
    -
    -
    -

    {course.price} {course.currency}

    -

    One-time payment

    +
    +
    +

    {course.price} {course.currency}

    +

    One-time payment

    -
    +
    - - Lifetime access + + Lifetime access
    - - Certificate of completion + + Certificate of completion
    - - 30-day money back guarantee + + 30-day money back guarantee
    -
    -

    +

    +

    Instructor: {course.instructor}

    @@ -354,7 +354,7 @@ const EnrollmentPage: React.FC = () => {
    {/* Enrollment Form */} -
    +
    { }; return ( -
    -
    -
    -

    Enrollment Flow Test

    -

    +

    +
    +
    +

    Enrollment Flow Test

    +

    Test the complete enrollment functionality including API endpoints, components, and utilities.

    -
    -
    -

    Test Data

    -
    +
    +
    +

    Test Data

    +
    Course ID: {mockCourse.id}
    @@ -196,7 +196,7 @@ const TestEnrollmentPage: React.FC = () => { Price: {mockCourse.price} {mockCourse.currency}
    - Wallet Address: {mockWallet.publicKey.slice(0, 20)}... + Wallet Address: {mockWallet.publicKey.slice(0, 16)}...
    Network: {mockWallet.network} @@ -204,9 +204,9 @@ const TestEnrollmentPage: React.FC = () => {
    -
    -

    Test Coverage

    -
      +
      +

      Test Coverage

      +
      • • Course data validation
      • • Wallet connection validation
      • • API POST endpoint
      • @@ -217,30 +217,30 @@ const TestEnrollmentPage: React.FC = () => {
    -
    +
    {testResults.length > 0 && ( -
    -

    Test Results

    -
    +
    +

    Test Results

    +
    {testResults.map((result, index) => (
    {
    )} -
    -

    Important Notes

    -
      +
      +

      Important Notes

      +
      • • Tests use mock data and may not reflect real blockchain interactions
      • • Stellar transaction validation requires a valid transaction hash
      • • Live enrollment requires a connected wallet with sufficient balance
      • diff --git a/frontend/src/components/EnrollmentForm.tsx b/frontend/src/components/EnrollmentForm.tsx index 041a9b48..cfcb677e 100644 --- a/frontend/src/components/EnrollmentForm.tsx +++ b/frontend/src/components/EnrollmentForm.tsx @@ -225,16 +225,16 @@ const EnrollmentForm: React.FC = ({ const CurrentStepComponent = steps[currentStep].component; return ( -
        - {/* Progress Steps */} -