Fix NewBook write mechanism, bundle push extension, remove vestigial hotel_id

- setBookingCustomFields: use bookings_update's own custom_fields array
  (confirmed from the real legacy plugin code) instead of
  instance_custom_fields_set, which was only ever proven for a different,
  unrelated plugin
- online_checkin_complete is now a configurable per-hotel custom field
  (online_checkin_complete_custom_field_id setting), not a bogus top-level
  bookings_update field NewBook would silently ignore
- Fix field-name mismatches vs a real live NewBook booking response:
  booking_arrival/departure/length, guest address (street/city/state/
  postcode/country, not a single field), phone/mobile (in contact_details,
  not top-level), and pickPrimaryGuest now checks primary_client (was
  checking fields that don't exist in the real API)
- Bundle the standalone push extension into backend/extension-src/, add
  GET /api/extension/download (zips on request via archiver) and a
  download button on the Settings page
- Remove newbook_hotel_id — vestigial from the legacy system's multi-hotel
  routing model, never actually reached NewBook's API, this stack is
  single-hotel-per-deployment so there's nothing to route between

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 18:11:35 +00:00
parent 3b2e25df3f
commit 4d3774ef0d
25 changed files with 3557 additions and 15 deletions

View file

@ -0,0 +1,161 @@
// Injects a "Send to Tablet" button into each row of NewBook's "due for
// arrival" list (bookings_lists_checkin), right under the page's own
// Check In button. Only runs on that one page (see manifest.json's
// content_scripts.matches) — the popup covers single-booking pages
// (bookings_view/edit/checkin) already.
//
// Row markup, booking-ID extraction, and the "generic_button_1"-is-not-
// actually-unique gotcha are all confirmed from a real sample row the user
// provided 2026-09-01 (see project_checkin memory) — not guessed. NewBook's
// own Check In button uses a non-unique id (`generic_button_1` repeated
// across every row), so this script never relies on ids from NewBook's own
// markup — only class/attribute selectors scoped per row, and its own
// injected elements get ids built from the booking ID
// (`tcp-btn-<bookingId>`) to stay collision-free and support idempotent
// re-injection checks.
//
// MutationObserver pattern (watching for AJAX-refreshed rows, not just
// initial page load) is the same approach chrome-newbook-assistant already
// had to use for this same NewBook UI (~/laptop-archive) — this system
// clearly refreshes table content via AJAX (see the `via_ajax` tooltip
// classes in the sample row) without a full page reload, so a one-time scan
// on document_idle alone would miss rows added/replaced afterward.
const PROCESSED_ATTR = 'data-tcp-processed';
function extractBookingId(row) {
const checkbox = row.querySelector('input.booking_id_checkbox');
if (checkbox?.value) return checkbox.value;
// Fallback: class name carries it too (`booking_37117`) — confirmed from
// the sample row, kept as a fallback in case a future NewBook markup
// change drops the checkbox but keeps the class.
const match = row.className.match(/\bbooking_(\d+)\b/);
return match ? match[1] : null;
}
function extractGuestName(row) {
// The guest/company link's own text node — tooltip content is a sibling
// element (easytooltip_content), not part of the visible text, so a plain
// textContent read on the link itself would double up with it. Grab just
// the first non-empty text child instead.
const link = row.querySelector('a.easytooltip[class*="booking_"]');
if (!link) return '';
for (const node of link.childNodes) {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
return node.textContent.trim();
}
}
return link.textContent.trim();
}
function findCheckInCell(row) {
const btn = row.querySelector('button[aria-label="Check In"]');
return btn ? btn.closest('td') : null;
}
function makeSendButton(bookingId, guestName) {
const btn = document.createElement('button');
btn.id = `tcp-btn-${bookingId}`;
btn.type = 'button';
btn.textContent = 'Send to Tablet';
btn.style.cssText = `
display: block; margin-top: 6px; width: 100%;
padding: 4px 8px; font-size: 12px; font-weight: 600;
background: #be123c; color: #fff; border: none; border-radius: 6px;
cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
`;
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
togglePanel(btn, bookingId, guestName);
});
return btn;
}
let openPanel = null;
async function togglePanel(anchorBtn, bookingId, guestName) {
if (openPanel) { openPanel.remove(); openPanel = null; }
const panel = document.createElement('div');
panel.style.cssText = `
margin-top: 6px; padding: 8px; background: #f4f5f7; border: 1px solid #e4e8ee;
border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px; color: #1e293b;
`;
panel.textContent = 'Loading tablets…';
anchorBtn.insertAdjacentElement('afterend', panel);
openPanel = panel;
const res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
if (!res?.ok) {
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return;
}
if (!res.devices.length) {
panel.textContent = 'No tablets registered yet.';
return;
}
const select = document.createElement('select');
select.style.cssText = 'width: 100%; margin-bottom: 6px; padding: 4px;';
select.innerHTML = res.devices.map(d =>
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`
).join('');
const sendBtn = document.createElement('button');
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
sendBtn.style.cssText = `
width: 100%; padding: 4px 8px; background: #c9a84c; color: #1a1a2e;
border: none; border-radius: 6px; font-weight: 600; cursor: pointer;
`;
sendBtn.addEventListener('click', async () => {
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
const result = await chrome.runtime.sendMessage({
action: 'tcpPush', bookingId, deviceId: select.value,
});
if (result?.ok) {
panel.textContent = 'Sent — the tablet should navigate now.';
} else {
panel.textContent = `Failed: ${result?.error || 'unknown error'}`;
}
});
panel.innerHTML = '';
panel.appendChild(select);
panel.appendChild(sendBtn);
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function processRow(row) {
if (row.hasAttribute(PROCESSED_ATTR)) return;
const bookingId = extractBookingId(row);
const cell = findCheckInCell(row);
if (!bookingId || !cell) return; // not a real booking row, or no Check In button in it
row.setAttribute(PROCESSED_ATTR, '1');
cell.appendChild(makeSendButton(bookingId, extractGuestName(row)));
}
function scanForRows(root = document) {
root.querySelectorAll('tr.checkbox_row').forEach(processRow);
}
scanForRows();
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches?.('tr.checkbox_row')) processRow(node);
else scanForRows(node);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });