Kiosk redesign: logo, tablet sizing, 3-step flow, better email/car-reg logic

- Uploadable reception logo (Settings, PNG/JPEG, public GET so the kiosk
  can show it without a staff login) — shown top-center on the kiosk
  login and idle screens, Welcome copy centered underneath.
- Tablet-sized styling pass on all kiosk views for a ~10" tablet (bigger
  touch targets, base text, spacing) rather than the general app's
  desktop-first sizing.
- New booking-confirm step (name/arrive/nights/depart/rate name(s)/rate
  total) replaces the old partial review step.
- Contact details, car registration and marketing merged into one step
  (was 3 separate steps) — 5 steps down to 3 overall.
- Email handling now matches the legacy system's confirmed behaviour:
  excluded-domain (OTA/agent relay) addresses are filtered out of what's
  shown as 'on file' entirely, not just flagged, and a new
  excluded_email_domains Settings field (CSV) replaces the old hardcoded
  domain list so any OTA/agent domain can be added without a code change.
- Single email field design: only pre-filled when there's exactly one
  valid existing email, left blank (not guessed) when there are several —
  marketing opt-in only ever touches whatever's in that one field, never
  blanket-subscribes multiple pre-existing emails (project_checkin memory,
  2026-09-01 'stay in touch' note).
- Car registration now allows multiple additions — add/remove list UI,
  buildContactDetails appends each new one not already on file rather
  than the old single-slot overwrite.
- Signature now required to actually complete (tracked live via
  SignatureCanvas onEnd, not just caught on submit) alongside accepted
  terms — Complete Check-In stays disabled until both are true.
- T&C block is height-capped with its own internal scroll (keeps the step
  on one non-scrolling screen) and now renders basic HTML (<b>, <br>,
  <p>, <ul> etc) instead of plain text — content is admin-authored
  (settings capability required), same trust boundary as any other
  setting. PDF generation strips tags back to plain text since pdfkit
  can't render HTML.
- PDF and booking-confirm screen both surface tariff name(s) (deduped
  across tariffs_quoted in case the rate changed mid-stay) and nights,
  not just the total.
- Also fixed: injected extension buttons could hang forever on 'Loading
  tablets…' with no feedback if the extension was reloaded without also
  refreshing the NewBook tab (stale content script) — now surfaces a
  clear 'Extension was reloaded — refresh this page' message instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 21:21:30 +00:00
parent a37654cdc4
commit 2b83f47556
15 changed files with 525 additions and 153 deletions

View file

@ -84,6 +84,20 @@ export function setKioskPassword(password: string): Promise<{ ok: boolean }> {
return request('/settings/kiosk-password', { method: 'POST', body: JSON.stringify({ password }) })
}
// Reception logo — shown top-center on the kiosk's login/idle screens.
// uploadLogo takes a data URL (image_base64) straight from a <input
// type="file"> read via FileReader, same shape as the signature capture
// already used elsewhere in this app. logoUrl is public (no auth, no
// credentials needed) — the kiosk isn't logged in as staff, and a hotel
// logo isn't sensitive. `v` cache-busts after a re-upload since the URL
// itself never changes.
export function uploadLogo(imageBase64: string): Promise<{ ok: boolean }> {
return request('/settings/logo', { method: 'POST', body: JSON.stringify({ image_base64: imageBase64 }) })
}
export function logoUrl(cacheBust?: number): string {
return `${BASE}/settings/logo${cacheBust ? `?v=${cacheBust}` : ''}`
}
// ── Kiosk (tablet's own persistent session) ─────────────────────────────────
// Deliberately separate from request() above: a 401 here means "the kiosk
// login lapsed (or was never set up)" — the right response is re-showing the

View file

