Initial scaffold: tablet-checkin app

Staff-push guest check-in tablet. Fastify backend + React/Vite frontend,
three-tier auth (staff JWT, terminal API key, kiosk cookie), NewBook
integration via bookings_get/guests_update/bookings_update, pdfkit-based
regcard generation, marketing opt-in with Sendy/Mailchimp adapters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-09-01 17:35:39 +00:00
commit 3b2e25df3f
51 changed files with 10296 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
uploads/
*.log

8
backend/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY src ./src
RUN mkdir -p /app/uploads
EXPOSE 3001
CMD ["node", "src/index.js"]

18
backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "hnf-tablet-checkin-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"pdfkit": "^0.15.1",
"pg": "^8.13.1"
}
}

99
backend/src/auth.js Normal file
View file

@ -0,0 +1,99 @@
import { jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
import { isOnsite } from './ip-check.js'
import { pool } from './db.js'
const APP_SLUG = process.env.APP_SLUG || 'tablet-checkin'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
// Deliberately its own cookie name — NOT 'hnf_session' (central auth's real
// staff-session cookie). Sharing that name caused a real collision bug in
// hvac's kiosk (see project_hvac_kiosk memory, hit 2026-08-26): a stale
// same-named cookie from testing the kiosk login could shadow a subsequent
// real staff login on the same origin. Mirroring that fix here rather than
// rediscovering it — this app's own requireAuth checks hnf_session first,
// falls back to this one, and the two are fully independent.
const KIOSK_COOKIE = 'tablet_checkin_kiosk_session'
// ── Tier 1: staff JWT (central auth) + tablet kiosk session ────────────────
// Gates the human-facing staff routes: admin/audit (checkins.js), device
// registry management, terminal key management, marketing config, settings.
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session || request.cookies?.[KIOSK_COOKIE]
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
caps = [] // legacy token — nothing granted until re-login
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}
export { KIOSK_COOKIE }
// ── Tier 2: terminal API key ────────────────────────────────────────────────
// Authorizes the standalone Chrome extension's push call (routes/staff.js)
// only — never used for anything human-facing. Distinct from both the staff
// JWT and the tablet's own kiosk session: hotel-owned physical/desktop
// hardware gets a device-level bearer credential instead of a per-user login.
// Header: `Authorization: Bearer <raw key>`. The raw key is shown once at
// creation (routes/terminals.js) and only its bcrypt hash is ever stored, so
// verifying means iterating the small set of non-revoked terminals and
// comparing — fine at this scale (a handful of reception PCs, not users).
export async function requireTerminalAuth(request, reply) {
const header = request.headers['authorization'] || ''
const key = header.startsWith('Bearer ') ? header.slice(7).trim() : null
if (!key) return reply.status(401).send({ error: 'Terminal API key required' })
const { rows } = await pool.query(
`SELECT terminal_id, api_key_hash FROM terminals WHERE revoked_at IS NULL`
)
for (const row of rows) {
if (await bcrypt.compare(key, row.api_key_hash)) {
await pool.query(`UPDATE terminals SET last_used_at = now() WHERE terminal_id = $1`, [row.terminal_id])
request.terminal = { terminal_id: row.terminal_id }
return
}
}
return reply.status(401).send({ error: 'Invalid or revoked terminal key' })
}

159
backend/src/db.js Normal file
View file

