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