diff --git a/static/offline-queue.js b/static/offline-queue.js new file mode 100644 index 0000000..59c0d76 --- /dev/null +++ b/static/offline-queue.js @@ -0,0 +1,223 @@ +/** + * Offline queue for kiosk-guestbook. + * + * Intercepts the guestbook form submit via fetch. On network failure, + * stores the submission in IndexedDB and shows an offline thank-you. + * Replays queued entries on the `online` event and on each page load. + * + * Works on both the form page (/) and the thank-you page (/thank-you). + * Background Sync is not used — it is unsupported in iOS Safari. + */ + +const OQ_DB_NAME = 'guestbook-offline-queue'; +const OQ_STORE = 'entries'; +const OQ_VERSION = 1; + +// --------------------------------------------------------------------------- +// IndexedDB helpers +// --------------------------------------------------------------------------- + +function oqOpenDb() { + return new Promise(function (resolve, reject) { + var req = indexedDB.open(OQ_DB_NAME, OQ_VERSION); + req.onupgradeneeded = function (e) { + e.target.result.createObjectStore(OQ_STORE, { keyPath: 'id', autoIncrement: true }); + }; + req.onsuccess = function (e) { resolve(e.target.result); }; + req.onerror = function (e) { reject(e.target.error); }; + }); +} + +function oqEnqueue(fields) { + return oqOpenDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(OQ_STORE, 'readwrite'); + var store = tx.objectStore(OQ_STORE); + store.add({ fields: fields, queued_at: new Date().toISOString() }); + tx.oncomplete = resolve; + tx.onerror = function (e) { reject(e.target.error); }; + }); + }); +} + +function oqDequeue(id) { + return oqOpenDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(OQ_STORE, 'readwrite'); + tx.objectStore(OQ_STORE).delete(id); + tx.oncomplete = resolve; + tx.onerror = function (e) { reject(e.target.error); }; + }); + }); +} + +function oqGetAll() { + return oqOpenDb().then(function (db) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(OQ_STORE, 'readonly'); + var req = tx.objectStore(OQ_STORE).getAll(); + req.onsuccess = function (e) { resolve(e.target.result); }; + req.onerror = function (e) { reject(e.target.error); }; + }); + }); +} + +// --------------------------------------------------------------------------- +// Collect form fields into a plain object (excludes csrf_token) +// --------------------------------------------------------------------------- + +function oqCollectFields(form) { + var fields = {}; + var fd = new FormData(form); + fd.forEach(function (value, key) { + if (key !== 'csrf_token') fields[key] = value; + }); + // Explicitly record the newsletter checkbox so a missing key means opt-out + var checkbox = form.querySelector('[name="newsletter_opt_in"]'); + if (checkbox && !checkbox.checked) { + delete fields['newsletter_opt_in']; + } + return fields; +} + +// --------------------------------------------------------------------------- +// Queue replay +// --------------------------------------------------------------------------- + +var oqReplaying = false; + +async function oqReplayQueue() { + if (oqReplaying) return; + oqReplaying = true; + + var items; + try { + items = await oqGetAll(); + } catch (e) { + oqReplaying = false; + return; + } + if (!items.length) { + oqReplaying = false; + return; + } + + // Fetch a fresh CSRF token once for the whole batch + var token; + try { + var csrfRes = await fetch('/api/csrf'); + var csrfJson = await csrfRes.json(); + token = csrfJson.csrf_token; + } catch (e) { + // Still offline + oqReplaying = false; + return; + } + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + try { + var fd = new FormData(); + fd.append('csrf_token', token); + Object.keys(item.fields).forEach(function (k) { + fd.append(k, item.fields[k]); + }); + + var res = await fetch('/', { method: 'POST', body: fd }); + + if (res.ok) { + await oqDequeue(item.id); + } else if (res.status === 429) { + // Rate-limited — leave remaining entries, try again later + break; + } else { + // Server rejected (validation error etc.) — discard to unblock queue + console.warn('oq: discarding entry', item.id, 'server returned', res.status); + await oqDequeue(item.id); + } + } catch (e) { + // Network error again — stop, leave entries for next online event + break; + } + } + + oqReplaying = false; + + // Update offline indicator if we're now fully synced + var remaining; + try { remaining = await oqGetAll(); } catch (e) { remaining = []; } + if (!remaining.length) { + oqSetIndicator(false); + } +} + +// --------------------------------------------------------------------------- +// Offline indicator (form page only) +// --------------------------------------------------------------------------- + +function oqSetIndicator(offline) { + var el = document.getElementById('offline-indicator'); + if (!el) return; + if (offline) { + el.classList.remove('d-none'); + } else { + el.classList.add('d-none'); + } +} + +// --------------------------------------------------------------------------- +// Form submit intercept (form page only) +// --------------------------------------------------------------------------- + +async function oqHandleSubmit(e) { + e.preventDefault(); + var form = e.target; + var fields = oqCollectFields(form); + + try { + var res = await fetch('/', { method: 'POST', body: new FormData(form) }); + // fetch follows redirects; res.url is the final URL (thank-you page on success) + window.location.href = res.url; + } catch (err) { + // Network failure — queue and show offline thank-you + try { + await oqEnqueue(fields); + } catch (dbErr) { + console.error('oq: failed to enqueue', dbErr); + } + var name = fields['first_name'] || ''; + window.location.href = '/thank-you?name=' + encodeURIComponent(name) + '&offline=1'; + } +} + +// --------------------------------------------------------------------------- +// Bootstrap on DOMContentLoaded +// --------------------------------------------------------------------------- + +document.addEventListener('DOMContentLoaded', function () { + // Intercept form submit (form page only) + var form = document.querySelector('form[action="/"]'); + if (form) { + form.addEventListener('submit', oqHandleSubmit); + } + + // Sync indicator with current state + if (!navigator.onLine) { + oqSetIndicator(true); + } + + // Replay queue on reconnect + window.addEventListener('online', function () { + oqSetIndicator(false); + oqReplayQueue(); + }); + + window.addEventListener('offline', function () { + oqSetIndicator(true); + }); + + // Replay any previously queued items on page load + if (navigator.onLine) { + oqReplayQueue(); + } +}); diff --git a/static/sw.js b/static/sw.js index 241efe2..95276c5 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,10 +1,11 @@ -const CACHE_NAME = 'guestbook-v1'; +const CACHE_NAME = 'guestbook-v2'; const STATIC_ASSETS = [ 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', 'https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js', 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js', 'https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap', '/static/images/logo.png', + '/static/offline-queue.js', ]; self.addEventListener('install', event => { diff --git a/templates/index.html.template b/templates/index.html.template index 979e58c..fa799e2 100644 --- a/templates/index.html.template +++ b/templates/index.html.template @@ -89,6 +89,10 @@ Providing your email is optional, but it helps us follow up if needed. +
+ No internet connection — your entry will be saved and submitted automatically when reconnected. +
+ {% if error %}