@ -0,0 +1,159 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- Multi-tablet device registry. Each physical tablet self-registers on first
-- load with a persisted device_id (generated client-side, stored in
-- localStorage) and gets a staff-given name via the Devices admin page.
-- status flips idle<->busy across a push session; last_seen is a heartbeat
-- from the tablet's own idle-page SSE connection, used to spot a tablet
-- that's gone offline (browser closed, tablet powered off) without a clean
-- disconnect. See [[project_checkin]] "Multi-tablet device registry".
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle', -- idle | busy
last_seen TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Per-terminal API keys — the extension's ("Send to Check-in Tablet") auth
-- mechanism, distinct from both staff JWT and the tablet's own kiosk login.
-- Raw key is shown once at creation and never stored — only its hash.
CREATE TABLE IF NOT EXISTS terminals (
terminal_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
-- Short-lived staff-push / matcher-minted session tokens — ties one device to
-- one booking for one check-in. TTL-cleaned; a small table (rather than
-- in-memory) so a tablet dropping mid-session is crash-safe, matching the
-- rest of the stack's conventions.
CREATE TABLE IF NOT EXISTS checkin_sessions (
token TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
booking_id TEXT NOT NULL,
hotel_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
-- Check-in audit/index — one row per completed (or partially-completed, see
-- 'pending' status) check-in. Lets staff browse past check-ins without
-- hitting the NewBook API per lookup, and is the PII-holding anchor row
-- behind the token-gated PDF retrieval route.
CREATE TABLE IF NOT EXISTS checkins (
id SERIAL PRIMARY KEY,
booking_id TEXT NOT NULL,
guest_id TEXT, -- nullable: a 'failed' row inserted before the booking/guest could be
-- re-fetched from NewBook (see routes/checkin.js's failCheckin()) has none yet
hotel_id TEXT NOT NULL,
device_id TEXT REFERENCES devices(device_id) ON DELETE SET NULL,
guest_name TEXT,
guest_email TEXT, -- kept locally (in addition to NewBook) so a failed
-- marketing_sync row can be retried without a NewBook re-fetch
pdf_path TEXT,
opted_in BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending', -- pending | complete | failed
error TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- cctv_snapshot_path added later once the deferred CCTV feature is picked up
);
CREATE INDEX IF NOT EXISTS checkins_submitted_idx ON checkins (submitted_at DESC);
-- Per-provider marketing push sync state. Not used to discover who needs
-- syncing (driven by checkins.opted_in at submission time) — purely tracks
-- whether the real-time push at submission succeeded, so a failed call can
-- be retried. See [[project_checkin]] "Marketing opt-in" section.
CREATE TABLE IF NOT EXISTS marketing_sync (
id SERIAL PRIMARY KEY,
checkin_id INTEGER NOT NULL REFERENCES checkins(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'sendy' | 'mailchimp'
status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS marketing_sync_checkin_idx ON marketing_sync (checkin_id);
-- Which marketing platforms are active + their API credentials. Config JSONB
-- shape is provider-specific (see lib/marketing/*.js) — e.g. sendy needs
-- { api_key, install_url, list_id }, mailchimp needs { api_key, server_prefix,
-- audience_id }.
CREATE TABLE IF NOT EXISTS marketing_providers (
provider TEXT PRIMARY KEY, -- 'sendy' | 'mailchimp'
active BOOLEAN NOT NULL DEFAULT false,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Cross-app-integration-credentials pattern (see feedback_app_conventions) —
-- generic key/value settings editable from the Settings page, DB value wins
-- over any .env fallback. Holds the NewBook hotel/location id and the
-- regcard-link custom field id (varies per NewBook install, see
-- [[project_checkin_legacy_plugin]]) rather than anything marketing-specific,
-- which has its own dedicated marketing_providers table above.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Bcrypt hash of the tablet kiosk's own persistent login password — set via
-- the tablet's own setup screen (routes/devices.js POST /api/kiosk/login),
-- never the plaintext. Kept in app_settings alongside the other small
-- key/value config, same pattern as hvac's kiosk_password_hash in its
-- 'config' table.
`)
await seedSettings()
await seedMarketingProviders()
}
const SETTINGS_DEFAULTS = {
newbook_hotel_id: '',
regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '',
kiosk_password_hash: '',
}
async function seedSettings() {
for (const [key, value] of Object.entries(SETTINGS_DEFAULTS)) {
await pool.query(
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[key, value]
)
}
}
async function seedMarketingProviders() {
for (const provider of ['sendy', 'mailchimp']) {
await pool.query(
`INSERT INTO marketing_providers (provider, active, config) VALUES ($1, false, '{}'::jsonb)
ON CONFLICT (provider) DO NOTHING`,
[provider]
)
}
}
export async function getSetting(key, fallback = '') {
const { rows } = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key])
return rows.length && rows[0].value != null ? rows[0].value : fallback
}
export async function setSetting(key, value) {
await pool.query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[key, value]
)
}

40
backend/src/index.js Normal file
View file

@ -0,0 +1,40 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { deviceRoutes } from './routes/devices.js'
import { terminalRoutes } from './routes/terminals.js'
import { staffRoutes } from './routes/staff.js'
import { checkinRoutes } from './routes/checkin.js'
import { checkinsRoutes } from './routes/checkins.js'
import { marketingRoutes } from './routes/marketing.js'
import { settingsRoutes } from './routes/settings.js'
import { kioskRoutes } from './routes/kiosk.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(deviceRoutes)
await app.register(terminalRoutes)
await app.register(staffRoutes)
await app.register(checkinRoutes)
await app.register(checkinsRoutes)
await app.register(marketingRoutes)
await app.register(settingsRoutes)
await app.register(kioskRoutes)
try {
await initDb()
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

View file

@ -0,0 +1,40 @@
// In-memory SSE registry — one process, no clustering, so a plain Map of
// device_id -> open response streams is sufficient (same "no new dependency"
// approach as the rest of this stack; plain `reply.raw` streaming rather than
// pulling in fastify-sse-v2 for what's a handful of small, well-understood
// event types). A tablet's idle page holds one of these open; staff.js's push
// route writes a 'navigate' event down it, the tablet's own JS redirects
// itself — see project_checkin's "Submission flow sequencing".
const streams = new Map() // device_id -> Set<FastifyReply.raw>
export function addStream(deviceId, res) {
if (!streams.has(deviceId)) streams.set(deviceId, new Set())
streams.get(deviceId).add(res)
}
export function removeStream(deviceId, res) {
streams.get(deviceId)?.delete(res)
if (streams.get(deviceId)?.size === 0) streams.delete(deviceId)
}
function send(deviceId, event, data) {
const set = streams.get(deviceId)
if (!set) return false
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
for (const res of set) {
try { res.write(payload) } catch { /* dead connection, will be cleaned up on close */ }
}
return true
}
// Returns true if the device had a live connection to push to. staff.js still
// succeeds the push either way (the tablet will pick up the session on next
// load if it reconnects), but this is useful for surfacing a "tablet may be
// offline" warning to the caller.
export function pushNavigate(deviceId, token) {
return send(deviceId, 'navigate', { token })
}
export function pushIdle(deviceId) {
return send(deviceId, 'idle', {})
}

View file

@ -0,0 +1,74 @@
// Pluggable marketing-platform push, common interface + retry-friendly
// sync-state bookkeeping. Every adapter exports a single
// `push({ email, name }, config)` that resolves on success and throws on
// failure — this module never talks to a provider directly, only via that
// interface, so adding a new provider is one new file + one ADAPTERS entry.
import { pool } from '../../db.js'
import * as sendy from './sendy.js'
import * as mailchimp from './mailchimp.js'
export const ADAPTERS = { sendy, mailchimp }
// Called at check-in submission time when opted_in = true. Deliberately
// fire-and-forget from the caller's perspective (see routes/checkin.js) —
// same lesson as hvac's TRV control routes blocking on a retry pipeline and
// causing a real gateway timeout (fixed 2026-08-26). The pending row is
// inserted synchronously (so it shows up immediately in the audit view even
// before the push resolves); the actual network call happens in the
// background.
export async function queueMarketingSync(checkinId, guest) {
const { rows: providers } = await pool.query(
`SELECT provider, config FROM marketing_providers WHERE active = true`
)
for (const p of providers) {
const { rows } = await pool.query(
`INSERT INTO marketing_sync (checkin_id, provider, status) VALUES ($1, $2, 'pending') RETURNING id`,
[checkinId, p.provider]
)
const syncId = rows[0].id
// Not awaited — see header comment.
attemptPush(syncId, p.provider, p.config, guest).catch(err => {
console.error(`[marketing] unexpected error pushing sync ${syncId} (${p.provider}):`, err.message)
})
}
}
async function attemptPush(syncId, provider, config, guest) {
const adapter = ADAPTERS[provider]
if (!adapter) {
await pool.query(
`UPDATE marketing_sync SET status = 'failed', error = $2, attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId, `Unknown provider: ${provider}`]
)
return
}
try {
await adapter.push(guest, config)
await pool.query(
`UPDATE marketing_sync SET status = 'success', attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId]
)
} catch (err) {
await pool.query(
`UPDATE marketing_sync SET status = 'failed', error = $2, attempts = attempts + 1, last_attempt_at = now() WHERE id = $1`,
[syncId, err.message]
)
}
}
// Manual retry of one failed sync row (routes/marketing.js) — re-reads the
// checkin's locally-stored guest_name/guest_email rather than re-fetching
// NewBook, since that's all any adapter needs.
export async function retrySync(syncId) {
const { rows } = await pool.query(
`SELECT ms.id, ms.provider, mp.config, c.guest_name, c.guest_email
FROM marketing_sync ms
JOIN marketing_providers mp ON mp.provider = ms.provider
JOIN checkins c ON c.id = ms.checkin_id
WHERE ms.id = $1`,
[syncId]
)
if (!rows.length) throw new Error('Sync row not found')
const row = rows[0]
await attemptPush(syncId, row.provider, row.config, { email: row.guest_email, name: row.guest_name })
}

View file

@ -0,0 +1,33 @@
// Mailchimp adapter — thin stub, same status as sendy.js (see that file's
// header comment). Mailchimp's real subscribe call is a PUT to
// `https://{server_prefix}.api.mailchimp.com/3.0/lists/{audience_id}/members/{md5(email)}`
// with Basic auth (`anystring:{api_key}`) and status `subscribed`/`pending`
// depending on double opt-in requirements — documented here so the TODO is a
// one-call fetch once config.api_key/server_prefix/audience_id exist.
export async function push({ email, name }, config) {
if (!email) throw new Error('Mailchimp push requires an email address')
const { api_key, server_prefix, audience_id } = config || {}
if (!api_key || !server_prefix || !audience_id) {
throw new Error('Mailchimp is active but not fully configured (api_key/server_prefix/audience_id)')
}
// TODO: real call once credentials exist, e.g.:
// const hash = createHash('md5').update(email.toLowerCase()).digest('hex')
// const res = await fetch(
// `https://${server_prefix}.api.mailchimp.com/3.0/lists/${audience_id}/members/${hash}`,
// {
// method: 'PUT',
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Basic ${Buffer.from(`anystring:${api_key}`).toString('base64')}`,
// },
// body: JSON.stringify({
// email_address: email,
// status_if_new: 'subscribed',
// merge_fields: name ? { FNAME: name.split(' ')[0] || '', LNAME: name.split(' ').slice(1).join(' ') } : {},
// }),
// }
// )
// if (!res.ok) throw new Error(`Mailchimp ${res.status}: ${await res.text()}`)
throw new Error('Mailchimp adapter not yet implemented — no live credentials configured')
}

View file

@ -0,0 +1,26 @@
// Sendy adapter — thin stub. Real credentials aren't available yet (see
// project_checkin's marketing opt-in section); the interface + sync-state
// bookkeeping around this (see lib/marketing/index.js) is real, this file
// just needs its TODO filled in with the actual API call once a Sendy
// install URL / API key / list_id are configured under Marketing settings.
//
// Sendy's real subscribe endpoint is a simple form-POST to
// `${install_url}/subscribe` with api_key/list/email/name — documented here
// so the TODO is a one-line fetch once config.install_url is populated.
export async function push({ email, name }, config) {
if (!email) throw new Error('Sendy push requires an email address')
const { api_key, install_url, list_id } = config || {}
if (!api_key || !install_url || !list_id) {
throw new Error('Sendy is active but not fully configured (api_key/install_url/list_id)')
}
// TODO: real call once credentials exist, e.g.:
// const res = await fetch(`${install_url}/subscribe`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
// body: new URLSearchParams({ api_key, list: list_id, email, name: name || '', boolean: 'true' }),
// })
// const text = await res.text()
// if (text.trim() !== '1') throw new Error(`Sendy rejected subscribe: ${text}`)
throw new Error('Sendy adapter not yet implemented — no live credentials configured')
}

194
backend/src/lib/newbook.js Normal file
View file

@ -0,0 +1,194 @@
// Adapted from hvac/backend/src/lib/newbook.js (closest architectural match —
// see project_checkin memory's scaffold checklist). Credentials come from the
// shared settings service, never a docker-compose env var.
const API_BASE = 'https://api.newbook.cloud/rest/'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`)
const s = await res.json()
return {
username: s.username || '',
password: s.password || '',
apiKey: s.api_key || '',
region: s.region || 'eu',
}
}
async function callApi(endpoint, data = {}) {
const creds = await getCredentials()
if (!creds.username || !creds.password || !creds.apiKey) {
throw new Error('NewBook API credentials not configured')
}
const locationId = process.env.NEWBOOK_LOCATION_ID
const body = { ...data, region: creds.region, api_key: creds.apiKey }
if (locationId) body.location_id = locationId
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 30000)
try {
const res = await fetch(API_BASE + endpoint, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` },
body: JSON.stringify(body),
})
clearTimeout(timer)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
} catch (err) {
clearTimeout(timer)
throw err
}
}
// Fetch a single booking by internal booking_id, with its guest list and
// contact details expanded — this is the staff-push path's whole identity
// check (see project_checkin: "staff selecting the booking in NewBook IS the
// identity verification"), so no separate matcher call is needed here.
export async function fetchBooking(bookingId) {
const res = await callApi('bookings_get', { booking_id: bookingId })
return res?.data ?? null
}
// Update a guest's contact_details array (name/address/phone/email/car_rego/
// allow_marketing all live in this one container in NewBook — see
// project_checkin's data-model decision). `contactDetails` is passed through
// as NewBook's own shape; callers build it (see routes/checkin.js).
export async function updateGuestContactDetails(guestId, contactDetails) {
return callApi('guests_update', { guest_id: guestId, contact_details: contactDetails })
}
// Sets one or more top-level (non-custom) fields on a booking. Kept generic
// for any future genuinely-native field — NOT for online_checkin_complete or
// regcard_link, which are per-hotel custom fields (see below), never native
// NewBook fields, despite an earlier draft of this code treating
// online_checkin_complete as if it were one.
export async function updateBooking(bookingId, fields) {
return callApi('bookings_update', { booking_id: bookingId, ...fields })
}
// Sets one or more custom fields on a booking in a single request. This is
// the mechanism the actual legacy contactlesscheckin plugin used — bundled
// directly into bookings_update's own `custom_fields` array (confirmed from
// form_submitted.php / save_pdf_newbook_link.php, see
// project_checkin_legacy_plugin) — NOT `instance_custom_fields_set`, which
// was only ever confirmed working for a different, unrelated plugin
// elsewhere in the archive and isn't established as valid for this account.
// `fields` is an array of { field_id, field_value }; entries with no
// field_id (not yet configured under Settings) are silently dropped so a
// caller can pass a mix of configured/unconfigured fields without checking
// each one first. Returns null (no API call made) if nothing is configured.
export async function setBookingCustomFields(bookingId, fields) {
const custom_fields = fields
.filter(f => f.field_id)
.map(f => ({ field_id: f.field_id, field_value: f.field_value }))
if (!custom_fields.length) return null
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) {
const domain = String(email || '').split('@')[1]?.toLowerCase()
return !!domain && OTA_RELAY_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith(`.${d}`))
}
// Picks the guest whose contact details this session edits. Tablet check-in
// is a single-form flow triggered by staff selecting one booking (no guest
// disambiguation step — see project_checkin's "no matcher at all" decision),
// so this always resolves to the booking's primary guest. Real NewBook
// bookings_get responses mark this with `primary_client: "1"` (confirmed from
// a live sample, 2026-09-01) — matters on multi-guest bookings, where falling
// back to array order instead could write contact details onto the wrong
// guest. Falls back to guests[0] only if no guest is explicitly marked
// primary (defensive — not yet observed on a real response).
export function pickPrimaryGuest(booking) {
if (!booking) return null
if (Array.isArray(booking.guests) && booking.guests.length) {
return booking.guests.find(g => g.primary_client === '1' || g.primary_client === 1) || booking.guests[0]
}
return booking.guest || null
}
// NewBook doesn't expose a single `address` field on a guest — street/city/
// state/postcode/country are all separate top-level fields (confirmed from a
// live sample). Formats them into one display string for the review screen
// and PDF.
export function formatGuestAddress(guest) {
if (!guest) return ''
return [guest.street, guest.city, guest.state_name || guest.state, guest.postcode, guest.country_name || guest.country]
.filter(Boolean)
.join(', ')
}
// Phone/mobile also aren't top-level guest fields — like email and car_rego,
// they live inside contact_details (confirmed from a live sample, which had
// a `type: 'mobile'` entry; the legacy plugin's guest_phones.php also checked
// for a distinct 'phone' type, so both are handled here). Returns the first
// match across the given types, in order.
export function getContactByType(contactDetails, types) {
const list = Array.isArray(contactDetails) ? contactDetails : []
for (const type of types) {
const found = list.find(d => d.type === type)
if (found) return found.content
}
return ''
}
// 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 }) {
const details = Array.isArray(existing) ? [...existing] : []
if (email) {
const idx = details.findIndex(d => d.type === 'email' && String(d.content).toLowerCase() === email.toLowerCase())
if (idx >= 0) {
details[idx] = { ...details[idx], allow_marketing: optedIn ? 1 : 0 }
} else {
details.push({ type: 'email', content: email, allow_transactional: 1, allow_marketing: optedIn ? 1 : 0 })
}
}
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 })
}
}
return details
}
export async function testConnection() {
try {
const res = await callApi('sites_list', {})
const sites = res?.data ?? []
return { ok: true, message: `Connected. Found ${sites.length} site(s).` }
} catch (err) {
return { ok: false, error: err.message }
}
}

95
backend/src/lib/pdf.js Normal file
View file

@ -0,0 +1,95 @@
// Pure-JS PDF generation (pdfkit) — deliberately NOT Puppeteer/Playwright.
// See project_checkin memory: headless-Chromium PDF rendering wedged the
// Rate Monitor app's 2GB LXC before; pdfkit draws text/shapes directly with
// no browser dependency at all.
import PDFDocument from 'pdfkit'
// Builds the signed regcard PDF for one check-in and returns it as a Buffer.
// The signature is drawn in here and then discarded by the caller — no
// separate raw-signature asset is ever persisted (see project_checkin's data
// model decision).
export function generateRegcardPdf({
hotelName,
booking,
guest,
contactEmail,
carRego,
optedIn,
termsText,
signaturePngBase64,
}) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 50 })
const chunks = []
doc.on('data', c => chunks.push(c))
doc.on('end', () => resolve(Buffer.concat(chunks)))
doc.on('error', reject)
// ── Header ────────────────────────────────────────────────────────────
doc.fontSize(18).fillColor('#1a1a2e').text(hotelName || 'Guest Registration', { align: 'left' })
doc.fontSize(10).fillColor('#64748b').text('Guest Registration Card', { align: 'left' })
doc.moveDown(1)
doc.strokeColor('#e4e8ee').moveTo(50, doc.y).lineTo(545, doc.y).stroke()
doc.moveDown(1)
// ── Booking summary ──────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Booking Summary', { underline: false })
doc.fontSize(10).fillColor('#1e293b')
doc.moveDown(0.3)
row(doc, 'Booking #', String(booking.booking_id ?? ''))
row(doc, 'Arrive', formatDate(booking.booking_arrival))
row(doc, 'Depart', formatDate(booking.booking_departure))
row(doc, 'Room / Site', String(booking.site_name ?? ''))
doc.moveDown(1)
// ── Guest details ────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Guest Details')
doc.fontSize(10).fillColor('#1e293b')
doc.moveDown(0.3)
row(doc, 'Name', String(guest?.name ?? ''))
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, 'Marketing opt-in', optedIn ? 'Yes' : 'No')
doc.moveDown(1)
// ── T&Cs ──────────────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Terms & Conditions')
doc.moveDown(0.3)
doc.fontSize(8.5).fillColor('#475569').text(termsText || 'No terms text configured.', {
align: 'justify',
})
doc.moveDown(1.5)
// ── Signature ─────────────────────────────────────────────────────────
doc.fontSize(12).fillColor('#1a1a2e').text('Signature')
doc.moveDown(0.3)
if (signaturePngBase64) {
try {
const b64 = signaturePngBase64.replace(/^data:image\/png;base64,/, '')
const buf = Buffer.from(b64, 'base64')
doc.image(buf, { fit: [220, 90] })
} catch {
doc.fontSize(9).fillColor('#dc2626').text('(signature image could not be rendered)')
}
} else {
doc.fontSize(9).fillColor('#dc2626').text('(no signature captured)')
}
doc.moveDown(1)
doc.fontSize(8).fillColor('#94a3b8').text(`Generated ${new Date().toISOString()}`)
doc.end()
})
}
function row(doc, label, value) {
doc.font('Helvetica-Bold').text(`${label}: `, { continued: true }).font('Helvetica').text(value || '—')
}
function formatDate(v) {
if (!v) return '—'
const d = new Date(v)
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
}

38
backend/src/lib/push.js Normal file
View file

@ -0,0 +1,38 @@
import { randomBytes } from 'crypto'
import { pool, getSetting } from '../db.js'
import { pushNavigate } from './device-stream.js'
const SESSION_TTL_MINUTES = 30
// Shared by routes/staff.js (terminal-key auth, the extension's real call)
// and routes/devices.js's admin test-push (staff JWT auth) — both just need
// "mint a session for this booking on this device", the auth tier differs,
// the logic doesn't. See project_checkin's "Submission flow sequencing".
export async function pushBookingToDevice(deviceId, bookingId) {
const { rows: existing } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId])
if (!existing.length) return { ok: false, status: 404, error: 'Device not found' }
// Atomic claim — the UPDATE's own WHERE status='idle' is the source of
// truth, not the SELECT above (which is only used for a friendly device
// name in the 409 message), so two near-simultaneous pushes to the same
// device can't both "win".
const { rows: claimed } = await pool.query(
`UPDATE devices SET status = 'busy' WHERE device_id = $1 AND status = 'idle' RETURNING *`,
[deviceId]
)
if (!claimed.length) {
return { ok: false, status: 409, error: `${existing[0].name} is busy with another check-in` }
}
const hotelId = (await getSetting('newbook_hotel_id', '')) || process.env.NEWBOOK_LOCATION_ID || ''
const token = randomBytes(24).toString('hex')
const expiresAt = new Date(Date.now() + SESSION_TTL_MINUTES * 60 * 1000)
await pool.query(
`INSERT INTO checkin_sessions (token, device_id, booking_id, hotel_id, expires_at) VALUES ($1,$2,$3,$4,$5)`,
[token, deviceId, String(bookingId), hotelId, expiresAt]
)
const delivered = pushNavigate(deviceId, token)
return { ok: true, token, delivered }
}

View file

@ -0,0 +1,26 @@
// Regcard PDF storage — standard uploads_data:/app/uploads volume pattern
// (matches cashup/calendar/hvac/plant). Files are never served as a bare
// 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 { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads')
// Saves a regcard PDF for one check-in and returns the path stored in
// checkins.pdf_path (relative to UPLOADS_DIR, so the DB never hardcodes an
// absolute host path).
export async function saveRegcardPdf(checkinId, buffer) {
const dir = join(UPLOADS_DIR, 'regcards')
await mkdir(dir, { recursive: true })
const relPath = `/regcards/${checkinId}.pdf`
await writeFile(join(UPLOADS_DIR, relPath), buffer)
return relPath
}
export function absolutePath(relPath) {
return join(UPLOADS_DIR, relPath)
}

View file

@ -0,0 +1,209 @@
import { randomUUID } from 'crypto'
import { pool, getSetting } from '../db.js'
import {
fetchBooking, updateGuestContactDetails, setBookingCustomFields,
buildContactDetails, pickPrimaryGuest, isRelayEmail,
formatGuestAddress, getContactByType,
} from '../lib/newbook.js'
import { generateRegcardPdf } from '../lib/pdf.js'
import { saveRegcardPdf } from '../lib/uploads.js'
import { queueMarketingSync } from '../lib/marketing/index.js'
import { pushIdle } from '../lib/device-stream.js'
const DEFAULT_TERMS = 'No terms & conditions text has been configured yet — set one under Settings.'
// Public/token-gated — deliberately NOT behind requireAuth. The
// checkin_sessions.token itself (short-lived, single-use, minted only by an
// already-authenticated staff push or, later, the online matcher) is the
// authorization here, same as every other app's write routes are gated by a
// capability — this route group is gated by "possession of a live token"
// instead. See project_checkin's "Submission flow sequencing + schema".
export async function checkinRoutes(app) {
app.get('/api/checkin/:token', async (req, reply) => {
const session = await loadSession(req.params.token)
if (!session.ok) return reply.status(session.status).send({ error: session.error })
let booking
try {
booking = await fetchBooking(session.row.booking_id)
} catch (err) {
return reply.status(502).send({ error: `Could not reach NewBook: ${err.message}` })
}
if (!booking) return reply.status(404).send({ error: 'Booking not found' })
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 || ''
return {
booking: {
booking_id: booking.booking_id ?? session.row.booking_id,
arrival: booking.booking_arrival ?? null,
departure: booking.booking_departure ?? null,
site_name: booking.site_name ?? null,
tariff_total: booking.tariff_total ?? null,
nights: booking.booking_length ?? null,
},
guest: {
guest_id: guest?.guest_id ?? null,
name: [guest?.firstname, guest?.lastname].filter(Boolean).join(' ') || '',
address: formatGuestAddress(guest),
phone: getContactByType(contactDetails, ['mobile', 'phone']),
existing_emails: existingEmails,
car_rego: existingCarRego,
},
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 || {}
if (!signature_png_base64 || !accepted_terms) {
return reply.status(400).send({ error: 'Signature and accepted terms are required' })
}
// Step 1 — atomic claim. Both double-submit AND a token that expired
// between page-load and submit are rejected here outright.
const { rows: claimedRows } = await pool.query(
`UPDATE checkin_sessions SET used_at = now()
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
RETURNING *`,
[req.params.token]
)
if (!claimedRows.length) {
return reply.status(409).send({ error: 'This check-in link has already been used or has expired.' })
}
const session = claimedRows[0]
let booking, guest
try {
booking = await fetchBooking(session.booking_id)
guest = pickPrimaryGuest(booking)
if (!guest) throw new Error('Could not resolve a guest for this booking')
} catch (err) {
await failCheckin(session, `Could not re-fetch booking from NewBook: ${err.message}`)
return reply.status(502).send({ error: 'Could not load your booking — please ask a member of staff for help.' })
}
const contactDetails = buildContactDetails(guest.contact_details, {
email: email?.trim() || null,
carRego: car_rego?.trim() || null,
optedIn: !!opted_in,
})
// Step 2 — NewBook write-back first. Only the guest's own contact details
// are treated as business-critical (must succeed or the check-in fails
// outright) — that's real guest data with no fallback. online_checkin_complete
// is a per-hotel custom field, same shape of problem as regcard_link below
// (its numeric field_id varies per hotel and may not be configured yet) —
// non-fatal for the same reason: a missing admin setting shouldn't block a
// guest from actually checking in.
try {
await retry(() => updateGuestContactDetails(guest.guest_id, contactDetails), 2)
} catch (err) {
await failCheckin(session, `NewBook update failed: ${err.message}`)
return reply.status(502).send({ error: 'Could not save your details — please ask a member of staff for help.' })
}
const checkinCompleteFieldId = await getSetting('online_checkin_complete_custom_field_id', '')
if (checkinCompleteFieldId) {
await retry(() => setBookingCustomFields(session.booking_id, [
{ field_id: checkinCompleteFieldId, field_value: 1 },
]), 2).catch(err => {
app.log.warn(`[checkin] online_checkin_complete write-back failed: ${err.message}`)
})
}
// Local audit row, inserted now that the business-critical write has
// landed — status starts 'pending' and only flips to 'complete' once the
// PDF is generated below, so a PDF failure never loses the record.
const guestName = [guest.firstname, guest.lastname].filter(Boolean).join(' ') || guest.name || ''
const { rows: checkinRows } = await pool.query(
`INSERT INTO checkins (booking_id, guest_id, hotel_id, device_id, guest_name, guest_email, opted_in, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending') RETURNING *`,
[session.booking_id, guest.guest_id, session.hotel_id, session.device_id, guestName, email || null, !!opted_in]
)
const checkin = checkinRows[0]
// Step 3 — PDF generation (synchronous, pure-JS — no headless browser).
try {
const buffer = await generateRegcardPdf({
hotelName: process.env.HOTEL_NAME || 'Hotel',
booking,
guest: { name: guestName, address: formatGuestAddress(guest), phone: getContactByType(guest.contact_details, ['mobile', 'phone']) },
contactEmail: email,
carRego: car_rego,
optedIn: !!opted_in,
termsText: await getSetting('terms_and_conditions_text', DEFAULT_TERMS),
signaturePngBase64: signature_png_base64,
})
const pdfPath = await saveRegcardPdf(randomUUID(), buffer)
await pool.query(`UPDATE checkins SET pdf_path = $1, status = 'complete' WHERE id = $2`, [pdfPath, checkin.id])
checkin.pdf_path = pdfPath
checkin.status = 'complete'
// Step 4 — write the PDF link back to NewBook, non-fatal if it fails
// (the guest is still checked in either way — reception can find the
// PDF from this app's own Checkins page regardless).
const fieldId = await getSetting('regcard_custom_field_id', '')
if (fieldId) {
const publicUrl = `${(process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '')}/tablet-checkin/api/checkins/${checkin.id}/pdf`
await setBookingCustomFields(session.booking_id, [
{ field_id: fieldId, field_value: publicUrl },
]).catch(err => {
app.log.warn(`[checkin] regcard_link custom field write-back failed: ${err.message}`)
})
}
} catch (err) {
await pool.query(`UPDATE checkins SET status = 'pending', error = $1 WHERE id = $2`, [err.message, checkin.id])
app.log.error(`[checkin] PDF generation failed for checkin ${checkin.id}: ${err.message}`)
}
// Step 6 — marketing push, fire-and-forget, never blocks the response.
if (opted_in) {
queueMarketingSync(checkin.id, { email, name: guestName }).catch(err => {
app.log.error(`[checkin] queueMarketingSync failed for checkin ${checkin.id}: ${err.message}`)
})
}
// Step 7 — flip the device back to idle so the next guest can be pushed.
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id])
pushIdle(session.device_id)
return { ok: true, checkin_id: checkin.id, pdf_generated: checkin.status === 'complete' }
})
}
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' }
const row = rows[0]
if (row.used_at) return { ok: false, status: 410, error: 'This check-in link has already been used' }
if (new Date(row.expires_at) < new Date()) return { ok: false, status: 410, error: 'This check-in link has expired' }
return { ok: true, row }
}
async function failCheckin(session, errorMessage) {
await pool.query(
`INSERT INTO checkins (booking_id, guest_id, hotel_id, device_id, status, error)
VALUES ($1, NULL, $2, $3, 'failed', $4)`,
[session.booking_id, session.hotel_id, session.device_id, errorMessage]
)
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [session.device_id])
pushIdle(session.device_id)
}
async function retry(fn, times) {
let lastErr
for (let i = 0; i <= times; i++) {
try {
return await fn()
} catch (err) {
lastErr = err
}
}
throw lastErr
}

