diff --git a/backend/extension-src/content-script.js b/backend/extension-src/content-script.js
index 10fd160..5334635 100644
--- a/backend/extension-src/content-script.js
+++ b/backend/extension-src/content-script.js
@@ -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;
diff --git a/backend/src/db.js b/backend/src/db.js
index 2ca3e84..a2976e0 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -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() {
diff --git a/backend/src/index.js b/backend/src/index.js
index 28c2a45..c2a0579 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -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()
diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js
index 5ac3da6..b9538a5 100644
--- a/backend/src/lib/newbook.js
+++ b/backend/src/lib/newbook.js
@@ -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 })
}
}
diff --git a/backend/src/lib/pdf.js b/backend/src/lib/pdf.js
index 553d428..8c58fea 100644
--- a/backend/src/lib/pdf.js
+++ b/backend/src/lib/pdf.js
@@ -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 (,
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(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
+ .trim()
+}
diff --git a/backend/src/lib/uploads.js b/backend/src/lib/uploads.js
index 4ab256d..0eb8190 100644
--- a/backend/src/lib/uploads.js
+++ b/backend/src/lib/uploads.js
@@ -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
+}
diff --git a/backend/src/routes/checkin.js b/backend/src/routes/checkin.js
index 2d3c283..81fbd37 100644
--- a/backend/src/routes/checkin.js
+++ b/backend/src/routes/checkin.js
@@ -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' }
diff --git a/backend/src/routes/logo.js b/backend/src/routes/logo.js
new file mode 100644
index 0000000..6d76387
--- /dev/null
+++ b/backend/src/routes/logo.js
@@ -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))
+ })
+}
diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js
index 13c85cb..0c6edc3 100644
--- a/backend/src/routes/settings.js
+++ b/backend/src/routes/settings.js
@@ -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) {
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 90db6b3..d9f3c4e 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -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 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
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 72b6d8d..0b44f23 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 //
/
/
+ 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. +
+
+ Basic formatting tags are supported and rendered as-is on the tablet — e.g. <b>,{' '}
+ <i>, <br>, <p>, <ul>/<li>.
+
+ Shown top-center on the tablet's login and welcome screens. +
+ {logoError &&PNG or JPEG, under 2MB.
+
A shared password every check-in tablet uses to set itself up at {loadError} {loadError} Arrive: {formatDate(data.booking.arrival)} Depart: {formatDate(data.booking.departure)} Room: {data.booking.site_name || '—'} Guest: {data.guest.name || '—'} Existing emails on file are kept — this adds a new one or corrects the flag on a matching one, it never removes anything.
+ 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.
+ Name, address and phone on file: {data.guest.name}, {data.guest.address || '—'}, {data.guest.phone || '—'}. Ask reception to correct any of these.
+ Address and phone on file: {data.guest.address || '—'}, {data.guest.phone || '—'}. Ask reception to
+ correct any of these.
+ Already on file: {data.guest.car_regos.join(', ')} Multiple vehicles? Add each one — leave blank if not driving. Submitting your details… Enjoy your stay. Please see reception for your key.
+
Enter the kiosk password to set up this tablet.
Please see reception to begin your check-in. {device.name} Please see reception to begin your check-in. {device.name}/tablet-checkin/kiosk —
diff --git a/frontend/src/pages/kiosk/CheckinFlow.tsx b/frontend/src/pages/kiosk/CheckinFlow.tsx
index fd356b8..9b0d9d9 100644
--- a/frontend/src/pages/kiosk/CheckinFlow.tsx
+++ b/frontend/src/pages/kiosk/CheckinFlow.tsx
@@ -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] = useStateYou're checked in!
+ You're checked in!
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 (
Tablet Check-In
- Tablet Check-In
+ Welcome
- Welcome
+