Initial scaffold: tablet-checkin app

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

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

13
frontend/Dockerfile Normal file
View file

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

18
frontend/index.html Normal file
View file

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

53
frontend/nginx.conf Normal file
View file

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

6320
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
frontend/package.json Normal file
View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

19
frontend/tsconfig.json Normal file
View file

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

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

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