tablet-checkin/backend/extension-src/popup/popup.js
jtricerolph 790431c446 Extension: show installed/latest version for drift tracking
Popup footer shows its own installed version (from manifest.json, via
chrome.runtime.getManifest()); Terminals page shows the latest bundled
version next to the download link (new GET /api/extension/version, reads
the same manifest.json). Bumped manifest.json to 0.2.0 for the Today's
Arrivals change, and documented in extension-src/README.md that every
future change to this folder must bump the version too — nothing else
keeps it in sync automatically.
2026-10-09 12:12:32 +00:00

351 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 () => {
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();