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
|
|
@ -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' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue