Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-08-31 09:47:36 +10:00
commit ed77493817
32 changed files with 5799 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — visitor sign in</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<!-- ------------------------------------------------------------ login -->
<div id="login" class="login" hidden>
<div class="login-card">
<h1 id="login-heading">Visitor admin</h1>
<form class="login-step" id="step-password">
<label>
<span>Email address</span>
<input type="email" id="login-email" autocomplete="username" autocapitalize="none">
</label>
<label>
<span>Password</span>
<input type="password" id="login-password" autocomplete="current-password">
</label>
<p class="hint" id="domain-rule" hidden></p>
<button type="submit">Continue</button>
</form>
<form class="login-step" id="step-2fa" hidden>
<p class="hint" id="twofa-intro"></p>
<div id="twofa-setup" hidden>
<img id="twofa-qr" alt="Two factor setup QR code" width="200" height="200">
<p class="hint">Can't scan it? Enter this key by hand:<br><code id="twofa-secret"></code></p>
</div>
<label>
<span id="twofa-label">6 digit code</span>
<input id="twofa-code" inputmode="numeric" autocomplete="one-time-code" maxlength="12">
</label>
<button type="submit">Verify</button>
<button type="button" class="link-quiet" id="twofa-cancel">Start again</button>
</form>
<div class="login-step" id="step-recovery" hidden>
<p class="hint">Save these recovery codes somewhere safe. Each one works once, if you ever
lose the phone with your authenticator app on it.</p>
<ul class="recovery" id="recovery-list"></ul>
<button type="button" id="recovery-done">I've saved them</button>
</div>
<form class="login-step" id="step-newpassword" hidden>
<p class="hint">Set a password only you know before you continue.</p>
<label>
<span>Current password</span>
<input type="password" id="pw-current" autocomplete="current-password">
</label>
<label>
<span>New password</span>
<input type="password" id="pw-new" autocomplete="new-password">
</label>
<label>
<span>New password again</span>
<input type="password" id="pw-again" autocomplete="new-password">
</label>
<button type="submit">Save and continue</button>
</form>
<div class="login-step" id="step-setup" hidden>
<p class="hint" id="setup-message"></p>
</div>
<p class="err" id="login-error" hidden></p>
</div>
</div>
<!-- ---------------------------------------------------------- console -->
<div id="console" hidden>
<header class="topbar">
<strong id="site-name">Visitor admin</strong>
<nav>
<button class="tab on" data-tab="onsite">On site</button>
<button class="tab" data-tab="log">Visit log</button>
<button class="tab" data-tab="recurring">Recurring visitors <span class="badge-count" id="alert-count" hidden></span></button>
<button class="tab" data-tab="hosts">People to visit</button>
<button class="tab" data-tab="sites">Sites</button>
<button class="tab owner-only" data-tab="admins" hidden>Admins</button>
<button class="tab" data-tab="system">System</button>
</nav>
<label class="site-switch" id="site-switch" hidden>
<span>Site</span>
<select id="site-filter"></select>
</label>
<button id="logout" class="link">Sign out</button>
</header>
<p class="banner" id="expiry-banner" hidden></p>
<main>
<!-- ------------------------------------------------------ on site -->
<section class="panel on" id="panel-onsite">
<div class="panel-head">
<h2>Currently on site</h2>
<button class="ghost" id="refresh-onsite">Refresh</button>
</div>
<p class="stat" id="onsite-count"></p>
<div id="onsite-table"></div>
</section>
<!-- -------------------------------------------------------- log -->
<section class="panel" id="panel-log">
<div class="panel-head">
<h2>Visit log</h2>
<a class="ghost" id="csv-link" href="/admin/api/visits.csv">Download CSV</a>
</div>
<div class="filters">
<label><span>From</span><input type="date" id="log-from"></label>
<label><span>To</span><input type="date" id="log-to"></label>
<label class="grow"><span>Search name, host or contact</span><input id="log-q"></label>
<button class="ghost" id="log-search">Apply</button>
</div>
<div id="log-table"></div>
</section>
<!-- ------------------------------------------------- recurring -->
<section class="panel" id="panel-recurring">
<div class="panel-head">
<h2>Recurring visitors</h2>
<button class="primary" id="new-recurring">Add a recurring visitor</button>
</div>
<div id="expiry-summary"></div>
<div id="recurring-table"></div>
</section>
<!-- ----------------------------------------------------- hosts -->
<section class="panel" id="panel-hosts">
<div class="panel-head">
<h2>People a visitor can ask for</h2>
<button class="primary" id="new-host">Add a person</button>
</div>
<p class="hint" id="hosts-scope"></p>
<details class="import">
<summary>Import from CSV</summary>
<p class="hint">
Paste the file contents below, or choose a .csv file. Recognised column headings are
<code>name</code>, <code>email</code> and <code>area</code> (or department / team).
A single column of names works too. The import applies to the site selected above.
</p>
<input type="file" id="host-file" accept=".csv,text/csv">
<textarea id="host-csv" rows="6" placeholder="name,email,area&#10;Jess Rogerson,jess@example.com,Front office"></textarea>
<label class="inline"><input type="checkbox" id="host-replace"> Deactivate anyone at this site who is not in the file</label>
<button class="primary" id="do-host-import">Import</button>
</details>
<div id="hosts-table"></div>
</section>
<!-- ----------------------------------------------------- sites -->
<section class="panel" id="panel-sites">
<div class="panel-head">
<h2>Sites</h2>
<button class="primary owner-only" id="new-site" hidden>Add a site</button>
</div>
<p class="hint">Each site has its own name, its own list of people to visit, and its own
badge settings. Point a kiosk at one with
<code>/?site=<em>slug</em></code>, or let staff pick on first use.</p>
<div id="sites-list"></div>
</section>
<!-- ---------------------------------------------------- admins -->
<section class="panel" id="panel-admins">
<div class="panel-head">
<h2>Admin accounts</h2>
<button class="primary" id="new-admin">Invite an admin</button>
</div>
<p class="hint" id="admins-note"></p>
<div id="admins-table"></div>
</section>
<!-- ---------------------------------------------------- system -->
<section class="panel" id="panel-system">
<h2>System</h2>
<div id="system-body"></div>
<h2 class="section-gap">Your account</h2>
<div id="account-body"></div>
</section>
</main>
</div>
<dialog id="modal">
<form method="dialog" id="modal-form">
<h3 id="modal-title"></h3>
<div id="modal-body"></div>
<div class="modal-actions">
<button value="cancel" class="ghost" id="modal-cancel">Cancel</button>
<button value="save" class="primary" id="modal-save">Save</button>
</div>
</form>
</dialog>
<p class="toast" id="toast" role="status" hidden></p>
<footer class="foot">Created by: Jess Rogerson (yelling commands at Claude.AI)</footer>
<script src="/js/admin.js"></script>
</body>
</html>
+375
View File
@@ -0,0 +1,375 @@
:root {
--paper: #eef1f4;
--card: #ffffff;
--ink: #16202b;
--muted: #5d6b7a;
--rule: #d4dce3;
--deep: #0b4f4a;
--exit: #2c4a6b;
--alert: #96162f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
}
h1, h2, h3 { letter-spacing: -0.015em; font-weight: 620; }
h2 { font-size: 20px; margin: 0; }
h3 { font-size: 18px; margin: 0 0 14px; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
.primary {
padding: 9px 16px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.ghost {
display: inline-block;
padding: 9px 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
text-decoration: none;
}
.danger { color: var(--alert); border-color: #e3bcc4; }
.link {
border: none;
background: none;
color: #d7e4e1;
text-decoration: underline;
text-underline-offset: 3px;
}
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; }
/* --------------------------------------------------------------- login */
.login { display: grid; place-items: center; min-height: 100vh; padding: 20px; }
.login-card {
width: min(380px, 100%);
padding: 30px;
background: var(--card);
border-radius: 3px;
border-top: 5px solid var(--deep);
}
.login-card h1 { font-size: 22px; margin: 0 0 20px; }
.login-card label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
.login-card input {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--rule);
border-radius: 3px;
margin-bottom: 18px;
}
.login-card button {
width: 100%;
padding: 12px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.err { color: var(--alert); margin: 14px 0 0; font-size: 14px; }
/* -------------------------------------------------------------- chrome */
.topbar {
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
padding: 12px 22px;
background: var(--deep);
color: #eef5f3;
}
.topbar nav { display: flex; gap: 4px; flex-wrap: wrap; margin-right: auto; }
.tab {
padding: 8px 14px;
border: none;
border-radius: 3px;
background: transparent;
color: #cfe0dd;
}
.tab.on { background: rgba(255, 255, 255, 0.14); color: #fff; font-weight: 600; }
main { max-width: 1100px; margin: 0 auto; padding: 26px 22px 60px; }
.panel { display: none; }
.panel.on { display: block; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.stat {
font-size: 40px;
font-weight: 650;
font-variant-numeric: tabular-nums;
margin: 0 0 6px;
color: var(--deep);
}
.hint { color: var(--muted); font-size: 14px; max-width: 70ch; }
/* -------------------------------------------------------------- tables */
table { width: 100%; border-collapse: collapse; background: var(--card); font-size: 14.5px; }
th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--rule); vertical-align: middle; }
th { font-weight: 600; color: var(--muted); font-size: 13.5px; }
tbody tr:last-child td { border-bottom: none; }
td.actions { text-align: right; white-space: nowrap; }
td.actions button { margin-left: 6px; padding: 6px 11px; font-size: 13.5px; }
.mono { font-variant-numeric: tabular-nums; }
.thumb { width: 42px; height: 42px; object-fit: cover; border-radius: 3px; display: block; }
.empty { padding: 26px; background: var(--card); color: var(--muted); }
.pill {
display: inline-block;
padding: 2px 9px;
border-radius: 2px;
font-size: 12.5px;
background: #e6efed;
color: var(--deep);
}
.pill.off { background: #eceff2; color: var(--muted); }
.pill.out { background: #e7edf4; color: var(--exit); }
/* ------------------------------------------------------------- filters */
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: 16px;
}
.filters label { display: block; }
.filters .grow { flex: 1 1 240px; }
.filters span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.filters input { width: 100%; padding: 9px 11px; border: 1px solid var(--rule); border-radius: 3px; }
/* -------------------------------------------------------------- import */
.import {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 3px;
padding: 14px 16px;
margin-bottom: 20px;
}
.import summary { cursor: pointer; font-weight: 600; }
.import textarea {
width: 100%;
margin: 12px 0;
padding: 10px;
border: 1px solid var(--rule);
border-radius: 3px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
}
.import input[type="file"] { margin-top: 12px; }
.inline { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; font-size: 14px; }
/* --------------------------------------------------------------- modal */
dialog {
border: none;
border-radius: 3px;
padding: 24px;
width: min(520px, calc(100vw - 32px));
border-top: 5px solid var(--deep);
}
dialog::backdrop { background: rgba(22, 32, 43, 0.45); }
.modal-field { margin-bottom: 14px; }
.modal-field span { display: block; font-size: 13.5px; color: var(--muted); margin-bottom: 5px; }
.modal-field input, .modal-field select, .modal-field textarea {
width: 100%;
padding: 10px 11px;
border: 1px solid var(--rule);
border-radius: 3px;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.pin-reveal {
font-size: 34px;
font-weight: 700;
letter-spacing: 0.22em;
color: var(--deep);
font-variant-numeric: tabular-nums;
margin: 6px 0 16px;
}
/* -------------------------------------------------------------- system */
#system-body { background: var(--card); padding: 20px; border-radius: 3px; }
#system-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
#system-body dt { color: var(--muted); }
#system-body dd { margin: 0; }
.sys-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* --------------------------------------------------------------- toast */
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
margin: 0;
padding: 13px 18px;
border-left: 5px solid var(--deep);
border-radius: 3px;
background: #fff;
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
max-width: min(560px, calc(100% - 32px));
}
.toast.bad { border-left-color: var(--alert); }
.foot { padding: 0 22px 26px; color: var(--muted); font-size: 12.5px; text-align: center; }
/* -------------------------------------------- login steps, 2FA, extras */
.login-step { display: block; }
.login-step label { display: block; }
.login-step label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
.login-step input {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--rule);
border-radius: 3px;
margin-bottom: 16px;
}
.login-step button[type="submit"],
.login-step > button {
width: 100%;
padding: 12px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.login-step .hint { margin: 0 0 16px; }
.link-quiet {
width: 100%;
margin-top: 10px;
border: none;
background: none;
color: var(--muted);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 400;
}
#twofa-qr { display: block; margin: 0 auto 14px; border: 1px solid var(--rule); }
#twofa-secret, code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
background: #eef1f4;
padding: 1px 5px;
border-radius: 2px;
}
#twofa-code { letter-spacing: 0.3em; text-align: center; font-size: 22px; }
.recovery {
list-style: none;
margin: 0 0 18px;
padding: 14px;
border: 1px solid var(--rule);
border-radius: 3px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 14px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 14px;
}
/* ----------------------------------------------------- site switching */
.site-switch { display: flex; align-items: center; gap: 8px; color: #cfe0dd; font-size: 14px; }
.site-switch select {
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.site-switch select option { color: var(--ink); }
.badge-count {
display: inline-block;
min-width: 20px;
margin-left: 6px;
padding: 0 6px;
border-radius: 10px;
background: var(--alert);
color: #fff;
font-size: 12px;
text-align: center;
}
/* ------------------------------------------------------ banner, cards */
.banner {
margin: 0;
padding: 12px 22px;
background: #fdf3d8;
border-bottom: 1px solid #e6d5a4;
font-size: 14.5px;
}
.banner.bad { background: #fbeaed; border-bottom-color: #e8c3cb; }
.notice {
padding: 12px 14px;
margin: 0 0 16px;
background: #fdf3d8;
border-left: 4px solid #d9a441;
font-size: 14.5px;
}
.card {
background: var(--card);
border-radius: 3px;
padding: 18px 20px;
margin-bottom: 14px;
}
.site-head { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: flex-start; }
.site-head h3 { margin: 0 0 4px; }
.site-head .hint { margin: 0; }
.site-meta { display: grid; grid-template-columns: minmax(130px, 24%) 1fr; gap: 6px 16px; margin: 14px 0 0; font-size: 14.5px; }
.site-meta dt { color: var(--muted); }
.site-meta dd { margin: 0; }
.section-gap { margin-top: 34px; }
#account-body { background: var(--card); padding: 20px; border-radius: 3px; }
#account-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
#account-body dt { color: var(--muted); }
#account-body dd { margin: 0; }
td small { display: block; color: var(--muted); font-size: 13px; }
tr.row-warn td { background: #fdf8ec; }
tr.row-bad td { background: #fdf0f2; }
.pill.warn { background: #f7e7c4; color: #7a5308; }
.pill.bad { background: #f6d5db; color: var(--alert); }
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-section { margin: 20px 0 12px; font-size: 15px; }
.pin-reveal.small { font-size: 24px; letter-spacing: 0.06em; word-break: break-all; }
#modal img { display: block; margin: 0 auto 12px; border: 1px solid var(--rule); }
+343
View File
@@ -0,0 +1,343 @@
:root {
--paper: #e7ecf0;
--card: #ffffff;
--ink: #16202b;
--muted: #5d6b7a;
--rule: #c9d3dc;
--deep: #0b4f4a;
--deep-dark: #083a36;
--exit: #2c4a6b;
--exit-dark: #1f3650;
--alert: #96162f;
--focus: #0b4f4a;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--paper);
color: var(--ink);
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.45;
-webkit-text-size-adjust: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ------------------------------------------------------------- chrome */
.bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 14px 22px;
background: var(--deep);
color: #eef5f3;
}
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
.clock { margin: 0; font-variant-numeric: tabular-nums; font-size: 16px; opacity: 0.85; }
.foot {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 14px 22px 20px;
font-size: 12.5px;
color: var(--muted);
}
.foot a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
#app {
flex: 1;
width: 100%;
max-width: 620px;
margin: 0 auto;
padding: 28px 22px 8px;
}
/* ------------------------------------------------------------ screens */
.screen { display: none; }
.screen.on { display: block; animation: rise 180ms ease-out; }
@keyframes rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.screen.on { animation: none; }
}
h1, h2 {
font-weight: 620;
letter-spacing: -0.015em;
line-height: 1.2;
}
h1 { font-size: 30px; margin: 8px 0 26px; }
h2 { font-size: 25px; margin: 4px 0 20px; }
.hint { margin: -12px 0 22px; color: var(--muted); font-size: 15px; max-width: 46ch; }
/* --------------------------------------------------------- home doors */
.welcome { max-width: 18ch; }
.doors { display: grid; gap: 14px; }
.door {
display: block;
width: 100%;
text-align: left;
padding: 26px 24px;
border: none;
border-left: 7px solid var(--deep-dark);
border-radius: 3px;
background: var(--deep);
color: #fff;
font: inherit;
cursor: pointer;
}
.door-out { background: var(--exit); border-left-color: var(--exit-dark); }
.door:active { transform: translateY(1px); }
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
.door-sub { display: block; margin-top: 4px; font-size: 15px; opacity: 0.82; }
.text-action {
display: block;
width: 100%;
margin-top: 22px;
padding: 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: transparent;
color: var(--ink);
font: inherit;
cursor: pointer;
}
.text-action:hover { background: var(--card); }
/* ------------------------------------------------------- step rail */
.rail {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 18px;
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.rail i {
display: block;
width: 26px;
height: 3px;
background: var(--rule);
border-radius: 2px;
}
.rail i.done { background: var(--deep); }
.rail span { margin-left: 6px; }
/* ---------------------------------------------------------- fields */
.field { display: block; margin-bottom: 18px; }
.field > span {
display: block;
margin-bottom: 6px;
font-size: 14.5px;
color: var(--muted);
}
.field input {
width: 100%;
padding: 15px 14px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 20px;
}
.field input:focus-visible,
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.field-pin input {
font-size: 30px;
letter-spacing: 0.5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* --------------------------------------------------------- choices */
.choices { display: grid; gap: 10px; margin-bottom: 20px; }
.choice, .host-option {
display: block;
width: 100%;
text-align: left;
padding: 18px 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 18px;
cursor: pointer;
}
.choice[aria-pressed="true"] {
border-color: var(--deep);
box-shadow: inset 0 0 0 1px var(--deep);
background: #f2f8f6;
}
.host-list {
max-height: 46vh;
overflow-y: auto;
display: grid;
gap: 8px;
margin-bottom: 20px;
-webkit-overflow-scrolling: touch;
}
.host-option small { display: block; color: var(--muted); font-size: 14px; }
.host-empty { color: var(--muted); font-size: 15px; padding: 10px 2px; }
/* ---------------------------------------------------------- camera */
.camera {
position: relative;
aspect-ratio: 4 / 3;
background: #0f1720;
border-radius: 3px;
overflow: hidden;
margin-bottom: 18px;
}
.camera video, .camera img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scaleX(-1);
}
.camera-error {
margin: -8px 0 18px;
padding: 14px;
border-left: 4px solid var(--alert);
background: #fff;
color: var(--ink);
font-size: 15px;
}
/* ---------------------------------------------------------- review */
.review {
display: grid;
grid-template-columns: minmax(120px, 34%) 1fr;
gap: 10px 16px;
margin: 0 0 24px;
padding-top: 16px;
border-top: 1px solid var(--rule);
font-size: 16.5px;
}
.review dt { color: var(--muted); }
.review dd { margin: 0; }
.review img { width: 78px; border-radius: 3px; display: block; }
/* ------------------------------------------------------------- done */
.screen-done .mark {
display: inline-block;
margin: 8px 0 14px;
padding: 7px 14px;
border-radius: 2px;
background: var(--deep);
color: #fff;
font-size: 14px;
font-weight: 600;
}
#screen-done-out .mark { background: var(--exit); }
/* ------------------------------------------------------------ buttons */
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 26px;
}
.nav button {
flex: 1 1 auto;
min-height: 62px;
padding: 16px 22px;
border-radius: 3px;
font: inherit;
font-size: 19px;
cursor: pointer;
}
.nav .primary {
border: 1px solid var(--deep);
background: var(--deep);
color: #fff;
font-weight: 600;
}
.nav .ghost {
flex: 0 1 auto;
border: 1px solid var(--rule);
background: transparent;
color: var(--muted);
}
.nav button[disabled] { opacity: 0.55; cursor: progress; }
/* ------------------------------------------------------------- alert */
.alert {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
width: min(560px, calc(100% - 32px));
margin: 0;
padding: 16px 18px;
border-left: 5px solid var(--alert);
border-radius: 3px;
background: #fff;
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
font-size: 16px;
}
@media (max-width: 420px) {
h1 { font-size: 26px; }
h2 { font-size: 22px; }
.door-title { font-size: 23px; }
}
/* -------------------------------------------------- site + badge bits */
.foot-link {
border: none;
background: none;
padding: 0 12px 0 0;
color: var(--muted);
font: inherit;
font-size: 12.5px;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
#badge-frame {
position: fixed;
width: 0;
height: 0;
border: 0;
visibility: hidden;
}
+236
View File
@@ -0,0 +1,236 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b4f4a">
<title>Visitor sign in</title>
<link rel="stylesheet" href="/css/kiosk.css">
</head>
<body>
<header class="bar">
<p class="site" id="siteName">Visitor sign in</p>
<p class="clock" id="clock"></p>
</header>
<main id="app">
<!-- ---------------------------------------------------- site picker -->
<section class="screen" id="screen-site">
<h1 class="welcome">Which site is this kiosk at?</h1>
<p class="hint">This tablet remembers the answer, so you only pick once.</p>
<div class="host-list" id="site-list"></div>
</section>
<!-- ---------------------------------------------------------- home -->
<section class="screen" id="screen-home">
<h1 class="welcome">Welcome. Are you coming in, or heading out?</h1>
<div class="doors">
<button class="door door-in" data-go="guest-name">
<span class="door-title">Sign in</span>
<span class="door-sub">First time, or an occasional visit</span>
</button>
<button class="door door-out" data-go="signout-find">
<span class="door-title">Sign out</span>
<span class="door-sub">Leaving the site</span>
</button>
</div>
<button class="text-action" data-go="freq-pin">I have a PIN — I come here regularly</button>
</section>
<!-- ------------------------------------------------- guest: name -->
<section class="screen" id="screen-guest-name" data-step="1">
<div class="rail" data-rail></div>
<h2>What's your name?</h2>
<label class="field">
<span>First name</span>
<input id="in-first" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Last name</span>
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" data-next="guest-name">Continue</button>
</div>
</section>
<!-- ------------------------------------------------- guest: host -->
<section class="screen" id="screen-guest-host" data-step="2">
<div class="rail" data-rail></div>
<h2>Who are you here to see?</h2>
<label class="field">
<span>Start typing a name</span>
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
</label>
<div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div>
<div class="nav">
<button class="ghost" data-back>Back</button>
</div>
</section>
<!-- ---------------------------------------------- guest: contact -->
<section class="screen" id="screen-guest-contact" data-step="3">
<div class="rail" data-rail></div>
<h2>How can we reach you today?</h2>
<p class="hint">One of these is enough. We use it to sign you out and in an emergency.</p>
<label class="field">
<span>Mobile number</span>
<input id="in-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field">
<span>Email address</span>
<input id="in-email" type="email" inputmode="email" autocomplete="email" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" data-next="guest-contact">Continue</button>
</div>
</section>
<!-- ------------------------------------------------ guest: check -->
<section class="screen" id="screen-guest-check" data-step="4">
<div class="rail" data-rail></div>
<h2>Do you hold a WWCC or VIT registration?</h2>
<div class="choices" id="check-choices">
<button class="choice" data-check="WWCC">Working with Children Check</button>
<button class="choice" data-check="VIT">Victorian Institute of Teaching</button>
<button class="choice" data-check="NONE">I don't have one</button>
</div>
<label class="field" id="check-number-field" hidden>
<span id="check-number-label">Card number</span>
<input id="in-check-number" autocomplete="off" autocapitalize="characters" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="check-continue" hidden>Continue</button>
</div>
</section>
<!-- ------------------------------------------------------- photo -->
<section class="screen" id="screen-photo" data-step="5">
<div class="rail" data-rail></div>
<h2>Look at the camera</h2>
<p class="hint">The photo stays on this site's server. It is not sent anywhere else.</p>
<div class="camera">
<video id="cam-video" playsinline muted autoplay></video>
<img id="cam-shot" alt="The photo you just took" hidden>
<canvas id="cam-canvas" hidden></canvas>
</div>
<p class="camera-error" id="cam-error" hidden></p>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="cam-take">Take photo</button>
<button class="ghost" id="cam-retake" hidden>Retake</button>
<button class="primary" id="cam-use" hidden>Use this photo</button>
<button class="ghost" id="cam-skip" hidden>Continue without a photo</button>
</div>
</section>
<!-- ------------------------------------------------------ review -->
<section class="screen" id="screen-review" data-step="6">
<div class="rail" data-rail></div>
<h2>Check these details, then sign in</h2>
<dl class="review" id="review-list"></dl>
<div class="nav">
<button class="ghost" data-go="home">Start over</button>
<button class="primary" id="do-signin">Sign in</button>
</div>
</section>
<!-- ------------------------------------------- recurring: the PIN -->
<section class="screen" id="screen-freq-pin">
<h2>Welcome back</h2>
<label class="field">
<span>Mobile number</span>
<input id="in-freq-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field field-pin">
<span>4 digit PIN</span>
<input id="in-freq-pin" type="password" inputmode="numeric" maxlength="4" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-freq-auth">Continue</button>
</div>
</section>
<!-- --------------------------------------------- recurring: host -->
<section class="screen" id="screen-freq-host">
<h2 id="freq-greeting">Who are you here to see?</h2>
<label class="field">
<span>Start typing a name</span>
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
</label>
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div>
<div class="nav">
<button class="ghost" data-go="home">Cancel</button>
</div>
</section>
<!-- --------------------------------------------------- signed in -->
<section class="screen screen-done" id="screen-done-in">
<p class="mark">Signed in</p>
<h2 id="done-in-message"></h2>
<p class="hint" id="done-in-detail"></p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
<button class="ghost" id="reprint-badge" hidden>Print the badge again</button>
</div>
</section>
<!-- ------------------------------------------------- sign out: find -->
<section class="screen" id="screen-signout-find">
<h2>Signing out</h2>
<label class="field">
<span>Last name</span>
<input id="out-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Mobile number or email</span>
<input id="out-contact" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-signout-find">Find my visit</button>
</div>
</section>
<!-- ------------------------------------------------ sign out: pick -->
<section class="screen" id="screen-signout-pick">
<h2>Is this you?</h2>
<div class="host-list" id="signout-list"></div>
<div class="nav">
<button class="ghost" data-go="signout-find">Back</button>
</div>
</section>
<!-- -------------------------------------------------- signed out -->
<section class="screen screen-done" id="screen-done-out">
<p class="mark">Signed out</p>
<h2 id="done-out-message"></h2>
<p class="hint">Thanks for visiting. Travel safely.</p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
</div>
</section>
</main>
<p class="alert" id="alert" role="alert" hidden></p>
<footer class="foot">
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
<span>
<button class="foot-link" id="change-site" hidden></button>
<a href="/admin">Admin</a>
</span>
</footer>
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
<script src="/js/kiosk.js"></script>
</body>
</html>
+1002
View File
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
/* Visitor kiosk — single page flow controller. */
const TOTAL_STEPS = 6;
const IDLE_MS = 120000;
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
mode: 'guest',
firstName: '',
lastName: '',
hostId: null,
hostName: '',
phone: '',
email: '',
checkType: '',
checkNumber: '',
photo: null,
frequentVisitorId: null,
};
let hosts = [];
let siteConfig = { requirePhoto: true, siteName: 'Visitor sign in', multiSite: false, site: null };
let history = [];
let current = 'home';
let idleTimer = null;
let lastBadgeUrl = null;
/* ------------------------------------------------------------- site */
// Which entrance this tablet belongs to. A ?site=slug in the address wins and is
// remembered, so a kiosk can be pointed at a site once during setup.
const SITE_KEY = 'visitorKioskSite';
function storedSite() {
const fromUrl = new URLSearchParams(location.search).get('site');
if (fromUrl) {
try {
localStorage.setItem(SITE_KEY, fromUrl);
} catch {
/* private browsing */
}
return fromUrl;
}
try {
return localStorage.getItem(SITE_KEY) || '';
} catch {
return '';
}
}
let siteSlug = storedSite();
function rememberSite(slug) {
siteSlug = slug;
try {
localStorage.setItem(SITE_KEY, slug);
} catch {
/* private browsing */
}
}
/* ------------------------------------------------------------ plumbing */
async function api(path, body) {
const url = body ? path : path + (path.includes('?') ? '&' : '?') + `site=${encodeURIComponent(siteSlug)}`;
const res = await fetch(url, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify({ ...body, site: siteSlug }) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Something went wrong. Try the front desk.');
return data;
}
let alertTimer = null;
function say(message) {
const box = $('#alert');
box.textContent = message;
box.hidden = false;
clearTimeout(alertTimer);
alertTimer = setTimeout(() => {
box.hidden = true;
}, 6000);
}
function clearAlert() {
$('#alert').hidden = true;
}
function drawRail(screen) {
const el = screen.querySelector('[data-rail]');
if (!el) return;
if (state.mode === 'frequent') {
el.innerHTML = '';
return;
}
const step = Number(screen.dataset.step || 0);
const bars = Array.from({ length: TOTAL_STEPS }, (_, i) =>
`<i class="${i < step ? 'done' : ''}"></i>`
).join('');
el.innerHTML = `${bars}<span>Step ${step} of ${TOTAL_STEPS}</span>`;
}
function show(name, { push = true } = {}) {
const next = document.getElementById(`screen-${name}`);
if (!next) return;
if (push && current !== name) history.push(current);
if (current === 'photo' && name !== 'photo') stopCamera();
$$('.screen').forEach((s) => s.classList.remove('on'));
next.classList.add('on');
current = name;
clearAlert();
drawRail(next);
window.scrollTo(0, 0);
const firstInput = next.querySelector('input');
if (firstInput && !('ontouchstart' in window)) firstInput.focus();
if (name === 'photo') startCamera();
if (name === 'home') resetState();
resetIdle();
}
function goBack() {
const previous = history.pop() || 'home';
show(previous, { push: false });
}
function resetState() {
Object.assign(state, {
mode: 'guest',
firstName: '',
lastName: '',
hostId: null,
hostName: '',
phone: '',
email: '',
checkType: '',
checkNumber: '',
photo: null,
frequentVisitorId: null,
});
history = [];
$$('#app input').forEach((i) => {
i.value = '';
});
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
$('#check-number-field').hidden = true;
$('#check-continue').hidden = true;
}
function resetIdle() {
clearTimeout(idleTimer);
if (current === 'home') return;
idleTimer = setTimeout(() => show('home', { push: false }), IDLE_MS);
}
['click', 'keydown', 'touchstart'].forEach((evt) =>
document.addEventListener(evt, resetIdle, { passive: true })
);
/* --------------------------------------------------------------- clock */
function tickClock() {
$('#clock').textContent = new Date().toLocaleString('en-AU', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
setInterval(tickClock, 15000);
tickClock();
/* --------------------------------------------------------------- hosts */
function renderHosts(listEl, searchValue, onPick) {
const term = searchValue.trim().toLowerCase();
const matches = term
? hosts.filter((h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term))
: hosts;
if (!matches.length) {
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`;
return;
}
listEl.innerHTML = matches
.slice(0, 60)
.map(
(h) =>
`<button class="host-option" data-host-id="${h.id}" data-host-name="${escapeHtml(h.name)}">
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''}
</button>`
)
.join('');
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
btn.addEventListener('click', () => {
state.hostId = Number(btn.dataset.hostId);
state.hostName = btn.dataset.hostName;
onPick();
});
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
/* -------------------------------------------------------------- camera */
let stream = null;
async function startCamera() {
const video = $('#cam-video');
const err = $('#cam-error');
err.hidden = true;
$('#cam-shot').hidden = true;
video.hidden = false;
$('#cam-take').hidden = false;
$('#cam-retake').hidden = true;
$('#cam-use').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
state.photo = null;
if (stream) return;
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('unsupported');
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
} catch (e) {
const insecure = !window.isSecureContext;
err.hidden = false;
err.textContent = insecure
? 'The camera is blocked because this kiosk is not on a secure connection. Ask IT to serve the kiosk over HTTPS, then reload.'
: 'No camera is available on this device. Ask the front desk to sign you in.';
$('#cam-take').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
}
}
function stopCamera() {
if (!stream) return;
stream.getTracks().forEach((t) => t.stop());
stream = null;
$('#cam-video').srcObject = null;
}
function capture() {
const video = $('#cam-video');
const canvas = $('#cam-canvas');
const width = 720;
const height = Math.round((video.videoHeight / video.videoWidth) * width) || 540;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, width, height);
state.photo = canvas.toDataURL('image/jpeg', 0.72);
const shot = $('#cam-shot');
shot.src = state.photo;
shot.hidden = false;
video.hidden = true;
$('#cam-take').hidden = true;
$('#cam-skip').hidden = true;
$('#cam-retake').hidden = false;
$('#cam-use').hidden = false;
}
/* ---------------------------------------------------------- validation */
const emailOk = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
const phoneOk = (v) => v.replace(/[^\d]/g, '').length >= 8;
function afterPhoto() {
if (state.mode === 'frequent') {
submitSignIn();
} else {
buildReview();
show('review');
}
}
function buildReview() {
const rows = [
['Name', `${state.firstName} ${state.lastName}`],
['Visiting', state.hostName],
['Mobile', state.phone || '—'],
['Email', state.email || '—'],
[
'Check',
state.checkType === 'NONE' ? 'None held' : `${state.checkType} ${state.checkNumber}`,
],
];
const photoRow = state.photo
? `<dt>Photo</dt><dd><img src="${state.photo}" alt="The photo you took"></dd>`
: '';
$('#review-list').innerHTML =
rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join('') + photoRow;
}
/* ------------------------------------------------------------ submits */
async function submitSignIn() {
const button = state.mode === 'frequent' ? $('#cam-use') : $('#do-signin');
button.disabled = true;
try {
const result = await api('/api/signin', {
mode: state.mode,
frequentVisitorId: state.frequentVisitorId,
firstName: state.firstName,
lastName: state.lastName,
hostId: state.hostId,
phone: state.phone,
email: state.email,
checkType: state.checkType,
checkNumber: state.checkNumber,
photo: state.photo,
});
stopCamera();
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
$('#done-in-detail').textContent = result.badgeUrl
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
lastBadgeUrl = result.badgeUrl;
$('#reprint-badge').hidden = !result.badgeUrl;
if (result.badgeUrl) printBadge(result.badgeUrl);
show('done-in', { push: false });
setTimeout(() => {
if (current === 'done-in') show('home', { push: false });
}, 12000);
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
}
/* --------------------------------------------------------------- badge */
/**
* The badge page prints itself once loaded, so dropping it into a hidden iframe
* gives one label without the visitor seeing a print dialog on most kiosks.
*/
function printBadge(url) {
const frame = $('#badge-frame');
frame.src = `${url}?t=${Date.now()}`;
}
$('#reprint-badge').addEventListener('click', () => {
if (lastBadgeUrl) printBadge(lastBadgeUrl);
});
/* ---------------------------------------------------------- site picker */
async function chooseSite() {
const sites = await api('/api/sites');
const list = $('#site-list');
if (!sites.length) {
list.innerHTML = `<p class="host-empty">No sites are set up yet. An admin needs to add one first.</p>`;
} else {
list.innerHTML = sites
.map(
(s) =>
`<button class="host-option" data-site="${escapeHtml(s.slug)}">${escapeHtml(s.name)}</button>`
)
.join('');
list.querySelectorAll('[data-site]').forEach((btn) => {
btn.addEventListener('click', async () => {
rememberSite(btn.dataset.site);
await loadSiteContext();
show('home', { push: false });
});
});
}
show('site', { push: false });
}
async function loadSiteContext() {
siteConfig = await api('/api/config');
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
document.title = name;
$('#siteName').textContent = name;
const change = $('#change-site');
change.hidden = !siteConfig.multiSite;
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
hosts = await api('/api/hosts');
renderHosts($('#host-list'), '', () => show('guest-contact'));
}
$('#change-site').addEventListener('click', chooseSite);
/* --------------------------------------------------------------- wiring */
document.addEventListener('click', (event) => {
const go = event.target.closest('[data-go]');
if (go) {
const target = go.dataset.go;
if (target === 'home') {
show('home', { push: false });
} else if (target === 'guest-name') {
state.mode = 'guest';
show('guest-name');
} else {
show(target);
}
return;
}
if (event.target.closest('[data-back]')) goBack();
});
$('[data-next="guest-name"]').addEventListener('click', () => {
const first = $('#in-first').value.trim();
const last = $('#in-last').value.trim();
if (!first) return say('Enter your first name.');
if (!last) return say('Enter your last name.');
state.firstName = first;
state.lastName = last;
show('guest-host');
});
$('#in-host-search').addEventListener('input', (e) =>
renderHosts($('#host-list'), e.target.value, () => show('guest-contact'))
);
$('[data-next="guest-contact"]').addEventListener('click', () => {
const phone = $('#in-phone').value.trim();
const email = $('#in-email').value.trim();
if (!phone && !email) return say('Add a mobile number or an email address.');
if (phone && !phoneOk(phone)) return say('That mobile number looks too short.');
if (email && !emailOk(email)) return say('That email address does not look right.');
state.phone = phone;
state.email = email;
show('guest-check');
});
$$('.choice').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
state.checkType = btn.dataset.check;
const needsNumber = state.checkType !== 'NONE';
$('#check-number-field').hidden = !needsNumber;
$('#check-number-label').textContent =
state.checkType === 'WWCC' ? 'WWCC card number' : 'VIT registration number';
$('#check-continue').hidden = false;
if (needsNumber) $('#in-check-number').focus();
});
});
$('#check-continue').addEventListener('click', () => {
if (state.checkType !== 'NONE') {
const number = $('#in-check-number').value.trim();
if (!number) return say('Enter the number on your card.');
state.checkNumber = number;
} else {
state.checkNumber = '';
}
show('photo');
});
$('#cam-take').addEventListener('click', capture);
$('#cam-retake').addEventListener('click', () => startCamera());
$('#cam-use').addEventListener('click', afterPhoto);
$('#cam-skip').addEventListener('click', () => {
state.photo = null;
afterPhoto();
});
$('#do-signin').addEventListener('click', submitSignIn);
/* recurring visitors */
$('#do-freq-auth').addEventListener('click', async () => {
const phone = $('#in-freq-phone').value.trim();
const pin = $('#in-freq-pin').value.trim();
if (!phoneOk(phone)) return say('Enter the mobile number on your card.');
if (!/^\d{4}$/.test(pin)) return say('Your PIN is 4 digits.');
const button = $('#do-freq-auth');
button.disabled = true;
try {
const person = await api('/api/frequent/auth', { phone, pin });
if (person.openVisit) {
say(`${person.firstName}, you are already signed in. Use Sign out instead.`);
return;
}
state.mode = 'frequent';
state.frequentVisitorId = person.id;
state.firstName = person.firstName;
state.lastName = person.lastName;
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = '';
renderHosts($('#freq-host-list'), '', () => show('photo'));
show('freq-host');
} catch (err) {
say(err.message);
$('#in-freq-pin').value = '';
} finally {
button.disabled = false;
}
});
$('#in-freq-host-search').addEventListener('input', (e) =>
renderHosts($('#freq-host-list'), e.target.value, () => show('photo'))
);
/* sign out */
$('#do-signout-find').addEventListener('click', async () => {
const lastName = $('#out-last').value.trim();
const contact = $('#out-contact').value.trim();
if (!lastName) return say('Enter your last name.');
if (!contact) return say('Enter your mobile number or email.');
const button = $('#do-signout-find');
button.disabled = true;
try {
const matches = await api('/api/signout/lookup', { lastName, contact });
const list = $('#signout-list');
list.innerHTML = matches
.map(
(m) =>
`<button class="host-option" data-visit="${m.id}">
${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)}
<small>Visiting ${escapeHtml(m.hostName)} · in at ${new Date(m.signedInAt).toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })}</small>
</button>`
)
.join('');
list.querySelectorAll('[data-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const done = await api('/api/signout', { visitId: Number(btn.dataset.visit) });
$('#done-out-message').textContent = `Goodbye, ${done.firstName}.`;
show('done-out', { push: false });
setTimeout(() => {
if (current === 'done-out') show('home', { push: false });
}, 10000);
} catch (err) {
say(err.message);
btn.disabled = false;
}
});
});
show('signout-pick');
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
});
/* Enter key moves the flow along on every screen. */
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
const screen = document.querySelector('.screen.on');
const primary = screen?.querySelector('.primary:not([hidden])');
if (primary) {
event.preventDefault();
primary.click();
}
});
/* -------------------------------------------------------------- start */
(async function init() {
try {
await loadSiteContext();
} catch {
/* fall through to the picker below */
}
// With one site the server resolves it for us; with several, ask once.
if (!siteConfig.siteChosen) {
await chooseSite();
return;
}
show('home', { push: false });
})();