From b20e118def81f381221178941fe7d3b254a94157 Mon Sep 17 00:00:00 2001 From: Steve Dogiakos Date: Sun, 29 Mar 2026 20:22:25 -0600 Subject: [PATCH] feat: add offline queue for kiosk form submissions Intercepts form submit via fetch and stores failed submissions in IndexedDB when offline. Replays queued entries on the online event and on each page load. Shows an offline banner on the form page and a sync-pending message on the thank-you page. Service worker bumped to guestbook-v2 to pre-cache offline-queue.js so the script is available when the kiosk has no network. --- static/offline-queue.js | 223 ++++++++++++++++++++++++++++++++++ static/sw.js | 3 +- templates/index.html.template | 6 + templates/thank_you.html | 6 + 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 static/offline-queue.js 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 %}