Kiosk: live-force reset to welcome screen, configurable inactivity timeout
Kiosk's SSE connection now stays open for the whole session instead of closing the moment a booking is pushed, so a staff-triggered Reset (from the extension/Terminals popup or the Devices admin page) now actually forces the tablet back to the welcome screen immediately, discarding whatever was mid-entry — not just flipping its status in the DB with nothing listening tablet-side. Removed the two now-redundant/harmful pushIdle() calls in routes/checkin.js's own submit success/failure paths, which would otherwise race the confirmation/error screen now that the stream stays open. Added a confirm() prompt before Reset on both trigger surfaces since it's now a real interruption, not just an unstick. Also made the tablet's inactivity auto-cancel timeout configurable from Settings (checkin_inactivity_timeout_minutes, default 5, same as the old hardcoded value) instead of a fixed constant, read via the existing token-gated GET /api/checkin/:token response.
This commit is contained in:
parent
790431c446
commit
5180adf8d2
10 changed files with 84 additions and 14 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Send to Check-In Tablet",
|
"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.",
|
"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"],
|
"permissions": ["cookies", "storage", "activeTab", "webNavigation"],
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,12 @@ function renderTablets(el, devices, baseUrl, apiKey) {
|
||||||
|
|
||||||
el.querySelectorAll('.reset-btn').forEach(btn => {
|
el.querySelectorAll('.reset-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
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.disabled = true;
|
||||||
btn.textContent = '…';
|
btn.textContent = '…';
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,11 @@ const SETTINGS_DEFAULTS = {
|
||||||
// project_checkin_legacy_plugin) but editable from Settings so any other
|
// project_checkin_legacy_plugin) but editable from Settings so any other
|
||||||
// OTA/agent domain can be added without a code change.
|
// OTA/agent domain can be added without a code change.
|
||||||
excluded_email_domains: 'guest.booking.com,m.expediapartnercentral.com',
|
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() {
|
async function seedSettings() {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import { generateRegcardPdf } from '../lib/pdf.js'
|
import { generateRegcardPdf } from '../lib/pdf.js'
|
||||||
import { saveRegcardPdf } from '../lib/uploads.js'
|
import { saveRegcardPdf } from '../lib/uploads.js'
|
||||||
import { queueMarketingSync } from '../lib/marketing/index.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.'
|
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,
|
car_regos: existingCarRegos,
|
||||||
},
|
},
|
||||||
terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
|
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.
|
// 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])
|
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' }
|
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)`,
|
VALUES ($1, NULL, $2, $3, 'failed', $4)`,
|
||||||
[session.booking_id, session.hotel_id, session.device_id, errorMessage]
|
[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])
|
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id])
|
||||||
pushIdle(session.device_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retry(fn, times) {
|
async function retry(fn, times) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { testConnection } from '../lib/newbook.js'
|
||||||
const ALLOWED_KEYS = [
|
const ALLOWED_KEYS = [
|
||||||
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
|
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
|
||||||
'terms_and_conditions_text', 'excluded_email_domains',
|
'terms_and_conditions_text', 'excluded_email_domains',
|
||||||
|
'checkin_inactivity_timeout_minutes',
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function settingsRoutes(app) {
|
export async function settingsRoutes(app) {
|
||||||
|
|
@ -28,6 +29,16 @@ export async function settingsRoutes(app) {
|
||||||
for (const key of Object.keys(body)) {
|
for (const key of Object.keys(body)) {
|
||||||
if (!ALLOWED_KEYS.includes(key)) return reply.status(400).send({ error: `Unknown or protected setting: ${key}` })
|
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)) {
|
for (const [key, value] of Object.entries(body)) {
|
||||||
await setSetting(key, String(value ?? ''))
|
await setSetting(key, String(value ?? ''))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ export default function Devices() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReset(deviceId: string) {
|
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 {
|
try {
|
||||||
await resetDevice(deviceId)
|
await resetDevice(deviceId)
|
||||||
setMsg('Device reset to idle')
|
setMsg('Device reset to idle')
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default function Settings() {
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
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: '',
|
||||||
excluded_email_domains: '',
|
excluded_email_domains: '', checkin_inactivity_timeout_minutes: '5',
|
||||||
})
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -168,6 +168,20 @@ export default function Settings() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-title">Tablet Inactivity Timeout</div>
|
||||||
|
<div className="field" style={{ maxWidth: 200, marginBottom: 20 }}>
|
||||||
|
<label>Minutes before an untouched check-in auto-cancels</label>
|
||||||
|
<input
|
||||||
|
type="number" min={1} max={60} value={settings.checkin_inactivity_timeout_minutes}
|
||||||
|
onChange={e => setSettings(s => ({ ...s, checkin_inactivity_timeout_minutes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<p className="field-hint">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>
|
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,12 @@ import type { CheckinTokenData } from '../../types'
|
||||||
|
|
||||||
// Safety-net timers per project_checkin's "Multi-tablet device registry" —
|
// Safety-net timers per project_checkin's "Multi-tablet device registry" —
|
||||||
// busy=hard-reject means an abandoned session must not wedge a tablet
|
// busy=hard-reject means an abandoned session must not wedge a tablet
|
||||||
// forever. Two nets: this inactivity auto-reset, and the explicit Cancel
|
// forever. Two nets: this inactivity auto-reset (duration admin-configurable
|
||||||
// button always visible below.
|
// via Settings -> data.inactivity_timeout_minutes, was hardcoded to 5 until
|
||||||
const INACTIVITY_MS = 5 * 60 * 1000
|
// 2026-10-09), and the explicit Cancel button always visible below. A third
|
||||||
|
// net — a server-pushed reset forcing the tablet back to idle even mid-flow
|
||||||
|
// — lives in Kiosk.tsx's SSE 'idle' handler, not here.
|
||||||
|
const DEFAULT_INACTIVITY_MINUTES = 5
|
||||||
const COMPLETE_AUTO_RETURN_MS = 15 * 1000
|
const COMPLETE_AUTO_RETURN_MS = 15 * 1000
|
||||||
|
|
||||||
// 3 real steps (down from 5, 2026-09-01) — contact/car-reg/marketing merged
|
// 3 real steps (down from 5, 2026-09-01) — contact/car-reg/marketing merged
|
||||||
|
|
@ -57,9 +60,10 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
|
||||||
// Inactivity auto-cancel — any pointer/keyboard activity resets the timer.
|
// Inactivity auto-cancel — any pointer/keyboard activity resets the timer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 'loading' || step === 'error' || step === 'complete') return
|
if (step === 'loading' || step === 'error' || step === 'complete') return
|
||||||
|
const timeoutMs = (data?.inactivity_timeout_minutes || DEFAULT_INACTIVITY_MINUTES) * 60 * 1000
|
||||||
function reset() {
|
function reset() {
|
||||||
if (idleTimer.current) clearTimeout(idleTimer.current)
|
if (idleTimer.current) clearTimeout(idleTimer.current)
|
||||||
idleTimer.current = setTimeout(() => { handleCancel() }, INACTIVITY_MS)
|
idleTimer.current = setTimeout(() => { handleCancel() }, timeoutMs)
|
||||||
}
|
}
|
||||||
const events = ['pointerdown', 'keydown', 'touchstart'] as const
|
const events = ['pointerdown', 'keydown', 'touchstart'] as const
|
||||||
events.forEach(e => window.addEventListener(e, reset))
|
events.forEach(e => window.addEventListener(e, reset))
|
||||||
|
|
@ -69,7 +73,7 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
|
||||||
events.forEach(e => window.removeEventListener(e, reset))
|
events.forEach(e => window.removeEventListener(e, reset))
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [step])
|
}, [step, data?.inactivity_timeout_minutes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 'complete') return
|
if (step !== 'complete') return
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,21 @@ export default function Kiosk() {
|
||||||
|
|
||||||
useEffect(() => { registerAndGoIdle() }, [])
|
useEffect(() => { registerAndGoIdle() }, [])
|
||||||
|
|
||||||
// Idle-page SSE — holds open while view === 'idle', reconnects on drop.
|
// Device-level SSE — holds open for as long as this tablet is registered,
|
||||||
|
// not just while view === 'idle'. Previously this closed the moment a push
|
||||||
|
// arrived (view -> 'checkin'), which meant a server-initiated reset (the
|
||||||
|
// extension/Terminals popup's "Reset" button, or the Devices admin page's
|
||||||
|
// test-push reset) had nothing listening on the tablet side while it was
|
||||||
|
// mid-flow — the device flipped back to idle server-side, but the tablet
|
||||||
|
// itself just sat on whatever CheckinFlow step it was on until its own
|
||||||
|
// inactivity timer eventually fired or someone walked over and tapped
|
||||||
|
// Cancel. Keeping one connection open across both views means an 'idle'
|
||||||
|
// event forces the tablet straight back to the welcome screen immediately,
|
||||||
|
// same as a local Cancel — whatever the guest had filled in is simply
|
||||||
|
// discarded, consistent with the existing hard-reject-not-queue design (see
|
||||||
|
// project_checkin's "Multi-tablet device registry").
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== 'idle' || !device) return
|
if (!device) return
|
||||||
|
|
||||||
const es = new EventSource(kioskStreamUrl(device.device_id))
|
const es = new EventSource(kioskStreamUrl(device.device_id))
|
||||||
esRef.current = es
|
esRef.current = es
|
||||||
|
|
@ -111,11 +123,11 @@ export default function Kiosk() {
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed event */ }
|
} catch { /* ignore malformed event */ }
|
||||||
})
|
})
|
||||||
es.addEventListener('idle', () => setActiveToken(null))
|
es.addEventListener('idle', () => { setActiveToken(null); setView('idle') })
|
||||||
es.onerror = () => { /* browser auto-reconnects EventSource on its own */ }
|
es.onerror = () => { /* browser auto-reconnects EventSource on its own */ }
|
||||||
|
|
||||||
return () => { es.close(); esRef.current = null }
|
return () => { es.close(); esRef.current = null }
|
||||||
}, [view, device])
|
}, [device])
|
||||||
|
|
||||||
// Fallback heartbeat, independent of whether the SSE connection happens to
|
// Fallback heartbeat, independent of whether the SSE connection happens to
|
||||||
// be up at any given moment — keeps devices.last_seen fresh for the admin
|
// be up at any given moment — keeps devices.last_seen fresh for the admin
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export interface AppSettings {
|
||||||
online_checkin_complete_custom_field_id: string
|
online_checkin_complete_custom_field_id: string
|
||||||
terms_and_conditions_text: string
|
terms_and_conditions_text: string
|
||||||
excluded_email_domains: string
|
excluded_email_domains: string
|
||||||
|
checkin_inactivity_timeout_minutes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Guest-facing token flow (routes/checkin.js) ─────────────────────────────
|
// ── Guest-facing token flow (routes/checkin.js) ─────────────────────────────
|
||||||
|
|
@ -121,6 +122,7 @@ export interface CheckinTokenData {
|
||||||
booking: CheckinBooking
|
booking: CheckinBooking
|
||||||
guest: CheckinGuest
|
guest: CheckinGuest
|
||||||
terms_text: string
|
terms_text: string
|
||||||
|
inactivity_timeout_minutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitCheckinPayload {
|
export interface SubmitCheckinPayload {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue