mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-03 21:37:51 -06:00
b20e118def
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.
224 lines
7.1 KiB
JavaScript
224 lines
7.1 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
});
|