From 9f3129b8b578a02970ae0c777a32b80a8448279c Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 1 Oct 2026 11:37:44 +0000 Subject: [PATCH] Extension: Send to Tablet on the checkin page, popup auto-push, reset, new icon - Inject Send to Tablet into the bookings_checkin page's top/bottom context menus (left of Confirm Check-In) and a dedicated banner above the tabs - Popup now auto-pushes to the default tablet instead of just pre-selecting it, with a terminal-key-authed reset route so reception can clear a stuck-busy tablet from the extension popup - Replace the extension icon with a person+checkmark glyph matching the app's own UserCheck icon and accent colour Co-Authored-By: Claude Sonnet 5 --- backend/extension-src/content-script.js | 160 +++++++++++++++++++-- backend/extension-src/icons/icon128.png | Bin 730 -> 7196 bytes backend/extension-src/icons/icon16.png | Bin 168 -> 755 bytes backend/extension-src/icons/icon48.png | Bin 305 -> 2514 bytes backend/extension-src/manifest.json | 3 +- backend/extension-src/popup/popup.css | 18 +++ backend/extension-src/popup/popup.js | 180 ++++++++++++++++++------ backend/src/lib/push.js | 20 ++- backend/src/routes/devices.js | 16 +-- backend/src/routes/staff.js | 12 +- 10 files changed, 337 insertions(+), 72 deletions(-) diff --git a/backend/extension-src/content-script.js b/backend/extension-src/content-script.js index 5334635..f322596 100644 --- a/backend/extension-src/content-script.js +++ b/backend/extension-src/content-script.js @@ -47,6 +47,17 @@ function extractBookingIdFromOnclick(button) { 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 @@ -72,19 +83,22 @@ function findCheckInCell(row) { // 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.textContent = 'Send to Tablet'; - const layout = variant === 'block' - ? 'display: block; margin-top: 6px; width: 100%;' - : 'display: inline-block; margin-left: 8px;'; + btn.className = variant === 'block' ? 'button most_width' : 'button'; + btn.innerHTML = ' Tablet Push'; 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; + ${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'} + background: #be123c; border-color: #be123c; color: #fff; `; btn.addEventListener('click', e => { e.preventDefault(); @@ -136,18 +150,38 @@ async function handleSendClick(anchorBtn, bookingId, guestName, variant) { 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; margin-top: 6px; ${width} font-size: 12px; color: #64748b; + 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; - anchorBtn.insertAdjacentElement('afterend', el); + if (floating) { + document.body.appendChild(el); + positionFloating(el, anchorBtn); + } else { + anchorBtn.insertAdjacentElement('afterend', el); + } openPanel = el; return el; } @@ -162,15 +196,22 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', n // 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; margin-top: 6px; ${width} padding: 8px; background: #f4f5f7; + 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…'; - anchorBtn.insertAdjacentElement('afterend', panel); + 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 @@ -238,6 +279,94 @@ function escapeHtml(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() { + 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); @@ -260,7 +389,10 @@ function processCheckinBanner(button) { if (!bookingId) return; button.setAttribute(PROCESSED_ATTR, '1'); - button.insertAdjacentElement('afterend', makeSendButton(bookingId, '', 'inline')); + // 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) { @@ -275,6 +407,8 @@ function scanForBanners(root = document) { function scanAll(root = document) { scanForRows(root); scanForBanners(root); + processCheckinMenus(); + processCheckinPageBanner(); } scanAll(); diff --git a/backend/extension-src/icons/icon128.png b/backend/extension-src/icons/icon128.png index d4bae688819905052a176ce03cd633c2219fa235..9719f1db01dc27e1f68724fb4d367c21caef906d 100644 GIT binary patch literal 7196 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 BD_#Hq diff --git a/backend/extension-src/icons/icon16.png b/backend/extension-src/icons/icon16.png index e1e7da9cdc7e9fa1ae513e82d932461a02876c7d..608b654df5dfb7be55e3c7c3bd4d01735431bc97 100644 GIT binary patch delta 732 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 a booking in NewBook to send it to a check-in tablet.

    `; - return; - } - - contentEl.innerHTML = ` -

    Booking #${bookingId}

    - - -
    - `; - - const deviceSelect = document.getElementById('device'); - const sendBtn = document.getElementById('send'); - const msgEl = document.getElementById('msg'); + // Two independent sections: tablet status + reset is always shown (not + // gated on a booking being open) so reception can clear a stuck-busy + // tablet from the terminal without needing to be on a booking page at all. + contentEl.innerHTML = `
    `; + const tabletsEl = document.getElementById('tabletsSection'); + const bookingEl = document.getElementById('bookingSection'); let devices = []; try { @@ -57,54 +48,155 @@ async function main() { if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); devices = await res.json(); } catch (err) { - deviceSelect.innerHTML = ''; - setMsg(msgEl, `Could not reach tablet-checkin: ${err.message}`, 'error'); + tabletsEl.innerHTML = `

    Could not reach tablet-checkin: ${escapeHtml(err.message)}

    `; return; } + renderTablets(tabletsEl, devices, baseUrl, apiKey); + + if (!bookingId) { + bookingEl.innerHTML = `

    Open a booking in NewBook to send it to a check-in tablet.

    `; + return; + } + + renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey); +} + +function renderTablets(el, devices, baseUrl, apiKey) { if (!devices.length) { - deviceSelect.innerHTML = ''; + el.innerHTML = `

    No tablets registered yet

    `; return; } - deviceSelect.innerHTML = devices.map(d => - `` - ).join(''); - // Pre-select this machine's configured default tablet — still just a - // starting point, staff can pick a different one from the dropdown. + el.innerHTML = ` + +
      + ${devices.map(d => ` +
    • + ${escapeHtml(d.name)} + ${escapeHtml(d.status)} + ${d.status === 'busy' ? `` : ''} +
    • + `).join('')} +
    + `; + + el.querySelectorAll('.reset-btn').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = '…'; + try { + const res = await fetch(`${baseUrl}/api/staff/devices/${btn.dataset.device}/reset`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); + // Simplest way to get the tablet list (and any in-flight booking + // auto-send, which depends on device status) back in sync — reload + // the whole popup rather than hand-patching multiple sections. + main(); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Reset'; + alert(`Reset failed: ${err.message}`); + } + }); + }); +} + +function renderBooking(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey) { + const defaultDevice = devices.find(d => d.device_id === defaultDeviceId); + + if (defaultDevice && defaultDevice.status !== 'busy') { + // Straight to pushing, no extra click — matches the injected row/banner + // buttons' behaviour (see content-script.js's handleSendClick). "Send to + // a different tablet instead" stays one click away below in case the + // default is wrong for this particular booking. + el.innerHTML = ` +

    Booking #${bookingId}

    +
    Sending to ${escapeHtml(defaultDevice.name)}…
    + Send to a different tablet instead + `; + document.getElementById('changeTablet').addEventListener('click', e => { + e.preventDefault(); + renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey); + }); + + sendPush(bookingId, defaultDevice.device_id, baseUrl, apiKey).then(result => { + const msgEl = document.getElementById('msg'); + if (!msgEl) return; // "Send to a different tablet" was clicked before this resolved + + if (result.ok) { + setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok'); + } else if (result.status === 409) { + // Default went busy in the moment between loading the popup and + // sending — fall back to the manual picker rather than leaving the + // guest on a dead-end message. + renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, 'Default tablet is busy — choose another:'); + } else { + setMsg(msgEl, `Failed: ${result.error}`, 'error'); + } + }); + return; + } + + renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey); +} + +function renderPicker(el, bookingId, devices, defaultDeviceId, baseUrl, apiKey, note = '') { + if (!devices.length) { + el.innerHTML = `

    Booking #${bookingId}

    No tablets registered yet

    `; + return; + } + + el.innerHTML = ` +

    Booking #${bookingId}

    + ${note ? `

    ${escapeHtml(note)}

    ` : ''} + + +
    + `; + + const deviceSelect = document.getElementById('device'); + const sendBtn = document.getElementById('send'); + const msgEl = document.getElementById('msg'); // Setting .value to a disabled (busy) option is a no-op in every browser // that matters here, so a busy default just falls through to whatever the // browser's own first-enabled-option default would have been anyway. if (defaultDeviceId) deviceSelect.value = defaultDeviceId; - sendBtn.disabled = false; sendBtn.addEventListener('click', async () => { sendBtn.disabled = true; setMsg(msgEl, 'Sending…', 'warn'); - try { - const res = await fetch(`${baseUrl}/api/staff/push`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, - body: JSON.stringify({ booking_id: bookingId, device_id: deviceSelect.value }), - }); - const body = await res.json().catch(() => ({})); - if (res.status === 409) { - setMsg(msgEl, body.error || 'That tablet is busy with another guest right now.', 'warn'); - } else if (!res.ok) { - throw new Error(body.error || `HTTP ${res.status}`); - } else { - setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok'); - } - } catch (err) { - setMsg(msgEl, `Failed: ${err.message}`, 'error'); - } finally { - sendBtn.disabled = false; + const result = await sendPush(bookingId, deviceSelect.value, baseUrl, apiKey); + if (result.ok) { + setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok'); + } else if (result.status === 409) { + setMsg(msgEl, result.error || 'That tablet is busy with another guest right now.', 'warn'); + } else { + setMsg(msgEl, `Failed: ${result.error}`, 'error'); } + sendBtn.disabled = false; }); } +async function sendPush(bookingId, deviceId, baseUrl, apiKey) { + try { + const res = await fetch(`${baseUrl}/api/staff/push`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` }; + return { ok: true }; + } catch (err) { + return { ok: false, status: 0, error: err.message }; + } +} + function setMsg(el, text, kind) { el.textContent = text; el.className = `msg ${kind}`; diff --git a/backend/src/lib/push.js b/backend/src/lib/push.js index 4a75189..ea5198f 100644 --- a/backend/src/lib/push.js +++ b/backend/src/lib/push.js @@ -1,6 +1,6 @@ import { randomBytes } from 'crypto' import { pool } from '../db.js' -import { pushNavigate } from './device-stream.js' +import { pushNavigate, pushIdle } from './device-stream.js' const SESSION_TTL_MINUTES = 30 @@ -67,3 +67,21 @@ export async function pushBookingToDevice(deviceId, bookingId) { const delivered = pushNavigate(deviceId, token) return { ok: true, token, delivered } } + +// Shared by routes/devices.js's own reset route (kiosk/admin auth, the +// tablet's own "cancel" button + the Devices admin page) and routes/staff.js +// (terminal-key auth, the extension popup's "Reset Tablet" action) — same +// "clear any open session, flip back to idle" logic regardless of who's +// allowed to trigger it. +export async function resetDevice(deviceId) { + const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId]) + if (!rows.length) return { ok: false, status: 404, error: 'Device not found' } + + await pool.query( + `UPDATE checkin_sessions SET used_at = now() WHERE device_id = $1 AND used_at IS NULL`, + [deviceId] + ) + await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [deviceId]) + pushIdle(deviceId) + return { ok: true } +} diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index f6a1ade..b3ab9f5 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -1,8 +1,8 @@ import { randomUUID } from 'crypto' import { requireAuth, requireCap, hasCap } from '../auth.js' import { pool } from '../db.js' -import { addStream, removeStream, pushIdle } from '../lib/device-stream.js' -import { pushBookingToDevice } from '../lib/push.js' +import { addStream, removeStream } from '../lib/device-stream.js' +import { pushBookingToDevice, resetDevice } from '../lib/push.js' // 'checkin' is a deliberately unofficial capability slug — it is never seeded // into app_capabilities (see seed-app.js, which only defines push/admin/ @@ -92,16 +92,8 @@ export async function deviceRoutes(app) { } }, }, async (req, reply) => { - const deviceId = req.params.id - const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId]) - if (!rows.length) return reply.status(404).send({ error: 'Device not found' }) - - await pool.query( - `UPDATE checkin_sessions SET used_at = now() WHERE device_id = $1 AND used_at IS NULL`, - [deviceId] - ) - await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [deviceId]) - pushIdle(deviceId) + const result = await resetDevice(req.params.id) + if (!result.ok) return reply.status(result.status).send({ error: result.error }) return { ok: true } }) diff --git a/backend/src/routes/staff.js b/backend/src/routes/staff.js index dae2d7e..1610a98 100644 --- a/backend/src/routes/staff.js +++ b/backend/src/routes/staff.js @@ -1,6 +1,6 @@ import { requireTerminalAuth } from '../auth.js' import { pool } from '../db.js' -import { pushBookingToDevice } from '../lib/push.js' +import { pushBookingToDevice, resetDevice } from '../lib/push.js' // The standalone Chrome extension's calls — terminal-key authenticated (NOT // a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's @@ -26,4 +26,14 @@ export async function staffRoutes(app) { if (!result.ok) return reply.status(result.status).send({ error: result.error }) return { ok: true, delivered: result.delivered } }) + + // POST /api/staff/devices/:id/reset — same safety-net reset the tablet's + // own cancel button and the Devices admin page already have, surfaced to + // the extension so reception can clear a stuck-busy tablet from the + // terminal without walking over to the admin panel or the tablet itself. + app.post('/api/staff/devices/:id/reset', { preHandler: requireTerminalAuth }, async (req, reply) => { + const result = await resetDevice(req.params.id) + if (!result.ok) return reply.status(result.status).send({ error: result.error }) + return { ok: true } + }) }