@ -278,22 +278,60 @@ table.data tr:last-child td { border-bottom: none; }
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Kiosk (tablet guest-facing shell) ─────────────────────── */
.kiosk-shell { height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg); overflow-y: auto; }
/* ── Kiosk (tablet guest-facing shell) ────────────────────────
Sized for a ~10" tablet (e.g. Galaxy Tab A7) used as a fixed reception
kiosk, not a phone or a desk-bound admin screen — bigger touch targets
(48px+ min height), bigger base text (kiosk-shell overrides the app-wide
14px html font-size), more generous spacing throughout. */
.kiosk-shell {
height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg);
overflow-y: auto; font-size: 17px;
}
.kiosk-center { align-items: center; justify-content: center; }
.kiosk-login-card {
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 28px 26px; width: 100%; max-width: 340px; text-align: center;
box-shadow: var(--shadow-md); padding: 40px 36px; width: 100%; max-width: 440px; text-align: center;
}
.kiosk-login-card input {
border: 1px solid var(--card-border); border-radius: 8px; padding: 10px 12px; font-size: 16px;
.kiosk-input, .kiosk-shell input[type="text"], .kiosk-shell input[type="email"], .kiosk-shell input[type="password"] {
border: 1px solid var(--card-border); border-radius: 10px; padding: 14px 16px;
font-size: 17px; width: 100%; min-height: 52px;
}
.kiosk-btn, .kiosk-shell .btn {
min-height: 52px; padding: 12px 22px; font-size: 16px; border-radius: 10px;
}
.kiosk-shell .btn-sm { min-height: 40px; padding: 8px 16px; font-size: 14px; }
.kiosk-header {
display: flex; align-items: center; gap: 16px;
padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0;
padding: 18px 24px; background: var(--navy); color: var(--text); flex-shrink: 0;
}
.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); flex-shrink: 0; }
.kiosk-header-info { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; font-size: 12px; color: var(--text-muted); }
.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 19px; color: var(--gold); flex-shrink: 0; }
.kiosk-header-info { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; font-size: 14px; color: var(--text-muted); }
.kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 24px; }
.kiosk-shell .card { padding: 22px 24px; }
.kiosk-shell .section-title { font-size: 15px; margin: 0 0 14px; }
.kiosk-shell .field { margin-bottom: 18px; }
.kiosk-shell .field label { font-size: 14px; margin-bottom: 6px; }
.kiosk-shell .field-check { font-size: 16px; gap: 12px; }
.kiosk-shell .field-check input { width: 22px; height: 22px; }
.kiosk-shell .field-hint { font-size: 13px; }
.kiosk-shell p { font-size: 16px; line-height: 1.5; }
.review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; margin-bottom: 8px; }
.review-row { display: flex; flex-direction: column; gap: 4px; }
.review-label { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--text-mid); }
.review-value { font-size: 19px; font-weight: 600; color: var(--text-dark); }
/* Admin-authored T&C content (may contain <b>/<i>/<br>/<p>/<ul> etc, see
Settings.tsx) rendered via dangerouslySetInnerHTML — max-height + its own
scroll keeps the page itself from needing to scroll on a 10" tablet even
with a long T&C block. */
.terms-box {
max-height: 38vh; overflow-y: auto; font-size: 14px; color: var(--text-mid);
border: 1px solid var(--card-border); border-radius: 8px; padding: 14px 16px; margin-bottom: 16px;
}
.terms-box p { margin: 0 0 10px; font-size: 14px; }
.terms-box ul, .terms-box ol { margin: 0 0 10px; padding-left: 22px; }
.terms-box p:last-child { margin-bottom: 0; }

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { MonitorCog } from 'lucide-react'
import { MonitorCog, Image as ImageIcon } from 'lucide-react'
import {
fetchSettings, updateSettings, testNewbookConnection,
fetchKioskPasswordStatus, setKioskPassword,
fetchKioskPasswordStatus, setKioskPassword, uploadLogo, logoUrl,
} from '../api'
import type { AppSettings } from '../types'
@ -10,6 +10,7 @@ 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: '',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -23,11 +24,45 @@ export default function Settings() {
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 {
@ -104,6 +139,21 @@ export default function Settings() {
<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 &amp; Conditions</div>
<div className="field">
<label>Text shown on the tablet before signing</label>
@ -112,6 +162,10 @@ export default function Settings() {
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>&lt;b&gt;</code>,{' '}
<code>&lt;i&gt;</code>, <code>&lt;br&gt;</code>, <code>&lt;p&gt;</code>, <code>&lt;ul&gt;/&lt;li&gt;</code>.
</p>
</div>
<div style={{ marginBottom: 24 }}>
@ -120,6 +174,29 @@ export default function 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> —

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import SignatureCanvas from 'react-signature-canvas'
import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertTriangle } from 'lucide-react'
import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertTriangle, Plus } from 'lucide-react'
import { fetchCheckinToken, submitCheckin, kioskReset } from '../../api'
import type { CheckinTokenData } from '../../types'
@ -11,7 +11,11 @@ import type { CheckinTokenData } from '../../types'
const INACTIVITY_MS = 5 * 60 * 1000
const COMPLETE_AUTO_RETURN_MS = 15 * 1000
type Step = 'loading' | 'error' | 'review' | 'contact' | 'carrego' | 'marketing' | 'terms' | 'submitting' | 'complete'
// 3 real steps (down from 5, 2026-09-01) — contact/car-reg/marketing merged
// into one page ('details'), and 'review' is now a proper booking
// acknowledgement (name/arrive/nights/depart/rate) rather than a partial
// summary.
type Step = 'loading' | 'error' | 'review' | 'details' | 'terms' | 'submitting' | 'complete'
export default function CheckinFlow({ token, deviceId, onDone }: { token: string; deviceId: string; onDone: () => void }) {
const [step, setStep] = useState<Step>('loading')
@ -19,9 +23,11 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
const [loadError, setLoadError] = useState('')
const [email, setEmail] = useState('')
const [carRego, setCarRego] = useState('')
const [newCarRego, setNewCarRego] = useState('')
const [addedCarRegos, setAddedCarRegos] = useState<string[]>([])
const [optedIn, setOptedIn] = useState(false)
const [acceptedTerms, setAcceptedTerms] = useState(false)
const [hasSignature, setHasSignature] = useState(false)
const [submitError, setSubmitError] = useState('')
const sigRef = useRef<SignatureCanvas>(null)
@ -31,14 +37,15 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
fetchCheckinToken(token)
.then(d => {
setData(d)
// Gap-fill: prefer a real (non-relay) existing email; a relay-domain
// address is deliberately NOT prefilled as-is, so the guest is
// prompted to supply a real one instead (see the shared gap-fill
// note in project_checkin — this belongs here, not just the online
// path, so the tablet inherits it automatically).
const realEmail = d.guest.existing_emails.find(e => !e.is_relay)
setEmail(realEmail?.content || '')
setCarRego(d.guest.car_rego || '')
// Gap-fill: only ever pre-fill when there's exactly one valid
// (non-excluded) email on file — with more than one there's no way
// to guess which the guest wants associated with this session, so
// it's left blank rather than guessing (see project_checkin's
// "stay in touch shouldn't blanket-subscribe every email on file"
// note, 2026-09-01 — this single-field design is what makes that
// guarantee trivial: marketing opt-in only ever touches whatever's
// in this one field at submit time, never any other existing email).
if (d.guest.existing_emails.length === 1) setEmail(d.guest.existing_emails[0].content)
setStep('review')
})
.catch(err => {
@ -75,6 +82,19 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
onDone()
}
function addCarRego() {
const trimmed = newCarRego.trim()
if (!trimmed) return
const existing = data?.guest.car_regos || []
const already = [...existing, ...addedCarRegos].some(r => r.toLowerCase() === trimmed.toLowerCase())
if (!already) setAddedCarRegos(list => [...list, trimmed])
setNewCarRego('')
}
function removeCarRego(reg: string) {
setAddedCarRegos(list => list.filter(r => r !== reg))
}
async function handleSubmit() {
if (!acceptedTerms) return
const sig = sigRef.current
@ -86,7 +106,7 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
setSubmitError('')
try {
await submitCheckin(token, {
email, car_rego: carRego, opted_in: optedIn, accepted_terms: acceptedTerms,
email, car_regos: addedCarRegos, opted_in: optedIn, accepted_terms: acceptedTerms,
signature_png_base64: sig.getTrimmedCanvas().toDataURL('image/png'),
})
setStep('complete')
@ -103,9 +123,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
if (step === 'error') {
return (
<div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center', maxWidth: 360 }}>
<AlertTriangle size={40} strokeWidth={1.5} color="var(--danger)" style={{ marginBottom: 12 }} />
<p style={{ color: 'var(--text-dark)', marginBottom: 16 }}>{loadError}</p>
<div style={{ textAlign: 'center', maxWidth: 400 }}>
<AlertTriangle size={48} strokeWidth={1.5} color="var(--danger)" style={{ marginBottom: 14 }} />
<p style={{ color: 'var(--text-dark)', marginBottom: 20 }}>{loadError}</p>
<button className="btn btn-primary" onClick={onDone}>Back to start</button>
</div>
</div>
@ -114,6 +134,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
if (!data) return null
const existingEmailCount = data.guest.existing_emails.length
const allCarRegos = [...data.guest.car_regos, ...addedCarRegos]
return (
<div className="kiosk-shell">
<header className="kiosk-header">
@ -128,108 +151,131 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
)}
</header>
<div className="kiosk-grid-wrap" style={{ maxWidth: 640, margin: '0 auto', width: '100%' }}>
<div className="kiosk-grid-wrap" style={{ maxWidth: 680, margin: '0 auto', width: '100%' }}>
{step === 'review' && (
<div className="card">
<div className="section-title">Booking Summary</div>
<p><strong>Arrive:</strong> {formatDate(data.booking.arrival)}</p>
<p><strong>Depart:</strong> {formatDate(data.booking.departure)}</p>
<p><strong>Room:</strong> {data.booking.site_name || '—'}</p>
<p><strong>Guest:</strong> {data.guest.name || '—'}</p>
<StepNav onNext={() => setStep('contact')} />
<div className="section-title">Please Confirm Your Booking</div>
<div className="review-grid">
<ReviewRow label="Name" value={data.guest.name || '—'} />
<ReviewRow label="Arrive" value={formatDate(data.booking.arrival)} />
<ReviewRow label="Nights" value={data.booking.nights != null ? String(data.booking.nights) : '—'} />
<ReviewRow label="Depart" value={formatDate(data.booking.departure)} />
<ReviewRow label="Rate" value={data.booking.tariff_names.length ? data.booking.tariff_names.join(', ') : '—'} />
<ReviewRow label="Total" value={formatMoney(data.booking.tariff_total)} />
</div>
<StepNav onNext={() => setStep('details')} />
</div>
)}
{step === 'contact' && (
{step === 'details' && (
<div className="card">
<div className="section-title">Contact Details</div>
<div className="section-title">Your Details</div>
{/* Existing emails shown read-only, same "can't remove/replace,
only add" convention as the legacy plugin's page 3 (see
project_checkin_legacy_plugin) — this app simplifies the
legacy form's 3-slot progressive "add another" UI down to one
add/correct field below, since v1's actual goal is just
capturing one good direct email per guest, not managing an
arbitrary list. */}
{data.guest.existing_emails.length > 0 && (
<div className="field">
<label>Email(s) already on file</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{data.guest.existing_emails.map((e, i) => (
<div key={i} style={{ fontSize: 13, color: e.is_relay ? 'var(--health-degraded)' : 'var(--text-dark)' }}>
{e.content}{e.is_relay && ' (booking-site relay — won\'t reach the guest directly)'}
</div>
))}
</div>
</div>
)}
{data.guest.existing_emails.some(ex => ex.is_relay) && (
{data.guest.had_excluded_email && (
<div className="error-banner" style={{ background: 'var(--warn-bg)', borderColor: 'var(--health-degraded)', color: '#92400e' }}>
The email we have on file looks like it came via a booking site and won't reach you directly —
please add your own email below.
please add your own below.
</div>
)}
<div className="field">
<label>{data.guest.existing_emails.length > 0 ? 'Add or correct email' : 'Email address'}</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="you@example.com" />
<p className="field-hint">Existing emails on file are kept — this adds a new one or corrects the flag on a matching one, it never removes anything.</p>
<label>
{existingEmailCount === 0 ? 'Your email address' : existingEmailCount === 1 ? 'Confirm your email' : 'Add an email'}
</label>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="you@example.com" required={existingEmailCount === 0}
/>
{existingEmailCount > 1 && (
<p className="field-hint">
On file: {data.guest.existing_emails.map(e => e.content).join(', ')}. Add a different one here if
you'd like, or leave blank to keep what's on file.
</p>
)}
</div>
<p className="field-hint">Name, address and phone on file: {data.guest.name}, {data.guest.address || '—'}, {data.guest.phone || '—'}. Ask reception to correct any of these.</p>
<StepNav onBack={() => setStep('review')} onNext={() => setStep('carrego')} />
</div>
)}
<p className="field-hint" style={{ marginBottom: 20 }}>
Address and phone on file: {data.guest.address || '—'}, {data.guest.phone || '—'}. Ask reception to
correct any of these.
</p>
{step === 'carrego' && (
<div className="card">
<div className="section-title">Car Registration</div>
<div className="field">
<label>Registration (leave blank if not driving)</label>
<input type="text" value={carRego} onChange={e => setCarRego(e.target.value.toUpperCase())} placeholder="e.g. AB12 CDE" />
{data.guest.car_regos.length > 0 && (
<p className="field-hint" style={{ marginBottom: 8 }}>Already on file: {data.guest.car_regos.join(', ')}</p>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input
type="text" value={newCarRego} placeholder="e.g. AB12 CDE"
onChange={e => setNewCarRego(e.target.value.toUpperCase())}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addCarRego() } }}
style={{ flex: 1 }}
/>
<button className="btn" onClick={addCarRego} type="button"><Plus size={16} strokeWidth={1.75} /> Add</button>
</div>
<StepNav onBack={() => setStep('contact')} onNext={() => setStep('marketing')} />
</div>
)}
{addedCarRegos.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{addedCarRegos.map(reg => (
<span key={reg} className="badge badge-outline" style={{ fontSize: 13, padding: '5px 10px' }}>
{reg}
<button
onClick={() => removeCarRego(reg)} type="button" aria-label={`Remove ${reg}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 0, marginLeft: 4, display: 'inline-flex' }}
>
<X size={13} strokeWidth={2} />
</button>
</span>
))}
</div>
)}
<p className="field-hint" style={{ marginBottom: 20 }}>Multiple vehicles? Add each one — leave blank if not driving.</p>
{step === 'marketing' && (
<div className="card">
<div className="section-title">Stay in Touch</div>
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={optedIn} onChange={e => setOptedIn(e.target.checked)} />
<label className="field-check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={optedIn} onChange={e => setOptedIn(e.target.checked)} disabled={!email} />
<span>Yes, send me offers and news by email</span>
</label>
<StepNav onBack={() => setStep('carrego')} onNext={() => setStep('terms')} />
{!email && <p className="field-hint" style={{ marginBottom: 14 }}>Add an email above first.</p>}
<StepNav onBack={() => setStep('review')} onNext={() => setStep('terms')} />
</div>
)}
{step === 'terms' && (
<div className="card">
<div className="section-title">Terms &amp; Conditions</div>
<div style={{
maxHeight: 160, overflowY: 'auto', fontSize: 12.5, color: 'var(--text-mid)',
border: '1px solid var(--card-border)', borderRadius: 8, padding: 10, marginBottom: 12, whiteSpace: 'pre-wrap',
}}>
{data.terms_text}
</div>
<label className="field-check" style={{ marginBottom: 14 }}>
{/* Admin-authored content (settings cap required to edit) —
trusted to render as HTML, same trust boundary as any other
setting. max-height + its own scroll keeps this contained to
one screen on a 10" tablet even with a long T&C block,
rather than the whole page needing to scroll. */}
<div
className="terms-box"
dangerouslySetInnerHTML={{ __html: data.terms_text }}
/>
<label className="field-check" style={{ marginBottom: 18 }}>
<input type="checkbox" checked={acceptedTerms} onChange={e => setAcceptedTerms(e.target.checked)} />
<span>I have read and accept the terms &amp; conditions above</span>
</label>
<div className="section-title">Signature</div>
<div style={{ border: '1px solid var(--card-border)', borderRadius: 8, background: '#fff', marginBottom: 8 }}>
<div style={{ border: '1px solid var(--card-border)', borderRadius: 8, background: '#fff', marginBottom: 10 }}>
<SignatureCanvas
ref={sigRef}
penColor="#1a1a2e"
canvasProps={{ width: 560, height: 160, style: { width: '100%', height: 160, touchAction: 'none' } }}
onEnd={() => setHasSignature(!sigRef.current?.isEmpty())}
canvasProps={{ width: 620, height: 180, style: { width: '100%', height: 180, touchAction: 'none' } }}
/>
</div>
<button className="btn btn-sm" onClick={() => sigRef.current?.clear()} style={{ marginBottom: 12 }}>Clear signature</button>
<button
className="btn btn-sm" type="button" style={{ marginBottom: 14 }}
onClick={() => { sigRef.current?.clear(); setHasSignature(false) }}
>
Clear signature
</button>
{submitError && <div className="error-banner">{submitError}</div>}
<div className="modal-actions" style={{ justifyContent: 'space-between' }}>
<button className="btn" onClick={() => setStep('marketing')}><ChevronLeft size={14} strokeWidth={1.75} /> Back</button>
<button className="btn btn-primary" disabled={!acceptedTerms} onClick={handleSubmit}>
<button className="btn" onClick={() => setStep('details')}><ChevronLeft size={14} strokeWidth={1.75} /> Back</button>
<button className="btn btn-primary" disabled={!acceptedTerms || !hasSignature} onClick={handleSubmit}>
Complete Check-In <ChevronRight size={14} strokeWidth={1.75} />
</button>
</div>
@ -237,17 +283,17 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
)}
{step === 'submitting' && (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<div className="card" style={{ textAlign: 'center', padding: '48px 16px' }}>
<p className="muted">Submitting your details…</p>
</div>
)}
{step === 'complete' && (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<CheckCircle2 size={48} strokeWidth={1.5} color="var(--health-healthy)" style={{ marginBottom: 12 }} />
<h2 style={{ fontSize: 18, margin: '0 0 6px' }}>You're checked in!</h2>
<div className="card" style={{ textAlign: 'center', padding: '48px 16px' }}>
<CheckCircle2 size={56} strokeWidth={1.5} color="var(--health-healthy)" style={{ marginBottom: 14 }} />
<h2 style={{ fontSize: 22, margin: '0 0 8px' }}>You're checked in!</h2>
<p className="muted">Enjoy your stay. Please see reception for your key.</p>
<button className="btn btn-primary" style={{ marginTop: 20 }} onClick={onDone}>Done</button>
<button className="btn btn-primary" style={{ marginTop: 24 }} onClick={onDone}>Done</button>
</div>
)}
</div>
@ -255,6 +301,15 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
)
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="review-row">
<span className="review-label">{label}</span>
<span className="review-value">{value}</span>
</div>
)
}
function StepNav({ onBack, onNext }: { onBack?: () => void; onNext: () => void }) {
return (
<div className="modal-actions" style={{ justifyContent: onBack ? 'space-between' : 'flex-end' }}>
@ -266,11 +321,9 @@ function StepNav({ onBack, onNext }: { onBack?: () => void; onNext: () => void }
function stepLabel(step: Step): string {
switch (step) {
case 'review': return 'Step 1 of 5 · Booking'
case 'contact': return 'Step 2 of 5 · Contact details'
case 'carrego': return 'Step 3 of 5 · Car registration'
case 'marketing': return 'Step 4 of 5 · Marketing'
case 'terms': return 'Step 5 of 5 · Terms & signature'
case 'review': return 'Step 1 of 3 · Confirm booking'
case 'details': return 'Step 2 of 3 · Your details'
case 'terms': return 'Step 3 of 3 · Terms & signature'
default: return ''
}
}
@ -280,3 +333,9 @@ function formatDate(v: string | null): string {
const d = new Date(v)
return isNaN(d.getTime()) ? v : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatMoney(v: string | number | null): string {
if (v == null || v === '') return '—'
const n = Number(v)
return isNaN(n) ? String(v) : `£${n.toFixed(2)}`
}

View file

@ -1,9 +1,24 @@
import { useEffect, useRef, useState } from 'react'
import { UserCheck } from 'lucide-react'
import { kioskLogin, kioskRegisterDevice, kioskHeartbeat, kioskStreamUrl, kioskReset, KioskSessionError } from '../../api'
import { kioskLogin, kioskRegisterDevice, kioskHeartbeat, kioskStreamUrl, kioskReset, logoUrl, KioskSessionError } from '../../api'
import type { Device } from '../../types'
import CheckinFlow from './CheckinFlow'
// Public endpoint, no auth needed (see backend/src/routes/logo.js) — shown
// top-center on the login and idle screens per the 2026-09-01 request.
// Rendered with onError hiding it entirely rather than showing a broken-
// image icon when nothing's been uploaded yet.
function KioskLogo() {
const [failed, setFailed] = useState(false)
if (failed) return null
return (
<img
src={logoUrl()} alt="" onError={() => setFailed(true)}
style={{ maxWidth: 220, maxHeight: 96, marginBottom: 20 }}
/>
)
}
const DEVICE_ID_KEY = 'tablet_checkin_device_id'
const HEARTBEAT_MS = 60_000
@ -142,19 +157,20 @@ export default function Kiosk() {
if (view === 'login') {
return (
<div className="kiosk-shell kiosk-center">
<div className="kiosk-login-card">
<UserCheck size={36} strokeWidth={1.75} color="var(--app-primary)" style={{ marginBottom: 10 }} />
<h1 style={{ fontSize: 18, margin: '0 0 4px' }}>Tablet Check-In</h1>
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>
<div className="kiosk-login-card" style={{ textAlign: 'center' }}>
<KioskLogo />
<UserCheck size={44} strokeWidth={1.5} color="var(--app-primary)" style={{ marginBottom: 12 }} />
<h1 style={{ fontSize: 24, margin: '0 0 6px' }}>Tablet Check-In</h1>
<p className="muted" style={{ fontSize: 16, marginBottom: 22 }}>
Enter the kiosk password to set up this tablet.
</p>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Kiosk password" autoFocus required
placeholder="Kiosk password" autoFocus required className="kiosk-input"
/>
{loginError && <div className="error-banner">{loginError}</div>}
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
<button className="btn btn-primary kiosk-btn" type="submit" disabled={loginLoading}>
{loginLoading ? 'Signing in…' : 'Continue'}
</button>
</form>
@ -171,10 +187,11 @@ export default function Kiosk() {
return (
<div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center' }}>
<UserCheck size={56} strokeWidth={1.25} color="var(--app-primary)" style={{ marginBottom: 16, opacity: 0.6 }} />
<h1 style={{ fontSize: 22, margin: '0 0 6px', color: 'var(--text-dark)' }}>Welcome</h1>
<p className="muted">Please see reception to begin your check-in.</p>
{device && <p className="muted" style={{ fontSize: 11, marginTop: 24 }}>{device.name}</p>}
<KioskLogo />
<UserCheck size={72} strokeWidth={1.25} color="var(--app-primary)" style={{ marginBottom: 20, opacity: 0.6 }} />
<h1 style={{ fontSize: 32, margin: '0 0 10px', color: 'var(--text-dark)' }}>Welcome</h1>
<p className="muted" style={{ fontSize: 18 }}>Please see reception to begin your check-in.</p>
{device && <p className="muted" style={{ fontSize: 13, marginTop: 32 }}>{device.name}</p>}
</div>
</div>
)

View file

@ -85,6 +85,7 @@ export interface AppSettings {
regcard_custom_field_id: string
online_checkin_complete_custom_field_id: string
terms_and_conditions_text: string
excluded_email_domains: string
}
// ── Guest-facing token flow (routes/checkin.js) ─────────────────────────────
@ -94,12 +95,16 @@ export interface CheckinBooking {
departure: string | null
site_name: string | null
tariff_total: number | string | null
tariff_names: string[]
nights: number | null
}
// No is_relay flag any more — excluded-domain emails are filtered out
// server-side entirely (matches the legacy system's confirmed behaviour,
// see project_checkin_legacy_plugin), not just flagged. had_excluded_email
// on CheckinGuest below covers "was something hidden" without exposing it.
export interface ExistingEmail {
content: string
is_relay: boolean
}
export interface CheckinGuest {
@ -108,7 +113,8 @@ export interface CheckinGuest {
address: string
phone: string
existing_emails: ExistingEmail[]
car_rego: string
had_excluded_email: boolean
car_regos: string[]
}
export interface CheckinTokenData {
@ -119,7 +125,7 @@ export interface CheckinTokenData {
export interface SubmitCheckinPayload {
email: string
car_rego: string
car_regos: string[]
opted_in: boolean
accepted_terms: boolean
signature_png_base64: string