tablet-checkin/backend/extension-src/content-script.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

440 lines
20 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.

// 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="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, variant);
});
return btn;
}
// Injected buttons skip asking which tablet entirely when a default is
// configured for this machine (see options.js) — push straight to it. Only
// falls back to the full device-picker panel if that specific push comes
// back busy (409), or if no default is configured at all — see
// project_checkin memory's extension notes, this was an explicit UX request
// ("injected buttons don't need to ask which tablet, just use/send to
// default unless busy"). 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, variant) {
const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId');
if (defaultDeviceId) {
const status = showInlineStatus(anchorBtn, variant, 'Sending…');
let result;
try {
result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId });
} catch {
status.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return;
}
if (result?.ok) {
if (result.delivered === false) {
status.textContent = 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.';
status.style.color = '#b45309';
} else {
status.textContent = 'Sent — the tablet should navigate now.';
status.style.color = '#166534';
}
return;
}
if (result?.status === 409) {
status.remove();
// fall through to the picker below, with a note explaining why it's
// asking despite a default being configured
await togglePanel(anchorBtn, bookingId, guestName, variant, 'Default tablet is busy — choose another:');
return;
}
status.textContent = `Failed: ${result?.error || 'unknown error'}`;
status.style.color = '#dc2626';
return;
}
await togglePanel(anchorBtn, bookingId, guestName, variant);
}
// The context-menu buttons (variant 'menu') live inside a horizontal nav
// bar <li> — inserting a block-level status/panel 'afterend' there (like the
// row/banner variants do) would stretch that menu item's row height instead
// of floating over the page. Anchoring to the viewport via the button's own
// bounding rect sidesteps needing the menu bar to be a positioned ancestor.
function positionFloating(el, anchorBtn) {
const rect = anchorBtn.getBoundingClientRect();
el.style.position = 'fixed';
el.style.top = `${rect.bottom + 4}px`;
el.style.left = `${rect.left}px`;
el.style.zIndex = '99999';
}
// Small text-only status line for the default-tablet fast path — no
// dropdown, nothing to pick, just feedback on what happened.
function showInlineStatus(anchorBtn, variant, text) {
if (openPanel) { openPanel.remove(); openPanel = null; }
const el = document.createElement('div');
const width = variant === 'block' ? 'width: 100%;' : '';
const floating = variant === 'menu';
el.style.cssText = `
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} font-size: 12px; color: #64748b;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
${floating ? 'background: #fff; padding: 6px 10px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);' : ''}
`;
el.textContent = text;
if (floating) {
document.body.appendChild(el);
positionFloating(el, anchorBtn);
} else {
anchorBtn.insertAdjacentElement('afterend', el);
}
openPanel = el;
return el;
}
let openPanel = null;
async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', note = '') {
if (openPanel) { openPanel.remove(); openPanel = null; }
const panel = document.createElement('div');
// Always its own block below the anchor regardless of variant — an inline
// anchor (the banner button) has no full-width block context to size
// against naturally, so give it an explicit min-width instead of relying
// on parent width like the row-cell variant can.
const floating = variant === 'menu';
const width = variant === 'block' ? 'width: 100%;' : 'min-width: 200px; max-width: 280px;';
panel.style.cssText = `
display: block; ${floating ? '' : 'margin-top: 6px;'} ${width} padding: 8px; background: #f4f5f7;
border: 1px solid #e4e8ee; border-radius: 8px;
${floating ? 'box-shadow: 0 2px 10px rgba(0,0,0,0.2);' : ''}
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 12px; color: #1e293b;
`;
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
if (floating) {
document.body.appendChild(panel);
positionFloating(panel, anchorBtn);
} else {
anchorBtn.insertAdjacentElement('afterend', panel);
}
openPanel = panel;
// 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 {
panel.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return;
}
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 = result.delivered === false
? 'Sent, but that tablet isn’t connected right now — it’ll pick this up if it reconnects.'
: 'Sent — the tablet should navigate now.';
panel.style.color = result.delivered === false ? '#b45309' : '#1e293b';
} else {
panel.textContent = `Failed: ${result?.error || 'unknown error'}`;
}
});
panel.innerHTML = '';
if (note) {
const noteEl = document.createElement('div');
noteEl.textContent = note;
noteEl.style.cssText = 'margin-bottom: 6px; color: #b45309; font-weight: 600;';
panel.appendChild(noteEl);
}
panel.appendChild(select);
panel.appendChild(sendBtn);
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
// 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 fallback) is identical, via the 'menu' variant plumbed through
// handleSendClick/showInlineStatus/togglePanel above.
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="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, '', 'menu');
});
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 });