View file

@ -0,0 +1,47 @@
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { absolutePath } from '../lib/uploads.js'
// Staff-facing audit browser (Dashboard page) — separate from the public
// token-gated flow in routes/checkin.js. Everything here needs the 'admin'
// capability: this data is guest PII, not general 'view'-level information.
export async function checkinsRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/checkins', async (req) => {
const limit = Math.min(parseInt(req.query?.limit) || 100, 500)
const { rows } = await pool.query(
`SELECT c.*, d.name AS device_name,
(SELECT json_agg(json_build_object('provider', ms.provider, 'status', ms.status, 'error', ms.error))
FROM marketing_sync ms WHERE ms.checkin_id = c.id) AS marketing_sync
FROM checkins c
LEFT JOIN devices d ON d.device_id = c.device_id
ORDER BY c.submitted_at DESC
LIMIT $1`,
[limit]
)
return rows
})
// Streams the PDF rather than serving it from a bare static path — this
// holds a signature image + guest PII, so retrieval must go through the
// same admin capability check as the rest of this file, not nginx statics.
app.get('/api/checkins/:id/pdf', async (req, reply) => {
const { rows } = await pool.query('SELECT pdf_path, booking_id FROM checkins WHERE id = $1', [req.params.id])
if (!rows.length || !rows[0].pdf_path) return reply.status(404).send({ error: 'No PDF available for this check-in' })
const filePath = absolutePath(rows[0].pdf_path)
try {
await stat(filePath)
} catch {
return reply.status(404).send({ error: 'PDF file is missing on disk' })
}
reply.header('Content-Type', 'application/pdf')
reply.header('Content-Disposition', `inline; filename="regcard-${rows[0].booking_id}.pdf"`)
return reply.send(createReadStream(filePath))
})
}

View file

