diff --git a/backend/extension-src/manifest.json b/backend/extension-src/manifest.json index 3a9c3d1..48b5bc3 100644 --- a/backend/extension-src/manifest.json +++ b/backend/extension-src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Send to Check-In Tablet", - "version": "0.2.0", + "version": "0.2.1", "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"], diff --git a/backend/extension-src/popup/popup.js b/backend/extension-src/popup/popup.js index a7d0ae5..0e2acb4 100644 --- a/backend/extension-src/popup/popup.js +++ b/backend/extension-src/popup/popup.js @@ -214,6 +214,12 @@ function renderTablets(el, devices, baseUrl, apiKey) { el.querySelectorAll('.reset-btn').forEach(btn => { btn.addEventListener('click', async () => { + // Reset now pushes a live 'idle' event straight to the tablet + // (2026-10-09, see tablet-checkin's Kiosk.tsx) rather than only + // flipping its status server-side — if a guest is mid check-in on it, + // this sends them back to the welcome screen immediately and discards + // whatever they'd filled in. + if (!confirm('Reset this tablet? If a guest is mid check-in, this returns them to the welcome screen immediately and loses anything they’ve entered.')) return; btn.disabled = true; btn.textContent = '…'; try { diff --git a/backend/src/db.js b/backend/src/db.js index a2976e0..2de1e37 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -130,6 +130,11 @@ const SETTINGS_DEFAULTS = { // project_checkin_legacy_plugin) but editable from Settings so any other // OTA/agent domain can be added without a code change. excluded_email_domains: 'guest.booking.com,m.expediapartnercentral.com', + // Minutes of no touch/keyboard activity on the tablet's check-in flow + // before it auto-cancels and returns to the welcome screen — see + // CheckinFlow.tsx's inactivity timer, previously hardcoded to 5 and now + // admin-tunable from Settings. + checkin_inactivity_timeout_minutes: '5', } async function seedSettings() { diff --git a/backend/src/routes/checkin.js b/backend/src/routes/checkin.js index 81fbd37..6a972a5 100644 --- a/backend/src/routes/checkin.js +++ b/backend/src/routes/checkin.js @@ -8,7 +8,6 @@ import { import { generateRegcardPdf } from '../lib/pdf.js' import { saveRegcardPdf } from '../lib/uploads.js' import { queueMarketingSync } from '../lib/marketing/index.js' -import { pushIdle } from '../lib/device-stream.js' const DEFAULT_TERMS = 'No terms & conditions text has been configured yet — set one under Settings.' @@ -66,6 +65,7 @@ export async function checkinRoutes(app) { car_regos: existingCarRegos, }, terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS), + inactivity_timeout_minutes: Number(await getSetting('checkin_inactivity_timeout_minutes', '5')) || 5, } }) @@ -180,8 +180,14 @@ export async function checkinRoutes(app) { } // Step 7 — flip the device back to idle so the next guest can be pushed. + // Deliberately no pushIdle() here, unlike lib/push.js's resetDevice() — + // this is the same browser that's already showing its own confirmation + // screen and will return to idle on its own timer (see CheckinFlow.tsx's + // COMPLETE_AUTO_RETURN_MS); forcing the SSE 'idle' event here as well + // would race that confirmation screen and could skip straight past it + // the moment Kiosk.tsx started listening for 'idle' across the whole + // session rather than only while idle (2026-10-09, see Kiosk.tsx). await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id]) - pushIdle(session.device_id) return { ok: true, checkin_id: checkin.id, pdf_generated: checkin.status === 'complete' } }) @@ -219,8 +225,12 @@ async function failCheckin(session, errorMessage) { VALUES ($1, NULL, $2, $3, 'failed', $4)`, [session.booking_id, session.hotel_id, session.device_id, errorMessage] ) + // No pushIdle() here either, same reasoning as the success path above — + // the guest/tablet is shown an error on this same session (see + // CheckinFlow's catch block, which stays on the 'terms' step rather than + // calling onDone()) and forcing an SSE 'idle' nudge would yank the tablet + // away from that error message instead of letting staff see it. await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id]) - pushIdle(session.device_id) } async function retry(fn, times) { diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 0c6edc3..eedc894 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -11,6 +11,7 @@ import { testConnection } from '../lib/newbook.js' const ALLOWED_KEYS = [ 'regcard_custom_field_id', 'online_checkin_complete_custom_field_id', 'terms_and_conditions_text', 'excluded_email_domains', + 'checkin_inactivity_timeout_minutes', ] export async function settingsRoutes(app) { @@ -28,6 +29,16 @@ export async function settingsRoutes(app) { for (const key of Object.keys(body)) { if (!ALLOWED_KEYS.includes(key)) return reply.status(400).send({ error: `Unknown or protected setting: ${key}` }) } + // Guarded separately from the generic string pass-through below — this + // one directly controls the tablet's inactivity safety-net timer (see + // CheckinFlow.tsx), so a stray "0"/non-numeric value here shouldn't be + // able to make every check-in auto-cancel instantly. + if ('checkin_inactivity_timeout_minutes' in body) { + const n = Number(body.checkin_inactivity_timeout_minutes) + if (!Number.isFinite(n) || n < 1 || n > 60) { + return reply.status(400).send({ error: 'Inactivity timeout must be a number of minutes between 1 and 60' }) + } + } for (const [key, value] of Object.entries(body)) { await setSetting(key, String(value ?? '')) } diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index dec07eb..97afda6 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -48,6 +48,12 @@ export default function Devices() { } async function handleReset(deviceId: string) { + // Reset now pushes a live 'idle' event straight to the tablet (2026-10-09, + // see Kiosk.tsx) rather than only flipping its status in the DB — if a + // guest is mid check-in on it, this sends them back to the welcome screen + // immediately and discards whatever they'd filled in, not just unsticking + // a tablet with no one at it. + if (!confirm('Reset this tablet? If a guest is mid check-in, this returns them to the welcome screen immediately and loses anything they’ve entered.')) return try { await resetDevice(deviceId) setMsg('Device reset to idle') diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4222e19..2be325f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -10,7 +10,7 @@ export default function Settings() { const [settings, setSettings] = useState({ regcard_custom_field_id: '', online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '', - excluded_email_domains: '', + excluded_email_domains: '', checkin_inactivity_timeout_minutes: '5', }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -168,6 +168,20 @@ export default function Settings() {

+
Tablet Inactivity Timeout
+
+ + setSettings(s => ({ ...s, checkin_inactivity_timeout_minutes: e.target.value }))} + /> +

+ If a guest starts a check-in on the tablet and stops touching the screen for this long, it + auto-cancels and returns to the welcome screen — same safety net as the Cancel button and a + staff-triggered Reset, just on a timer instead of a tap. +

+
+