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

@ -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> —