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) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (<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.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(/<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
|
||||
// (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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
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).
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue