tablet-checkin/backend/extension-src/content-script.js
jtricerolph 2b83f47556 Kiosk redesign: logo, tablet sizing, 3-step flow, better email/car-reg logic
- Uploadable reception logo (Settings, PNG/JPEG, public GET so the kiosk
  can show it without a staff login) — shown top-center on the kiosk
  login and idle screens, Welcome copy centered underneath.
- Tablet-sized styling pass on all kiosk views for a ~10" tablet (bigger
  touch targets, base text, spacing) rather than the general app's
  desktop-first sizing.
- New booking-confirm step (name/arrive/nights/depart/rate name(s)/rate
  total) replaces the old partial review step.
- Contact details, car registration and marketing merged into one step
  (was 3 separate steps) — 5 steps down to 3 overall.
- Email handling now matches the legacy system's confirmed behaviour:
  excluded-domain (OTA/agent relay) addresses are filtered out of what's
  shown as 'on file' entirely, not just flagged, and a new
  excluded_email_domains Settings field (CSV) replaces the old hardcoded
  domain list so any OTA/agent domain can be added without a code change.
- Single email field design: only pre-filled when there's exactly one
  valid existing email, left blank (not guessed) when there are several —
  marketing opt-in only ever touches whatever's in that one field, never
  blanket-subscribes multiple pre-existing emails (project_checkin memory,
  2026-09-01 'stay in touch' note).
- Car registration now allows multiple additions — add/remove list UI,
  buildContactDetails appends each new one not already on file rather
  than the old single-slot overwrite.
- Signature now required to actually complete (tracked live via
  SignatureCanvas onEnd, not just caught on submit) alongside accepted
  terms — Complete Check-In stays disabled until both are true.
- T&C block is height-capped with its own internal scroll (keeps the step
  on one non-scrolling screen) and now renders basic HTML (<b>, <br>,
  <p>, <ul> etc) instead of plain text — content is admin-authored
  (settings capability required), same trust boundary as any other
  setting. PDF generation strips tags back to plain text since pdfkit
  can't render HTML.
- PDF and booking-confirm screen both surface tariff name(s) (deduped
  across tariffs_quoted in case the rate changed mid-stay) and nights,
  not just the total.
- Also fixed: injected extension buttons could hang forever on 'Loading
  tablets…' with no feedback if the extension was reloaded without also
  refreshing the NewBook tab (stale content script) — now surfaces a
  clear 'Extension was reloaded — refresh this page' message instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:21:30 +00:00

291 lines
12 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;
}
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).
function makeSendButton(bookingId, guestName, variant = 'block') {
const btn = document.createElement('button');
btn.id = `tcp-btn-${bookingId}-${variant}`;
btn.type = 'button';
btn.textContent = 'Send to Tablet';
const layout = variant === 'block'
? 'display: block; margin-top: 6px; width: 100%;'
: 'display: inline-block; margin-left: 8px;';
btn.style.cssText = `
${layout}
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();
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) {
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);
}
// 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%;' : '';
el.style.cssText = `
display: block; margin-top: 6px; ${width} font-size: 12px; color: #64748b;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
`;
el.textContent = text;
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 width = variant === 'block' ? 'width: 100%;' : 'min-width: 200px; max-width: 280px;';
panel.style.cssText = `
display: block; margin-top: 6px; ${width} 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 = note ? `${note} loading…` : 'Loading tablets…';
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 = 'Sent — the tablet should navigate now.';
} 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;
}
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');
button.insertAdjacentElement('afterend', 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);
}
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 });