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) { if (defaultDeviceId) {
const status = showInlineStatus(anchorBtn, variant, 'Sending…'); const status = showInlineStatus(anchorBtn, variant, 'Sending…');
const result = await chrome.runtime.sendMessage({ let result;
action: 'tcpPush', bookingId, deviceId: defaultDeviceId, 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) { if (result?.ok) {
status.textContent = 'Sent — the tablet should navigate now.'; status.textContent = 'Sent — the tablet should navigate now.';
status.style.color = '#166534'; status.style.color = '#166534';
@ -169,7 +173,20 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', n
anchorBtn.insertAdjacentElement('afterend', panel); anchorBtn.insertAdjacentElement('afterend', panel);
openPanel = 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) { if (!res?.ok) {
panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`; panel.textContent = `Could not load tablets: ${res?.error || 'not configured yet — see extension Settings'}`;
return; return;

View file

@ -123,6 +123,13 @@ const SETTINGS_DEFAULTS = {
regcard_custom_field_id: '', regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '', online_checkin_complete_custom_field_id: '',
kiosk_password_hash: '', 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() { async function seedSettings() {

View file

@ -11,8 +11,13 @@ import { marketingRoutes } from './routes/marketing.js'
import { settingsRoutes } from './routes/settings.js' import { settingsRoutes } from './routes/settings.js'
import { kioskRoutes } from './routes/kiosk.js' import { kioskRoutes } from './routes/kiosk.js'
import { extensionRoutes } from './routes/extension.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() const startedAt = Date.now()
await app.register(cookie) await app.register(cookie)
@ -32,6 +37,7 @@ await app.register(marketingRoutes)
await app.register(settingsRoutes) await app.register(settingsRoutes)
await app.register(kioskRoutes) await app.register(kioskRoutes)
await app.register(extensionRoutes) await app.register(extensionRoutes)
await app.register(logoRoutes)
try { try {
await initDb() await initDb()

View file

@ -97,18 +97,21 @@ export async function setBookingCustomFields(bookingId, fields) {
return callApi('bookings_update', { booking_id: bookingId, custom_fields }) return callApi('bookings_update', { booking_id: bookingId, custom_fields })
} }
// Known OTA relay-email domains — a guest's real inbox is never actually // OTA/agent relay-email detection — a guest's real inbox is never actually
// behind one of these, so it's flagged for the shared gap-fill step to prompt // behind one of these domains, so it's excluded from what's shown as "on
// a correction rather than silently accepting it as "the guest's email". // file" and the guest is prompted for a real one. Mirrors the legacy
// Mirrors the legacy plugin's filtering (see project_checkin_legacy_plugin) — // plugin's filtering (see project_checkin_legacy_plugin), including its
// deliberately a shared helper (not duplicated per-route) so both the tablet // "only show emails that weren't excluded, require at least one after
// and any future online-checkin path inherit new domains added here for free // exclusions" behavior (confirmed by the user 2026-09-01) — this app
// (see project_checkin's "Flow-parity risk" note). // exposes the domain list as the `excluded_email_domains` setting rather
export const OTA_RELAY_EMAIL_DOMAINS = ['guest.booking.com', 'm.expediapartnercentral.com'] // than hardcoding it, so any OTA/agent domain can be added without a code
// change. `excludedDomains` is the caller's already-parsed array (from
export function isRelayEmail(email) { // 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() 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 // 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 // Builds an updated contact_details array for guests_update — additive only
// (existing entries are never removed/replaced, matching the legacy system's // (existing entries are never removed/replaced, matching the legacy system's
// explicit "can't remove/replace, only add" convention). Updates // explicit "can't remove/replace, only add" convention, and matching
// allow_marketing on a matching existing email write-only (see // NewBook's own API — it has no edit, only add). Updates allow_marketing on
// project_checkin's marketing opt-in decision: never read back, just kept // a matching existing email write-only (see project_checkin's marketing
// accurate going forward). // opt-in decision: never read back, just kept accurate going forward).
export function buildContactDetails(existing, { email, carRego, optedIn }) { // `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] : [] const details = Array.isArray(existing) ? [...existing] : []
if (email) { if (email) {
@ -171,12 +182,12 @@ export function buildContactDetails(existing, { email, carRego, optedIn }) {
} }
} }
if (carRego) { for (const reg of (carRegos || [])) {
const idx = details.findIndex(d => d.type === 'car_rego') const trimmed = String(reg || '').trim()
if (idx >= 0) { if (!trimmed) continue
details[idx] = { ...details[idx], content: carRego } const alreadyOnFile = details.some(d => d.type === 'car_rego' && String(d.content).toLowerCase() === trimmed.toLowerCase())
} else { if (!alreadyOnFile) {
details.push({ type: 'car_rego', content: carRego, allow_transactional: 1, allow_marketing: 0 }) details.push({ type: 'car_rego', content: trimmed, allow_transactional: 1, allow_marketing: 0 })
} }
} }

View file

@ -13,7 +13,7 @@ export function generateRegcardPdf({
booking, booking,
guest, guest,
contactEmail, contactEmail,
carRego, carRegos,
optedIn, optedIn,
termsText, termsText,
signaturePngBase64, signaturePngBase64,
@ -38,8 +38,11 @@ export function generateRegcardPdf({
doc.moveDown(0.3) doc.moveDown(0.3)
row(doc, 'Booking #', String(booking.booking_id ?? '')) row(doc, 'Booking #', String(booking.booking_id ?? ''))
row(doc, 'Arrive', formatDate(booking.booking_arrival)) row(doc, 'Arrive', formatDate(booking.booking_arrival))
row(doc, 'Nights', String(booking.booking_length ?? '—'))
row(doc, 'Depart', formatDate(booking.booking_departure)) row(doc, 'Depart', formatDate(booking.booking_departure))
row(doc, 'Room / Site', String(booking.site_name ?? '')) row(doc, 'Room / Site', String(booking.site_name ?? ''))
row(doc, 'Rate', tariffNamesText(booking))
row(doc, 'Rate Total', String(booking.tariff_total ?? '—'))
doc.moveDown(1) doc.moveDown(1)
// ── Guest details ──────────────────────────────────────────────────── // ── Guest details ────────────────────────────────────────────────────
@ -50,14 +53,18 @@ export function generateRegcardPdf({
row(doc, 'Address', String(guest?.address ?? '')) row(doc, 'Address', String(guest?.address ?? ''))
row(doc, 'Phone', String(guest?.phone ?? '')) row(doc, 'Phone', String(guest?.phone ?? ''))
row(doc, 'Email', String(contactEmail ?? '')) 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') row(doc, 'Marketing opt-in', optedIn ? 'Yes' : 'No')
doc.moveDown(1) doc.moveDown(1)
// ── T&Cs ────────────────────────────────────────────────────────────── // ── 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.fontSize(12).fillColor('#1a1a2e').text('Terms & Conditions')
doc.moveDown(0.3) 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', align: 'justify',
}) })
doc.moveDown(1.5) doc.moveDown(1.5)
@ -93,3 +100,19 @@ function formatDate(v) {
const d = new Date(v) const d = new Date(v)
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10) 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 // public URL: retrieval goes through an admin-capped, token-checked route
// (routes/checkins.js GET /api/checkins/:id/pdf) since the PDF holds guest // (routes/checkins.js GET /api/checkins/:id/pdf) since the PDF holds guest
// PII + a signature image. // PII + a signature image.
import { mkdir, writeFile } from 'fs/promises' import { mkdir, writeFile, unlink, access } from 'fs/promises'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@ -24,3 +24,39 @@ export async function saveRegcardPdf(checkinId, buffer) {
export function absolutePath(relPath) { export function absolutePath(relPath) {
return join(UPLOADS_DIR, 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 guest = pickPrimaryGuest(booking)
const contactDetails = Array.isArray(guest?.contact_details) ? guest.contact_details : [] const contactDetails = Array.isArray(guest?.contact_details) ? guest.contact_details : []
const existingEmails = contactDetails const excludedDomains = await getExcludedDomains()
.filter(d => d.type === 'email') // Matches the legacy system's confirmed behaviour (2026-09-01, see
.map(d => ({ content: d.content, is_relay: isRelayEmail(d.content) })) // project_checkin_legacy_plugin): excluded-domain emails aren't shown as
const existingCarRego = contactDetails.find(d => d.type === 'car_rego')?.content || '' // "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 { return {
booking: { booking: {
@ -45,6 +53,7 @@ export async function checkinRoutes(app) {
departure: booking.booking_departure ?? null, departure: booking.booking_departure ?? null,
site_name: booking.site_name ?? null, site_name: booking.site_name ?? null,
tariff_total: booking.tariff_total ?? null, tariff_total: booking.tariff_total ?? null,
tariff_names: extractTariffNames(booking),
nights: booking.booking_length ?? null, nights: booking.booking_length ?? null,
}, },
guest: { guest: {
@ -53,14 +62,15 @@ export async function checkinRoutes(app) {
address: formatGuestAddress(guest), address: formatGuestAddress(guest),
phone: getContactByType(contactDetails, ['mobile', 'phone']), phone: getContactByType(contactDetails, ['mobile', 'phone']),
existing_emails: existingEmails, existing_emails: existingEmails,
car_rego: existingCarRego, had_excluded_email: hadExcludedEmail,
car_regos: existingCarRegos,
}, },
terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS), terms_text: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
} }
}) })
app.post('/api/checkin/:token/submit', async (req, reply) => { 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) { if (!signature_png_base64 || !accepted_terms) {
return reply.status(400).send({ error: 'Signature and accepted terms are required' }) 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, { const contactDetails = buildContactDetails(guest.contact_details, {
email: email?.trim() || null, email: email?.trim() || null,
carRego: car_rego?.trim() || null, carRegos: Array.isArray(car_regos) ? car_regos : [],
optedIn: !!opted_in, optedIn: !!opted_in,
}) })
@ -135,7 +145,7 @@ export async function checkinRoutes(app) {
booking, booking,
guest: { name: guestName, address: formatGuestAddress(guest), phone: getContactByType(guest.contact_details, ['mobile', 'phone']) }, guest: { name: guestName, address: formatGuestAddress(guest), phone: getContactByType(guest.contact_details, ['mobile', 'phone']) },
contactEmail: email, contactEmail: email,
carRego: car_rego, carRegos: Array.isArray(car_regos) ? car_regos : [],
optedIn: !!opted_in, optedIn: !!opted_in,
termsText: await getSetting('terms_and_conditions_text', DEFAULT_TERMS), termsText: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
signaturePngBase64: signature_png_base64, 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) { async function loadSession(token) {
const { rows } = await pool.query('SELECT * FROM checkin_sessions WHERE token = $1', [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' } 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). // below (mirrors hvac's Settings -> Kiosk pattern).
const ALLOWED_KEYS = [ const ALLOWED_KEYS = [
'regcard_custom_field_id', 'online_checkin_complete_custom_field_id', '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) { 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 }) }) 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) ───────────────────────────────── // ── Kiosk (tablet's own persistent session) ─────────────────────────────────
// Deliberately separate from request() above: a 401 here means "the kiosk // 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 // 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::-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; } .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Kiosk (tablet guest-facing shell) ─────────────────────── */ /* ── Kiosk (tablet guest-facing shell) ────────────────────────
.kiosk-shell { height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg); overflow-y: auto; } 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-center { align-items: center; justify-content: center; }
.kiosk-login-card { .kiosk-login-card {
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); 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 { .kiosk-header {
display: flex; align-items: center; gap: 16px; 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-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: 12px; color: var(--text-muted); } .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 { useEffect, useState } from 'react'
import { MonitorCog } from 'lucide-react' import { MonitorCog, Image as ImageIcon } from 'lucide-react'
import { import {
fetchSettings, updateSettings, testNewbookConnection, fetchSettings, updateSettings, testNewbookConnection,
fetchKioskPasswordStatus, setKioskPassword, fetchKioskPasswordStatus, setKioskPassword, uploadLogo, logoUrl,
} from '../api' } from '../api'
import type { AppSettings } from '../types' import type { AppSettings } from '../types'
@ -10,6 +10,7 @@ export default function Settings() {
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
regcard_custom_field_id: '', regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '', online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
excluded_email_domains: '',
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -23,11 +24,45 @@ export default function Settings() {
const [kioskMsg, setKioskMsg] = useState('') const [kioskMsg, setKioskMsg] = useState('')
const [kioskError, setKioskError] = 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(() => { useEffect(() => {
fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false)) fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false))
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {}) 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() { async function handleSave() {
setSaving(true); setError(''); setMsg('') setSaving(true); setError(''); setMsg('')
try { try {
@ -104,6 +139,21 @@ export default function Settings() {
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button> <button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
</div> </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="section-title">Terms &amp; Conditions</div>
<div className="field"> <div className="field">
<label>Text shown on the tablet before signing</label> <label>Text shown on the tablet before signing</label>
@ -112,6 +162,10 @@ export default function Settings() {
value={settings.terms_and_conditions_text} value={settings.terms_and_conditions_text}
onChange={e => setSettings(s => ({ ...s, terms_and_conditions_text: e.target.value }))} 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>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
@ -120,6 +174,29 @@ export default function Settings() {
</button> </button>
</div> </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> <div className="section-title">Kiosk Tablets</div>
<p className="field-hint" style={{ marginBottom: 10 }}> <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> — 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 { useEffect, useRef, useState } from 'react'
import SignatureCanvas from 'react-signature-canvas' 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 { fetchCheckinToken, submitCheckin, kioskReset } from '../../api'
import type { CheckinTokenData } from '../../types' import type { CheckinTokenData } from '../../types'
@ -11,7 +11,11 @@ import type { CheckinTokenData } from '../../types'
const INACTIVITY_MS = 5 * 60 * 1000 const INACTIVITY_MS = 5 * 60 * 1000
const COMPLETE_AUTO_RETURN_MS = 15 * 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 }) { export default function CheckinFlow({ token, deviceId, onDone }: { token: string; deviceId: string; onDone: () => void }) {
const [step, setStep] = useState<Step>('loading') const [step, setStep] = useState<Step>('loading')
@ -19,9 +23,11 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [carRego, setCarRego] = useState('') const [newCarRego, setNewCarRego] = useState('')
const [addedCarRegos, setAddedCarRegos] = useState<string[]>([])
const [optedIn, setOptedIn] = useState(false) const [optedIn, setOptedIn] = useState(false)
const [acceptedTerms, setAcceptedTerms] = useState(false) const [acceptedTerms, setAcceptedTerms] = useState(false)
const [hasSignature, setHasSignature] = useState(false)
const [submitError, setSubmitError] = useState('') const [submitError, setSubmitError] = useState('')
const sigRef = useRef<SignatureCanvas>(null) const sigRef = useRef<SignatureCanvas>(null)
@ -31,14 +37,15 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
fetchCheckinToken(token) fetchCheckinToken(token)
.then(d => { .then(d => {
setData(d) setData(d)
// Gap-fill: prefer a real (non-relay) existing email; a relay-domain // Gap-fill: only ever pre-fill when there's exactly one valid
// address is deliberately NOT prefilled as-is, so the guest is // (non-excluded) email on file — with more than one there's no way
// prompted to supply a real one instead (see the shared gap-fill // to guess which the guest wants associated with this session, so
// note in project_checkin — this belongs here, not just the online // it's left blank rather than guessing (see project_checkin's
// path, so the tablet inherits it automatically). // "stay in touch shouldn't blanket-subscribe every email on file"
const realEmail = d.guest.existing_emails.find(e => !e.is_relay) // note, 2026-09-01 — this single-field design is what makes that
setEmail(realEmail?.content || '') // guarantee trivial: marketing opt-in only ever touches whatever's
setCarRego(d.guest.car_rego || '') // 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') setStep('review')
}) })
.catch(err => { .catch(err => {
@ -75,6 +82,19 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
onDone() 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() { async function handleSubmit() {
if (!acceptedTerms) return if (!acceptedTerms) return
const sig = sigRef.current const sig = sigRef.current
@ -86,7 +106,7 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
setSubmitError('') setSubmitError('')
try { try {
await submitCheckin(token, { 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'), signature_png_base64: sig.getTrimmedCanvas().toDataURL('image/png'),
}) })
setStep('complete') setStep('complete')
@ -103,9 +123,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
if (step === 'error') { if (step === 'error') {
return ( return (
<div className="kiosk-shell kiosk-center"> <div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center', maxWidth: 360 }}> <div style={{ textAlign: 'center', maxWidth: 400 }}>
<AlertTriangle size={40} strokeWidth={1.5} color="var(--danger)" style={{ marginBottom: 12 }} /> <AlertTriangle size={48} strokeWidth={1.5} color="var(--danger)" style={{ marginBottom: 14 }} />
<p style={{ color: 'var(--text-dark)', marginBottom: 16 }}>{loadError}</p> <p style={{ color: 'var(--text-dark)', marginBottom: 20 }}>{loadError}</p>
<button className="btn btn-primary" onClick={onDone}>Back to start</button> <button className="btn btn-primary" onClick={onDone}>Back to start</button>
</div> </div>
</div> </div>
@ -114,6 +134,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
if (!data) return null if (!data) return null
const existingEmailCount = data.guest.existing_emails.length
const allCarRegos = [...data.guest.car_regos, ...addedCarRegos]
return ( return (
<div className="kiosk-shell"> <div className="kiosk-shell">
<header className="kiosk-header"> <header className="kiosk-header">
@ -128,108 +151,131 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
)} )}
</header> </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' && ( {step === 'review' && (
<div className="card"> <div className="card">
<div className="section-title">Booking Summary</div> <div className="section-title">Please Confirm Your Booking</div>
<p><strong>Arrive:</strong> {formatDate(data.booking.arrival)}</p> <div className="review-grid">
<p><strong>Depart:</strong> {formatDate(data.booking.departure)}</p> <ReviewRow label="Name" value={data.guest.name || '—'} />
<p><strong>Room:</strong> {data.booking.site_name || '—'}</p> <ReviewRow label="Arrive" value={formatDate(data.booking.arrival)} />
<p><strong>Guest:</strong> {data.guest.name || '—'}</p> <ReviewRow label="Nights" value={data.booking.nights != null ? String(data.booking.nights) : '—'} />
<StepNav onNext={() => setStep('contact')} /> <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> </div>
)} )}
{step === 'contact' && ( {step === 'details' && (
<div className="card"> <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, {data.guest.had_excluded_email && (
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) && (
<div className="error-banner" style={{ background: 'var(--warn-bg)', borderColor: 'var(--health-degraded)', color: '#92400e' }}> <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 — 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>
)} )}
<div className="field"> <div className="field">
<label>{data.guest.existing_emails.length > 0 ? 'Add or correct email' : 'Email address'}</label> <label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="you@example.com" /> {existingEmailCount === 0 ? 'Your email address' : existingEmailCount === 1 ? 'Confirm your email' : 'Add an email'}
<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>
</div> <input
<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> type="email" value={email} onChange={e => setEmail(e.target.value)}
<StepNav onBack={() => setStep('review')} onNext={() => setStep('carrego')} /> placeholder="you@example.com" required={existingEmailCount === 0}
</div> />
{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" 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="section-title">Car Registration</div>
<div className="field"> {data.guest.car_regos.length > 0 && (
<label>Registration (leave blank if not driving)</label> <p className="field-hint" style={{ marginBottom: 8 }}>Already on file: {data.guest.car_regos.join(', ')}</p>
<input type="text" value={carRego} onChange={e => setCarRego(e.target.value.toUpperCase())} placeholder="e.g. AB12 CDE" /> )}
<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> </div>
<StepNav onBack={() => setStep('contact')} onNext={() => setStep('marketing')} /> {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> </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> <div className="section-title">Stay in Touch</div>
<label className="field-check" style={{ marginBottom: 12 }}> <label className="field-check" style={{ marginBottom: 4 }}>
<input type="checkbox" checked={optedIn} onChange={e => setOptedIn(e.target.checked)} /> <input type="checkbox" checked={optedIn} onChange={e => setOptedIn(e.target.checked)} disabled={!email} />
<span>Yes, send me offers and news by email</span> <span>Yes, send me offers and news by email</span>
</label> </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> </div>
)} )}
{step === 'terms' && ( {step === 'terms' && (
<div className="card"> <div className="card">
<div className="section-title">Terms &amp; Conditions</div> <div className="section-title">Terms &amp; Conditions</div>
<div style={{ {/* Admin-authored content (settings cap required to edit) —
maxHeight: 160, overflowY: 'auto', fontSize: 12.5, color: 'var(--text-mid)', trusted to render as HTML, same trust boundary as any other
border: '1px solid var(--card-border)', borderRadius: 8, padding: 10, marginBottom: 12, whiteSpace: 'pre-wrap', setting. max-height + its own scroll keeps this contained to
}}> one screen on a 10" tablet even with a long T&C block,
{data.terms_text} rather than the whole page needing to scroll. */}
</div> <div
<label className="field-check" style={{ marginBottom: 14 }}> 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)} /> <input type="checkbox" checked={acceptedTerms} onChange={e => setAcceptedTerms(e.target.checked)} />
<span>I have read and accept the terms &amp; conditions above</span> <span>I have read and accept the terms &amp; conditions above</span>
</label> </label>
<div className="section-title">Signature</div> <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 <SignatureCanvas
ref={sigRef} ref={sigRef}
penColor="#1a1a2e" 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> </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>} {submitError && <div className="error-banner">{submitError}</div>}
<div className="modal-actions" style={{ justifyContent: 'space-between' }}> <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" onClick={() => setStep('details')}><ChevronLeft size={14} strokeWidth={1.75} /> Back</button>
<button className="btn btn-primary" disabled={!acceptedTerms} onClick={handleSubmit}> <button className="btn btn-primary" disabled={!acceptedTerms || !hasSignature} onClick={handleSubmit}>
Complete Check-In <ChevronRight size={14} strokeWidth={1.75} /> Complete Check-In <ChevronRight size={14} strokeWidth={1.75} />
</button> </button>
</div> </div>
@ -237,17 +283,17 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string
)} )}
{step === 'submitting' && ( {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> <p className="muted">Submitting your details…</p>
</div> </div>
)} )}
{step === 'complete' && ( {step === 'complete' && (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}> <div className="card" style={{ textAlign: 'center', padding: '48px 16px' }}>
<CheckCircle2 size={48} strokeWidth={1.5} color="var(--health-healthy)" style={{ marginBottom: 12 }} /> <CheckCircle2 size={56} strokeWidth={1.5} color="var(--health-healthy)" style={{ marginBottom: 14 }} />
<h2 style={{ fontSize: 18, margin: '0 0 6px' }}>You're checked in!</h2> <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> <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>
)} )}
</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 }) { function StepNav({ onBack, onNext }: { onBack?: () => void; onNext: () => void }) {
return ( return (
<div className="modal-actions" style={{ justifyContent: onBack ? 'space-between' : 'flex-end' }}> <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 { function stepLabel(step: Step): string {
switch (step) { switch (step) {
case 'review': return 'Step 1 of 5 · Booking' case 'review': return 'Step 1 of 3 · Confirm booking'
case 'contact': return 'Step 2 of 5 · Contact details' case 'details': return 'Step 2 of 3 · Your details'
case 'carrego': return 'Step 3 of 5 · Car registration' case 'terms': return 'Step 3 of 3 · Terms & signature'
case 'marketing': return 'Step 4 of 5 · Marketing'
case 'terms': return 'Step 5 of 5 · Terms & signature'
default: return '' default: return ''
} }
} }
@ -280,3 +333,9 @@ function formatDate(v: string | null): string {
const d = new Date(v) const d = new Date(v)
return isNaN(d.getTime()) ? v : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) 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 { useEffect, useRef, useState } from 'react'
import { UserCheck } from 'lucide-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 type { Device } from '../../types'
import CheckinFlow from './CheckinFlow' 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 DEVICE_ID_KEY = 'tablet_checkin_device_id'
const HEARTBEAT_MS = 60_000 const HEARTBEAT_MS = 60_000
@ -142,19 +157,20 @@ export default function Kiosk() {
if (view === 'login') { if (view === 'login') {
return ( return (
<div className="kiosk-shell kiosk-center"> <div className="kiosk-shell kiosk-center">
<div className="kiosk-login-card"> <div className="kiosk-login-card" style={{ textAlign: 'center' }}>
<UserCheck size={36} strokeWidth={1.75} color="var(--app-primary)" style={{ marginBottom: 10 }} /> <KioskLogo />
<h1 style={{ fontSize: 18, margin: '0 0 4px' }}>Tablet Check-In</h1> <UserCheck size={44} strokeWidth={1.5} color="var(--app-primary)" style={{ marginBottom: 12 }} />
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}> <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. Enter the kiosk password to set up this tablet.
</p> </p>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<input <input
type="password" value={password} onChange={e => setPassword(e.target.value)} 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>} {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'} {loginLoading ? 'Signing in…' : 'Continue'}
</button> </button>
</form> </form>
@ -171,10 +187,11 @@ export default function Kiosk() {
return ( return (
<div className="kiosk-shell kiosk-center"> <div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<UserCheck size={56} strokeWidth={1.25} color="var(--app-primary)" style={{ marginBottom: 16, opacity: 0.6 }} /> <KioskLogo />
<h1 style={{ fontSize: 22, margin: '0 0 6px', color: 'var(--text-dark)' }}>Welcome</h1> <UserCheck size={72} strokeWidth={1.25} color="var(--app-primary)" style={{ marginBottom: 20, opacity: 0.6 }} />
<p className="muted">Please see reception to begin your check-in.</p> <h1 style={{ fontSize: 32, margin: '0 0 10px', color: 'var(--text-dark)' }}>Welcome</h1>
{device && <p className="muted" style={{ fontSize: 11, marginTop: 24 }}>{device.name}</p>} <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>
</div> </div>
) )

View file

@ -85,6 +85,7 @@ export interface AppSettings {
regcard_custom_field_id: string regcard_custom_field_id: string
online_checkin_complete_custom_field_id: string online_checkin_complete_custom_field_id: string
terms_and_conditions_text: string terms_and_conditions_text: string
excluded_email_domains: string
} }
// ── Guest-facing token flow (routes/checkin.js) ───────────────────────────── // ── Guest-facing token flow (routes/checkin.js) ─────────────────────────────
@ -94,12 +95,16 @@ export interface CheckinBooking {
departure: string | null departure: string | null
site_name: string | null site_name: string | null
tariff_total: number | string | null tariff_total: number | string | null
tariff_names: string[]
nights: number | null 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 { export interface ExistingEmail {
content: string content: string
is_relay: boolean
} }
export interface CheckinGuest { export interface CheckinGuest {
@ -108,7 +113,8 @@ export interface CheckinGuest {
address: string address: string
phone: string phone: string
existing_emails: ExistingEmail[] existing_emails: ExistingEmail[]
car_rego: string had_excluded_email: boolean
car_regos: string[]
} }
export interface CheckinTokenData { export interface CheckinTokenData {
@ -119,7 +125,7 @@ export interface CheckinTokenData {
export interface SubmitCheckinPayload { export interface SubmitCheckinPayload {
email: string email: string
car_rego: string car_regos: string[]
opted_in: boolean opted_in: boolean
accepted_terms: boolean accepted_terms: boolean
signature_png_base64: string signature_png_base64: string