Initial scaffold: tablet-checkin app
Staff-push guest check-in tablet. Fastify backend + React/Vite frontend, three-tier auth (staff JWT, terminal API key, kiosk cookie), NewBook integration via bookings_get/guests_update/bookings_update, pdfkit-based regcard generation, marketing opt-in with Sendy/Mailchimp adapters. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
3b2e25df3f
51 changed files with 10296 additions and 0 deletions
161
frontend/src/pages/Settings.tsx
Normal file
161
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { MonitorCog } from 'lucide-react'
|
||||
import {
|
||||
fetchSettings, updateSettings, testNewbookConnection,
|
||||
fetchKioskPasswordStatus, setKioskPassword,
|
||||
} from '../api'
|
||||
import type { AppSettings } from '../types'
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
newbook_hotel_id: '', regcard_custom_field_id: '',
|
||||
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
|
||||
})
|
||||
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('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false))
|
||||
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
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>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">
|
||||
<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">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 }))}
|
||||
/>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue