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.
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { MonitorCog, Image as ImageIcon } from 'lucide-react'
|
|
import {
|
|
fetchSettings, updateSettings, testNewbookConnection,
|
|
fetchKioskPasswordStatus, setKioskPassword, uploadLogo, logoUrl,
|
|
} from '../api'
|
|
import type { AppSettings } from '../types'
|
|
|
|
export default function Settings() {
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
regcard_custom_field_id: '',
|
|
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
|
|
excluded_email_domains: '', checkin_inactivity_timeout_minutes: '5',
|
|
})
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [msg, setMsg] = useState('')
|
|
const [testing, setTesting] = useState(false)
|
|
|
|
const [kioskConfigured, setKioskConfigured] = useState<boolean | null>(null)
|
|
const [kioskPassword, setKioskPasswordInput] = useState('')
|
|
const [kioskSaving, setKioskSaving] = useState(false)
|
|
const [kioskMsg, setKioskMsg] = useState('')
|
|
const [kioskError, setKioskError] = useState('')
|
|
|
|
const [logoVersion, setLogoVersion] = useState(0)
|
|
const [logoExists, setLogoExists] = useState<boolean | null>(null)
|
|
const [logoUploading, setLogoUploading] = useState(false)
|
|
const [logoError, setLogoError] = useState('')
|
|
|
|
useEffect(() => {
|
|
fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false))
|
|
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
|
|
fetch(logoUrl()).then(r => setLogoExists(r.ok)).catch(() => setLogoExists(false))
|
|
}, [])
|
|
|
|
async function handleLogoFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0]
|
|
e.target.value = '' // allow re-selecting the same file later
|
|
if (!file) return
|
|
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
|
setLogoError('Must be a PNG or JPEG image'); return
|
|
}
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
setLogoError('Must be under 2MB'); return
|
|
}
|
|
setLogoUploading(true); setLogoError('')
|
|
try {
|
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(reader.result as string)
|
|
reader.onerror = reject
|
|
reader.readAsDataURL(file)
|
|
})
|
|
await uploadLogo(dataUrl)
|
|
setLogoExists(true)
|
|
setLogoVersion(v => v + 1) // cache-bust the <img> so the new logo shows immediately
|
|
} catch (e) {
|
|
setLogoError(e instanceof Error ? e.message : 'Upload failed')
|
|
} finally {
|
|
setLogoUploading(false)
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
setSaving(true); setError(''); setMsg('')
|
|
try {
|
|
await updateSettings(settings)
|
|
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
setTesting(true); setError(''); setMsg('')
|
|
try {
|
|
const res = await testNewbookConnection()
|
|
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Test failed')
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveKioskPassword() {
|
|
setKioskSaving(true); setKioskError(''); setKioskMsg('')
|
|
try {
|
|
await setKioskPassword(kioskPassword)
|
|
setKioskPasswordInput('')
|
|
setKioskConfigured(true)
|
|
setKioskMsg('Kiosk password saved — enter it once on each tablet at /tablet-checkin/kiosk.')
|
|
} catch (e) {
|
|
setKioskError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setKioskSaving(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-header"><h1>Settings</h1></div>
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{msg && <div className="ok-banner">{msg}</div>}
|
|
|
|
<div className="section-title">NewBook Integration</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Regcard PDF Link — Custom Field ID</label>
|
|
<input
|
|
type="text" value={settings.regcard_custom_field_id}
|
|
onChange={e => setSettings(s => ({ ...s, regcard_custom_field_id: e.target.value }))}
|
|
placeholder="varies per hotel — confirm in NewBook config"
|
|
/>
|
|
</div>
|
|
<div className="field">
|
|
<label>Online Check-in Complete — Custom Field ID</label>
|
|
<input
|
|
type="text" value={settings.online_checkin_complete_custom_field_id}
|
|
onChange={e => setSettings(s => ({ ...s, online_checkin_complete_custom_field_id: e.target.value }))}
|
|
placeholder="varies per hotel — confirm in NewBook config"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="field-hint" style={{ marginBottom: 12 }}>
|
|
Neither custom field ID is known for this hotel yet — the legacy plugin's fields were named
|
|
<code> regcard_link</code> and <code>online_checkin_complete</code>, but their numeric IDs varied per
|
|
install. Confirm both in NewBook (Settings → Custom Fields → Booking) before these write-backs will
|
|
work; until they're set, check-ins still complete and generate a PDF, they just skip writing back
|
|
into NewBook.
|
|
</p>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
|
|
</div>
|
|
|
|
<div className="section-title">Guest Email Handling</div>
|
|
<div className="field">
|
|
<label>Excluded email domains (OTA/agent relay addresses)</label>
|
|
<input
|
|
type="text" value={settings.excluded_email_domains}
|
|
onChange={e => setSettings(s => ({ ...s, excluded_email_domains: e.target.value }))}
|
|
placeholder="guest.booking.com, m.expediapartnercentral.com"
|
|
/>
|
|
<p className="field-hint">
|
|
Comma-separated. An email at one of these domains never reaches the guest directly, so it's
|
|
never shown as "on file" and the guest is prompted to add their real one instead — matches
|
|
the legacy system's original behaviour. Add any other OTA/agent domain here as they turn up.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="section-title">Terms & Conditions</div>
|
|
<div className="field">
|
|
<label>Text shown on the tablet before signing</label>
|
|
<textarea
|
|
style={{ minHeight: 160 }}
|
|
value={settings.terms_and_conditions_text}
|
|
onChange={e => setSettings(s => ({ ...s, terms_and_conditions_text: e.target.value }))}
|
|
/>
|
|
<p className="field-hint">
|
|
Basic formatting tags are supported and rendered as-is on the tablet — e.g. <code><b></code>,{' '}
|
|
<code><i></code>, <code><br></code>, <code><p></code>, <code><ul>/<li></code>.
|
|
</p>
|
|
</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 }}>
|
|
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>
|
|
{saving ? 'Saving…' : 'Save Settings'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="section-title">Kiosk Branding</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Shown top-center on the tablet's login and welcome screens.
|
|
</p>
|
|
{logoError && <div className="error-banner">{logoError}</div>}
|
|
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 24 }}>
|
|
<div style={{
|
|
width: 64, height: 64, borderRadius: 8, border: '1px solid var(--card-border)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--body-bg)', flexShrink: 0,
|
|
}}>
|
|
{logoExists
|
|
? <img src={logoUrl(logoVersion)} alt="Logo" style={{ maxWidth: '100%', maxHeight: '100%' }} />
|
|
: <ImageIcon size={22} strokeWidth={1.5} color="var(--text-mid)" />}
|
|
</div>
|
|
<div>
|
|
<label className="btn btn-sm" style={{ cursor: 'pointer' }}>
|
|
{logoUploading ? 'Uploading…' : logoExists ? 'Replace logo' : 'Upload logo'}
|
|
<input type="file" accept="image/png,image/jpeg" onChange={handleLogoFile} disabled={logoUploading} style={{ display: 'none' }} />
|
|
</label>
|
|
<p className="field-hint" style={{ marginTop: 6 }}>PNG or JPEG, under 2MB.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section-title">Kiosk Tablets</div>
|
|
<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> —
|
|
entered once per tablet, then left signed in indefinitely (same pattern as hvac's wall panel).
|
|
It only grants the narrow ability to register itself and run the guest check-in flow — never
|
|
Devices, Terminals, Marketing or this Settings page.
|
|
</p>
|
|
{kioskError && <div className="error-banner">{kioskError}</div>}
|
|
{kioskMsg && <div className="ok-banner">{kioskMsg}</div>}
|
|
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
<MonitorCog size={16} strokeWidth={1.75} color={kioskConfigured ? 'var(--health-healthy)' : 'var(--text-mid)'} />
|
|
<span>
|
|
{kioskConfigured == null ? 'Checking…' : kioskConfigured ? 'A kiosk password is configured.' : 'No kiosk password set yet — tablets cannot set themselves up until one is set below.'}
|
|
</span>
|
|
</div>
|
|
<div className="field-row" style={{ marginBottom: 12 }}>
|
|
<div className="field">
|
|
<label>{kioskConfigured ? 'New kiosk password' : 'Kiosk password'}</label>
|
|
<input
|
|
type="password" value={kioskPassword} onChange={e => setKioskPasswordInput(e.target.value)}
|
|
placeholder="At least 6 characters" autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<button className="btn btn-primary" disabled={kioskSaving || kioskPassword.length < 6} onClick={handleSaveKioskPassword}>
|
|
{kioskSaving ? 'Saving…' : kioskConfigured ? 'Change kiosk password' : 'Set kiosk password'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|