Fix NewBook write mechanism, bundle push extension, remove vestigial hotel_id
- setBookingCustomFields: use bookings_update's own custom_fields array (confirmed from the real legacy plugin code) instead of instance_custom_fields_set, which was only ever proven for a different, unrelated plugin - online_checkin_complete is now a configurable per-hotel custom field (online_checkin_complete_custom_field_id setting), not a bogus top-level bookings_update field NewBook would silently ignore - Fix field-name mismatches vs a real live NewBook booking response: booking_arrival/departure/length, guest address (street/city/state/ postcode/country, not a single field), phone/mobile (in contact_details, not top-level), and pickPrimaryGuest now checks primary_client (was checking fields that don't exist in the real API) - Bundle the standalone push extension into backend/extension-src/, add GET /api/extension/download (zips on request via archiver) and a download button on the Settings page - Remove newbook_hotel_id — vestigial from the legacy system's multi-hotel routing model, never actually reached NewBook's API, this stack is single-hotel-per-deployment so there's nothing to route between Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3b2e25df3f
commit
4d3774ef0d
25 changed files with 3557 additions and 15 deletions
|
|
@ -3,6 +3,7 @@ WORKDIR /app
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY extension-src ./extension-src
|
||||||
RUN mkdir -p /app/uploads
|
RUN mkdir -p /app/uploads
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
|
|
|
||||||
67
backend/extension-src/README.md
Normal file
67
backend/extension-src/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Send to Check-In Tablet
|
||||||
|
|
||||||
|
Standalone Chrome extension — deliberately separate from `chrome-newbook-assistant`
|
||||||
|
(see `project_checkin` memory's "Standalone push extension" decision). Its one job:
|
||||||
|
detect a NewBook booking page and push that booking to a physical check-in tablet
|
||||||
|
running the [`tablet-checkin`](../tablet-checkin) app.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- **Detection**: a background service worker watches for NewBook booking pages
|
||||||
|
(`bookings_view/<id>`, `bookings_edit/<id>`, `bookings_checkin/<id>` — confirmed real
|
||||||
|
URLs, 2026-09-01) and shows a badge on the toolbar icon when one's open. The
|
||||||
|
SPA-navigation handling (`webNavigation.onHistoryStateUpdated`) is lifted directly from
|
||||||
|
`chrome-newbook-assistant/background.js` (`~/laptop-archive`) — already proven against
|
||||||
|
real NewBook navigation, not guessed here.
|
||||||
|
- **Popup**: click the toolbar icon → shows the detected booking number, a device
|
||||||
|
picker (busy tablets shown disabled), and a "Send to Check-In Tablet" button.
|
||||||
|
- **Row-injected button**: on NewBook's "due for arrival" list
|
||||||
|
(`bookings_lists_checkin`), a content script adds a "Send to Tablet" button under each
|
||||||
|
row's own Check In button, with an inline device picker. Row detection and the
|
||||||
|
booking-ID extraction are built from a real sample row the user provided (2026-09-01,
|
||||||
|
see `project_checkin` memory) — deliberately doesn't rely on any `id` attribute from
|
||||||
|
NewBook's own markup, since its Check In button reuses the same `id` across every row
|
||||||
|
(`generic_button_1`), only class/attribute selectors scoped per row. A
|
||||||
|
`MutationObserver` re-scans for new rows since this page refreshes its table via AJAX
|
||||||
|
without a full reload.
|
||||||
|
- **NewBook login indicator**: the popup shows whether the browser looks logged into
|
||||||
|
NewBook (cookie-based check, also lifted from `chrome-newbook-assistant`'s
|
||||||
|
`AuthManager.checkNewBookAuth`). This is a **UX signal only** — it does not authorize
|
||||||
|
anything. The actual push call authenticates with a terminal API key (below), not the
|
||||||
|
NewBook cookie.
|
||||||
|
- **Auth**: the push call itself is authorized by a **terminal API key**, generated from
|
||||||
|
tablet-checkin's own Terminals admin page (`admin` capability) and pasted into this
|
||||||
|
extension's Settings page. This mirrors the same trust model already used for the
|
||||||
|
tablets themselves — hotel-owned hardware gets a device-level credential, not a
|
||||||
|
per-user login. See `project_checkin` memory's "Extension auth" section for the full
|
||||||
|
reasoning.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Deploy `tablet-checkin` and note its base URL.
|
||||||
|
2. In tablet-checkin, go to **Terminals** (requires `admin` capability) → create a
|
||||||
|
terminal (e.g. "Reception PC 1") → copy the key shown (only shown once).
|
||||||
|
3. Load this extension unpacked: `chrome://extensions` → Developer mode → **Load
|
||||||
|
unpacked** → select this folder.
|
||||||
|
4. Click the extension icon → **Open Settings** (or right-click the icon → Options) →
|
||||||
|
enter the tablet-checkin base URL and the terminal key → Save → Test connection.
|
||||||
|
|
||||||
|
## Known TODOs before this is production-ready
|
||||||
|
|
||||||
|
- **Row-detection selectors on `bookings_lists_checkin` are built from a single sample
|
||||||
|
row** (2026-09-01) — robust in principle (class/attribute-based, not relying on
|
||||||
|
NewBook's non-unique ids), but only actually tested against one row's markup so far.
|
||||||
|
Worth confirming against a live page with multiple/varied rows (different booking
|
||||||
|
statuses, group bookings, etc.) before relying on it daily.
|
||||||
|
- `host_permissions` in `manifest.json` only covers the NewBook domains (needed for the
|
||||||
|
cookie-detection API) — tablet-checkin's own backend host isn't declared there since
|
||||||
|
its production URL isn't fixed yet. Cross-origin `fetch()` calls from the popup/options
|
||||||
|
pages generally work regardless, but add the real host once known if CORS issues show
|
||||||
|
up.
|
||||||
|
- Icons are placeholder-generated (simple crimson square + gold arrow, matching
|
||||||
|
tablet-checkin's theme color) — replace if a proper design is wanted.
|
||||||
|
- Not yet published anywhere (Chrome Web Store or otherwise) — currently load-unpacked
|
||||||
|
only, meaning it needs manually installing on every reception PC. tablet-checkin's own
|
||||||
|
Settings page has a download link (a bundled copy of this source, zipped on request) as
|
||||||
|
of 2026-09-01 — keep `backend/extension-src/` in that repo in sync with this folder,
|
||||||
|
they are not currently the same copy.
|
||||||
111
backend/extension-src/background.js
Normal file
111
backend/extension-src/background.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// Background service worker — detects when the active tab is a NewBook
|
||||||
|
// booking page and shows a badge, so staff notice the extension has
|
||||||
|
// something to offer before even opening the popup. Tab-update/activated/
|
||||||
|
// history-state listeners and the booking-ID regex are adapted directly from
|
||||||
|
// chrome-newbook-assistant/background.js (~/laptop-archive) — that pattern
|
||||||
|
// is already proven working against real NewBook navigation (including its
|
||||||
|
// SPA-style in-page routing, hence the webNavigation listener), not
|
||||||
|
// reinvented here. See project_checkin memory's "Standalone push extension".
|
||||||
|
|
||||||
|
// Confirmed 2026-09-01 by the user directly (real NewBook page URLs):
|
||||||
|
// bookings_view/<id>, bookings_edit/<id>, bookings_checkin/<id>.
|
||||||
|
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
|
||||||
|
const NEWBOOK_HOSTS = ['appeu.newbook.cloud', 'login.newbook.cloud'];
|
||||||
|
|
||||||
|
function isNewBookUrl(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return NEWBOOK_HOSTS.some(h => u.hostname === h);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTabUpdate(tabId, url) {
|
||||||
|
if (!url || !isNewBookUrl(url)) {
|
||||||
|
await chrome.action.setBadgeText({ tabId, text: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = url.match(BOOKING_URL_RE);
|
||||||
|
if (match) {
|
||||||
|
const bookingId = match[1];
|
||||||
|
await chrome.storage.session.set({ [`bookingId:${tabId}`]: bookingId });
|
||||||
|
await chrome.action.setBadgeText({ tabId, text: '→' }); // →
|
||||||
|
await chrome.action.setBadgeBackgroundColor({ tabId, color: '#be123c' });
|
||||||
|
await chrome.action.setTitle({ tabId, title: `Send booking #${bookingId} to a check-in tablet` });
|
||||||
|
} else {
|
||||||
|
await chrome.storage.session.remove(`bookingId:${tabId}`);
|
||||||
|
await chrome.action.setBadgeText({ tabId, text: '' });
|
||||||
|
await chrome.action.setTitle({ tabId, title: 'Send to Check-In Tablet' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (changeInfo.status === 'complete' && tab.url) handleTabUpdate(tabId, tab.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||||
|
const tab = await chrome.tabs.get(tabId);
|
||||||
|
if (tab.url) handleTabUpdate(tabId, tab.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NewBook's booking view is (per chrome-newbook-assistant's own findings) an
|
||||||
|
// SPA that can change URL without a full page load — this catches that case,
|
||||||
|
// same as the assistant already has to.
|
||||||
|
chrome.webNavigation.onHistoryStateUpdated.addListener(details => {
|
||||||
|
handleTabUpdate(details.tabId, details.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Messages from content-script.js (bookings_lists_checkin row buttons) ───
|
||||||
|
// Content scripts route their actual network calls through here rather than
|
||||||
|
// fetching directly — keeps the baseUrl/apiKey read-and-fetch logic in one
|
||||||
|
// place instead of duplicated across popup.js and content-script.js.
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message.action === 'tcpListDevices') {
|
||||||
|
listDevices().then(sendResponse);
|
||||||
|
return true; // keep the message channel open for the async response
|
||||||
|
}
|
||||||
|
if (message.action === 'tcpPush') {
|
||||||
|
pushBooking(message.bookingId, message.deviceId).then(sendResponse);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getConfig() {
|
||||||
|
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
||||||
|
return { baseUrl, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDevices() {
|
||||||
|
const { baseUrl, apiKey } = await getConfig();
|
||||||
|
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` };
|
||||||
|
return { ok: true, devices: body };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushBooking(bookingId, deviceId) {
|
||||||
|
const { baseUrl, apiKey } = await getConfig();
|
||||||
|
if (!baseUrl || !apiKey) return { ok: false, error: 'not configured' };
|
||||||
|
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, error: body.error || `HTTP ${res.status}` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
161
backend/extension-src/content-script.js
Normal file
161
backend/extension-src/content-script.js
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// Injects a "Send to Tablet" button into each row of NewBook's "due for
|
||||||
|
// arrival" list (bookings_lists_checkin), right under the page's own
|
||||||
|
// Check In button. Only runs on that one page (see manifest.json's
|
||||||
|
// content_scripts.matches) — the popup covers single-booking pages
|
||||||
|
// (bookings_view/edit/checkin) already.
|
||||||
|
//
|
||||||
|
// Row markup, booking-ID extraction, and the "generic_button_1"-is-not-
|
||||||
|
// actually-unique gotcha are all confirmed from a real sample row the user
|
||||||
|
// provided 2026-09-01 (see project_checkin memory) — not guessed. NewBook's
|
||||||
|
// own Check In button uses a non-unique id (`generic_button_1` repeated
|
||||||
|
// across every row), so this script never relies on ids from NewBook's own
|
||||||
|
// markup — only class/attribute selectors scoped per row, and its own
|
||||||
|
// injected elements get ids built from the booking ID
|
||||||
|
// (`tcp-btn-<bookingId>`) to stay collision-free and support idempotent
|
||||||
|
// re-injection checks.
|
||||||
|
//
|
||||||
|
// MutationObserver pattern (watching for AJAX-refreshed rows, not just
|
||||||
|
// initial page load) is the same approach chrome-newbook-assistant already
|
||||||
|
// had to use for this same NewBook UI (~/laptop-archive) — this system
|
||||||
|
// clearly refreshes table content via AJAX (see the `via_ajax` tooltip
|
||||||
|
// classes in the sample row) without a full page reload, so a one-time scan
|
||||||
|
// on document_idle alone would miss rows added/replaced afterward.
|
||||||
|
|
||||||
|
const PROCESSED_ATTR = 'data-tcp-processed';
|
||||||
|
|
||||||
|
function extractBookingId(row) {
|
||||||
|
const checkbox = row.querySelector('input.booking_id_checkbox');
|
||||||
|
if (checkbox?.value) return checkbox.value;
|
||||||
|
// Fallback: class name carries it too (`booking_37117`) — confirmed from
|
||||||
|
// the sample row, kept as a fallback in case a future NewBook markup
|
||||||
|
// change drops the checkbox but keeps the class.
|
||||||
|
const match = row.className.match(/\bbooking_(\d+)\b/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGuestName(row) {
|
||||||
|
// The guest/company link's own text node — tooltip content is a sibling
|
||||||
|
// element (easytooltip_content), not part of the visible text, so a plain
|
||||||
|
// textContent read on the link itself would double up with it. Grab just
|
||||||
|
// the first non-empty text child instead.
|
||||||
|
const link = row.querySelector('a.easytooltip[class*="booking_"]');
|
||||||
|
if (!link) return '';
|
||||||
|
for (const node of link.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
|
||||||
|
return node.textContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return link.textContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCheckInCell(row) {
|
||||||
|
const btn = row.querySelector('button[aria-label="Check In"]');
|
||||||
|
return btn ? btn.closest('td') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSendButton(bookingId, guestName) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = `tcp-btn-${bookingId}`;
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = 'Send to Tablet';
|
||||||
|
btn.style.cssText = `
|
||||||
|
display: block; margin-top: 6px; width: 100%;
|
||||||
|
padding: 4px 8px; font-size: 12px; font-weight: 600;
|
||||||
|
background: #be123c; color: #fff; border: none; border-radius: 6px;
|
||||||
|
cursor: pointer; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
`;
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePanel(btn, bookingId, guestName);
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
let openPanel = null;
|
||||||
|
|
||||||
|
async function togglePanel(anchorBtn, bookingId, guestName) {
|
||||||
|
if (openPanel) { openPanel.remove(); openPanel = null; }
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.cssText = `
|
||||||
|
margin-top: 6px; padding: 8px; background: #f4f5f7; border: 1px solid #e4e8ee;
|
||||||
|
border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 12px; color: #1e293b;
|
||||||
|
`;
|
||||||
|
panel.textContent = 'Loading tablets…';
|
||||||
|
anchorBtn.insertAdjacentElement('afterend', panel);
|
||||||
|
openPanel = panel;
|
||||||
|
|
||||||
|
const res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
|
||||||
|
if (!res?.ok) {
|
||||||
|
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.devices.length) {
|
||||||
|
panel.textContent = 'No tablets registered yet.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.style.cssText = 'width: 100%; margin-bottom: 6px; padding: 4px;';
|
||||||
|
select.innerHTML = res.devices.map(d =>
|
||||||
|
`<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 = '';
|
||||||
|
panel.appendChild(select);
|
||||||
|
panel.appendChild(sendBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processRow(row) {
|
||||||
|
if (row.hasAttribute(PROCESSED_ATTR)) return;
|
||||||
|
const bookingId = extractBookingId(row);
|
||||||
|
const cell = findCheckInCell(row);
|
||||||
|
if (!bookingId || !cell) return; // not a real booking row, or no Check In button in it
|
||||||
|
|
||||||
|
row.setAttribute(PROCESSED_ATTR, '1');
|
||||||
|
cell.appendChild(makeSendButton(bookingId, extractGuestName(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanForRows(root = document) {
|
||||||
|
root.querySelectorAll('tr.checkbox_row').forEach(processRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanForRows();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
for (const node of m.addedNodes) {
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
if (node.matches?.('tr.checkbox_row')) processRow(node);
|
||||||
|
else scanForRows(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
BIN
backend/extension-src/icons/icon128.png
Normal file
BIN
backend/extension-src/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
BIN
backend/extension-src/icons/icon16.png
Normal file
BIN
backend/extension-src/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 168 B |
BIN
backend/extension-src/icons/icon48.png
Normal file
BIN
backend/extension-src/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 B |
38
backend/extension-src/manifest.json
Normal file
38
backend/extension-src/manifest.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Send to Check-In Tablet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Pushes the currently open NewBook booking to a check-in tablet — standalone from NewBook Assistant, see project_checkin memory.",
|
||||||
|
|
||||||
|
"permissions": ["cookies", "storage", "activeTab", "webNavigation"],
|
||||||
|
|
||||||
|
"host_permissions": [
|
||||||
|
"https://appeu.newbook.cloud/*",
|
||||||
|
"https://login.newbook.cloud/*"
|
||||||
|
],
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://appeu.newbook.cloud/bookings_lists_checkin*"],
|
||||||
|
"js": ["content-script.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup/popup.html",
|
||||||
|
"default_title": "Send to Check-In Tablet"
|
||||||
|
},
|
||||||
|
|
||||||
|
"options_page": "options/options.html",
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/extension-src/newbook-auth.js
Normal file
38
backend/extension-src/newbook-auth.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Detects whether the browser currently has a live NewBook session — used
|
||||||
|
// ONLY to decide whether to show a "you don't look logged into NewBook"
|
||||||
|
// hint in the popup. NOT used as authorization for the push call itself —
|
||||||
|
// that's the terminal API key (see options.js/popup.js). A NewBook cookie
|
||||||
|
// proves you're logged into NewBook, not that you hold this extension's own
|
||||||
|
// terminal credential; conflating the two was explicitly rejected as a
|
||||||
|
// design decision, see project_checkin memory's "Extension auth" section.
|
||||||
|
//
|
||||||
|
// Cookie-detection heuristic (domains + name match) is lifted directly from
|
||||||
|
// chrome-newbook-assistant/sidepanel/sidepanel.js's AuthManager.checkNewBookAuth
|
||||||
|
// (~/laptop-archive) — already proven against real NewBook cookies, not
|
||||||
|
// guessed here.
|
||||||
|
const NEWBOOK_COOKIE_DOMAINS = [
|
||||||
|
'https://appeu.newbook.cloud',
|
||||||
|
'https://login.newbook.cloud',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function isNewBookLoggedIn() {
|
||||||
|
try {
|
||||||
|
let allCookies = [];
|
||||||
|
for (const url of NEWBOOK_COOKIE_DOMAINS) {
|
||||||
|
const cookies = await chrome.cookies.getAll({ url });
|
||||||
|
allCookies = allCookies.concat(cookies);
|
||||||
|
}
|
||||||
|
const sessionCookie = allCookies.find(
|
||||||
|
c => c.name === 'PHPSESSID'
|
||||||
|
|| c.name.toLowerCase().includes('session')
|
||||||
|
|| c.name.toLowerCase().includes('newbook')
|
||||||
|
);
|
||||||
|
if (!sessionCookie) return false;
|
||||||
|
// A session cookie with no expirationDate is a browser-session cookie —
|
||||||
|
// still valid until the browser closes, not "expired".
|
||||||
|
if (sessionCookie.expirationDate && sessionCookie.expirationDate * 1000 < Date.now()) return false;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/extension-src/options/options.css
Normal file
32
backend/extension-src/options/options.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 32px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
h1 { font-size: 18px; color: #1a1a2e; }
|
||||||
|
label { display: block; margin-top: 16px; font-weight: 600; font-size: 13px; }
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid #e4e8ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.hint { color: #64748b; font-size: 12px; margin: 4px 0 0; }
|
||||||
|
button {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #c9a84c;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button#test { background: #f4f5f7; color: #1e293b; border: 1px solid #e4e8ee; }
|
||||||
|
#status { margin-top: 12px; font-size: 13px; }
|
||||||
33
backend/extension-src/options/options.html
Normal file
33
backend/extension-src/options/options.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Send to Check-In Tablet — Settings</title>
|
||||||
|
<link rel="stylesheet" href="options.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Send to Check-In Tablet</h1>
|
||||||
|
<p class="hint">
|
||||||
|
Two things needed before this extension can push a booking to a tablet — both come from the
|
||||||
|
<strong>tablet-checkin</strong> app itself, not from here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="baseUrl">Tablet Check-In base URL</label>
|
||||||
|
<input id="baseUrl" type="text" placeholder="https://<host>/tablet-checkin — not confirmed yet, see TODO in background.js" />
|
||||||
|
<p class="hint">The app's own address once deployed. Include no trailing slash.</p>
|
||||||
|
|
||||||
|
<label for="apiKey">Terminal API key</label>
|
||||||
|
<input id="apiKey" type="password" placeholder="Paste the key shown once when you created this terminal" />
|
||||||
|
<p class="hint">
|
||||||
|
Create one from tablet-checkin's <strong>Terminals</strong> admin page (requires the <code>admin</code>
|
||||||
|
capability). The raw key is shown exactly once at creation — if you've lost it, revoke it there and
|
||||||
|
create a new one.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<button id="test">Test connection</button>
|
||||||
|
<p id="status"></p>
|
||||||
|
|
||||||
|
<script type="module" src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
backend/extension-src/options/options.js
Normal file
48
backend/extension-src/options/options.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
const baseUrlInput = document.getElementById('baseUrl');
|
||||||
|
const apiKeyInput = document.getElementById('apiKey');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
||||||
|
if (baseUrl) baseUrlInput.value = baseUrl;
|
||||||
|
if (apiKey) apiKeyInput.value = apiKey; // deliberately shown back here (options page,
|
||||||
|
// not the popup) so the value can be corrected without regenerating the key.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const baseUrl = baseUrlInput.value.trim().replace(/\/$/, '');
|
||||||
|
const apiKey = apiKeyInput.value.trim();
|
||||||
|
await chrome.storage.local.set({ baseUrl, apiKey });
|
||||||
|
statusEl.textContent = 'Saved.';
|
||||||
|
statusEl.style.color = '#16a34a';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
||||||
|
if (!baseUrl || !apiKey) {
|
||||||
|
statusEl.textContent = 'Set both fields and save first.';
|
||||||
|
statusEl.style.color = '#dc2626';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = 'Testing…';
|
||||||
|
statusEl.style.color = '#64748b';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const devices = await res.json();
|
||||||
|
statusEl.textContent = `Connected — found ${devices.length} tablet(s).`;
|
||||||
|
statusEl.style.color = '#16a34a';
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = `Failed: ${err.message}`;
|
||||||
|
statusEl.style.color = '#dc2626';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('save').addEventListener('click', save);
|
||||||
|
document.getElementById('test').addEventListener('click', test);
|
||||||
|
load();
|
||||||
47
backend/extension-src/popup/popup.css
Normal file
47
backend/extension-src/popup/popup.css
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
width: 300px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
background: #f4f5f7;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.title { font-weight: 700; font-size: 14px; color: #1a1a2e; }
|
||||||
|
.nb-status { font-size: 11px; padding: 2px 8px; border-radius: 999px; }
|
||||||
|
.nb-status.ok { background: #dcfce7; color: #166534; }
|
||||||
|
.nb-status.no { background: #fee2e2; color: #991b1b; }
|
||||||
|
.muted { color: #64748b; font-size: 13px; }
|
||||||
|
.booking { font-size: 13px; margin-bottom: 10px; }
|
||||||
|
.booking strong { color: #1a1a2e; }
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e8ee;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #c9a84c;
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.primary:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.msg { margin-top: 10px; font-size: 12px; }
|
||||||
|
.msg.error { color: #dc2626; }
|
||||||
|
.msg.ok { color: #16a34a; }
|
||||||
|
.msg.warn { color: #b45309; }
|
||||||
|
a.settings-link { display: block; margin-top: 12px; font-size: 12px; color: #7b4f00; }
|
||||||
20
backend/extension-src/popup/popup.html
Normal file
20
backend/extension-src/popup/popup.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Send to Check-In Tablet</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">Check-In Tablet</span>
|
||||||
|
<span id="nbStatus" class="nb-status"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content">
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
backend/extension-src/popup/popup.js
Normal file
113
backend/extension-src/popup/popup.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { isNewBookLoggedIn } from '../newbook-auth.js';
|
||||||
|
|
||||||
|
// Same regex as background.js — re-derived independently here from the live
|
||||||
|
// tab URL each time the popup opens (rather than trusting chrome.storage
|
||||||
|
// left over from the background script) so a stale badge state can never
|
||||||
|
// show a booking that isn't actually the one currently open. Confirmed
|
||||||
|
// 2026-09-01 by the user directly (real NewBook page URLs).
|
||||||
|
const BOOKING_URL_RE = /\/bookings_(?:view|edit|checkin)\/(\d+)/i;
|
||||||
|
|
||||||
|
const contentEl = document.getElementById('content');
|
||||||
|
const nbStatusEl = document.getElementById('nbStatus');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const bookingMatch = tab?.url?.match(BOOKING_URL_RE);
|
||||||
|
const bookingId = bookingMatch ? bookingMatch[1] : null;
|
||||||
|
|
||||||
|
isNewBookLoggedIn().then(ok => {
|
||||||
|
nbStatusEl.textContent = ok ? 'NewBook: signed in' : 'NewBook: not detected';
|
||||||
|
nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']);
|
||||||
|
if (!baseUrl || !apiKey) {
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<p class="muted">Not set up yet — add the tablet-checkin URL and a terminal API key first.</p>
|
||||||
|
<a class="settings-link" href="#" id="openSettings">Open Settings</a>
|
||||||
|
`;
|
||||||
|
document.getElementById('openSettings').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookingId) {
|
||||||
|
contentEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<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 = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/staff/devices`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
|
||||||
|
devices = await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
deviceSelect.innerHTML = '<option>Could not load tablets</option>';
|
||||||
|
setMsg(msgEl, `Could not reach tablet-checkin: ${err.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!devices.length) {
|
||||||
|
deviceSelect.innerHTML = '<option>No tablets registered yet</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSelect.innerHTML = devices.map(d =>
|
||||||
|
`<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>
|
||||||
|
${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}
|
||||||
|
</option>`
|
||||||
|
).join('');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMsg(el, text, kind) {
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = `msg ${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
2774
backend/package-lock.json
generated
Normal file
2774
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^9.4.0",
|
"@fastify/cookie": "^9.4.0",
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,6 @@ export async function initDb() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_DEFAULTS = {
|
const SETTINGS_DEFAULTS = {
|
||||||
newbook_hotel_id: '',
|
|
||||||
regcard_custom_field_id: '',
|
regcard_custom_field_id: '',
|
||||||
online_checkin_complete_custom_field_id: '',
|
online_checkin_complete_custom_field_id: '',
|
||||||
kiosk_password_hash: '',
|
kiosk_password_hash: '',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { checkinsRoutes } from './routes/checkins.js'
|
||||||
import { marketingRoutes } from './routes/marketing.js'
|
import { marketingRoutes } from './routes/marketing.js'
|
||||||
import { settingsRoutes } from './routes/settings.js'
|
import { settingsRoutes } from './routes/settings.js'
|
||||||
import { kioskRoutes } from './routes/kiosk.js'
|
import { kioskRoutes } from './routes/kiosk.js'
|
||||||
|
import { extensionRoutes } from './routes/extension.js'
|
||||||
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
@ -30,6 +31,7 @@ await app.register(checkinsRoutes)
|
||||||
await app.register(marketingRoutes)
|
await app.register(marketingRoutes)
|
||||||
await app.register(settingsRoutes)
|
await app.register(settingsRoutes)
|
||||||
await app.register(kioskRoutes)
|
await app.register(kioskRoutes)
|
||||||
|
await app.register(extensionRoutes)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initDb()
|
await initDb()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { pool, getSetting } from '../db.js'
|
import { pool } from '../db.js'
|
||||||
import { pushNavigate } from './device-stream.js'
|
import { pushNavigate } from './device-stream.js'
|
||||||
|
|
||||||
const SESSION_TTL_MINUTES = 30
|
const SESSION_TTL_MINUTES = 30
|
||||||
|
|
@ -24,7 +24,14 @@ export async function pushBookingToDevice(deviceId, bookingId) {
|
||||||
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
|
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotelId = (await getSetting('newbook_hotel_id', '')) || process.env.NEWBOOK_LOCATION_ID || ''
|
// Single-hotel-per-stack deployment (see project_checkin memory) — no
|
||||||
|
// per-request "which hotel" lookup needed, this whole app instance already
|
||||||
|
// is one hotel. hotel_id here is purely a local audit-row label, sourced
|
||||||
|
// from the same env var lib/newbook.js sends to the NewBook API itself
|
||||||
|
// (not a separate app-level setting — a local `newbook_hotel_id` Settings
|
||||||
|
// field existed here before 2026-09-01 but was removed as vestigial
|
||||||
|
// multi-hotel-model plumbing that no longer did anything real).
|
||||||
|
const hotelId = process.env.NEWBOOK_LOCATION_ID || ''
|
||||||
const token = randomBytes(24).toString('hex')
|
const token = randomBytes(24).toString('hex')
|
||||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)
|
||||||
|
|
||||||
|
|
|
||||||
39
backend/src/routes/extension.js
Normal file
39
backend/src/routes/extension.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import archiver from 'archiver'
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
// Bundled copy of tablet-checkin-push (~/dev/HNF-PROXMOX/tablet-checkin-push),
|
||||||
|
// baked into this image at build time (see Dockerfile) so the extension ships
|
||||||
|
// with the app rather than needing separate distribution. Zipped fresh on
|
||||||
|
// each request rather than a pre-built static file — small folder, cheap to
|
||||||
|
// zip, and always reflects whatever's actually deployed (no separate
|
||||||
|
// "remember to rebuild the zip" step to forget). See project_checkin memory's
|
||||||
|
// "Standalone push extension" section.
|
||||||
|
const EXTENSION_SRC = path.join(__dirname, '..', '..', 'extension-src')
|
||||||
|
|
||||||
|
export async function extensionRoutes(app) {
|
||||||
|
// Gated the same as the Settings page it's linked from — any staff who can
|
||||||
|
// see Settings can download this, nothing sensitive lives in the extension
|
||||||
|
// itself (the terminal API key is entered by whoever installs it, not
|
||||||
|
// baked in here).
|
||||||
|
app.get('/api/extension/download', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => {
|
||||||
|
reply.header('Content-Type', 'application/zip')
|
||||||
|
reply.header('Content-Disposition', 'attachment; filename="tablet-checkin-push.zip"')
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||||
|
archive.on('error', err => {
|
||||||
|
app.log.error(`[extension] zip stream error: ${err.message}`)
|
||||||
|
reply.raw.destroy(err)
|
||||||
|
})
|
||||||
|
// Root the zip's contents at "tablet-checkin-push/" rather than dumping
|
||||||
|
// files at the archive root — so extracting it doesn't scatter files
|
||||||
|
// into whatever folder the user unzipped into.
|
||||||
|
archive.directory(EXTENSION_SRC, 'tablet-checkin-push')
|
||||||
|
|
||||||
|
reply.hijack()
|
||||||
|
archive.pipe(reply.raw)
|
||||||
|
await archive.finalize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import { testConnection } from '../lib/newbook.js'
|
||||||
// readable back to the browser, only settable via its own dedicated route
|
// readable back to the browser, only settable via its own dedicated route
|
||||||
// below (mirrors hvac's Settings -> Kiosk pattern).
|
// below (mirrors hvac's Settings -> Kiosk pattern).
|
||||||
const ALLOWED_KEYS = [
|
const ALLOWED_KEYS = [
|
||||||
'newbook_hotel_id', 'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
|
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
|
||||||
'terms_and_conditions_text',
|
'terms_and_conditions_text',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ export function deleteTerminal(id: string): Promise<{ ok: boolean }> {
|
||||||
export function fetchCheckins(limit = 100): Promise<Checkin[]> { return request(`/checkins?limit=${limit}`) }
|
export function fetchCheckins(limit = 100): Promise<Checkin[]> { return request(`/checkins?limit=${limit}`) }
|
||||||
export function checkinPdfUrl(id: number): string { return `${BASE}/checkins/${id}/pdf` }
|
export function checkinPdfUrl(id: number): string { return `${BASE}/checkins/${id}/pdf` }
|
||||||
|
|
||||||
|
// Standalone push extension — bundled into this app at build time (see
|
||||||
|
// backend/extension-src), zipped fresh on each request rather than served as
|
||||||
|
// a static file. See project_checkin memory's "Standalone push extension".
|
||||||
|
export function extensionDownloadUrl(): string { return `${BASE}/extension/download` }
|
||||||
|
|
||||||
// Marketing
|
// Marketing
|
||||||
export function fetchMarketingProviders(): Promise<MarketingProvider[]> { return request('/marketing/providers') }
|
export function fetchMarketingProviders(): Promise<MarketingProvider[]> { return request('/marketing/providers') }
|
||||||
export function updateMarketingProvider(provider: string, body: { active: boolean; config: Record<string, string> }): Promise<MarketingProvider> {
|
export function updateMarketingProvider(provider: string, body: { active: boolean; config: Record<string, string> }): Promise<MarketingProvider> {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { MonitorCog } from 'lucide-react'
|
import { MonitorCog, Download } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchSettings, updateSettings, testNewbookConnection,
|
fetchSettings, updateSettings, testNewbookConnection,
|
||||||
fetchKioskPasswordStatus, setKioskPassword,
|
fetchKioskPasswordStatus, setKioskPassword, extensionDownloadUrl,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { AppSettings } from '../types'
|
import type { AppSettings } from '../types'
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
newbook_hotel_id: '', regcard_custom_field_id: '',
|
regcard_custom_field_id: '',
|
||||||
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
|
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -76,13 +76,6 @@ export default function Settings() {
|
||||||
|
|
||||||
<div className="section-title">NewBook Integration</div>
|
<div className="section-title">NewBook Integration</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
|
||||||
<label>NewBook Hotel / Location ID</label>
|
|
||||||
<input
|
|
||||||
type="text" value={settings.newbook_hotel_id}
|
|
||||||
onChange={e => setSettings(s => ({ ...s, newbook_hotel_id: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Regcard PDF Link — Custom Field ID</label>
|
<label>Regcard PDF Link — Custom Field ID</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -127,6 +120,20 @@ export default function Settings() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title">Push Extension</div>
|
||||||
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||||
|
Standalone Chrome extension — adds a "Send to Check-In Tablet" button when a NewBook booking
|
||||||
|
is open. Not published anywhere yet, so it needs installing manually on each reception PC:
|
||||||
|
download, unzip, then <code>chrome://extensions</code> → enable Developer mode → Load
|
||||||
|
unpacked → select the unzipped folder. It'll need a Terminal API key from the Terminals page
|
||||||
|
to actually authorize pushes — create one there first.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<a className="btn" href={extensionDownloadUrl()} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, textDecoration: 'none' }}>
|
||||||
|
<Download size={16} strokeWidth={1.75} /> Download Extension (.zip)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="section-title">Kiosk Tablets</div>
|
<div className="section-title">Kiosk Tablets</div>
|
||||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||||
A shared password every check-in tablet uses to set itself up at <code>/tablet-checkin/kiosk</code> —
|
A shared password every check-in tablet uses to set itself up at <code>/tablet-checkin/kiosk</code> —
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ export interface MarketingSyncRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
newbook_hotel_id: string
|
|
||||||
regcard_custom_field_id: string
|
regcard_custom_field_id: string
|
||||||
online_checkin_complete_custom_field_id: string
|
online_checkin_complete_custom_field_id: string
|
||||||
terms_and_conditions_text: string
|
terms_and_conditions_text: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue