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
+ 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. +
+