tablet-checkin/backend/extension-src/popup/popup.js
jtricerolph ebf9875ca8 Extension: add Today's Arrivals list to popup, pushable independent of a NewBook booking page
Adds GET /api/staff/arrivals (NewBook bookings_list, same list_type: 'staying'
pattern room-planner/hvac/maintenance already use) and a popup section that
lists each arrival with its own Push button, falling back to a per-row device
picker on busy/offline/no-default — a second route to the same push flow,
separate from the content-script's injected NewBook page buttons.
2026-10-09 12:09:01 +00:00

342 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');
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();