@ -0,0 +1,144 @@
import { randomUUID } from 'crypto'
import { requireAuth, requireCap, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { addStream, removeStream, pushIdle } from '../lib/device-stream.js'
import { pushBookingToDevice } from '../lib/push.js'
// 'checkin' is a deliberately unofficial capability slug — it is never seeded
// into app_capabilities (see seed-app.js, which only defines push/admin/
// settings) and so can never be granted to a human staff role via the portal.
// It only ever appears inside a JWT minted by routes/kiosk.js's own login —
// i.e. "this request came from a tablet that knows the kiosk password", not
// "this staff member has a capability". Scoping the tablet's self-service
// routes (register/heartbeat/stream/reset) to it keeps them unreachable by a
// normal staff login and unreachable by a stale/guessed cookie.
const KIOSK_CAP = 'checkin'
export async function deviceRoutes(app) {
app.addHook('preHandler', requireAuth)
// ── Kiosk-side self-service (tablet_checkin_kiosk_session only) ─────────
// POST /api/devices/register — { device_id?, name? }. If device_id is
// omitted or unknown (fresh tablet / localStorage cleared), the server
// mints a new one and a placeholder name — staff rename it from the
// Devices admin page afterwards. If device_id is known, this doubles as a
// heartbeat + is idempotent to call repeatedly on every idle-page load.
app.post('/api/devices/register', { preHandler: requireCap(KIOSK_CAP) }, async (req) => {
const { device_id, name } = req.body || {}
if (device_id) {
const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [device_id])
if (rows.length) {
await pool.query('UPDATE devices SET last_seen = now() WHERE device_id = $1', [device_id])
return rows[0]
}
}
const newId = device_id || randomUUID()
const { rows } = await pool.query(
`INSERT INTO devices (device_id, name, status, last_seen) VALUES ($1, $2, 'idle', now()) RETURNING *`,
[newId, name || 'New Tablet']
)
return rows[0]
})
app.post('/api/devices/:id/heartbeat', { preHandler: requireCap(KIOSK_CAP) }, async (req, reply) => {
const { rows } = await pool.query(
'UPDATE devices SET last_seen = now() WHERE device_id = $1 RETURNING *',
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Device not found — re-register' })
return rows[0]
})
// GET /api/devices/:id/stream — SSE held open by the tablet's idle page.
// Emits 'navigate' (staff.js push) and 'idle' (reset). A comment ping every
// 20s keeps intermediate proxies (nginx) from timing the connection out.
app.get('/api/devices/:id/stream', { preHandler: requireCap(KIOSK_CAP) }, async (req, reply) => {
const deviceId = req.params.id
// Takes the response out of Fastify's normal send lifecycle — without
// this, Fastify tries to send its own reply once this async handler
// resolves (which happens immediately after registering listeners
// below, long before the SSE connection actually closes), colliding
// with the raw writes already made to reply.raw.
reply.hijack()
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
})
reply.raw.write(': connected\n\n')
addStream(deviceId, reply.raw)
await pool.query('UPDATE devices SET last_seen = now() WHERE device_id = $1', [deviceId])
const ping = setInterval(() => {
try { reply.raw.write(': ping\n\n') } catch { /* connection already closed */ }
}, 20000)
req.raw.on('close', () => {
clearInterval(ping)
removeStream(deviceId, reply.raw)
})
})
// POST /api/devices/:id/reset — the "safety net" reset/cancel button
// reachable from mid-flow on the tablet itself, plus an admin-usable
// equivalent for an operator who spots a stuck device from the Devices
// page. Expires any live session for the device and flips it back to idle.
app.post('/api/devices/:id/reset', {
preHandler: async (req, reply) => {
if (!hasCap(req, KIOSK_CAP) && !hasCap(req, 'admin')) {
return reply.status(403).send({ error: 'Missing capability: checkin or admin' })
}
},
}, async (req, reply) => {
const deviceId = req.params.id
const { rows } = await pool.query('SELECT * FROM devices WHERE device_id = $1', [deviceId])
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
await pool.query(
`UPDATE checkin_sessions SET used_at = now() WHERE device_id = $1 AND used_at IS NULL`,
[deviceId]
)
await pool.query(`UPDATE devices SET status = 'idle' WHERE device_id = $1`, [deviceId])
pushIdle(deviceId)
return { ok: true }
})
// ── Staff-side admin (standard AuthGate, admin cap) ──────────────────────
app.get('/api/devices', { preHandler: requireCap('admin') }, async () => {
const { rows } = await pool.query('SELECT * FROM devices ORDER BY name')
return rows
})
app.patch('/api/devices/:id', { preHandler: requireCap('admin') }, async (req, reply) => {
const { name } = req.body || {}
if (!name || !String(name).trim()) return reply.status(400).send({ error: 'name is required' })
const { rows } = await pool.query(
'UPDATE devices SET name = $1 WHERE device_id = $2 RETURNING *',
[String(name).trim(), req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
return rows[0]
})
app.delete('/api/devices/:id', { preHandler: requireCap('admin') }, async (req, reply) => {
const { rowCount } = await pool.query('DELETE FROM devices WHERE device_id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'Device not found' })
return { ok: true }
})
// POST /api/devices/:id/test-push — manual "Push to Tablet" from inside the
// app itself (Devices page), same underlying logic the extension's
// terminal-key-authed route uses (see lib/push.js). Exists so the 'push'
// capability has a real, testable surface without needing the extension
// installed — e.g. to verify a tablet's SSE connection is alive.
app.post('/api/devices/:id/test-push', { preHandler: requireCap('push') }, async (req, reply) => {
const { booking_id } = req.body || {}
if (!booking_id) return reply.status(400).send({ error: 'booking_id is required' })
const result = await pushBookingToDevice(req.params.id, booking_id)
if (!result.ok) return reply.status(result.status).send({ error: result.error })
return { ok: true, delivered: result.delivered }
})
}

View file

@ -0,0 +1,60 @@
import { SignJWT } from 'jose'
import bcrypt from 'bcryptjs'
import { getSetting } from '../db.js'
import { KIOSK_COOKIE } from '../auth.js'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
const APP_SLUG = process.env.APP_SLUG || 'tablet-checkin'
// ~10 years — a wall-mounted/counter tablet's whole point is entering this
// once and never again. Mirrors hvac's kiosk session exactly, including the
// distinct-cookie-name fix (see auth.js's KIOSK_COOKIE comment) — that bug is
// specifically what this file is built to avoid repeating from scratch.
const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650
function cookieOpts(request) {
const proto = request.headers['x-forwarded-proto'] || request.protocol
return {
httpOnly: true,
secure: proto === 'https',
sameSite: 'lax',
path: '/',
maxAge: KIOSK_SESSION_SECONDS,
}
}
export async function kioskRoutes(app) {
// Deliberately outside requireAuth — this IS the login route. Narrow caps
// only: a tablet can register/heartbeat itself and run the guest check-in
// flow, never reach Devices/Terminals/Marketing/Settings admin pages.
app.post('/api/kiosk/login', async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string') {
return reply.status(400).send({ error: 'Password required' })
}
const hash = await getSetting('kiosk_password_hash', '')
if (!hash) {
return reply.status(503).send({ error: 'Kiosk login has not been set up yet — set a Kiosk Password under Settings first.' })
}
const ok = await bcrypt.compare(password, hash)
if (!ok) return reply.status(401).send({ error: 'Incorrect password' })
const token = await new SignJWT({
apps: [APP_SLUG],
caps: [`${APP_SLUG}:checkin`], // narrower than any staff cap set — see routes/checkin.js
is_admin: false,
offsite_allowed: true, // physical on-site tablet — no IP-based onsite check needed
name: 'Kiosk',
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject('tablet-checkin-kiosk')
.setIssuedAt()
.setExpirationTime(`${KIOSK_SESSION_SECONDS}s`)
.sign(secret)
reply.setCookie(KIOSK_COOKIE, token, cookieOpts(req))
return { ok: true }
})
}

View file

@ -0,0 +1,48 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
import { retrySync, ADAPTERS } from '../lib/marketing/index.js'
export async function marketingRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/marketing/providers', async () => {
const { rows } = await pool.query('SELECT provider, active, config, updated_at FROM marketing_providers ORDER BY provider')
return rows.map(r => ({ ...r, implemented: !!ADAPTERS[r.provider] }))
})
app.put('/api/marketing/providers/:provider', async (req, reply) => {
const { provider } = req.params
if (!ADAPTERS[provider]) return reply.status(400).send({ error: `Unknown provider: ${provider}` })
const { active, config } = req.body || {}
const { rows } = await pool.query(
`UPDATE marketing_providers SET active = $1, config = $2, updated_at = now() WHERE provider = $3 RETURNING *`,
[!!active, JSON.stringify(config || {}), provider]
)
if (!rows.length) return reply.status(404).send({ error: 'Provider not found' })
return rows[0]
})
// Failed-sync list for the Marketing admin page's retry UI.
app.get('/api/marketing/sync', async (req) => {
const status = req.query?.status || 'failed'
const { rows } = await pool.query(
`SELECT ms.*, c.booking_id, c.guest_name, c.guest_email
FROM marketing_sync ms JOIN checkins c ON c.id = ms.checkin_id
WHERE ms.status = $1
ORDER BY ms.created_at DESC
LIMIT 200`,
[status]
)
return rows
})
app.post('/api/marketing/sync/:id/retry', async (req, reply) => {
try {
await retrySync(parseInt(req.params.id))
return { ok: true }
} catch (err) {
return reply.status(400).send({ error: err.message })
}
})
}

View file

@ -0,0 +1,55 @@
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js'
import { getSetting, setSetting } from '../db.js'
import { testConnection } from '../lib/newbook.js'
// Generic key/value settings — cross-app-integration-credentials pattern (see
// feedback_app_conventions). kiosk_password_hash deliberately excluded from
// both GET and the ALLOWED_KEYS allowlist: it's a password hash, never
// readable back to the browser, only settable via its own dedicated route
// below (mirrors hvac's Settings -> Kiosk pattern).
const ALLOWED_KEYS = [
'newbook_hotel_id', 'regcard_custom_field_id', 'online_checkin_complete_custom_field_id',
'terms_and_conditions_text',
]
export async function settingsRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('settings'))
app.get('/api/settings', async () => {
const out = {}
for (const key of ALLOWED_KEYS) out[key] = await getSetting(key, '')
return out
})
app.put('/api/settings', async (req, reply) => {
const body = req.body || {}
for (const key of Object.keys(body)) {
if (!ALLOWED_KEYS.includes(key)) return reply.status(400).send({ error: `Unknown or protected setting: ${key}` })
}
for (const [key, value] of Object.entries(body)) {
await setSetting(key, String(value ?? ''))
}
return { ok: true }
})
app.post('/api/settings/newbook-test', async () => {
return testConnection()
})
app.get('/api/settings/kiosk-password-status', async () => {
const hash = await getSetting('kiosk_password_hash', '')
return { configured: !!hash }
})
app.post('/api/settings/kiosk-password', async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string' || password.length < 6) {
return reply.status(400).send({ error: 'Password must be at least 6 characters' })
}
const hash = await bcrypt.hash(password, 12)
await setSetting('kiosk_password_hash', hash)
return { ok: true }
})
}

View file

@ -0,0 +1,29 @@
import { requireTerminalAuth } from '../auth.js'
import { pool } from '../db.js'
import { pushBookingToDevice } from '../lib/push.js'
// The standalone Chrome extension's calls — terminal-key authenticated (NOT
// a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's
// "Extension auth" decision.
export async function staffRoutes(app) {
// GET /api/staff/devices — device picker source for the extension's popup.
// Deliberately its own minimal read (id/name/status only, no last_seen/
// created_at) rather than reusing the admin GET /api/devices response
// shape — the extension has no staff login, only a terminal key, so it
// gets the narrowest view that's actually useful for picking a target.
app.get('/api/staff/devices', { preHandler: requireTerminalAuth }, async () => {
const { rows } = await pool.query('SELECT device_id, name, status FROM devices ORDER BY name')
return rows
})
app.post('/api/staff/push', { preHandler: requireTerminalAuth }, async (req, reply) => {
const { booking_id, device_id } = req.body || {}
if (!booking_id || !device_id) {
return reply.status(400).send({ error: 'booking_id and device_id are required' })
}
const result = await pushBookingToDevice(device_id, booking_id)
if (!result.ok) return reply.status(result.status).send({ error: result.error })
return { ok: true, delivered: result.delivered }
})
}

View file

@ -0,0 +1,54 @@
import { randomUUID, randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js'
import { pool } from '../db.js'
// Admin-capped CRUD for the standalone Chrome extension's per-terminal API
// keys (see auth.js's requireTerminalAuth, consumed by routes/staff.js). The
// raw key is returned exactly once, at creation — only its bcrypt hash is
// ever stored, so a lost key means "revoke and issue a new one", not
// "look it up again".
export async function terminalRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('admin'))
app.get('/api/terminals', async () => {
const { rows } = await pool.query(
`SELECT terminal_id, name, created_at, last_used_at, revoked_at FROM terminals ORDER BY created_at DESC`
)
return rows
})
app.post('/api/terminals', async (req, reply) => {
const { name } = req.body || {}
if (!name || !String(name).trim()) return reply.status(400).send({ error: 'name is required' })
const rawKey = randomBytes(32).toString('hex')
const hash = await bcrypt.hash(rawKey, 12)
const terminalId = randomUUID()
await pool.query(
`INSERT INTO terminals (terminal_id, name, api_key_hash) VALUES ($1, $2, $3)`,
[terminalId, String(name).trim(), hash]
)
// The only time the raw key is ever returned — the extension's settings
// popup must capture it now (chrome.storage.local) or a new key needs issuing.
return { terminal_id: terminalId, name: String(name).trim(), api_key: rawKey }
})
app.post('/api/terminals/:id/revoke', async (req, reply) => {
const { rows } = await pool.query(
`UPDATE terminals SET revoked_at = now() WHERE terminal_id = $1 AND revoked_at IS NULL RETURNING terminal_id`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Terminal not found or already revoked' })
return { ok: true }
})
app.delete('/api/terminals/:id', async (req, reply) => {
const { rowCount } = await pool.query('DELETE FROM terminals WHERE terminal_id = $1', [req.params.id])
if (!rowCount) return reply.status(404).send({ error: 'Terminal not found' })
return { ok: true }
})
}

44
docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
services:
backend:
build: ./backend
security_opt:
- apparmor=unconfined
environment:
- DATABASE_URL=${DATABASE_URL}
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
- SETTINGS_URL=${SETTINGS_URL}
- SETTINGS_SECRET=${SETTINGS_SECRET}
- APP_SLUG=tablet-checkin
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
- NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID:-}
- HOTEL_NAME=${SITE_NAME:-Hotel}
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
volumes:
- uploads_data:/app/uploads
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
interval: 10s
retries: 5
start_period: 20s
restart: unless-stopped
frontend:
build:
context: ./frontend
args:
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
security_opt:
- apparmor=unconfined
ports:
- "${FRONTEND_PORT:-3080}:80"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
networks:
default:
driver: bridge
volumes:
uploads_data:

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/tablet-checkin
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

18
frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Tablet Check-In" />
<link rel="apple-touch-icon" href="/tablet-checkin/icons/icon-192.png" />
<meta name="theme-color" content="#be123c" />
<title>Tablet Check-In</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

53
frontend/nginx.conf Normal file
View file

@ -0,0 +1,53 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
client_max_body_size 12m;
location /tablet-checkin/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SSE (devices/:id/stream) needs buffering off and no timeout — otherwise
# nginx buffers the event-stream response and the tablet's idle page
# never actually receives a push in real time.
location /tablet-checkin/api/devices/ {
proxy_pass http://backend:3001/api/devices/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 3600s;
add_header Cache-Control "no-store";
}
location /tablet-checkin/api/ {
proxy_pass http://backend:3001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location /tablet-checkin/health {
proxy_pass http://backend:3001/health;
}
location ~* /tablet-checkin/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /tablet-checkin/ {
add_header Cache-Control "no-cache" always;
try_files $uri /tablet-checkin/index.html;
}
location = / {
return 301 /tablet-checkin/;
}
}

