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