Kiosk redesign: logo, tablet sizing, 3-step flow, better email/car-reg logic
- Uploadable reception logo (Settings, PNG/JPEG, public GET so the kiosk can show it without a staff login) — shown top-center on the kiosk login and idle screens, Welcome copy centered underneath. - Tablet-sized styling pass on all kiosk views for a ~10" tablet (bigger touch targets, base text, spacing) rather than the general app's desktop-first sizing. - New booking-confirm step (name/arrive/nights/depart/rate name(s)/rate total) replaces the old partial review step. - Contact details, car registration and marketing merged into one step (was 3 separate steps) — 5 steps down to 3 overall. - Email handling now matches the legacy system's confirmed behaviour: excluded-domain (OTA/agent relay) addresses are filtered out of what's shown as 'on file' entirely, not just flagged, and a new excluded_email_domains Settings field (CSV) replaces the old hardcoded domain list so any OTA/agent domain can be added without a code change. - Single email field design: only pre-filled when there's exactly one valid existing email, left blank (not guessed) when there are several — marketing opt-in only ever touches whatever's in that one field, never blanket-subscribes multiple pre-existing emails (project_checkin memory, 2026-09-01 'stay in touch' note). - Car registration now allows multiple additions — add/remove list UI, buildContactDetails appends each new one not already on file rather than the old single-slot overwrite. - Signature now required to actually complete (tracked live via SignatureCanvas onEnd, not just caught on submit) alongside accepted terms — Complete Check-In stays disabled until both are true. - T&C block is height-capped with its own internal scroll (keeps the step on one non-scrolling screen) and now renders basic HTML (<b>, <br>, <p>, <ul> etc) instead of plain text — content is admin-authored (settings capability required), same trust boundary as any other setting. PDF generation strips tags back to plain text since pdfkit can't render HTML. - PDF and booking-confirm screen both surface tariff name(s) (deduped across tariffs_quoted in case the rate changed mid-stay) and nights, not just the total. - Also fixed: injected extension buttons could hang forever on 'Loading tablets…' with no feedback if the extension was reloaded without also refreshing the NewBook tab (stale content script) — now surfaces a clear 'Extension was reloaded — refresh this page' message instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a37654cdc4
commit
2b83f47556
15 changed files with 525 additions and 153 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
|
|
|
||||||
34
backend/src/routes/logo.js
Normal file
34
backend/src/routes/logo.js
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</div>
|
<div className="section-title">Terms & 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><b></code>,{' '}
|
||||||
|
<code><i></code>, <code><br></code>, <code><p></code>, <code><ul>/<li></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> —
|
||||||
|
|
|
||||||
|
|
@ -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 & Conditions</div>
|
<div className="section-title">Terms & 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 & conditions above</span>
|
<span>I have read and accept the terms & 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)}`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue