Kiosk's SSE connection now stays open for the whole session instead of closing the moment a booking is pushed, so a staff-triggered Reset (from the extension/Terminals popup or the Devices admin page) now actually forces the tablet back to the welcome screen immediately, discarding whatever was mid-entry — not just flipping its status in the DB with nothing listening tablet-side. Removed the two now-redundant/harmful pushIdle() calls in routes/checkin.js's own submit success/failure paths, which would otherwise race the confirmation/error screen now that the stream stays open. Added a confirm() prompt before Reset on both trigger surfaces since it's now a real interruption, not just an unstick. Also made the tablet's inactivity auto-cancel timeout configurable from Settings (checkin_inactivity_timeout_minutes, default 5, same as the old hardcoded value) instead of a fixed constant, read via the existing token-gated GET /api/checkin/:token response.
357 lines
15 KiB
JavaScript
357 lines
15 KiB
JavaScript
import { isNewBookLoggedIn } from '../newbook-auth.js';
|
||
|
||
// Same regex as background.js — re-derived independently here from the live
|
||
// tab URL each time the popup opens (rather than trusting chrome.storage
|
||
// left over from the background script) so a stale badge state can never
|
||
// show a booking that isn't actually the one currently open. Confirmed
|
||
// 2026-09-01 by the user directly (real NewBook page URLs).
|
||
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
|
||
|
||
const contentEl = document.getElementById('content');
|
||
const nbStatusEl = document.getElementById('nbStatus');
|
||
const versionEl = document.getElementById('version');
|
||
|
||
// Shown so staff/admin can tell at a glance whether a given reception PC is
|
||
// running an old install — compare against the "Latest version" shown next
|
||
// to the download link on tablet-checkin's own Terminals page (see
|
||
// routes/extension.js's /api/extension/version). Pulled from the manifest
|
||
// rather than hardcoded here, so it can never itself drift out of sync with
|
||
// manifest.json's own version field.
|
||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||
|
||
async function main() {
|
||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||
const bookingMatch = tab?.url?.match(BOOKING_URL_RE);
|
||
const bookingId = bookingMatch ? bookingMatch[1] : null;
|
||
|
||
isNewBookLoggedIn().then(ok => {
|
||
nbStatusEl.textContent = ok ? 'NewBook: signed in' : 'NewBook: not detected';
|
||
nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`;
|
||
});
|
||
|
||
const { baseUrl, apiKey, defaultDeviceId } = await chrome.storage.local.get(['baseUrl', 'apiKey', 'defaultDeviceId']);
|
||
if (!baseUrl || !apiKey) {
|
||
contentEl.innerHTML = `
|
||
<p class="muted">Not set up yet — add the tablet-checkin URL and a terminal API key first.</p>
|
||
<a class="settings-link" href="#" id="openSettings">Open Settings</a>
|
||
`;
|
||
document.getElementById('openSettings').addEventListener('click', e => {
|
||
e.preventDefault();
|
||
chrome.runtime.openOptionsPage();
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Three independent sections: tablet status + reset is always shown (not
|
||
// gated on a booking being open) so reception can clear a stuck-busy
|
||
// tablet from the terminal without needing to be on a booking page at all;
|
||
// arrivals is likewise always shown — it's the alternative route to a push
|
||
// for when reception isn't on a booking page either, picking the guest
|
||
// from today's list instead of from NewBook itself.
|
||
contentEl.innerHTML = `<div id="tabletsSection"></div><div id="arrivalsSection"></div><div id="bookingSection"></div>`;
|
||
const tabletsEl = document.getElementById('tabletsSection');
|
||
const arrivalsEl = document.getElementById('arrivalsSection');
|
||
const bookingEl = document.getElementById('bookingSection');
|
||
|
||
let devices = [];
|
||
try {
|
||
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
});
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||
devices = await res.json();
|
||
} catch (err) {
|
||
tabletsEl.innerHTML = `<p class="muted">Could not reach tablet-checkin: ${escapeHtml(err.message)}</p>`;
|
||
return;
|
||
}
|
||
|
||
renderTablets(tabletsEl, devices, baseUrl, apiKey);
|
||
loadArrivals(arrivalsEl, devices, defaultDeviceId, baseUrl, apiKey);
|
||
|
||
if (!bookingId) {
|
||
bookingEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
|
||
return;
|
||
}
|
||
|
||
renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
|
||
}
|
||
|
||
async function loadArrivals(el, devices, defaultDeviceId, baseUrl, apiKey) {
|
||
el.innerHTML = `<p class="section-label">Today's Arrivals</p><p class="muted">Loading…</p>`;
|
||
let arrivals;
|
||
try {
|
||
const res = await fetch(`${baseUrl}/api/staff/arrivals`, {
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
});
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||
arrivals = await res.json();
|
||
} catch (err) {
|
||
el.innerHTML = `<p class="section-label">Today's Arrivals</p><p class="muted">Could not load: ${escapeHtml(err.message)}</p>`;
|
||
return;
|
||
}
|
||
renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey);
|
||
}
|
||
|
||
function renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey) {
|
||
if (!arrivals.length) {
|
||
el.innerHTML = `<p class="section-label">Today's Arrivals</p><p class="muted">No arrivals today.</p>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<p class="section-label">Today's Arrivals</p>
|
||
<ul class="arrival-list">
|
||
${arrivals.map(a => `
|
||
<li class="arrival-row">
|
||
<div class="arrival-info">
|
||
<span class="arrival-name">${escapeHtml(a.guest_name)}</span>
|
||
${a.site_name ? `<span class="arrival-site">${escapeHtml(a.site_name)}</span>` : ''}
|
||
</div>
|
||
${a.checked_in
|
||
? `<span class="arrival-done">Checked in</span>`
|
||
: `<button type="button" class="push-btn" data-booking="${a.booking_id}">Push</button>`}
|
||
<div class="arrival-msg"></div>
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
`;
|
||
|
||
el.querySelectorAll('.push-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => handleArrivalPush(btn, devices, defaultDeviceId, baseUrl, apiKey));
|
||
});
|
||
}
|
||
|
||
// Mirrors renderBooking's own fast-path/picker split (straight to the
|
||
// default tablet when it's free, a manual picker when it isn't or there's
|
||
// none), just scoped to this one row instead of the whole popup — several
|
||
// of these can be mid-send at once across different arrivals.
|
||
async function handleArrivalPush(btn, devices, defaultDeviceId, baseUrl, apiKey) {
|
||
const row = btn.closest('.arrival-row');
|
||
const bookingId = btn.dataset.booking;
|
||
const msgEl = row.querySelector('.arrival-msg');
|
||
const defaultDevice = devices.find(d => d.device_id === defaultDeviceId);
|
||
|
||
if (defaultDevice && defaultDevice.status !== 'busy') {
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
const result = await sendPush(bookingId, defaultDevice.device_id, baseUrl, apiKey);
|
||
if (result.ok && result.delivered !== false) {
|
||
btn.remove();
|
||
setMsg(msgEl, 'Sent', 'ok');
|
||
return;
|
||
}
|
||
if (result.ok) {
|
||
setMsg(msgEl, 'Not connected — choose another:', 'warn');
|
||
} else if (result.status === 409) {
|
||
setMsg(msgEl, 'Busy — choose another:', 'warn');
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Push';
|
||
setMsg(msgEl, `Failed: ${result.error}`, 'error');
|
||
return;
|
||
}
|
||
}
|
||
|
||
showArrivalPicker(row, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
|
||
}
|
||
|
||
function showArrivalPicker(row, bookingId, devices, defaultDeviceId, baseUrl, apiKey) {
|
||
row.querySelector('.push-btn')?.remove();
|
||
const msgEl = row.querySelector('.arrival-msg');
|
||
|
||
if (!devices.length) {
|
||
setMsg(msgEl, 'No tablets registered yet', 'error');
|
||
return;
|
||
}
|
||
|
||
const picker = document.createElement('div');
|
||
picker.className = 'arrival-picker';
|
||
picker.innerHTML = `
|
||
<select>
|
||
${devices.map(d => `<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`).join('')}
|
||
</select>
|
||
<button type="button" class="send-small">Send</button>
|
||
`;
|
||
row.appendChild(picker);
|
||
const select = picker.querySelector('select');
|
||
if (defaultDeviceId) select.value = defaultDeviceId;
|
||
|
||
picker.querySelector('.send-small').addEventListener('click', async () => {
|
||
const sendBtn = picker.querySelector('.send-small');
|
||
sendBtn.disabled = true;
|
||
const result = await sendPush(bookingId, select.value, baseUrl, apiKey);
|
||
if (result.ok && result.delivered !== false) {
|
||
picker.remove();
|
||
setMsg(msgEl, 'Sent', 'ok');
|
||
} else if (result.ok) {
|
||
setMsg(msgEl, 'Sent, but that tablet isn’t connected right now', 'warn');
|
||
sendBtn.disabled = false;
|
||
} else {
|
||
setMsg(msgEl, result.error || 'Failed', 'error');
|
||
sendBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderTablets(el, devices, baseUrl, apiKey) {
|
||
if (!devices.length) {
|
||
el.innerHTML = `<p class="muted">No tablets registered yet</p>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<p class="section-label">Tablets</p>
|
||
<ul class="device-list">
|
||
${devices.map(d => `
|
||
<li class="device-row">
|
||
<span class="device-name">${escapeHtml(d.name)}</span>
|
||
<span class="device-status ${d.status}">${escapeHtml(d.status)}</span>
|
||
${d.status === 'busy' ? `<button type="button" class="reset-btn" data-device="${d.device_id}">Reset</button>` : ''}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
`;
|
||
|
||
el.querySelectorAll('.reset-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
// Reset now pushes a live 'idle' event straight to the tablet
|
||
// (2026-10-09, see tablet-checkin's Kiosk.tsx) rather than only
|
||
// flipping its status server-side — if a guest is mid check-in on it,
|
||
// this sends them back to the welcome screen immediately and discards
|
||
// whatever they'd filled in.
|
||
if (!confirm('Reset this tablet? If a guest is mid check-in, this returns them to the welcome screen immediately and loses anything they’ve entered.')) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
try {
|
||
const res = await fetch(`${baseUrl}/api/staff/devices/${btn.dataset.device}/reset`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${apiKey}` },
|
||
});
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||
// Simplest way to get the tablet list (and any in-flight booking
|
||
// auto-send, which depends on device status) back in sync — reload
|
||
// the whole popup rather than hand-patching multiple sections.
|
||
main();
|
||
} catch (err) {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Reset';
|
||
alert(`Reset failed: ${err.message}`);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderBooking(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey) {
|
||
const defaultDevice = devices.find(d => d.device_id === defaultDeviceId);
|
||
|
||
if (defaultDevice && defaultDevice.status !== 'busy') {
|
||
// Straight to pushing, no extra click — matches the injected row/banner
|
||
// buttons' behaviour (see content-script.js's handleSendClick). "Send to
|
||
// a different tablet instead" stays one click away below in case the
|
||
// default is wrong for this particular booking.
|
||
el.innerHTML = `
|
||
<p class="booking">Booking <strong>#${bookingId}</strong></p>
|
||
<div id="msg" class="msg warn">Sending to ${escapeHtml(defaultDevice.name)}…</div>
|
||
<a href="#" class="settings-link" id="changeTablet">Send to a different tablet instead</a>
|
||
`;
|
||
document.getElementById('changeTablet').addEventListener('click', e => {
|
||
e.preventDefault();
|
||
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
|
||
});
|
||
|
||
sendPush(bookingId, defaultDevice.device_id, baseUrl, apiKey).then(result => {
|
||
const msgEl = document.getElementById('msg');
|
||
if (!msgEl) return; // "Send to a different tablet" was clicked before this resolved
|
||
|
||
if (result.ok && result.delivered !== false) {
|
||
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
|
||
} else if (result.ok) {
|
||
// Session created server-side but nothing's actually listening on
|
||
// that device right now — same "needs a human decision" case as
|
||
// busy, so offer the picker rather than a dead-end message.
|
||
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, 'Default tablet isn’t connected — choose another:');
|
||
} else if (result.status === 409) {
|
||
// Default went busy in the moment between loading the popup and
|
||
// sending — fall back to the manual picker rather than leaving the
|
||
// guest on a dead-end message.
|
||
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, 'Default tablet is busy — choose another:');
|
||
} else {
|
||
setMsg(msgEl, `Failed: ${result.error}`, 'error');
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
|
||
}
|
||
|
||
function renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, note = '') {
|
||
if (!devices.length) {
|
||
el.innerHTML = `<p class="booking">Booking <strong>#${bookingId}</strong></p><p class="muted">No tablets registered yet</p>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<p class="booking">Booking <strong>#${bookingId}</strong></p>
|
||
${note ? `<p class="msg warn">${escapeHtml(note)}</p>` : ''}
|
||
<select id="device">
|
||
${devices.map(d => `<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`).join('')}
|
||
</select>
|
||
<button class="primary" id="send">Send to Check-In Tablet</button>
|
||
<div id="msg"></div>
|
||
`;
|
||
|
||
const deviceSelect = document.getElementById('device');
|
||
const sendBtn = document.getElementById('send');
|
||
const msgEl = document.getElementById('msg');
|
||
// Setting .value to a disabled (busy) option is a no-op in every browser
|
||
// that matters here, so a busy default just falls through to whatever the
|
||
// browser's own first-enabled-option default would have been anyway.
|
||
if (defaultDeviceId) deviceSelect.value = defaultDeviceId;
|
||
|
||
sendBtn.addEventListener('click', async () => {
|
||
sendBtn.disabled = true;
|
||
setMsg(msgEl, 'Sending…', 'warn');
|
||
const result = await sendPush(bookingId, deviceSelect.value, baseUrl, apiKey);
|
||
if (result.ok) {
|
||
if (result.delivered === false) {
|
||
setMsg(msgEl, 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.', 'warn');
|
||
} else {
|
||
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
|
||
}
|
||
} else if (result.status === 409) {
|
||
setMsg(msgEl, result.error || 'That tablet is busy with another guest right now.', 'warn');
|
||
} else {
|
||
setMsg(msgEl, `Failed: ${result.error}`, 'error');
|
||
}
|
||
sendBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
async function sendPush(bookingId, deviceId, baseUrl, apiKey) {
|
||
try {
|
||
const res = await fetch(`${baseUrl}/api/staff/push`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||
body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }),
|
||
});
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` };
|
||
return { ok: true, delivered: body.delivered };
|
||
} catch (err) {
|
||
return { ok: false, status: 0, error: err.message };
|
||
}
|
||
}
|
||
|
||
function setMsg(el, text, kind) {
|
||
el.textContent = text;
|
||
el.className = `msg ${kind}`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
const div = document.createElement('div');
|
||
div.textContent = s;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
main();
|