tablet-checkin/backend/extension-src/popup/popup.js
jtricerolph 4aeca15281 Extension: surface a disconnected-tablet warning instead of silent success
A push can succeed server-side (session created) with nowhere to actually
deliver to — the backend's pushNavigate() already distinguishes this via a
delivered:false return (see device-stream.js), but nothing in the extension
read it: every surface just said "Sent" regardless. Confirmed live on the
dev stack 2026-10-01 — the registered dev tablet hadn't heartbeated in a
month, so a push looked successful but nothing happened. Now every send
path (injected buttons' fast path + manual picker, popup's fast path +
manual picker) shows an amber "tablet isn't connected right now" warning
when delivered is false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 12:02:56 +00:00

219 lines
8.8 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;
}
// Two 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.
contentEl.innerHTML = `<div id="tabletsSection"></div><div id="bookingSection"></div>`;
const tabletsEl = document.getElementById('tabletsSection');
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);
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);
}
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) {
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) {
// 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();