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:
commit
3b2e25df3f
51 changed files with 10296 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
uploads/
|
||||
*.log
|
||||
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal 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
18
backend/package.json
Normal 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
99
backend/src/auth.js
Normal 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
159
backend/src/db.js
Normal 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
40
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
|
||||
}
|
||||
40
backend/src/lib/device-stream.js
Normal file
40
backend/src/lib/device-stream.js
Normal 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', {})
|
||||
}
|
||||
74
backend/src/lib/marketing/index.js
Normal file
74
backend/src/lib/marketing/index.js
Normal 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 })
|
||||
}
|
||||
33
backend/src/lib/marketing/mailchimp.js
Normal file
33
backend/src/lib/marketing/mailchimp.js
Normal 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')
|
||||
}
|
||||
26
backend/src/lib/marketing/sendy.js
Normal file
26
backend/src/lib/marketing/sendy.js
Normal 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
194
backend/src/lib/newbook.js
Normal 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
95
backend/src/lib/pdf.js
Normal 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
38
backend/src/lib/push.js
Normal 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 }
|
||||
}
|
||||
26
backend/src/lib/uploads.js
Normal file
26
backend/src/lib/uploads.js
Normal 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)
|
||||
}
|
||||
209
backend/src/routes/checkin.js
Normal file
209
backend/src/routes/checkin.js
Normal 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
|
||||
}
|
||||
47
backend/src/routes/checkins.js
Normal file
47
backend/src/routes/checkins.js
Normal 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))
|
||||
})
|
||||
}
|
||||
144
backend/src/routes/devices.js
Normal file
144
backend/src/routes/devices.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
60
backend/src/routes/kiosk.js
Normal file
60
backend/src/routes/kiosk.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
48
backend/src/routes/marketing.js
Normal file
48
backend/src/routes/marketing.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
55
backend/src/routes/settings.js
Normal file
55
backend/src/routes/settings.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
29
backend/src/routes/staff.js
Normal file
29
backend/src/routes/staff.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
54
backend/src/routes/terminals.js
Normal file
54
backend/src/routes/terminals.js
Normal 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
44
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
18
frontend/index.html
Normal 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
53
frontend/nginx.conf
Normal 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
6320
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
frontend/public/kiosk-manifest.webmanifest
Normal file
13
frontend/public/kiosk-manifest.webmanifest
Normal 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
55
frontend/src/App.tsx
Normal 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
138
frontend/src/api.ts
Normal 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) })
|
||||
}
|
||||
169
frontend/src/components/AuthGate.tsx
Normal file
169
frontend/src/components/AuthGate.tsx
Normal 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',
|
||||
}
|
||||
89
frontend/src/components/IosInstallHint.tsx
Normal file
89
frontend/src/components/IosInstallHint.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/components/Layout.tsx
Normal file
74
frontend/src/components/Layout.tsx
Normal 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
299
frontend/src/index.css
Normal 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
19
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
94
frontend/src/pages/Dashboard.tsx
Normal file
94
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
frontend/src/pages/Devices.tsx
Normal file
146
frontend/src/pages/Devices.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
frontend/src/pages/Marketing.tsx
Normal file
156
frontend/src/pages/Marketing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
frontend/src/pages/Settings.tsx
Normal file
161
frontend/src/pages/Settings.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
140
frontend/src/pages/Terminals.tsx
Normal file
140
frontend/src/pages/Terminals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
282
frontend/src/pages/kiosk/CheckinFlow.tsx
Normal file
282
frontend/src/pages/kiosk/CheckinFlow.tsx
Normal 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 & 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 & 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' })
|
||||
}
|
||||
167
frontend/src/pages/kiosk/Kiosk.tsx
Normal file
167
frontend/src/pages/kiosk/Kiosk.tsx
Normal 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
140
frontend/src/types.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal 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
31
frontend/vite.config.ts
Normal 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
47
seed-app.js
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue