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

@ -109,9 +109,13 @@ async function handleSendClick(anchorBtn, bookingId, guestName, variant) {
if (defaultDeviceId) {
const status = showInlineStatus(anchorBtn, variant, 'Sending…');
const result = await chrome.runtime.sendMessage({
action: 'tcpPush', bookingId, deviceId: defaultDeviceId,
});
let result;
try {
result = await chrome.runtime.sendMessage({ action: 'tcpPush', bookingId, deviceId: defaultDeviceId });
} catch {
status.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return;
}
if (result?.ok) {
status.textContent = 'Sent — the tablet should navigate now.';
status.style.color = '#166534';
@ -169,7 +173,20 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', n
anchorBtn.insertAdjacentElement('afterend', panel);
openPanel = panel;
const res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
// A stale content script (injected before the extension was last
// reloaded) throws "Extension context invalidated" the moment it touches
// chrome.runtime here — sendMessage() rejects rather than hanging, but an
// uncaught rejection previously left the panel stuck on "Loading
// tablets…" forever with no feedback at all. Reloading the extension does
// NOT re-inject content scripts into already-open tabs; only a full page
// refresh does that (hit for real 2026-09-01, see project_checkin memory).
let res;
try {
res = await chrome.runtime.sendMessage({ action: 'tcpListDevices' });
} catch {
panel.textContent = 'Extension was reloaded — refresh this NewBook page and try again.';
return;
}
if (!res?.ok) {
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return;

View file

@ -123,6 +123,13 @@ const SETTINGS_DEFAULTS = {
regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '',
kiosk_password_hash: '',
// Comma-separated domains treated as OTA/agent relay addresses — an email
// at one of these never actually reaches the guest, so it's excluded from
// what's shown as "on file" and the guest is prompted for a real one.
// Seeded with the two confirmed from the legacy plugin (see
// project_checkin_legacy_plugin) but editable from Settings so any other
// OTA/agent domain can be added without a code change.
excluded_email_domains: 'guest.booking.com,m.expediapartnercentral.com',
}
async function seedSettings() {

View file

@ -11,8 +11,13 @@ import { marketingRoutes } from './routes/marketing.js'
import { settingsRoutes } from './routes/settings.js'
import { kioskRoutes } from './routes/kiosk.js'
import { extensionRoutes } from './routes/extension.js'
import { logoRoutes } from './routes/logo.js'
const app = Fastify({ logger: true, trustProxy: true })
// Fastify's default bodyLimit (1MB) is too small for the logo upload — a
// 2MB image becomes a ~2.7MB base64 JSON payload (see routes/logo.js's own
// 2MB source-file cap). nginx's client_max_body_size (12m) already allows
// for this; this just needs to not reject it before nginx's limit matters.
const app = Fastify({ logger: true, trustProxy: true, bodyLimit: 4 * 1024 * 1024 })
const startedAt = Date.now()
await app.register(cookie)
@ -32,6 +37,7 @@ await app.register(marketingRoutes)
await app.register(settingsRoutes)
await app.register(kioskRoutes)
await app.register(extensionRoutes)
await app.register(logoRoutes)
try {
await initDb()

View file

@ -97,18 +97,21 @@ export async function setBookingCustomFields(bookingId, fields) {
return callApi('bookings_update', { booking_id: bookingId, custom_fields })
}
// Known OTA relay-email domains — a guest's real inbox is never actually
// behind one of these, so it's flagged for the shared gap-fill step to prompt
// a correction rather than silently accepting it as "the guest's email".
// Mirrors the legacy plugin's filtering (see project_checkin_legacy_plugin) —
// deliberately a shared helper (not duplicated per-route) so both the tablet
// and any future online-checkin path inherit new domains added here for free
// (see project_checkin's "Flow-parity risk" note).
export const OTA_RELAY_EMAIL_DOMAINS = ['guest.booking.com', 'm.expediapartnercentral.com']
export function isRelayEmail(email) {
// OTA/agent relay-email detection — a guest's real inbox is never actually
// behind one of these domains, so it's excluded from what's shown as "on
// file" and the guest is prompted for a real one. Mirrors the legacy
// plugin's filtering (see project_checkin_legacy_plugin), including its
// "only show emails that weren't excluded, require at least one after
// exclusions" behavior (confirmed by the user 2026-09-01) — this app
// exposes the domain list as the `excluded_email_domains` setting rather
// than hardcoding it, so any OTA/agent domain can be added without a code
// change. `excludedDomains` is the caller's already-parsed array (from
// that setting) — kept a plain sync function, not reaching into settings
// itself, so callers control exactly when/how often it's read.
export function isRelayEmail(email, excludedDomains) {
const domain = String(email || '').split('@')[1]?.toLowerCase()
return !!domain && OTA_RELAY_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith(`.${d}`))
if (!domain || !Array.isArray(excludedDomains)) return false
return excludedDomains.some(d => domain === d || domain.endsWith(`.${d}`))
}
// Picks the guest whose contact details this session edits. Tablet check-in
@ -155,11 +158,19 @@ export function getContactByType(contactDetails, types) {
// Builds an updated contact_details array for guests_update — additive only
// (existing entries are never removed/replaced, matching the legacy system's
// explicit "can't remove/replace, only add" convention). Updates
// allow_marketing on a matching existing email write-only (see
// project_checkin's marketing opt-in decision: never read back, just kept
// accurate going forward).
export function buildContactDetails(existing, { email, carRego, optedIn }) {
// explicit "can't remove/replace, only add" convention, and matching
// NewBook's own API — it has no edit, only add). Updates allow_marketing on
// a matching existing email write-only (see project_checkin's marketing
// opt-in decision: never read back, just kept accurate going forward).
// `email` is deliberately singular — the guest-facing form only ever
// collects one email per session (see project_checkin's "stay in touch
// shouldn't blanket-subscribe every email on file" note, 2026-09-01), so
// marketing consent only ever touches this one entry, never any other
// pre-existing email. `carRegos` is plural — car reg allows multiple
// additions (2026-09-01 request), so each one not already on file (a
// case-insensitive match against existing car_rego content) gets appended
// as its own new entry rather than the old single-slot overwrite behavior.
export function buildContactDetails(existing, { email, carRegos, optedIn }) {
const details = Array.isArray(existing) ? [...existing] : []
if (email) {
@ -171,12 +182,12 @@ export function buildContactDetails(existing, { email, carRego, optedIn }) {
}
}
if (carRego) {
const idx = details.findIndex(d => d.type === 'car_rego')
if (idx >= 0) {
details[idx] = { ...details[idx], content: carRego }
} else {
details.push({ type: 'car_rego', content: carRego, allow_transactional: 1, allow_marketing: 0 })
for (const reg of (carRegos || [])) {
const trimmed = String(reg || '').trim()
if (!trimmed) continue
const alreadyOnFile = details.some(d => d.type === 'car_rego' && String(d.content).toLowerCase() === trimmed.toLowerCase())
if (!alreadyOnFile) {
details.push({ type: 'car_rego', content: trimmed, allow_transactional: 1, allow_marketing: 0 })
}
}

View file

@ -13,7 +13,7 @@ export function generateRegcardPdf({
booking,
guest,
contactEmail,
carRego,
carRegos,
optedIn,
termsText,
signaturePngBase64,
@ -38,8 +38,11 @@ export function generateRegcardPdf({
doc.moveDown(0.3)
row(doc, 'Booking #', String(booking.booking_id ?? ''))
row(doc, 'Arrive', formatDate(booking.booking_arrival))
row(doc, 'Nights', String(booking.booking_length ?? '—'))
row(doc, 'Depart', formatDate(booking.booking_departure))
row(doc, 'Room / Site', String(booking.site_name ?? ''))
row(doc, 'Rate', tariffNamesText(booking))
row(doc, 'Rate Total', String(booking.tariff_total ?? '—'))
doc.moveDown(1)
// ── Guest details ────────────────────────────────────────────────────
@ -50,14 +53,18 @@ export function generateRegcardPdf({
row(doc, 'Address', String(guest?.address ?? ''))
row(doc, 'Phone', String(guest?.phone ?? ''))
row(doc, 'Email', String(contactEmail ?? ''))
row(doc, 'Car Registration', String(carRego ?? '—'))
row(doc, 'Car Registration', (carRegos && carRegos.length) ? carRegos.join(', ') : '—')
row(doc, 'Marketing opt-in', optedIn ? 'Yes' : 'No')
doc.moveDown(1)
// ── T&Cs ──────────────────────────────────────────────────────────────
// termsText may contain basic HTML (<b>, <br> etc — the kiosk renders it
// directly, see CheckinFlow.tsx) since it's admin-authored, not guest
// input. pdfkit has no HTML renderer, so strip tags here rather than
// showing literal markup in the printed/stored record.
doc.fontSize(12).fillColor('#1a1a2e').text('Terms & Conditions')
doc.moveDown(0.3)
doc.fontSize(8.5).fillColor('#475569').text(termsText || 'No terms text configured.', {
doc.fontSize(8.5).fillColor('#475569').text(stripHtml(termsText) || 'No terms text configured.', {
align: 'justify',
})
doc.moveDown(1.5)
@ -93,3 +100,19 @@ function formatDate(v) {
const d = new Date(v)
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
}
function tariffNamesText(booking) {
const quoted = Array.isArray(booking.tariffs_quoted) ? booking.tariffs_quoted : []
const labels = [...new Set(quoted.map(t => t.label).filter(Boolean))]
if (labels.length) return labels.join(', ')
return booking.tariff_name || '—'
}
function stripHtml(html) {
return String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
.trim()
}

View file

@ -3,7 +3,7 @@
// public URL: retrieval goes through an admin-capped, token-checked route
// (routes/checkins.js GET /api/checkins/:id/pdf) since the PDF holds guest
// PII + a signature image.
import { mkdir, writeFile } from 'fs/promises'
import { mkdir, writeFile, unlink, access } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
@ -24,3 +24,39 @@ export async function saveRegcardPdf(checkinId, buffer) {
export function absolutePath(relPath) {
return join(UPLOADS_DIR, relPath)
}
// Reception logo — one active file at a time (not versioned/per-checkin
// like regcards), served publicly (a hotel logo isn't sensitive) for the
// kiosk's idle/login screens. Only PNG/JPEG accepted; storing under a
// fixed name per extension rather than a generic "logo" file with no
// extension, so the serve route can set an honest Content-Type without
// needing to sniff file contents.
const LOGO_EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg' }
export async function saveLogo(buffer, mimeType) {
const ext = LOGO_EXTENSIONS[mimeType]
if (!ext) throw new Error('Logo must be PNG or JPEG')
await mkdir(UPLOADS_DIR, { recursive: true })
// Clear any previously-saved logo under a different extension first, so
// switching from a .png to a .jpg (or back) doesn't leave a stale file
// that findLogo() could pick up in preference to the new one.
for (const otherExt of Object.values(LOGO_EXTENSIONS)) {
if (otherExt !== ext) await unlink(join(UPLOADS_DIR, `logo.${otherExt}`)).catch(() => {})
}
const relPath = `logo.${ext}`
await writeFile(join(UPLOADS_DIR, relPath), buffer)
return relPath
}
// Returns { path, mimeType } for whichever logo file currently exists, or
// null if none has been uploaded yet.
export async function findLogo() {
for (const [mimeType, ext] of Object.entries(LOGO_EXTENSIONS)) {
const path = join(UPLOADS_DIR, `logo.${ext}`)
try {
await access(path)
return { path, mimeType }
} catch { /* try next extension */ }
}
return null
}

View file

@ -33,10 +33,18 @@ export async function checkinRoutes(app) {
const guest = pickPrimaryGuest(booking)
const contactDetails = Array.isArray(guest?.contact_details) ? guest.contact_details : []
const existingEmails = contactDetails
.filter(d => d.type === 'email')
.map(d => ({ content: d.content, is_relay: isRelayEmail(d.content) }))
const existingCarRego = contactDetails.find(d => d.type === 'car_rego')?.content || ''
const excludedDomains = await getExcludedDomains()
// Matches the legacy system's confirmed behaviour (2026-09-01, see
// project_checkin_legacy_plugin): excluded-domain emails aren't shown as
// "on file" at all, not just flagged — hadExcludedEmail tells the
// frontend whether to explain *why* nothing (or fewer than expected)
// showed up, without exposing the actual excluded address.
const allEmails = contactDetails.filter(d => d.type === 'email')
const existingEmails = allEmails
.filter(d => !isRelayEmail(d.content, excludedDomains))
.map(d => ({ content: d.content }))
const hadExcludedEmail = allEmails.length > existingEmails.length
const existingCarRegos = contactDetails.filter(d => d.type === 'car_rego').map(d => d.content)
return {
booking: {
@ -45,6 +53,7 @@ export async function checkinRoutes(app) {
departure: booking.booking_departure ?? null,
site_name: booking.site_name ?? null,
tariff_total: booking.tariff_total ?? null,
tariff_names: extractTariffNames(booking),
nights: booking.booking_length ?? null,
},
guest: {
@ -53,14 +62,15 @@ export async function checkinRoutes(app) {
address: formatGuestAddress(guest),
phone: getContactByType(contactDetails, ['mobile', 'phone']),
existing_emails: existingEmails,
car_rego: existingCarRego,
had_excluded_email: hadExcludedEmail,
car_regos: existingCarRegos,
},
terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
}
})
app.post('/api/checkin/:token/submit', async (req, reply) => {
const { email, car_rego, opted_in, signature_png_base64, accepted_terms } = req.body || {}
const { email, car_regos, opted_in, signature_png_base64, accepted_terms } = req.body || {}
if (!signature_png_base64 || !accepted_terms) {
return reply.status(400).send({ error: 'Signature and accepted terms are required' })
}
@ -90,7 +100,7 @@ export async function checkinRoutes(app) {
const contactDetails = buildContactDetails(guest.contact_details, {
email: email?.trim() || null,
carRego: car_rego?.trim() || null,
carRegos: Array.isArray(car_regos) ? car_regos : [],
optedIn: !!opted_in,
})
@ -135,7 +145,7 @@ export async function checkinRoutes(app) {
booking,
guest: { name: guestName, address: formatGuestAddress(guest), phone: getContactByType(guest.contact_details, ['mobile', 'phone']) },
contactEmail: email,
carRego: car_rego,
carRegos: Array.isArray(car_regos) ? car_regos : [],
optedIn: !!opted_in,
termsText: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
signaturePngBase64: signature_png_base64,
@ -177,6 +187,23 @@ export async function checkinRoutes(app) {
})
}
async function getExcludedDomains() {
const raw = await getSetting('excluded_email_domains', '')
return raw.split(',').map(d => d.trim().toLowerCase()).filter(Boolean)
}
// "Rate type name(s)" for the booking-confirm step — a stay can run across
// more than one tariff if the rate changed mid-stay, so this surfaces every
// distinct label actually charged rather than just the single top-level
// tariff_name (which reflects whichever tariff applies to the first night).
// Falls back to that single field if tariffs_quoted isn't present at all.
function extractTariffNames(booking) {
const quoted = Array.isArray(booking?.tariffs_quoted) ? booking.tariffs_quoted : []
const labels = [...new Set(quoted.map(t => t.label).filter(Boolean))]
if (labels.length) return labels
return booking?.tariff_name ? [booking.tariff_name] : []
}
async function loadSession(token) {
const { rows } = await pool.query('SELECT * FROM checkin_sessions WHERE token = $1', [token])
if (!rows.length) return { ok: false, status: 404, error: 'Check-in link not found' }

View file

@ -0,0 +1,34 @@
import { createReadStream } from 'fs'
import { requireAuth, requireCap } from '../auth.js'
import { saveLogo, findLogo } from '../lib/uploads.js'
// Deliberately its own route file, not part of settingsRoutes — the upload
// is admin-capped like every other setting, but the serve route must be
// genuinely public (no auth at all): the kiosk needs to show the logo on
// its idle/login screens, which run on a separate cookie from the staff
// session entirely, and a hotel logo isn't sensitive information anyway
// (same reasoning as the app's own static icons being public).
export async function logoRoutes(app) {
app.post('/api/settings/logo', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => {
const { image_base64 } = req.body || {}
if (!image_base64) return reply.status(400).send({ error: 'image_base64 is required' })
const match = String(image_base64).match(/^data:(image\/png|image\/jpeg);base64,(.+)$/)
if (!match) return reply.status(400).send({ error: 'Must be a PNG or JPEG data URL' })
const [, mimeType, b64] = match
const buffer = Buffer.from(b64, 'base64')
if (buffer.length > 2 * 1024 * 1024) return reply.status(400).send({ error: 'Logo must be under 2MB' })
await saveLogo(buffer, mimeType)
return { ok: true }
})
app.get('/api/settings/logo', async (req, reply) => {
const logo = await findLogo()
if (!logo) return reply.status(404).send({ error: 'No logo uploaded yet' })
reply.header('Content-Type', logo.mimeType)
reply.header('Cache-Control', 'no-cache') // small file, always want the latest after a re-upload
return reply.send(createReadStream(logo.path))
})
}

View file

@ -10,7 +10,7 @@ import { testConnection } from '../lib/newbook.js'
// below (mirrors hvac's Settings -> Kiosk pattern).
const ALLOWED_KEYS = [
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
'terms_and_conditions_text',
'terms_and_conditions_text', 'excluded_email_domains',
]
export async function settingsRoutes(app) {

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