6320
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "hnf-tablet-checkin-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"react-signature-canvas": "^1.0.6"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react-signature-canvas": "^1.0.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vite-plugin-pwa": "^1.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,13 @@
{
"name": "Tablet Check-In Kiosk",
"short_name": "Check-In Kiosk",
"start_url": "/tablet-checkin/kiosk",
"scope": "/tablet-checkin/kiosk",
"display": "standalone",
"theme_color": "#be123c",
"background_color": "#be123c",
"icons": [
{ "src": "/tablet-checkin/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/tablet-checkin/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

55
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,55 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
import AuthGate, { useAuth } from './components/AuthGate'
import IosInstallHint from './components/IosInstallHint'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Devices from './pages/Devices'
import Terminals from './pages/Terminals'
import Marketing from './pages/Marketing'
import Settings from './pages/Settings'
import Kiosk from './pages/kiosk/Kiosk'
import { can } from './types'
function Home() {
const { user } = useAuth()
if (can(user, 'admin')) return <Navigate to="/checkins" replace />
if (can(user, 'settings')) return <Navigate to="/settings" replace />
return <div className="page"><p className="muted">You don't have access to any Tablet Check-In pages yet.</p></div>
}
// The normal staff SPA shell — everything except /kiosk goes through the
// central-auth AuthGate + sidebar Layout.
function StaffShell() {
return (
<AuthGate>
<Layout>
<Outlet />
</Layout>
</AuthGate>
)
}
export default function App() {
return (
<>
<BrowserRouter basename="/tablet-checkin">
<Routes>
{/* Kiosk: its own full-screen shell, own login (a dedicated kiosk
password, not central auth) — deliberately outside StaffShell.
See pages/kiosk/Kiosk.tsx and backend/src/routes/kiosk.js. */}
<Route path="/kiosk" element={<Kiosk />} />
<Route element={<StaffShell />}>
<Route path="/" element={<Home />} />
<Route path="/checkins" element={<Dashboard />} />
<Route path="/devices" element={<Devices />} />
<Route path="/terminals" element={<Terminals />} />
<Route path="/marketing" element={<Marketing />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
<IosInstallHint />
</>
)
}

138
frontend/src/api.ts Normal file
View file

@ -0,0 +1,138 @@
import type {
Device, Terminal, CreatedTerminal, Checkin, MarketingProvider, MarketingSyncRow,
AppSettings, CheckinTokenData, SubmitCheckinPayload,
} from './types'
const BASE = '/tablet-checkin/api'
// ── Staff requests (standard AuthGate session) ──────────────────────────────
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Devices
export function fetchDevices(): Promise<Device[]> { return request('/devices') }
export function renameDevice(deviceId: string, name: string): Promise<Device> {
return request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify({ name }) })
}
export function deleteDevice(deviceId: string): Promise<{ ok: boolean }> {
return request(`/devices/${deviceId}`, { method: 'DELETE' })
}
export function testPushDevice(deviceId: string, bookingId: string): Promise<{ ok: boolean; delivered: boolean }> {
return request(`/devices/${deviceId}/test-push`, { method: 'POST', body: JSON.stringify({ booking_id: bookingId }) })
}
export function resetDevice(deviceId: string): Promise<{ ok: boolean }> {
return request(`/devices/${deviceId}/reset`, { method: 'POST', body: JSON.stringify({}) })
}
// Terminals
export function fetchTerminals(): Promise<Terminal[]> { return request('/terminals') }
export function createTerminal(name: string): Promise<CreatedTerminal> {
return request('/terminals', { method: 'POST', body: JSON.stringify({ name }) })
}
export function revokeTerminal(id: string): Promise<{ ok: boolean }> {
return request(`/terminals/${id}/revoke`, { method: 'POST', body: JSON.stringify({}) })
}
export function deleteTerminal(id: string): Promise<{ ok: boolean }> {
return request(`/terminals/${id}`, { method: 'DELETE' })
}
// Checkins (audit)
export function fetchCheckins(limit = 100): Promise<Checkin[]> { return request(`/checkins?limit=${limit}`) }
export function checkinPdfUrl(id: number): string { return `${BASE}/checkins/${id}/pdf` }
// Marketing
export function fetchMarketingProviders(): Promise<MarketingProvider[]> { return request('/marketing/providers') }
export function updateMarketingProvider(provider: string, body: { active: boolean; config: Record<string, string> }): Promise<MarketingProvider> {
return request(`/marketing/providers/${provider}`, { method: 'PUT', body: JSON.stringify(body) })
}
export function fetchFailedSyncs(): Promise<MarketingSyncRow[]> { return request('/marketing/sync?status=failed') }
export function retryMarketingSync(id: number): Promise<{ ok: boolean }> {
return request(`/marketing/sync/${id}/retry`, { method: 'POST', body: JSON.stringify({}) })
}
// Settings
export function fetchSettings(): Promise<AppSettings> { return request('/settings') }
export function updateSettings(body: Partial<AppSettings>): Promise<{ ok: boolean }> {
return request('/settings', { method: 'PUT', body: JSON.stringify(body) })
}
export function testNewbookConnection(): Promise<{ ok: boolean; message?: string; error?: string }> {
return request('/settings/newbook-test', { method: 'POST', body: JSON.stringify({}) })
}
export function fetchKioskPasswordStatus(): Promise<{ configured: boolean }> {
return request('/settings/kiosk-password-status')
}
export function setKioskPassword(password: string): Promise<{ ok: boolean }> {
return request('/settings/kiosk-password', { method: 'POST', body: JSON.stringify({ password }) })
}
// ── Kiosk (tablet's own persistent session) ─────────────────────────────────
// Deliberately separate from request() above: a 401 here means "the kiosk
// login lapsed (or was never set up)" — the right response is re-showing the
// on-screen password prompt, not redirecting the tablet to the staff portal.
export class KioskSessionError extends Error {}
async function krequest<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) throw new KioskSessionError('Kiosk not authenticated')
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
export function kioskLogin(password: string): Promise<{ ok: boolean }> {
return krequest('/kiosk/login', { method: 'POST', body: JSON.stringify({ password }) })
}
export function kioskRegisterDevice(deviceId: string | null, name?: string): Promise<Device> {
return krequest('/devices/register', { method: 'POST', body: JSON.stringify({ device_id: deviceId, name }) })
}
export function kioskHeartbeat(deviceId: string): Promise<Device> {
return krequest(`/devices/${deviceId}/heartbeat`, { method: 'POST', body: JSON.stringify({}) })
}
export function kioskReset(deviceId: string): Promise<{ ok: boolean }> {
return krequest(`/devices/${deviceId}/reset`, { method: 'POST', body: JSON.stringify({}) })
}
export function kioskStreamUrl(deviceId: string): string {
return `${BASE}/devices/${deviceId}/stream`
}
// ── Public token flow (no auth cookie required at all — the token itself is
// the authorization, see backend/src/routes/checkin.js) ────────────────────
async function tokenRequest<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
export function fetchCheckinToken(token: string): Promise<CheckinTokenData> {
return tokenRequest(`/checkin/${token}`)
}
export function submitCheckin(token: string, payload: SubmitCheckinPayload): Promise<{ ok: boolean; checkin_id: number; pdf_generated: boolean }> {
return tokenRequest(`/checkin/${token}/submit`, { method: 'POST', body: JSON.stringify(payload) })
}

View file

@ -0,0 +1,169 @@
import { useEffect, useRef, useState, createContext, useContext } from 'react'
import type { User } from '../types'
function getInactivityMs(): number | null {
if (window.matchMedia('(display-mode: standalone)').matches) return null
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
if (!c) return null
const mins = parseInt(c.split('=')[1])
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
}
// Only bounce to the central login when actually embedded in the portal shell.
// A directly-opened browser tab must never navigate away from its own scope.
function isEmbedded() {
return window.top !== window
}
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
const [user, setUser] = useState<User | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetch('/api/auth/verify?app=tablet-checkin', { credentials: 'include' })
.then(async r => {
if (r.ok) {
setUser(await r.json())
setState('authed')
} else if (isEmbedded()) {
window.top!.location.href = `/login?from=${encodeURIComponent('/app/tablet-checkin')}`
} else {
setState('login')
}
})
.catch(() => { if (!isEmbedded()) setState('login') })
}, [])
// Inactivity auto-logout — configurable per device (Admin Settings → Device).
useEffect(() => {
const ms = getInactivityMs()
if (state !== 'authed' || !ms) return
const timeoutMs: number = ms
async function forceLogout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
setUser(null)
setState('login')
}
function reset() {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(forceLogout, timeoutMs)
}
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
reset()
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach(e => window.removeEventListener(e, reset))
}
}, [state])
async function login(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await fetch('/api/auth/login', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) { setError('Invalid email or password'); return }
const verify = await fetch('/api/auth/verify?app=tablet-checkin', { credentials: 'include' })
if (verify.ok) {
setUser(await verify.json())
setState('authed')
} else {
setError("You don't have access to this app.")
}
} catch {
setError('Connection error — please try again')
} finally {
setLoading(false)
}
}
if (state === 'checking') {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
}}>
Loading…
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--gold)' }}>
Tablet Check-In
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email"
style={inputStyle}
/>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password"
style={inputStyle}
/>
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={{
background: loading ? 'var(--surface-2)' : 'var(--gold)',
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
border: 'none', borderRadius: '6px', padding: '0.625rem',
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
}}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return (
<Ctx.Provider value={{ user: user! }}>
{children}
</Ctx.Provider>
)
}
const inputStyle: React.CSSProperties = {
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
fontSize: '1rem', width: '100%', outline: 'none',
}

View file

@ -0,0 +1,89 @@
import { useEffect, useState } from 'react'
import { Share, ExternalLink, X } from 'lucide-react'
function isIos() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
// iPadOS 13+ reports as 'MacIntel' but has touch support, unlike a real Mac
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true
}
// Safari's UA also matches "Safari", so this checks for the other iOS
// browsers/in-app webviews that spoof it — none of them can add to the
// home screen; only Safari itself can.
function isNonSafariIosBrowser() {
return /CriOS|FxiOS|EdgiOS|OPiOS|mercury|GSA|DuckDuckGo|Instagram|FBAN|FBAV|Line\//.test(navigator.userAgent)
}
/**
* iOS has no `beforeinstallprompt` API — Safari never fires it, so the
* portal's Install button (?install=1) does nothing there. This shows the
* manual steps instead: Share -> Add to Home Screen in Safari, or a prompt
* to switch to Safari first if the page was opened in another browser/app.
*/
export default function IosInstallHint() {
const [mode, setMode] = useState<'safari' | 'other' | null>(null)
useEffect(() => {
if (!isIos() || isStandalone()) return
if (!new URLSearchParams(window.location.search).has('install')) return
setMode(isNonSafariIosBrowser() ? 'other' : 'safari')
}, [])
if (!mode) return null
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(15,15,32,0.55)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
padding: '0 16px 24px',
}}>
<div style={{
position: 'relative',
width: '100%', maxWidth: '360px',
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-md)',
padding: '20px', textAlign: 'center',
}}>
<button
onClick={() => setMode(null)}
aria-label="Dismiss"
style={{
position: 'absolute', top: '10px', right: '10px',
background: 'none', border: 'none', color: 'var(--text-mid)',
padding: '4px', cursor: 'pointer', lineHeight: 0,
}}
>
<X size={16} strokeWidth={1.75} />
</button>
<div style={{
width: '44px', height: '44px', margin: '0 auto 12px',
borderRadius: '50%', background: 'var(--gold)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{mode === 'safari'
? <Share size={22} strokeWidth={1.75} color="var(--navy)" />
: <ExternalLink size={22} strokeWidth={1.75} color="var(--navy)" />}
</div>
{mode === 'safari' ? (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
To install this app, tap the <strong>Share</strong> icon in Safari's
toolbar, then choose <strong>Add to Home Screen</strong>.
</p>
) : (
<p style={{ fontSize: '14px', color: 'var(--text-dark)', lineHeight: 1.5, margin: 0 }}>
iOS only allows installing apps from <strong>Safari</strong>. Open this
page in Safari, then tap Share → <strong>Add to Home Screen</strong>.
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { UserCheck, ClipboardList, Tablet, KeyRound, Send, Settings, Menu, LogOut } from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/checkins', label: 'Check-Ins', icon: ClipboardList, cap: 'admin' },
{ to: '/devices', label: 'Devices', icon: Tablet, cap: 'admin' },
{ to: '/terminals', label: 'Terminals', icon: KeyRound, cap: 'admin' },
{ to: '/marketing', label: 'Marketing', icon: Send, cap: 'admin' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
async function logout() {
await fetch('/tablet-checkin/api/auth/logout', { method: 'POST', credentials: 'include' })
window.location.reload()
}
useEffect(() => { setMenuOpen(false) }, [location.pathname])
return (
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
<aside className="sidebar">
<div className="sidebar-logo">
<UserCheck size={18} strokeWidth={1.75} />
Tablet Check-In
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON_PROPS} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '6px',
background: 'none', border: 'none', color: 'inherit',
fontSize: '12px', padding: 0, cursor: 'pointer',
}}>
<LogOut size={13} strokeWidth={1.75} />
Sign out
</button>
</div>
</aside>
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
<header className="top-bar">
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
<Menu size={20} strokeWidth={1.75} />
</button>
<UserCheck size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Tablet Check-In</span>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

299
frontend/src/index.css Normal file
View file

@ -0,0 +1,299 @@
/* Stack design system tokens — include verbatim in every app */
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body { background: var(--body-bg); color: var(--text-dark); font-family: var(--font); }
/* App theme + semantic tokens */
:root {
--app-primary: #be123c;
--app-primary-light: #e11d48;
--health-healthy: #16a34a;
--health-degraded: #d97706;
--health-poor: #ea580c;
--health-unresponsive: #dc2626;
--danger-bg: #fef2f2;
--warn-bg: #fffbeb;
--ok-bg: #f0fdf4;
--sidebar-w: 240px;
--topbar-h: 56px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; font-size: 14px; }
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--card-border); border-radius: 2px; }
/* ── App shell ─────────────────────────────────────────────── */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: var(--sidebar-w);
background: var(--navy);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-logo {
padding: 20px 16px 12px;
color: var(--gold);
font-size: 13px;
font-weight: 600;
letter-spacing: .05em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-logo svg { opacity: .8; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
border-top: 1px solid var(--surface-2);
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar {
display: none;
height: var(--topbar-h);
background: var(--navy);
color: var(--text);
align-items: center;
padding: 0 12px;
gap: 10px;
flex-shrink: 0;
}
.top-bar-title { flex: 1; font-size: 15px; font-weight: 600; color: var(--gold); }
.page-content { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 200;
transform: translateX(calc(-1 * var(--sidebar-w)));
transition: transform 0.25s ease;
}
.app-shell.menu-open .sidebar { transform: translateX(0); }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
}
.menu-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
.top-bar-burger {
background: none;
border: none;
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
/* ── Page chrome ───────────────────────────────────────────── */
.page { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; }
.page-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.page-header h1 { font-size: 18px; margin: 0; flex: 1; }
/* ── Buttons ───────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text-dark);
border-radius: var(--radius);
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
font-family: var(--font);
transition: background .12s, border-color .12s;
}
.btn:hover { border-color: var(--text-mid); }
.btn:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--gold); border-color: var(--gold); color: var(--navy); font-weight: 600; }
.btn-primary:hover { background: var(--gold-light); border-color: var(--gold-light); }
.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 8px; }
/* ── Forms ─────────────────────────────────────────────────── */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: var(--text-mid); margin-bottom: 4px; }
.field input[type="text"], .field input[type="email"], .field input[type="password"], .field input[type="date"],
.field input[type="number"], .field select, .field textarea {
width: 100%;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
.field textarea { min-height: 72px; resize: vertical; }
.field-row { display: flex; gap: 12px; }
.field-row > .field { flex: 1; }
.field-check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; cursor: pointer; }
.field-check input { width: 16px; height: 16px; accent-color: var(--gold); }
.field-hint { font-size: 11.5px; color: var(--text-mid); margin-top: 3px; }
input[type="text"], input[type="email"], input[type="password"], input[type="number"] {
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13.5px;
font-family: var(--font);
color: var(--text-dark);
background: var(--card-bg);
}
/* ── Cards ─────────────────────────────────────────────────── */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 14px 16px;
margin-bottom: 10px;
}
.zone-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
/* ── Badges ────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 20px;
padding: 2px 9px;
font-size: 11px;
font-weight: 600;
color: #fff;
white-space: nowrap;
}
.badge-health-healthy { background: var(--health-healthy); }
.badge-health-degraded { background: var(--health-degraded); }
.badge-health-unresponsive { background: var(--health-unresponsive); }
.badge-outline {
background: transparent;
border: 1px solid var(--card-border);
color: var(--text-mid);
font-weight: 500;
}
/* ── Modal / step nav ──────────────────────────────────────── */
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
/* ── Tables ────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
table.data { width: 100%; border-collapse: collapse; font-size: 13px; }
table.data th {
text-align: left;
padding: 9px 12px;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-mid);
border-bottom: 1px solid var(--card-border);
white-space: nowrap;
}
table.data td { padding: 9px 12px; border-bottom: 1px solid var(--card-border); vertical-align: top; }
table.data tr:last-child td { border-bottom: none; }
/* ── Misc ──────────────────────────────────────────────────── */
.empty-state { text-align: center; color: var(--text-mid); padding: 40px 16px; font-size: 13.5px; }
.error-banner {
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.ok-banner {
background: var(--ok-bg);
border: 1px solid var(--health-healthy);
color: var(--health-healthy);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
}
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.sidebar::-webkit-scrollbar, .sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track, .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb, .sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }
/* ── Kiosk (tablet guest-facing shell) ─────────────────────── */
.kiosk-shell { height: 100dvh; display: flex; flex-direction: column; background: var(--body-bg); overflow-y: auto; }
.kiosk-center { align-items: center; justify-content: center; }
.kiosk-login-card {
background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 28px 26px; width: 100%; max-width: 340px; text-align: center;
}
.kiosk-login-card input {
border: 1px solid var(--card-border); border-radius: 8px; padding: 10px 12px; font-size: 16px;
}
.kiosk-header {
display: flex; align-items: center; gap: 16px;
padding: 14px 18px; background: var(--navy); color: var(--text); flex-shrink: 0;
}
.kiosk-header-title { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 16px; color: var(--gold); flex-shrink: 0; }
.kiosk-header-info { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; font-size: 12px; color: var(--text-muted); }
.kiosk-grid-wrap { flex: 1; overflow-y: auto; padding: 16px; }

