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:
parent
a37654cdc4
commit
2b83f47556
15 changed files with 525 additions and 153 deletions
|
|
@ -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 & 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><b></code>,{' '}
|
||||
<code><i></code>, <code><br></code>, <code><p></code>, <code><ul>/<li></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> —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue