// 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 = ' Tablet Push'; 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
  • — 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 => `` ).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 —
  • //
  • — 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 = `
    Tablet Push `; 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/ — 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
  • 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
  • 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
  • s one at // a time — when one of those individual
  • 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/, 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 = `
    Information
    Prefer the guest to complete this on a tablet instead?
    `; 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/ 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 });