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 //
/

/

    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; } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9ba23c4..4222e19 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' -import { MonitorCog } from 'lucide-react' +import { MonitorCog, Image as ImageIcon } from 'lucide-react' import { fetchSettings, updateSettings, testNewbookConnection, - fetchKioskPasswordStatus, setKioskPassword, + fetchKioskPasswordStatus, setKioskPassword, uploadLogo, logoUrl, } from '../api' import type { AppSettings } from '../types' @@ -10,6 +10,7 @@ export default function Settings() { const [settings, setSettings] = useState({ regcard_custom_field_id: '', online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '', + excluded_email_domains: '', }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -23,11 +24,45 @@ export default function Settings() { const [kioskMsg, setKioskMsg] = useState('') const [kioskError, setKioskError] = useState('') + const [logoVersion, setLogoVersion] = useState(0) + const [logoExists, setLogoExists] = useState(null) + const [logoUploading, setLogoUploading] = useState(false) + const [logoError, setLogoError] = useState('') + useEffect(() => { fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false)) fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {}) + fetch(logoUrl()).then(r => setLogoExists(r.ok)).catch(() => setLogoExists(false)) }, []) + async function handleLogoFile(e: React.ChangeEvent) { + 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((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 so the new logo shows immediately + } catch (e) { + setLogoError(e instanceof Error ? e.message : 'Upload failed') + } finally { + setLogoUploading(false) + } + } + async function handleSave() { setSaving(true); setError(''); setMsg('') try { @@ -104,6 +139,21 @@ export default function Settings() { +
    Guest Email Handling
    +
    + + setSettings(s => ({ ...s, excluded_email_domains: e.target.value }))} + placeholder="guest.booking.com, m.expediapartnercentral.com" + /> +

    + 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. +

    +
    +
    Terms & Conditions
    @@ -112,6 +162,10 @@ export default function Settings() { value={settings.terms_and_conditions_text} onChange={e => setSettings(s => ({ ...s, terms_and_conditions_text: e.target.value }))} /> +

    + Basic formatting tags are supported and rendered as-is on the tablet — e.g. <b>,{' '} + <i>, <br>, <p>, <ul>/<li>. +

    @@ -120,6 +174,29 @@ export default function Settings() {
    +
    Kiosk Branding
    +

    + Shown top-center on the tablet's login and welcome screens. +

    + {logoError &&
    {logoError}
    } +
    +
    + {logoExists + ? Logo + : } +
    +
    + +

    PNG or JPEG, under 2MB.

    +
    +
    +
    Kiosk Tablets

    A shared password every check-in tablet uses to set itself up at /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] = useState('loading') @@ -19,9 +23,11 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string const [loadError, setLoadError] = useState('') const [email, setEmail] = useState('') - const [carRego, setCarRego] = useState('') + const [newCarRego, setNewCarRego] = useState('') + const [addedCarRegos, setAddedCarRegos] = useState([]) const [optedIn, setOptedIn] = useState(false) const [acceptedTerms, setAcceptedTerms] = useState(false) + const [hasSignature, setHasSignature] = useState(false) const [submitError, setSubmitError] = useState('') const sigRef = useRef(null) @@ -31,14 +37,15 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string fetchCheckinToken(token) .then(d => { setData(d) - // Gap-fill: prefer a real (non-relay) existing email; a relay-domain - // address is deliberately NOT prefilled as-is, so the guest is - // prompted to supply a real one instead (see the shared gap-fill - // note in project_checkin — this belongs here, not just the online - // path, so the tablet inherits it automatically). - const realEmail = d.guest.existing_emails.find(e => !e.is_relay) - setEmail(realEmail?.content || '') - setCarRego(d.guest.car_rego || '') + // Gap-fill: only ever pre-fill when there's exactly one valid + // (non-excluded) email on file — with more than one there's no way + // to guess which the guest wants associated with this session, so + // it's left blank rather than guessing (see project_checkin's + // "stay in touch shouldn't blanket-subscribe every email on file" + // note, 2026-09-01 — this single-field design is what makes that + // guarantee trivial: marketing opt-in only ever touches whatever's + // in this one field at submit time, never any other existing email). + if (d.guest.existing_emails.length === 1) setEmail(d.guest.existing_emails[0].content) setStep('review') }) .catch(err => { @@ -75,6 +82,19 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string onDone() } + function addCarRego() { + const trimmed = newCarRego.trim() + if (!trimmed) return + const existing = data?.guest.car_regos || [] + const already = [...existing, ...addedCarRegos].some(r => r.toLowerCase() === trimmed.toLowerCase()) + if (!already) setAddedCarRegos(list => [...list, trimmed]) + setNewCarRego('') + } + + function removeCarRego(reg: string) { + setAddedCarRegos(list => list.filter(r => r !== reg)) + } + async function handleSubmit() { if (!acceptedTerms) return const sig = sigRef.current @@ -86,7 +106,7 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string setSubmitError('') try { await submitCheckin(token, { - email, car_rego: carRego, opted_in: optedIn, accepted_terms: acceptedTerms, + email, car_regos: addedCarRegos, opted_in: optedIn, accepted_terms: acceptedTerms, signature_png_base64: sig.getTrimmedCanvas().toDataURL('image/png'), }) setStep('complete') @@ -103,9 +123,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string if (step === 'error') { return (

    -
    - -

    {loadError}

    +
    + +

    {loadError}

    @@ -114,6 +134,9 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string if (!data) return null + const existingEmailCount = data.guest.existing_emails.length + const allCarRegos = [...data.guest.car_regos, ...addedCarRegos] + return (
    @@ -128,108 +151,131 @@ export default function CheckinFlow({ token, deviceId, onDone }: { token: string )}
    -
    +
    {step === 'review' && (
    -
    Booking Summary
    -

    Arrive: {formatDate(data.booking.arrival)}

    -

    Depart: {formatDate(data.booking.departure)}

    -

    Room: {data.booking.site_name || '—'}

    -

    Guest: {data.guest.name || '—'}

    - setStep('contact')} /> +
    Please Confirm Your Booking
    +
    + + + + + + +
    + setStep('details')} />
    )} - {step === 'contact' && ( + {step === 'details' && (
    -
    Contact Details
    +
    Your Details
    - {/* Existing emails shown read-only, same "can't remove/replace, - only add" convention as the legacy plugin's page 3 (see - project_checkin_legacy_plugin) — this app simplifies the - legacy form's 3-slot progressive "add another" UI down to one - add/correct field below, since v1's actual goal is just - capturing one good direct email per guest, not managing an - arbitrary list. */} - {data.guest.existing_emails.length > 0 && ( -
    - -
    - {data.guest.existing_emails.map((e, i) => ( -
    - {e.content}{e.is_relay && ' (booking-site relay — won\'t reach the guest directly)'} -
    - ))} -
    -
    - )} - {data.guest.existing_emails.some(ex => ex.is_relay) && ( + {data.guest.had_excluded_email && (
    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.
    )}
    - - setEmail(e.target.value)} placeholder="you@example.com" /> -

    Existing emails on file are kept — this adds a new one or corrects the flag on a matching one, it never removes anything.

    + + setEmail(e.target.value)} + placeholder="you@example.com" required={existingEmailCount === 0} + /> + {existingEmailCount > 1 && ( +

    + 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.

    - setStep('review')} onNext={() => setStep('carrego')} /> -
    - )} +

    + Address and phone on file: {data.guest.address || '—'}, {data.guest.phone || '—'}. Ask reception to + correct any of these. +

    - {step === 'carrego' && ( -
    Car Registration
    -
    - - setCarRego(e.target.value.toUpperCase())} placeholder="e.g. AB12 CDE" /> + {data.guest.car_regos.length > 0 && ( +

    Already on file: {data.guest.car_regos.join(', ')}

    + )} +
    + setNewCarRego(e.target.value.toUpperCase())} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addCarRego() } }} + style={{ flex: 1 }} + /> +
    - setStep('contact')} onNext={() => setStep('marketing')} /> -
    - )} + {addedCarRegos.length > 0 && ( +
    + {addedCarRegos.map(reg => ( + + {reg} + + + ))} +
    + )} +

    Multiple vehicles? Add each one — leave blank if not driving.

    - {step === 'marketing' && ( -
    Stay in Touch
    -
    )} {step === 'terms' && (
    Terms & Conditions
    -
    - {data.terms_text} -
    -