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 d4bae68..9719f1d 100644 Binary files a/backend/extension-src/icons/icon128.png and b/backend/extension-src/icons/icon128.png differ diff --git a/backend/extension-src/icons/icon16.png b/backend/extension-src/icons/icon16.png index e1e7da9..608b654 100644 Binary files a/backend/extension-src/icons/icon16.png and b/backend/extension-src/icons/icon16.png differ diff --git a/backend/extension-src/icons/icon48.png b/backend/extension-src/icons/icon48.png index 9ca1dc4..a4e4804 100644 Binary files a/backend/extension-src/icons/icon48.png and b/backend/extension-src/icons/icon48.png differ diff --git a/backend/extension-src/manifest.json b/backend/extension-src/manifest.json index 3e7cf7c..2ca4eca 100644 --- a/backend/extension-src/manifest.json +++ b/backend/extension-src/manifest.json @@ -21,7 +21,8 @@ { "matches": [ "https://appeu.newbook.cloud/bookings_lists_checkin*", - "https://appeu.newbook.cloud/bookings_view/*" + "https://appeu.newbook.cloud/bookings_view/*", + "https://appeu.newbook.cloud/bookings_checkin/*" ], "js": ["content-script.js"], "run_at": "document_idle" diff --git a/backend/extension-src/popup/popup.css b/backend/extension-src/popup/popup.css index 37ac3b9..f2826c3 100644 --- a/backend/extension-src/popup/popup.css +++ b/backend/extension-src/popup/popup.css @@ -45,3 +45,21 @@ button.primary:disabled { opacity: 0.5; cursor: default; } .msg.ok { color: #16a34a; } .msg.warn { color: #b45309; } a.settings-link { display: block; margin-top: 12px; font-size: 12px; color: #7b4f00; } + +.section-label { + font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.04em; color: #64748b; margin: 0 0 6px; +} +.device-list { list-style: none; margin: 0 0 12px; padding: 0; } +.device-row { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: 12px; } +.device-name { flex: 1; color: #1a1a2e; } +.device-status { + font-size: 10px; padding: 1px 6px; border-radius: 999px; text-transform: capitalize; +} +.device-status.idle { background: #dcfce7; color: #166534; } +.device-status.busy { background: #fee2e2; color: #991b1b; } +.reset-btn { + border: none; background: #be123c; color: #fff; font-size: 10px; font-weight: 600; + padding: 2px 8px; border-radius: 999px; cursor: pointer; +} +.reset-btn:disabled { opacity: 0.5; cursor: default; } diff --git a/backend/extension-src/popup/popup.js b/backend/extension-src/popup/popup.js index 03e35d8..1f7f68d 100644 --- a/backend/extension-src/popup/popup.js +++ b/backend/extension-src/popup/popup.js @@ -33,21 +33,12 @@ async function main() { return; } - if (!bookingId) { - contentEl.innerHTML = `

    Open 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 } + }) }