- 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>
517 lines
22 KiB
JavaScript
517 lines
22 KiB
JavaScript
// 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 });
|