tablet-checkin/backend/extension-src/background.js
jtricerolph a37654cdc4 Extension: per-machine default tablet, skip asking unless busy
- Options page gains a 'Default tablet for this machine' picker,
  populated from the device list once base URL + terminal key are
  configured, auto-saved on change (small enough setting not to warrant
  its own Save button).
- Popup pre-selects the default in its device dropdown but still shows
  the full picker — opening the popup is already a deliberate action, so
  staff can still override it there.
- Injected buttons (row + banner) skip the dropdown entirely when a
  default is configured — one click pushes straight to it. Only falls
  back to the full picker if that specific push comes back busy (409),
  with a note explaining why it's asking despite a default being set.
  background.js's pushBooking() now returns the HTTP status alongside
  ok/error so this can be detected reliably rather than string-matching
  the error message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:42:35 +00:00

114 lines
4.6 KiB
JavaScript

// Background service worker — detects when the active tab is a NewBook
// booking page and shows a badge, so staff notice the extension has
// something to offer before even opening the popup. Tab-update/activated/
// history-state listeners and the booking-ID regex are adapted directly from
// chrome-newbook-assistant/background.js (~/laptop-archive) — that pattern
// is already proven working against real NewBook navigation (including its
// SPA-style in-page routing, hence the webNavigation listener), not
// reinvented here. See project_checkin memory's "Standalone push extension".
// Confirmed 2026-09-01 by the user directly (real NewBook page URLs):
// bookings_view/<id>, bookings_edit/<id>, bookings_checkin/<id>.
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
const NEWBOOK_HOSTS = ['appeu.newbook.cloud', 'login.newbook.cloud'];
function isNewBookUrl(url) {
try {
const u = new URL(url);
return NEWBOOK_HOSTS.some(h => u.hostname === h);
} catch {
return false;
}
}
async function handleTabUpdate(tabId, url) {
if (!url || !isNewBookUrl(url)) {
await chrome.action.setBadgeText({ tabId, text: '' });
return;
}
const match = url.match(BOOKING_URL_RE);
if (match) {
const bookingId = match[1];
await chrome.storage.session.set({ [`bookingId:${tabId}`]: bookingId });
await chrome.action.setBadgeText({ tabId, text: '→' }); // →
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#be123c' });
await chrome.action.setTitle({ tabId, title: `Send booking #${bookingId} to a check-in tablet` });
} else {
await chrome.storage.session.remove(`bookingId:${tabId}`);
await chrome.action.setBadgeText({ tabId, text: '' });
await chrome.action.setTitle({ tabId, title: 'Send to Check-In Tablet' });
}
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url) handleTabUpdate(tabId, tab.url);
});
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
const tab = await chrome.tabs.get(tabId);
if (tab.url) handleTabUpdate(tabId, tab.url);
});
// NewBook's booking view is (per chrome-newbook-assistant's own findings) an
// SPA that can change URL without a full page load — this catches that case,
// same as the assistant already has to.
chrome.webNavigation.onHistoryStateUpdated.addListener(details => {
handleTabUpdate(details.tabId, details.url);
});
// ── Messages from content-script.js (bookings_lists_checkin row buttons) ───
// Content scripts route their actual network calls through here rather than
// fetching directly — keeps the baseUrl/apiKey read-and-fetch logic in one
// place instead of duplicated across popup.js and content-script.js.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.action === 'tcpListDevices') {
listDevices().then(sendResponse);
return true; // keep the message channel open for the async response
}
if (message.action === 'tcpPush') {
pushBooking(message.bookingId, message.deviceId).then(sendResponse);
return true;
}
return false;
});
async function getConfig() {
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
return { baseUrl, apiKey };
}
async function listDevices() {
const { baseUrl, apiKey } = await getConfig();
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
try {
const res = await fetch(`${baseUrl}/api/staff/devices`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const body = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` };
return { ok: true, devices: body };
} catch (err) {
return { ok: false, error: err.message };
}
}
async function pushBooking(bookingId, deviceId) {
const { baseUrl, apiKey } = await getConfig();
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
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(() => ({}));
// status included so callers can reliably tell "device busy" (409) apart
// from other failures — used by content-script.js's default-tablet
// fast-path to decide whether to fall back to the device picker.
if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` };
return { ok: true, status: res.status };
} catch (err) {
return { ok: false, error: err.message };
}
}