tablet-checkin/backend/extension-src/content-script.js
jtricerolph d7fbdce04d Extension: icon-state feedback + overlay picker instead of inline panels
- Injected buttons now show state via the tablet icon itself — spinner
  while sending, check on success, warning triangle on busy/offline/error —
  with detail in a hover tooltip, rather than a persistent inline text block
- Replaced the in-page dropdown panel (block-inserted for row/banner
  buttons, fixed-position floating for menu items) with a single centred
  modal overlay used by all three injection shapes — simpler than
  maintaining per-variant positioning logic, and looks the same everywhere
- Picker overlay now opens automatically when the default tablet is busy
  (409) or configured but not actually connected (delivered: false), not
  just when no default is set at all — mirrored in popup.js's own fallback

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

517 lines
22 KiB
JavaScript
Raw Permalink 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.

// Injects a "Send to Tablet" button next to two different shapes of
// NewBook's own Check In button, on whichever pages carry them (see
// manifest.json's content_scripts.matches) — the popup covers single-booking
// pages independently of this, this is purely about inline injection:
// 1. Each row of the "due for arrival" list (bookings_lists_checkin).
// 2. The "This Booking is due to Arrive today. Do you want to go to
// Check-In?" reminder banner NewBook shows inline (confirmed 2026-09-01
// from a real sample — a `.newbook_message.plain` block, most likely on
// bookings_view/edit but the exact page isn't nailed down, hence the
// slightly broader matches list below rather than one exact page).
//
// Both shapes share the same "generic_button_1"-is-not-actually-unique
// gotcha (NewBook reuses that id on every instance of its own Check In
// button, confirmed 2026-09-01) — this script never relies on ids from
// NewBook's own markup for either shape, only class/attribute selectors
// scoped to each button's own container. Booking-ID extraction differs
// between the two shapes though: the list row carries it in a checkbox's
// `value` (see extractBookingIdFromRow), the banner button only carries it
// inside its `onclick` attribute's URL (see extractBookingIdFromOnclick) —
// there's no separate structured attribute for it there.
//
// MutationObserver pattern (watching for AJAX-refreshed content, 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 content via AJAX (see the `via_ajax` tooltip classes in
// the row sample) without a full page reload, so a one-time scan on
// document_idle alone would miss content added/replaced afterward.
const PROCESSED_ATTR = 'data-tcp-processed';
function extractBookingIdFromRow(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;
}
// The reminder banner's Check In button has no separate booking-ID
// attribute at all — only its onclick handler carries it, embedded in the
// NewBook URL it navigates to: load_url("https://.../bookings_checkin/37129")
function extractBookingIdFromOnclick(button) {
const onclick = button.getAttribute('onclick') || '';
const match = onclick.match(/bookings_checkin\/(\d+)/);
return match ? match[1] : null;
}
// Same URL shape as background.js/popup.js's BOOKING_URL_RE — re-derived here
// too since this needs to resolve synchronously inside the MutationObserver
// callback (menu items and bookings_checkin's own Confirm Check-In link
// carry no separate booking-id attribute at all, unlike the row/banner
// shapes above).
const MENU_BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
function extractBookingIdFromLocation() {
const match = window.location.href.match(MENU_BOOKING_URL_RE);
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;
}
// variant 'block' = full-width, stacked below the anchor (table cell — the
// list row context, where width is already constrained by the column).
// variant 'inline' = sized to its own content, sitting next to the anchor
// (the reminder banner context, which flows inline with other text/buttons
// rather than living in a narrow fixed-width cell).
// Reuses NewBook's own `.button` class (and `.most_width` for the row
// variant) rather than hand-rolled CSS, so the injected button inherits
// NewBook's real height/padding/border-radius/font and sits flush with its
// own Check In button — only the colour is overridden, to keep it visually
// distinct as a non-native action. The tablet icon is Font Awesome, already
// loaded by NewBook itself for its own icons (e.g. fa-concierge-bell on
// Check In), so no extra asset is needed.
function makeSendButton(bookingId, guestName, variant = 'block') {
const btn = document.createElement('button');
btn.id = `tcp-btn-${bookingId}-${variant}`;
btn.type = 'button';
btn.className = variant === 'block' ? 'button most_width' : 'button';
btn.innerHTML = '<i class="tcp-icon far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>';
btn.style.cssText = `
${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'}
background: #be123c; border-color: #be123c; color: #fff;
`;
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
handleSendClick(btn, bookingId, guestName);
});
return btn;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// Visual feedback is split two ways: a per-button icon swap for the simple
// pass/fail cases (no page real estate taken, self-explanatory at a glance),
// and the picker overlay below for anything that actually needs a human
// decision (no default configured, default busy, default offline). Detail
// text lives in the title attribute (hover tooltip) rather than a persistent
// inline block — keeps every variant (table-cell row, banner, nav-bar menu
// item) visually identical instead of needing per-container layout math.
const STATE_ICON = {
idle: 'fa-tablet-alt',
loading: 'fa-spinner fa-spin',
success: 'fa-check',
warning: 'fa-exclamation-triangle',
error: 'fa-exclamation-triangle',
};
const STATE_BG = {
idle: '#be123c',
loading: '#be123c',
success: '#166534',
warning: '#b45309',
error: '#dc2626',
};
const STATE_ICON_COLOR = {
success: '#86efac',
warning: '#fde68a',
error: '#fecaca',
};
// <button> variants (row/banner) get their whole background tinted; the
// context-menu variant is an <a> with no background box of its own — tinting
// that would look out of place in NewBook's own nav bar, so only its icon
// recolours.
function setButtonState(anchorBtn, state, title = '') {
const icon = anchorBtn.querySelector('.tcp-icon');
if (icon) {
icon.className = `tcp-icon far ${STATE_ICON[state] || STATE_ICON.idle} fa-fw`;
icon.style.color = STATE_ICON_COLOR[state] || '';
}
if (anchorBtn.tagName === 'BUTTON') {
const bg = STATE_BG[state] || STATE_BG.idle;
anchorBtn.style.background = bg;
anchorBtn.style.borderColor = bg;
}
if (title) anchorBtn.title = title; else anchorBtn.removeAttribute('title');
}
function scheduleRevert(anchorBtn) {
setTimeout(() => {
// Button may already be gone (NewBook rebuilt its container) — touching
// a detached node is harmless, but skip the state reset if so, nothing
// to show it on anymore.
if (document.body.contains(anchorBtn)) setButtonState(anchorBtn, 'idle');
}, 4000);
}
// Injected buttons skip asking which tablet entirely when a default is
// configured for this machine (see options.js) — push straight to it. Only
// opens the picker overlay if that specific push comes back busy (409), the
// default is configured but not actually connected (delivered: false — see
// device-stream.js), or no default is configured at all. The popup is
// deliberately different — it always shows the picker, just pre-selected to
// the default (see popup.js) — since opening the popup is already a more
// deliberate action than a one-click inline button.
async function handleSendClick(anchorBtn, bookingId, guestName) {
if (anchorBtn.dataset.tcpBusy === '1') return; // already mid-send, ignore a double-click
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
if (!defaultDeviceId) {
showPickerOverlay(anchorBtn, bookingId, guestName);
return;
}
anchorBtn.dataset.tcpBusy = '1';
setButtonState(anchorBtn, 'loading');
let result;
try {
result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId });
} catch {
delete anchorBtn.dataset.tcpBusy;
setButtonState(anchorBtn, 'error', 'Extension was reloaded — refresh this NewBook page and try again.');
scheduleRevert(anchorBtn);
return;
}
delete anchorBtn.dataset.tcpBusy;
if (result?.ok && result.delivered !== false) {
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
scheduleRevert(anchorBtn);
return;
}
if (result?.ok) {
// delivered === false — session created server-side but nothing's
// actually listening on that device right now.
setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet isn’t connected — choose another:');
return;
}
if (result?.status === 409) {
setButtonState(anchorBtn, 'warning', result.error || 'That tablet is busy.');
showPickerOverlay(anchorBtn, bookingId, guestName, 'Default tablet is busy — choose another:');
return;
}
setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
scheduleRevert(anchorBtn);
}
// Centred modal overlay — replaces the old in-page dropdown panel entirely
// (both its block-flow insertion for row/banner buttons and the fixed-
// position floating variant the nav-bar menu items needed). A real overlay
// doesn't care where its anchor lives on the page, so one implementation now
// covers all three injection shapes.
let overlayBackdrop = null;
function closeOverlay() {
if (!overlayBackdrop) return;
overlayBackdrop.remove();
overlayBackdrop = null;
document.removeEventListener('keydown', handleOverlayKeydown);
}
function handleOverlayKeydown(e) {
if (e.key === 'Escape') closeOverlay();
}
function buildOverlay() {
closeOverlay();
const backdrop = document.createElement('div');
backdrop.style.cssText = `
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
z-index: 999998; display: flex; align-items: center; justify-content: center;
`;
backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) closeOverlay(); });
const panel = document.createElement('div');
panel.style.cssText = `
position: relative; background: #fff; border-radius: 12px; padding: 20px;
min-width: 260px; max-width: 320px; box-shadow: 0 10px 40px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; color: #1e293b;
`;
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.style.cssText = `
position: absolute; top: 6px; right: 10px; border: none; background: none;
font-size: 20px; line-height: 1; cursor: pointer; color: #94a3b8; padding: 4px;
`;
closeBtn.addEventListener('click', closeOverlay);
panel.appendChild(closeBtn);
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
overlayBackdrop = backdrop;
document.addEventListener('keydown', handleOverlayKeydown);
return panel;
}
async function showPickerOverlay(anchorBtn, bookingId, guestName, note = '') {
const panel = buildOverlay();
const title = document.createElement('div');
title.style.cssText = 'font-weight: 700; margin: 0 20px 10px 0; color: #1a1a2e;';
title.textContent = guestName ? `Send ${guestName} to a tablet` : 'Send to a tablet';
panel.appendChild(title);
if (note) {
const noteEl = document.createElement('div');
noteEl.textContent = note;
noteEl.style.cssText = 'margin-bottom: 10px; color: #b45309; font-weight: 600;';
panel.appendChild(noteEl);
}
const body = document.createElement('div');
body.textContent = 'Loading tablets…';
panel.appendChild(body);
// A stale content script (injected before the extension was last
// reloaded) throws "Extension context invalidated" the moment it touches
// chrome.runtime here — sendMessage() rejects rather than hanging, but an
// uncaught rejection previously left the panel stuck on "Loading
// tablets…" forever with no feedback at all. Reloading the extension does
// NOT re-inject content scripts into already-open tabs; only a full page
// refresh does that (hit for real 2026-09-01, see project_checkin memory).
let res;
try {
res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
} catch {
body.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return;
}
if (overlayBackdrop !== panel.parentElement) return; // closed while loading
if (!res?.ok) {
body.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return;
}
if (!res.devices.length) {
body.textContent = 'No tablets registered yet.';
return;
}
const select = document.createElement('select');
select.style.cssText = 'width: 100%; margin-bottom: 10px; padding: 6px; border-radius: 6px; border: 1px solid #e4e8ee;';
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.type = 'button';
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
sendBtn.style.cssText = `
width: 100%; padding: 8px; background: #c9a84c; color: #1a1a2e;
border: none; border-radius: 6px; font-weight: 700; cursor: pointer;
`;
const statusEl = document.createElement('div');
statusEl.style.cssText = 'margin-top: 8px; font-size: 12px;';
sendBtn.addEventListener('click', async () => {
sendBtn.disabled = true;
sendBtn.textContent = 'Sending…';
setButtonState(anchorBtn, 'loading');
const result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: select.value });
sendBtn.disabled = false;
sendBtn.textContent = `Send${guestName ? ` — ${guestName}` : ''}`;
if (result?.ok && result.delivered !== false) {
statusEl.textContent = 'Sent — the tablet should navigate now.';
statusEl.style.color = '#166534';
setButtonState(anchorBtn, 'success', 'Sent — the tablet should navigate now.');
scheduleRevert(anchorBtn);
setTimeout(closeOverlay, 1000);
} else if (result?.ok) {
statusEl.textContent = 'Sent, but that tablet isn’t connected right now.';
statusEl.style.color = '#b45309';
setButtonState(anchorBtn, 'warning', 'That tablet isn’t connected right now.');
} else {
statusEl.textContent = `Failed: ${result?.error || 'unknown error'}`;
statusEl.style.color = '#dc2626';
setButtonState(anchorBtn, 'error', `Failed: ${result?.error || 'unknown error'}`);
}
});
body.innerHTML = '';
body.appendChild(select);
body.appendChild(sendBtn);
body.appendChild(statusEl);
}
// Context-menu entries use a completely different markup shape than the
// .button-based row/banner injections above — <li><a><div class="page_icon">
// <i/></div><span/></a></li> — so this builds that shape directly rather
// than reusing makeSendButton. Click handling (default-tablet fast path,
// picker overlay fallback) is identical via the shared handleSendClick.
function makeMenuLink(bookingId) {
const li = document.createElement('li');
li.className = 'context tcp-menu-item';
const a = document.createElement('a');
a.href = '#';
a.innerHTML = `
<div class="page_icon"><i class="tcp-icon far fa-tablet-alt fa-fw" alt="" style="margin-right: 0; margin-left: 0px; vertical-align: middle; font-size: 24px;" aria-hidden="true"></i></div>
<span>Tablet Push</span>
`;
a.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
handleSendClick(a, bookingId, '');
});
li.appendChild(a);
return li;
}
// The top (#context-menu-header) and bottom (#context-menu-footer) nav bars
// on bookings_checkin/<id> — NewBook clears each one (`.html("")`) and
// rebuilds it from a fixed sequence of `.append()` calls on every load/AJAX
// navigation, always ending with "Confirm Check-In" (identified by its
// `a.checkin_button` class — the only stable marker across both nav bars and
// the "More" dropdown they sometimes collapse into on narrow viewports).
// Inserting immediately before that <li> is what the user asked for
// ("both of those to the left of the default checkin button"). No
// PROCESSED_ATTR here — the clear+rebuild wipes our injected <li> along with
// everything else, so re-running this on every mutation is what makes it
// self-healing rather than a bug; the previousElementSibling check below is
// what keeps it from double-inserting within a single rebuild.
function injectCheckinMenuButton(container) {
if (!container) return;
const checkinLink = container.querySelector('a.checkin_button');
if (!checkinLink) return;
const checkinLi = checkinLink.closest('li');
if (!checkinLi || checkinLi.previousElementSibling?.classList.contains('tcp-menu-item')) return;
const bookingId = extractBookingIdFromLocation();
if (!bookingId) return;
checkinLi.insertAdjacentElement('beforebegin', makeMenuLink(bookingId));
}
// Looked up by id directly (not scoped to whatever `root` the observer
// happened to pass in) because NewBook's rebuild adds the menu <li>s one at
// a time — when one of those individual <li>s is itself the mutation's
// added node, it doesn't contain '#context-menu-header' as a descendant, so
// a root-scoped querySelector would silently miss it.
function processCheckinMenus() {
injectCheckinMenuButton(document.getElementById('context-menu-header'));
injectCheckinMenuButton(document.getElementById('context-menu-footer'));
}
// Dedicated banner on bookings_checkin/<id>, purely for "send this guest to
// a tablet instead" — distinct from processCheckinBanner above, which
// detects and augments NewBook's own existing arrival reminder on
// bookings_view. Reuses NewBook's own `.newbook_message.plain` class for
// free styling consistency (same approach as makeSendButton reusing
// `.button`), positioned directly above the Mandatory/Optional Information
// tabs as requested.
function buildTabletBanner(bookingId) {
const banner = document.createElement('div');
banner.className = 'newbook_message plain';
banner.innerHTML = `
<i class="message_icon far fa-info-circle fa-fw" alt="" style="vertical-align: middle;font-size:16px;" aria-hidden="true"></i>
<div class="nb-header">Information</div>
<div class="indent"><div class="note_output"><div class="note_text">Prefer the guest to complete this on a tablet instead?</div></div></div>
`;
banner.querySelector('.note_text').appendChild(makeSendButton(bookingId, '', 'inline'));
return banner;
}
function processCheckinPageBanner() {
// bookings_view has its own `ui-tabs-nav` tablist too (Mandatory
// Information / Optional Booking Details / Newbook Drive) — without this
// guard this banner duplicated there alongside processCheckinBanner's
// augmented reminder banner (confirmed 2026-10-01 from a live screenshot).
// This one is purely for the dedicated bookings_checkin/<id> page.
if (!/^\/bookings_checkin\//.test(location.pathname)) return;
const tablist = document.querySelector('ul[role="tablist"].ui-tabs-nav');
if (!tablist || tablist.hasAttribute(PROCESSED_ATTR)) return;
const bookingId = extractBookingIdFromLocation();
if (!bookingId) return;
tablist.setAttribute(PROCESSED_ATTR, '1');
tablist.insertAdjacentElement('beforebegin', buildTabletBanner(bookingId));
}
function processRow(row) {
if (row.hasAttribute(PROCESSED_ATTR)) return;
const bookingId = extractBookingIdFromRow(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), 'block'));
}
// The "due to Arrive today" reminder banner on bookings_view pages
// (confirmed 2026-09-01 — see the class comment at the top of this file).
// Scoped specifically to a Check In button sitting inside a
// .newbook_message.plain block, not just any button with that aria-label
// anywhere on the page, so this can't accidentally match something
// unrelated that happens to reuse the same label.
function processCheckinBanner(button) {
if (button.hasAttribute(PROCESSED_ATTR)) return;
const bookingId = extractBookingIdFromOnclick(button);
if (!bookingId) return;
button.setAttribute(PROCESSED_ATTR, '1');
// NewBook floats these buttons right, so the earlier element in DOM order
// ends up rightmost visually — 'beforebegin' puts ours to the right of
// Check In (not 'afterend', which would float it to the left instead).
button.insertAdjacentElement('beforebegin', makeSendButton(bookingId, '', 'inline'));
}
function scanForRows(root = document) {
root.querySelectorAll('tr.checkbox_row').forEach(processRow);
}
function scanForBanners(root = document) {
root.querySelectorAll('.newbook_message.plain .note_text button[aria-label="Check In"]')
.forEach(processCheckinBanner);
}
function scanAll(root = document) {
scanForRows(root);
scanForBanners(root);
processCheckinMenus();
processCheckinPageBanner();
}
scanAll();
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 scanAll(node);
}
}
});
observer.observe(document.body, { childList: true, subtree: true });