19
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
// When opened from the portal's install button (?install=1), trigger the
// PWA install prompt as soon as the browser offers it (Chrome/Edge only).
if (new URLSearchParams(window.location.search).has('install')) {
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault()
;(e as Event & { prompt: () => Promise<void> }).prompt()
}, { once: true })
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { FileText, RefreshCw } from 'lucide-react'
import { fetchCheckins, checkinPdfUrl } from '../api'
import type { Checkin } from '../types'
const STATUS_LABEL: Record<Checkin['status'], string> = {
pending: 'Pending (no PDF yet)',
complete: 'Complete',
failed: 'Failed',
}
export default function Dashboard() {
const [checkins, setCheckins] = useState<Checkin[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
function load() {
setLoading(true)
fetchCheckins()
.then(setCheckins)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load check-ins'))
.finally(() => setLoading(false))
}
useEffect(load, [])
return (
<div className="page">
<div className="page-header">
<h1>Check-Ins</h1>
<button className="btn" onClick={load}><RefreshCw size={14} strokeWidth={1.75} /> Refresh</button>
</div>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<p className="muted">Loading…</p>
) : checkins.length === 0 ? (
<div className="empty-state">No check-ins yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Submitted</th>
<th>Booking</th>
<th>Guest</th>
<th>Email</th>
<th>Device</th>
<th>Marketing</th>
<th>Status</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
{checkins.map(c => (
<tr key={c.id}>
<td>{new Date(c.submitted_at).toLocaleString('en-GB')}</td>
<td>{c.booking_id}</td>
<td>{c.guest_name || '—'}</td>
<td>{c.guest_email || '—'}</td>
<td>{c.device_name || '—'}</td>
<td>
{c.opted_in ? (
(c.marketing_sync || []).length
? c.marketing_sync!.map(m => (
<span key={m.provider} className={`badge ${m.status === 'success' ? 'badge-health-healthy' : m.status === 'failed' ? 'badge-health-unresponsive' : 'badge-outline'}`} style={{ marginRight: 4 }}>
{m.provider}
</span>
))
: <span className="badge badge-outline">opted in</span>
) : <span className="muted">No</span>}
</td>
<td>
<span className={`badge ${c.status === 'complete' ? 'badge-health-healthy' : c.status === 'failed' ? 'badge-health-unresponsive' : 'badge-health-degraded'}`}>
{STATUS_LABEL[c.status]}
</span>
{c.error && <div className="field-hint" style={{ color: 'var(--danger)' }}>{c.error}</div>}
</td>
<td>
{c.pdf_path ? (
<a href={checkinPdfUrl(c.id)} target="_blank" rel="noreferrer" className="btn btn-sm">
<FileText size={13} strokeWidth={1.75} /> View
</a>
) : <span className="muted">—</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
import { RefreshCw, Send, RotateCcw, Trash2, Pencil } from 'lucide-react'
import { fetchDevices, renameDevice, deleteDevice, testPushDevice, resetDevice } from '../api'
import type { Device } from '../types'
// Considered "stale" if no heartbeat/SSE ping in this long — either the
// tablet's browser is closed or it's lost its network connection.
const STALE_MS = 3 * 60 * 1000
export default function Devices() {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [pushBookingId, setPushBookingId] = useState<Record<string, string>>({})
function load() {
setLoading(true)
fetchDevices()
.then(setDevices)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load devices'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function saveRename(deviceId: string) {
if (!renameValue.trim()) return
try {
await renameDevice(deviceId, renameValue.trim())
setRenamingId(null)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Rename failed')
}
}
async function handleDelete(deviceId: string) {
if (!confirm('Remove this tablet from the registry? It will self-register again next time it loads.')) return
try {
await deleteDevice(deviceId)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
}
}
async function handleReset(deviceId: string) {
try {
await resetDevice(deviceId)
setMsg('Device reset to idle')
setTimeout(() => setMsg(''), 2500)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Reset failed')
}
}
async function handleTestPush(deviceId: string) {
const bookingId = pushBookingId[deviceId]?.trim()
if (!bookingId) return
setError(''); setMsg('')
try {
const res = await testPushDevice(deviceId, bookingId)
setMsg(res.delivered ? 'Pushed — tablet should navigate now.' : 'Session created, but the tablet has no live connection (it may be offline).')
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Push failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Devices</h1>
<button className="btn" onClick={load}><RefreshCw size={14} strokeWidth={1.75} /> Refresh</button>
</div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<p className="field-hint" style={{ marginBottom: 14 }}>
Tablets self-register the first time they open <code>/tablet-checkin/kiosk</code> with the kiosk password.
Rename them here once they appear. "Test Push" sends a booking straight to a device the same way the
NewBook extension does, useful to verify a tablet's connection without the extension installed.
</p>
{loading ? <p className="muted">Loading…</p> : devices.length === 0 ? (
<div className="empty-state">No tablets have registered yet.</div>
) : (
<div className="zone-grid">
{devices.map(d => {
const stale = !d.last_seen || Date.now() - new Date(d.last_seen).getTime() > STALE_MS
return (
<div key={d.device_id} className="card">
{renamingId === d.device_id ? (
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input type="text" value={renameValue} onChange={e => setRenameValue(e.target.value)} autoFocus />
<button className="btn btn-sm btn-primary" onClick={() => saveRename(d.device_id)}>Save</button>
<button className="btn btn-sm" onClick={() => setRenamingId(null)}>Cancel</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<strong>{d.name}</strong>
<button className="btn btn-sm" onClick={() => { setRenamingId(d.device_id); setRenameValue(d.name) }}>
<Pencil size={12} strokeWidth={1.75} />
</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<span className={`badge ${d.status === 'idle' ? 'badge-health-healthy' : 'badge-health-degraded'}`}>{d.status}</span>
{stale && <span className="badge badge-outline">offline?</span>}
</div>
<p className="muted" style={{ fontSize: 11.5, marginBottom: 10 }}>
Last seen: {d.last_seen ? new Date(d.last_seen).toLocaleString('en-GB') : 'never'}
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
type="text" placeholder="Booking ID" style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '6px 8px', fontSize: 12.5 }}
value={pushBookingId[d.device_id] || ''}
onChange={e => setPushBookingId(v => ({ ...v, [d.device_id]: e.target.value }))}
/>
<button className="btn btn-sm" onClick={() => handleTestPush(d.device_id)}>
<Send size={12} strokeWidth={1.75} /> Push
</button>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-sm" onClick={() => handleReset(d.device_id)}>
<RotateCcw size={12} strokeWidth={1.75} /> Reset
</button>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(d.device_id)}>
<Trash2 size={12} strokeWidth={1.75} /> Remove
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,156 @@
import { useEffect, useState } from 'react'
import { RefreshCw, RotateCcw } from 'lucide-react'
import { fetchMarketingProviders, updateMarketingProvider, fetchFailedSyncs, retryMarketingSync } from '../api'
import type { MarketingProvider, MarketingSyncRow } from '../types'
const PROVIDER_FIELDS: Record<string, { key: string; label: string; secret?: boolean }[]> = {
sendy: [
{ key: 'install_url', label: 'Install URL' },
{ key: 'api_key', label: 'API Key', secret: true },
{ key: 'list_id', label: 'List ID' },
],
mailchimp: [
{ key: 'api_key', label: 'API Key', secret: true },
{ key: 'server_prefix', label: 'Server Prefix (e.g. us21)' },
{ key: 'audience_id', label: 'Audience ID' },
],
}
export default function Marketing() {
const [providers, setProviders] = useState<MarketingProvider[]>([])
const [failed, setFailed] = useState<MarketingSyncRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
const [saving, setSaving] = useState<string | null>(null)
function load() {
setLoading(true)
Promise.all([fetchMarketingProviders(), fetchFailedSyncs()])
.then(([p, f]) => {
setProviders(p)
setFailed(f)
setDrafts(Object.fromEntries(p.map(pr => [pr.provider, { ...pr.config as Record<string, string> }])))
})
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load marketing settings'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function handleSave(provider: MarketingProvider) {
setSaving(provider.provider); setError(''); setMsg('')
try {
await updateMarketingProvider(provider.provider, { active: provider.active, config: drafts[provider.provider] || {} })
setMsg(`${provider.provider} saved`)
setTimeout(() => setMsg(''), 2500)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(null)
}
}
async function handleToggleActive(provider: MarketingProvider) {
setSaving(provider.provider); setError('')
try {
await updateMarketingProvider(provider.provider, { active: !provider.active, config: drafts[provider.provider] || {} })
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(null)
}
}
async function handleRetry(id: number) {
try {
await retryMarketingSync(id)
setMsg('Retry queued')
setTimeout(() => setMsg(''), 2000)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Retry failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Marketing</h1>
<button className="btn" onClick={load}><RefreshCw size={14} strokeWidth={1.75} /> Refresh</button>
</div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
{loading ? <p className="muted">Loading…</p> : (
<>
<div className="section-title">Providers</div>
{providers.map(p => (
<div key={p.provider} className="card" style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ fontWeight: 600, textTransform: 'capitalize' }}>{p.provider}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{!p.implemented && <span className="badge badge-outline">not yet implemented</span>}
<label className="field-check">
<input type="checkbox" checked={p.active} disabled={saving === p.provider} onChange={() => handleToggleActive(p)} />
<span>Active</span>
</label>
</div>
</div>
{!p.implemented && (
<p className="field-hint" style={{ marginBottom: 10 }}>
The interface and retry bookkeeping are wired up, but no live API credentials are configured yet —
see the code comment in <code>backend/src/lib/marketing/{p.provider}.js</code>.
</p>
)}
<div className="field-row" style={{ flexWrap: 'wrap' }}>
{PROVIDER_FIELDS[p.provider]?.map(f => (
<div className="field" key={f.key} style={{ minWidth: 180 }}>
<label>{f.label}</label>
<input
type={f.secret ? 'password' : 'text'}
value={drafts[p.provider]?.[f.key] || ''}
onChange={e => setDrafts(d => ({ ...d, [p.provider]: { ...d[p.provider], [f.key]: e.target.value } }))}
/>
</div>
))}
</div>
<button className="btn btn-primary" disabled={saving === p.provider} onClick={() => handleSave(p)}>
{saving === p.provider ? 'Saving…' : 'Save'}
</button>
</div>
))}
<div className="section-title">Failed Pushes</div>
{failed.length === 0 ? (
<div className="empty-state">Nothing failed — all opted-in check-ins have synced.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Booking</th><th>Guest</th><th>Provider</th><th>Attempts</th><th>Last Error</th><th></th></tr></thead>
<tbody>
{failed.map(f => (
<tr key={f.id}>
<td>{f.booking_id}</td>
<td>{f.guest_name || f.guest_email || '—'}</td>
<td style={{ textTransform: 'capitalize' }}>{f.provider}</td>
<td>{f.attempts}</td>
<td style={{ color: 'var(--danger)', maxWidth: 260 }}>{f.error || '—'}</td>
<td>
<button className="btn btn-sm" onClick={() => handleRetry(f.id)}>
<RotateCcw size={12} strokeWidth={1.75} /> Retry
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { MonitorCog } from 'lucide-react'
import {
fetchSettings, updateSettings, testNewbookConnection,
fetchKioskPasswordStatus, setKioskPassword,
} from '../api'
import type { AppSettings } from '../types'
export default function Settings() {
const [settings, setSettings] = useState<AppSettings>({
newbook_hotel_id: '', regcard_custom_field_id: '',
online_checkin_complete_custom_field_id: '', terms_and_conditions_text: '',
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [testing, setTesting] = useState(false)
const [kioskConfigured, setKioskConfigured] = useState<boolean | null>(null)
const [kioskPassword, setKioskPasswordInput] = useState('')
const [kioskSaving, setKioskSaving] = useState(false)
const [kioskMsg, setKioskMsg] = useState('')
const [kioskError, setKioskError] = useState('')
useEffect(() => {
fetchSettings().then(setSettings).catch(() => {}).finally(() => setLoading(false))
fetchKioskPasswordStatus().then(s => setKioskConfigured(s.configured)).catch(() => {})
}, [])
async function handleSave() {
setSaving(true); setError(''); setMsg('')
try {
await updateSettings(settings)
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(false)
}
}
async function handleTest() {
setTesting(true); setError(''); setMsg('')
try {
const res = await testNewbookConnection()
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Test failed')
} finally {
setTesting(false)
}
}
async function handleSaveKioskPassword() {
setKioskSaving(true); setKioskError(''); setKioskMsg('')
try {
await setKioskPassword(kioskPassword)
setKioskPasswordInput('')
setKioskConfigured(true)
setKioskMsg('Kiosk password saved — enter it once on each tablet at /tablet-checkin/kiosk.')
} catch (e) {
setKioskError(e instanceof Error ? e.message : 'Save failed')
} finally {
setKioskSaving(false)
}
}
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
return (
<div className="page">
<div className="page-header"><h1>Settings</h1></div>
{error && <div className="error-banner">{error}</div>}
{msg && <div className="ok-banner">{msg}</div>}
<div className="section-title">NewBook Integration</div>
<div className="field-row">
<div className="field">
<label>NewBook Hotel / Location ID</label>
<input
type="text" value={settings.newbook_hotel_id}
onChange={e => setSettings(s => ({ ...s, newbook_hotel_id: e.target.value }))}
/>
</div>
<div className="field">
<label>Regcard PDF Link — Custom Field ID</label>
<input
type="text" value={settings.regcard_custom_field_id}
onChange={e => setSettings(s => ({ ...s, regcard_custom_field_id: e.target.value }))}
placeholder="varies per hotel — confirm in NewBook config"
/>
</div>
<div className="field">
<label>Online Check-in Complete — Custom Field ID</label>
<input
type="text" value={settings.online_checkin_complete_custom_field_id}
onChange={e => setSettings(s => ({ ...s, online_checkin_complete_custom_field_id: e.target.value }))}
placeholder="varies per hotel — confirm in NewBook config"
/>
</div>
</div>
<p className="field-hint" style={{ marginBottom: 12 }}>
Neither custom field ID is known for this hotel yet — the legacy plugin's fields were named
<code> regcard_link</code> and <code>online_checkin_complete</code>, but their numeric IDs varied per
install. Confirm both in NewBook (Settings → Custom Fields → Booking) before these write-backs will
work; until they're set, check-ins still complete and generate a PDF, they just skip writing back
into NewBook.
</p>
<div style={{ marginBottom: 24 }}>
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
</div>
<div className="section-title">Terms &amp; Conditions</div>
<div className="field">
<label>Text shown on the tablet before signing</label>
<textarea
style={{ minHeight: 160 }}
value={settings.terms_and_conditions_text}
onChange={e => setSettings(s => ({ ...s, terms_and_conditions_text: e.target.value }))}
/>
</div>
<div style={{ marginBottom: 24 }}>
<button className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Saving…' : 'Save Settings'}
</button>
</div>
<div className="section-title">Kiosk Tablets</div>
<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> —
entered once per tablet, then left signed in indefinitely (same pattern as hvac's wall panel).
It only grants the narrow ability to register itself and run the guest check-in flow — never
Devices, Terminals, Marketing or this Settings page.
</p>
{kioskError && <div className="error-banner">{kioskError}</div>}
{kioskMsg && <div className="ok-banner">{kioskMsg}</div>}
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<MonitorCog size={16} strokeWidth={1.75} color={kioskConfigured ? 'var(--health-healthy)' : 'var(--text-mid)'} />
<span>
{kioskConfigured == null ? 'Checking…' : kioskConfigured ? 'A kiosk password is configured.' : 'No kiosk password set yet — tablets cannot set themselves up until one is set below.'}
</span>
</div>
<div className="field-row" style={{ marginBottom: 12 }}>
<div className="field">
<label>{kioskConfigured ? 'New kiosk password' : 'Kiosk password'}</label>
<input
type="password" value={kioskPassword} onChange={e => setKioskPasswordInput(e.target.value)}
placeholder="At least 6 characters" autoComplete="new-password"
/>
</div>
</div>
<div>
<button className="btn btn-primary" disabled={kioskSaving || kioskPassword.length < 6} onClick={handleSaveKioskPassword}>
{kioskSaving ? 'Saving…' : kioskConfigured ? 'Change kiosk password' : 'Set kiosk password'}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,140 @@
import { useEffect, useState } from 'react'
import { RefreshCw, KeyRound, Ban, Trash2, Copy } from 'lucide-react'
import { fetchTerminals, createTerminal, revokeTerminal, deleteTerminal } from '../api'
import type { Terminal } from '../types'
export default function Terminals() {
const [terminals, setTerminals] = useState<Terminal[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null)
function load() {
setLoading(true)
fetchTerminals()
.then(setTerminals)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load terminals'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
setCreating(true); setError('')
try {
const t = await createTerminal(newName.trim())
setRevealedKey({ name: t.name, key: t.api_key })
setNewName('')
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Create failed')
} finally {
setCreating(false)
}
}
async function handleRevoke(id: string) {
if (!confirm('Revoke this terminal key? The Chrome extension using it will stop working immediately.')) return
try {
await revokeTerminal(id)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Revoke failed')
}
}
async function handleDelete(id: string) {
if (!confirm('Delete this terminal permanently?')) return
try {
await deleteTerminal(id)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Terminals</h1>
<button className="btn" onClick={load}><RefreshCw size={14} strokeWidth={1.75} /> Refresh</button>
</div>
<p className="field-hint" style={{ marginBottom: 14 }}>
API keys for the standalone "Send to Check-in Tablet" Chrome extension — one per reception PC.
The extension calls this app's push endpoint using a terminal key instead of a staff login
(hotel-owned hardware gets a device credential, same trust tier as the tablets themselves).
</p>
{error && <div className="error-banner">{error}</div>}
{revealedKey && (
<div className="card" style={{ borderColor: 'var(--gold)', marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>
<KeyRound size={14} strokeWidth={1.75} style={{ verticalAlign: '-2px' }} /> Key for "{revealedKey.name}"
</div>
<p className="field-hint" style={{ marginBottom: 8 }}>
Copy this now and paste it into the extension's settings popup — it will never be shown again.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={{ flex: 1, wordBreak: 'break-all', fontSize: 12, background: 'var(--body-bg)', padding: '6px 8px', borderRadius: 6 }}>
{revealedKey.key}
</code>
<button className="btn btn-sm" onClick={() => navigator.clipboard.writeText(revealedKey.key)}>
<Copy size={12} strokeWidth={1.75} /> Copy
</button>
</div>
<button className="btn btn-sm" style={{ marginTop: 10 }} onClick={() => setRevealedKey(null)}>Done</button>
</div>
)}
<form onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
type="text" placeholder="e.g. Reception PC 1" value={newName}
onChange={e => setNewName(e.target.value)}
style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }}
/>
<button className="btn btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create Terminal'}
</button>
</form>
{loading ? <p className="muted">Loading…</p> : terminals.length === 0 ? (
<div className="empty-state">No terminals yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Name</th><th>Created</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
{terminals.map(t => (
<tr key={t.terminal_id}>
<td>{t.name}</td>
<td>{new Date(t.created_at).toLocaleDateString('en-GB')}</td>
<td>{t.last_used_at ? new Date(t.last_used_at).toLocaleString('en-GB') : 'never'}</td>
<td>
{t.revoked_at
? <span className="badge badge-health-unresponsive">Revoked</span>
: <span className="badge badge-health-healthy">Active</span>}
</td>
<td style={{ display: 'flex', gap: 6 }}>
{!t.revoked_at && (
<button className="btn btn-sm" onClick={() => handleRevoke(t.terminal_id)}>
<Ban size={12} strokeWidth={1.75} /> Revoke
</button>
)}
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(t.terminal_id)}>
<Trash2 size={12} strokeWidth={1.75} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,282 @@
import { useEffect, useRef, useState } from 'react'
import SignatureCanvas from 'react-signature-canvas'
import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertTriangle } from 'lucide-react'
import { fetchCheckinToken, submitCheckin, kioskReset } from '../../api'
import type { CheckinTokenData } from '../../types'
// Safety-net timers per project_checkin's "Multi-tablet device registry" —
// busy=hard-reject means an abandoned session must not wedge a tablet
// forever. Two nets: this inactivity auto-reset, and the explicit Cancel
// button always visible below.
const INACTIVITY_MS = 5 * 60 * 1000
const COMPLETE_AUTO_RETURN_MS = 15 * 1000
type Step = 'loading' | 'error' | 'review' | 'contact' | 'carrego' | 'marketing' | 'terms' | 'submitting' | 'complete'
export default function CheckinFlow({ token, deviceId, onDone }: { token: string; deviceId: string; onDone: () => void }) {
const [step, setStep] = useState<Step>('loading')
const [data, setData] = useState<CheckinTokenData | null>(null)
const [loadError, setLoadError] = useState('')
const [email, setEmail] = useState('')
const [carRego, setCarRego] = useState('')
const [optedIn, setOptedIn] = useState(false)
const [acceptedTerms, setAcceptedTerms] = useState(false)
const [submitError, setSubmitError] = useState('')
const sigRef = useRef<SignatureCanvas>(null)
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
fetchCheckinToken(token)
.then(d => {
setData(d)
// Gap-fill: prefer a real (non-relay) existing email; a relay-domain
// address is deliberately NOT prefilled as-is, so the guest is
// prompted to supply a real one instead (see the shared gap-fill
// note in project_checkin — this belongs here, not just the online
// path, so the tablet inherits it automatically).
const realEmail = d.guest.existing_emails.find(e => !e.is_relay)
setEmail(realEmail?.content || '')
setCarRego(d.guest.car_rego || '')
setStep('review')
})
.catch(err => {
setLoadError(err instanceof Error ? err.message : 'Could not load this check-in')
setStep('error')
})
}, [token])
// Inactivity auto-cancel — any pointer/keyboard activity resets the timer.
useEffect(() => {
if (step === 'loading' || step === 'error' || step === 'complete') return
function reset() {
if (idleTimer.current) clearTimeout(idleTimer.current)
idleTimer.current = setTimeout(() => { handleCancel() }, INACTIVITY_MS)
}
const events = ['pointerdown', 'keydown', 'touchstart'] as const
events.forEach(e => window.addEventListener(e, reset))
reset()
return () => {
if (idleTimer.current) clearTimeout(idleTimer.current)
events.forEach(e => window.removeEventListener(e, reset))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step])
useEffect(() => {
if (step !== 'complete') return
const t = setTimeout(onDone, COMPLETE_AUTO_RETURN_MS)
return () => clearTimeout(t)
}, [step, onDone])
async function handleCancel() {
try { await kioskReset(deviceId) } catch { /* best-effort — the inactivity/device page can also clear it */ }
onDone()
}
async function handleSubmit() {
if (!acceptedTerms) return
const sig = sigRef.current
if (!sig || sig.isEmpty()) {
setSubmitError('A signature is required')
return
}
setStep('submitting')
setSubmitError('')
try {
await submitCheckin(token, {
email, car_rego: carRego, opted_in: optedIn, accepted_terms: acceptedTerms,
signature_png_base64: sig.getTrimmedCanvas().toDataURL('image/png'),
})
setStep('complete')
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Submission failed — please try again')
setStep('terms')
}
}
if (step === 'loading') {
return <div className="kiosk-shell kiosk-center"><p className="muted">Loading your booking…</p></div>
}
if (step === 'error') {
return (
<div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center', maxWidth: 360 }}>
<AlertTriangle size={40} strokeWidth={1.5} color="var(--danger)" style={{ marginBottom: 12 }} />
<p style={{ color: 'var(--text-dark)', marginBottom: 16 }}>{loadError}</p>
<button className="btn btn-primary" onClick={onDone}>Back to start</button>
</div>
</div>
)
}
if (!data) return null
return (
<div className="kiosk-shell">
<header className="kiosk-header">
<div className="kiosk-header-title">
<span>Check-In · Booking #{data.booking.booking_id}</span>
</div>
<div className="kiosk-header-info">{stepLabel(step)}</div>
{step !== 'submitting' && (
<button className="btn btn-sm" onClick={handleCancel}>
<X size={14} strokeWidth={1.75} /> Cancel
</button>
)}
</header>
<div className="kiosk-grid-wrap" style={{ maxWidth: 640, margin: '0 auto', width: '100%' }}>
{step === 'review' && (
<div className="card">
<div className="section-title">Booking Summary</div>
<p><strong>Arrive:</strong> {formatDate(data.booking.arrival)}</p>
<p><strong>Depart:</strong> {formatDate(data.booking.departure)}</p>
<p><strong>Room:</strong> {data.booking.site_name || '—'}</p>
<p><strong>Guest:</strong> {data.guest.name || '—'}</p>
<StepNav onNext={() => setStep('contact')} />
</div>
)}
{step === 'contact' && (
<div className="card">
<div className="section-title">Contact Details</div>
{/* Existing emails shown read-only, same "can't remove/replace,
only add" convention as the legacy plugin's page 3 (see
project_checkin_legacy_plugin) — this app simplifies the
legacy form's 3-slot progressive "add another" UI down to one
add/correct field below, since v1's actual goal is just
capturing one good direct email per guest, not managing an
arbitrary list. */}
{data.guest.existing_emails.length > 0 && (
<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' }}>
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.
</div>
)}
<div className="field">
<label>{data.guest.existing_emails.length > 0 ? 'Add or correct email' : 'Email address'}</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="you@example.com" />
<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>
</div>
<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>
<StepNav onBack={() => setStep('review')} onNext={() => setStep('carrego')} />
</div>
)}
{step === 'carrego' && (
<div className="card">
<div className="section-title">Car Registration</div>
<div className="field">
<label>Registration (leave blank if not driving)</label>
<input type="text" value={carRego} onChange={e => setCarRego(e.target.value.toUpperCase())} placeholder="e.g. AB12 CDE" />
</div>
<StepNav onBack={() => setStep('contact')} onNext={() => setStep('marketing')} />
</div>
)}
{step === 'marketing' && (
<div className="card">
<div className="section-title">Stay in Touch</div>
<label className="field-check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={optedIn} onChange={e => setOptedIn(e.target.checked)} />
<span>Yes, send me offers and news by email</span>
</label>
<StepNav onBack={() => setStep('carrego')} onNext={() => setStep('terms')} />
</div>
)}
{step === 'terms' && (
<div className="card">
<div className="section-title">Terms &amp; Conditions</div>
<div style={{
maxHeight: 160, overflowY: 'auto', fontSize: 12.5, color: 'var(--text-mid)',
border: '1px solid var(--card-border)', borderRadius: 8, padding: 10, marginBottom: 12, whiteSpace: 'pre-wrap',
}}>
{data.terms_text}
</div>
<label className="field-check" style={{ marginBottom: 14 }}>
<input type="checkbox" checked={acceptedTerms} onChange={e => setAcceptedTerms(e.target.checked)} />
<span>I have read and accept the terms &amp; conditions above</span>
</label>
<div className="section-title">Signature</div>
<div style={{ border: '1px solid var(--card-border)', borderRadius: 8, background: '#fff', marginBottom: 8 }}>
<SignatureCanvas
ref={sigRef}
penColor="#1a1a2e"
canvasProps={{ width: 560, height: 160, style: { width: '100%', height: 160, touchAction: 'none' } }}
/>
</div>
<button className="btn btn-sm" onClick={() => sigRef.current?.clear()} style={{ marginBottom: 12 }}>Clear signature</button>
{submitError && <div className="error-banner">{submitError}</div>}
<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 btn-primary" disabled={!acceptedTerms} onClick={handleSubmit}>
Complete Check-In <ChevronRight size={14} strokeWidth={1.75} />
</button>
</div>
</div>
)}
{step === 'submitting' && (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<p className="muted">Submitting your details…</p>
</div>
)}
{step === 'complete' && (
<div className="card" style={{ textAlign: 'center', padding: '40px 16px' }}>
<CheckCircle2 size={48} strokeWidth={1.5} color="var(--health-healthy)" style={{ marginBottom: 12 }} />
<h2 style={{ fontSize: 18, margin: '0 0 6px' }}>You're checked in!</h2>
<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>
</div>
)}
</div>
</div>
)
}
function StepNav({ onBack, onNext }: { onBack?: () => void; onNext: () => void }) {
return (
<div className="modal-actions" style={{ justifyContent: onBack ? 'space-between' : 'flex-end' }}>
{onBack && <button className="btn" onClick={onBack}><ChevronLeft size={14} strokeWidth={1.75} /> Back</button>}
<button className="btn btn-primary" onClick={onNext}>Next <ChevronRight size={14} strokeWidth={1.75} /></button>
</div>
)
}
function stepLabel(step: Step): string {
switch (step) {
case 'review': return 'Step 1 of 5 · Booking'
case 'contact': return 'Step 2 of 5 · Contact details'
case 'carrego': return 'Step 3 of 5 · Car registration'
case 'marketing': return 'Step 4 of 5 · Marketing'
case 'terms': return 'Step 5 of 5 · Terms & signature'
default: return ''
}
}
function formatDate(v: string | null): string {
if (!v) return '—'
const d = new Date(v)
return isNaN(d.getTime()) ? v : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
}

View file

@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react'
import { UserCheck } from 'lucide-react'
import { kioskLogin, kioskRegisterDevice, kioskHeartbeat, kioskStreamUrl, KioskSessionError } from '../../api'
import type { Device } from '../../types'
import CheckinFlow from './CheckinFlow'
const DEVICE_ID_KEY = 'tablet_checkin_device_id'
const HEARTBEAT_MS = 60_000
type View = 'checking' | 'login' | 'idle' | 'checkin'
// The whole app shares one index.html -> one <link rel="manifest">, so
// without this, tapping "Install" from /tablet-checkin/kiosk would install
// the main staff app's manifest (start_url '/tablet-checkin/') — the
// installed shortcut would open the staff AuthGate login instead of the
// kiosk password prompt. Same bug hvac's kiosk hit and fixed (see
// project_hvac_kiosk memory) — swap the manifest link while this route is
// mounted, restore it on unmount.
function useKioskManifest() {
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('link[rel="manifest"]')
const original = link?.getAttribute('href') || null
link?.setAttribute('href', '/tablet-checkin/kiosk-manifest.webmanifest')
return () => { if (link && original) link.setAttribute('href', original) }
}, [])
}
// Top-level kiosk shell — deliberately outside the normal StaffShell/AuthGate
// (see App.tsx). Owns: the kiosk's own persistent login, this tablet's
// device_id (self-registered, persisted in localStorage), the idle-page SSE
// connection that waits for a staff push, and handing off to CheckinFlow once
// a 'navigate' event carries a session token. See project_checkin's "Tablet
// check-in v1 scope" and the kiosk auth section of project_checkin_kiosk-
// adjacent notes in project_hvac_kiosk (the collision bug this mirrors the
// fix for).
export default function Kiosk() {
useKioskManifest()
const [view, setView] = useState<View>('checking')
const [device, setDevice] = useState<Device | null>(null)
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loginLoading, setLoginLoading] = useState(false)
const [activeToken, setActiveToken] = useState<string | null>(null)
const esRef = useRef<EventSource | null>(null)
async function registerAndGoIdle() {
try {
const storedId = localStorage.getItem(DEVICE_ID_KEY)
const d = await kioskRegisterDevice(storedId)
localStorage.setItem(DEVICE_ID_KEY, d.device_id)
setDevice(d)
// Always land on the idle screen regardless of the device's last known
// status — if it was left 'busy' from a session that never completed
// (tablet lost power mid-checkin), the idle screen's own SSE
// connection is what would receive a fresh 'navigate' push anyway, and
// a stuck busy device is fixed from the Devices admin page, not here.
setView('idle')
} catch (err) {
if (err instanceof KioskSessionError) {
setView('login')
} else {
setLoginError(err instanceof Error ? err.message : 'Could not register this tablet')
setView('login')
}
}
}
useEffect(() => { registerAndGoIdle() }, [])
// Idle-page SSE — holds open while view === 'idle', reconnects on drop.
useEffect(() => {
if (view !== 'idle' || !device) return
const es = new EventSource(kioskStreamUrl(device.device_id))
esRef.current = es
es.addEventListener('navigate', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (data?.token) {
setActiveToken(data.token)
setView('checkin')
}
} catch { /* ignore malformed event */ }
})
es.addEventListener('idle', () => setActiveToken(null))
es.onerror = () => { /* browser auto-reconnects EventSource on its own */ }
return () => { es.close(); esRef.current = null }
}, [view, device])
// Fallback heartbeat, independent of whether the SSE connection happens to
// be up at any given moment — keeps devices.last_seen fresh for the admin
// Devices page even through a flaky network blip.
useEffect(() => {
if (!device) return
const id = setInterval(() => { kioskHeartbeat(device.device_id).catch(() => {}) }, HEARTBEAT_MS)
return () => clearInterval(id)
}, [device])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginLoading(true)
setLoginError('')
try {
await kioskLogin(password)
setPassword('')
await registerAndGoIdle()
} catch (err) {
setLoginError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoginLoading(false)
}
}
function handleCheckinDone() {
setActiveToken(null)
setView('idle')
}
if (view === 'checking') {
return (
<div className="kiosk-shell kiosk-center">
<p className="muted">Loading…</p>
</div>
)
}
if (view === 'login') {
return (
<div className="kiosk-shell kiosk-center">
<div className="kiosk-login-card">
<UserCheck size={36} strokeWidth={1.75} color="var(--app-primary)" style={{ marginBottom: 10 }} />
<h1 style={{ fontSize: 18, margin: '0 0 4px' }}>Tablet Check-In</h1>
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>
Enter the kiosk password to set up this tablet.
</p>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Kiosk password" autoFocus required
/>
{loginError && <div className="error-banner">{loginError}</div>}
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
{loginLoading ? 'Signing in…' : 'Continue'}
</button>
</form>
</div>
</div>
)
}
if (view === 'checkin' && activeToken && device) {
return <CheckinFlow token={activeToken} deviceId={device.device_id} onDone={handleCheckinDone} />
}
// idle
return (
<div className="kiosk-shell kiosk-center">
<div style={{ textAlign: 'center' }}>
<UserCheck size={56} strokeWidth={1.25} color="var(--app-primary)" style={{ marginBottom: 16, opacity: 0.6 }} />
<h1 style={{ fontSize: 22, margin: '0 0 6px', color: 'var(--text-dark)' }}>Welcome</h1>
<p className="muted">Please see reception to begin your check-in.</p>
{device && <p className="muted" style={{ fontSize: 11, marginTop: 24 }}>{device.name}</p>}
</div>
</div>
)
}

140
frontend/src/types.ts Normal file
View file

@ -0,0 +1,140 @@
export type DeviceStatus = 'idle' | 'busy'
export interface Device {
device_id: string
name: string
status: DeviceStatus
last_seen: string | null
created_at: string
}
export interface Terminal {
terminal_id: string
name: string
created_at: string
last_used_at: string | null
revoked_at: string | null
}
export interface CreatedTerminal extends Terminal {
api_key: string // present only in the create response — never again
}
export type CheckinStatus = 'pending' | 'complete' | 'failed'
export interface MarketingSyncSummary {
provider: string
status: 'pending' | 'success' | 'failed'
error: string | null
}
export interface Checkin {
id: number
booking_id: string
guest_id: string | null
hotel_id: string
device_id: string | null
device_name: string | null
guest_name: string | null
guest_email: string | null
pdf_path: string | null
opted_in: boolean
status: CheckinStatus
error: string | null
submitted_at: string
marketing_sync: MarketingSyncSummary[] | null
}
export type MarketingProviderId = 'sendy' | 'mailchimp'
export interface SendyConfig {
api_key?: string
install_url?: string
list_id?: string
}
export interface MailchimpConfig {
api_key?: string
server_prefix?: string
audience_id?: string
}
export interface MarketingProvider {
provider: MarketingProviderId
active: boolean
config: SendyConfig | MailchimpConfig
updated_at: string
implemented: boolean
}
export interface MarketingSyncRow {
id: number
checkin_id: number
provider: string
status: 'pending' | 'success' | 'failed'
attempts: number
last_attempt_at: string | null
error: string | null
created_at: string
booking_id: string
guest_name: string | null
guest_email: string | null
}
export interface AppSettings {
newbook_hotel_id: string
regcard_custom_field_id: string
online_checkin_complete_custom_field_id: string
terms_and_conditions_text: string
}
// ── Guest-facing token flow (routes/checkin.js) ─────────────────────────────
export interface CheckinBooking {
booking_id: string
arrival: string | null
departure: string | null
site_name: string | null
tariff_total: number | string | null
nights: number | null
}
export interface ExistingEmail {
content: string
is_relay: boolean
}
export interface CheckinGuest {
guest_id: string | null
name: string
address: string
phone: string
existing_emails: ExistingEmail[]
car_rego: string
}
export interface CheckinTokenData {
booking: CheckinBooking
guest: CheckinGuest
terms_text: string
}
export interface SubmitCheckinPayload {
email: string
car_rego: string
opted_in: boolean
accepted_terms: boolean
signature_png_base64: string
}
// ── Staff auth ──────────────────────────────────────────────────────────────
export interface User {
user_id: number
name: string
email: string
is_admin: boolean
caps: string[] // bare slugs — verify?app=tablet-checkin strips the prefix
}
export function can(user: User, cap: string): boolean {
return user.is_admin || user.caps.includes(cap)
}

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

31
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
base: '/tablet-checkin/',
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Tablet Check-In',
short_name: 'Check-In',
start_url: '/tablet-checkin/',
scope: '/tablet-checkin/',
display: 'standalone',
theme_color: '#be123c',
background_color: '#be123c',
icons: [
{ src: '/tablet-checkin/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/tablet-checkin/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
navigateFallback: '/tablet-checkin/index.html',
navigateFallbackDenylist: [/\/api\//],
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
})

47
seed-app.js Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env node
// Run from tablet-checkin/ dir: DATABASE_URL=... node seed-app.js
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query(`
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
VALUES ('tablet-checkin', 'Tablet Check-In', 'Staff-push guest check-in tablet — contact details, car reg, marketing opt-in, T&Cs and signature capture', '/tablet-checkin', 'UserCheck', '#be123c', 'Hotel', '10.10.10.132', 3080)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
base_path = EXCLUDED.base_path,
icon = EXCLUDED.icon,
theme_color = EXCLUDED.theme_color,
category = EXCLUDED.category,
internal_host = EXCLUDED.internal_host,
internal_port = EXCLUDED.internal_port
`)
// Cap slugs are plain, not dotted/app-prefixed — scoped per-app via app_id
// already (confirmed convention, see plant/seed-app.js). 'checkin' (the
// tablet kiosk's own internal JWT cap, see backend/src/routes/devices.js) is
// deliberately NOT seeded here — it must never be assignable to a human
// staff role via the portal, only minted by this app's own kiosk login route.
await pool.query(`
INSERT INTO app_capabilities (app_id, slug, name, description, sort_order)
SELECT a.id, c.slug, c.name, c.description, c.sort_order
FROM apps a, (VALUES
('push', 'Push to Tablet', 'Push a booking to a check-in tablet (staff push trigger + manual test-push)', 1),
('admin', 'Admin', 'View check-in audit history, manage devices, terminals and marketing config', 2),
('settings', 'Settings', 'Configure tablet-checkin app settings', 3)
) AS c(slug, name, description, sort_order)
WHERE a.slug = 'tablet-checkin'
ON CONFLICT (app_id, slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order
`)
// No default Staff grants — this app writes guest PII to NewBook and holds
// signed regcards, access is admin-assigned per role via the portal (same
// convention as hvac/plant/wages/utilities).
console.log('tablet-checkin app seeded.')
await pool.end()