// 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-`) 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 => `` ).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 });