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 <noreply@anthropic.com>
This commit is contained in:
parent
2b83f47556
commit
9f3129b8b5
10 changed files with 337 additions and 72 deletions
|
|
@ -47,6 +47,17 @@ function extractBookingIdFromOnclick(button) {
|
||||||
return match ? match[1] : null;
|
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) {
|
function extractGuestName(row) {
|
||||||
// The guest/company link's own text node — tooltip content is a sibling
|
// 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
|
// 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
|
// variant 'inline' = sized to its own content, sitting next to the anchor
|
||||||
// (the reminder banner context, which flows inline with other text/buttons
|
// (the reminder banner context, which flows inline with other text/buttons
|
||||||
// rather than living in a narrow fixed-width cell).
|
// 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') {
|
function makeSendButton(bookingId, guestName, variant = 'block') {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.id = `tcp-btn-${bookingId}-${variant}`;
|
btn.id = `tcp-btn-${bookingId}-${variant}`;
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.textContent = 'Send to Tablet';
|
btn.className = variant === 'block' ? 'button most_width' : 'button';
|
||||||
const layout = variant === 'block'
|
btn.innerHTML = '<i class="far fa-tablet-alt fa-fw" aria-hidden="true"></i> <span>Tablet Push</span>';
|
||||||
? 'display: block; margin-top: 6px; width: 100%;'
|
|
||||||
: 'display: inline-block; margin-left: 8px;';
|
|
||||||
btn.style.cssText = `
|
btn.style.cssText = `
|
||||||
${layout}
|
${variant === 'block' ? 'margin-top: 6px;' : 'margin-right: 8px;'}
|
||||||
padding: 4px 8px; font-size: 12px; font-weight: 600;
|
background: #be123c; border-color: #be123c; color: #fff;
|
||||||
background: #be123c; color: #fff; border: none; border-radius: 6px;
|
|
||||||
cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
`;
|
`;
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -136,18 +150,38 @@ async function handleSendClick(anchorBtn, bookingId, guestName, variant) {
|
||||||
await togglePanel(anchorBtn, bookingId, guestName, variant);
|
await togglePanel(anchorBtn, bookingId, guestName, variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The context-menu buttons (variant 'menu') live inside a horizontal nav
|
||||||
|
// bar <li> — 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
|
// Small text-only status line for the default-tablet fast path — no
|
||||||
// dropdown, nothing to pick, just feedback on what happened.
|
// dropdown, nothing to pick, just feedback on what happened.
|
||||||
function showInlineStatus(anchorBtn, variant, text) {
|
function showInlineStatus(anchorBtn, variant, text) {
|
||||||
if (openPanel) { openPanel.remove(); openPanel = null; }
|
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
const width = variant === 'block' ? 'width: 100%;' : '';
|
const width = variant === 'block' ? 'width: 100%;' : '';
|
||||||
|
const floating = variant === 'menu';
|
||||||
el.style.cssText = `
|
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;
|
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;
|
el.textContent = text;
|
||||||
|
if (floating) {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
positionFloating(el, anchorBtn);
|
||||||
|
} else {
|
||||||
anchorBtn.insertAdjacentElement('afterend', el);
|
anchorBtn.insertAdjacentElement('afterend', el);
|
||||||
|
}
|
||||||
openPanel = el;
|
openPanel = el;
|
||||||
return 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
|
// anchor (the banner button) has no full-width block context to size
|
||||||
// against naturally, so give it an explicit min-width instead of relying
|
// against naturally, so give it an explicit min-width instead of relying
|
||||||
// on parent width like the row-cell variant can.
|
// 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;';
|
const width = variant === 'block' ? 'width: 100%;' : 'min-width: 200px; max-width: 280px;';
|
||||||
panel.style.cssText = `
|
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;
|
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-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
font-size: 12px; color: #1e293b;
|
font-size: 12px; color: #1e293b;
|
||||||
`;
|
`;
|
||||||
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
|
panel.textContent = note ? `${note} loading…` : 'Loading tablets…';
|
||||||
|
if (floating) {
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
positionFloating(panel, anchorBtn);
|
||||||
|
} else {
|
||||||
anchorBtn.insertAdjacentElement('afterend', panel);
|
anchorBtn.insertAdjacentElement('afterend', panel);
|
||||||
|
}
|
||||||
openPanel = panel;
|
openPanel = panel;
|
||||||
|
|
||||||
// A stale content script (injected before the extension was last
|
// A stale content script (injected before the extension was last
|
||||||
|
|
@ -238,6 +279,94 @@ function escapeHtml(s) {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 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 = `
|
||||||
|
<div class="page_icon"><i class="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, '', 'menu');
|
||||||
|
});
|
||||||
|
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() {
|
||||||
|
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) {
|
function processRow(row) {
|
||||||
if (row.hasAttribute(PROCESSED_ATTR)) return;
|
if (row.hasAttribute(PROCESSED_ATTR)) return;
|
||||||
const bookingId = extractBookingIdFromRow(row);
|
const bookingId = extractBookingIdFromRow(row);
|
||||||
|
|
@ -260,7 +389,10 @@ function processCheckinBanner(button) {
|
||||||
if (!bookingId) return;
|
if (!bookingId) return;
|
||||||
|
|
||||||
button.setAttribute(PROCESSED_ATTR, '1');
|
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) {
|
function scanForRows(root = document) {
|
||||||
|
|
@ -275,6 +407,8 @@ function scanForBanners(root = document) {
|
||||||
function scanAll(root = document) {
|
function scanAll(root = document) {
|
||||||
scanForRows(root);
|
scanForRows(root);
|
||||||
scanForBanners(root);
|
scanForBanners(root);
|
||||||
|
processCheckinMenus();
|
||||||
|
processCheckinPageBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
scanAll();
|
scanAll();
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 730 B After Width: | Height: | Size: 7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 755 B |
Binary file not shown.
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 2.5 KiB |
|
|
@ -21,7 +21,8 @@
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"https://appeu.newbook.cloud/bookings_lists_checkin*",
|
"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"],
|
"js": ["content-script.js"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,21 @@ button.primary:disabled { opacity: 0.5; cursor: default; }
|
||||||
.msg.ok { color: #16a34a; }
|
.msg.ok { color: #16a34a; }
|
||||||
.msg.warn { color: #b45309; }
|
.msg.warn { color: #b45309; }
|
||||||
a.settings-link { display: block; margin-top: 12px; font-size: 12px; color: #7b4f00; }
|
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; }
|
||||||
|
|
|
||||||
|
|
@ -33,21 +33,12 @@ async function main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookingId) {
|
// Two independent sections: tablet status + reset is always shown (not
|
||||||
contentEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
|
// gated on a booking being open) so reception can clear a stuck-busy
|
||||||
return;
|
// tablet from the terminal without needing to be on a booking page at all.
|
||||||
}
|
contentEl.innerHTML = `<div id="tabletsSection"></div><div id="bookingSection"></div>`;
|
||||||
|
const tabletsEl = document.getElementById('tabletsSection');
|
||||||
contentEl.innerHTML = `
|
const bookingEl = document.getElementById('bookingSection');
|
||||||
<p class="booking">Booking <strong>#${bookingId}</strong></p>
|
|
||||||
<select id="device"><option>Loading tablets…</option></select>
|
|
||||||
<button class="primary" id="send" disabled>Send to Check-In Tablet</button>
|
|
||||||
<div id="msg"></div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const deviceSelect = document.getElementById('device');
|
|
||||||
const sendBtn = document.getElementById('send');
|
|
||||||
const msgEl = document.getElementById('msg');
|
|
||||||
|
|
||||||
let devices = [];
|
let devices = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,52 +48,153 @@ async function main() {
|
||||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
devices = await res.json();
|
devices = await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deviceSelect.innerHTML = '<option>Could not load tablets</option>';
|
tabletsEl.innerHTML = `<p class="muted">Could not reach tablet-checkin: ${escapeHtml(err.message)}</p>`;
|
||||||
setMsg(msgEl, `Could not reach tablet-checkin: ${err.message}`, 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTablets(tabletsEl, devices, baseUrl, apiKey);
|
||||||
|
|
||||||
|
if (!bookingId) {
|
||||||
|
bookingEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTablets(el, devices, baseUrl, apiKey) {
|
||||||
if (!devices.length) {
|
if (!devices.length) {
|
||||||
deviceSelect.innerHTML = '<option>No tablets registered yet</option>';
|
el.innerHTML = `<p class="muted">No tablets registered yet</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceSelect.innerHTML = devices.map(d =>
|
el.innerHTML = `
|
||||||
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>
|
<p class="section-label">Tablets</p>
|
||||||
${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}
|
<ul class="device-list">
|
||||||
</option>`
|
${devices.map(d => `
|
||||||
).join('');
|
<li class="device-row">
|
||||||
// Pre-select this machine's configured default tablet — still just a
|
<span class="device-name">${escapeHtml(d.name)}</span>
|
||||||
// starting point, staff can pick a different one from the dropdown.
|
<span class="device-status ${d.status}">${escapeHtml(d.status)}</span>
|
||||||
|
${d.status === 'busy' ? `<button type="button" class="reset-btn" data-device="${d.device_id}">Reset</button>` : ''}
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<p class="booking">Booking <strong>#${bookingId}</strong></p>
|
||||||
|
<div id="msg" class="msg warn">Sending to ${escapeHtml(defaultDevice.name)}…</div>
|
||||||
|
<a href="#" class="settings-link" id="changeTablet">Send to a different tablet instead</a>
|
||||||
|
`;
|
||||||
|
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 = `<p class="booking">Booking <strong>#${bookingId}</strong></p><p class="muted">No tablets registered yet</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<p class="booking">Booking <strong>#${bookingId}</strong></p>
|
||||||
|
${note ? `<p class="msg warn">${escapeHtml(note)}</p>` : ''}
|
||||||
|
<select id="device">
|
||||||
|
${devices.map(d => `<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<button class="primary" id="send">Send to Check-In Tablet</button>
|
||||||
|
<div id="msg"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
// 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
|
// that matters here, so a busy default just falls through to whatever the
|
||||||
// browser's own first-enabled-option default would have been anyway.
|
// browser's own first-enabled-option default would have been anyway.
|
||||||
if (defaultDeviceId) deviceSelect.value = defaultDeviceId;
|
if (defaultDeviceId) deviceSelect.value = defaultDeviceId;
|
||||||
sendBtn.disabled = false;
|
|
||||||
|
|
||||||
sendBtn.addEventListener('click', async () => {
|
sendBtn.addEventListener('click', async () => {
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
setMsg(msgEl, 'Sending…', 'warn');
|
setMsg(msgEl, 'Sending…', 'warn');
|
||||||
|
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 {
|
try {
|
||||||
const res = await fetch(`${baseUrl}/api/staff/push`, {
|
const res = await fetch(`${baseUrl}/api/staff/push`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
body: JSON.stringify({ booking_id: bookingId, device_id: deviceSelect.value }),
|
body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (res.status === 409) {
|
if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` };
|
||||||
setMsg(msgEl, body.error || 'That tablet is busy with another guest right now.', 'warn');
|
return { ok: true };
|
||||||
} else if (!res.ok) {
|
|
||||||
throw new Error(body.error || `HTTP ${res.status}`);
|
|
||||||
} else {
|
|
||||||
setMsg(msgEl, 'Sent — the tablet should navigate now.', 'ok');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMsg(msgEl, `Failed: ${err.message}`, 'error');
|
return { ok: false, status: 0, error: err.message };
|
||||||
} finally {
|
|
||||||
sendBtn.disabled = false;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMsg(el, text, kind) {
|
function setMsg(el, text, kind) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { pool } from '../db.js'
|
import { pool } from '../db.js'
|
||||||
import { pushNavigate } from './device-stream.js'
|
import { pushNavigate, pushIdle } from './device-stream.js'
|
||||||
|
|
||||||
const SESSION_TTL_MINUTES = 30
|
const SESSION_TTL_MINUTES = 30
|
||||||
|
|
||||||
|
|
@ -67,3 +67,21 @@ export async function pushBookingToDevice(deviceId, bookingId) {
|
||||||
const delivered = pushNavigate(deviceId, token)
|
const delivered = pushNavigate(deviceId, token)
|
||||||
return { ok: true, token, delivered }
|
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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { requireAuth, requireCap, hasCap } from '../auth.js'
|
import { requireAuth, requireCap, hasCap } from '../auth.js'
|
||||||
import { pool } from '../db.js'
|
import { pool } from '../db.js'
|
||||||
import { addStream, removeStream, pushIdle } from '../lib/device-stream.js'
|
import { addStream, removeStream } from '../lib/device-stream.js'
|
||||||
import { pushBookingToDevice } from '../lib/push.js'
|
import { pushBookingToDevice, resetDevice } from '../lib/push.js'
|
||||||
|
|
||||||
// 'checkin' is a deliberately unofficial capability slug — it is never seeded
|
// 'checkin' is a deliberately unofficial capability slug — it is never seeded
|
||||||
// into app_capabilities (see seed-app.js, which only defines push/admin/
|
// into app_capabilities (see seed-app.js, which only defines push/admin/
|
||||||
|
|
@ -92,16 +92,8 @@ export async function deviceRoutes(app) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
const deviceId = req.params.id
|
const result = await resetDevice(req.params.id)
|
||||||
const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId])
|
if (!result.ok) return reply.status(result.status).send({ error: result.error })
|
||||||
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)
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { requireTerminalAuth } from '../auth.js'
|
import { requireTerminalAuth } from '../auth.js'
|
||||||
import { pool } from '../db.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
|
// The standalone Chrome extension's calls — terminal-key authenticated (NOT
|
||||||
// a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's
|
// 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 })
|
if (!result.ok) return reply.status(result.status).send({ error: result.error })
|
||||||
return { ok: true, delivered: result.delivered }
|
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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue