Files
visitor-signin/public/js/kiosk.js
T

709 lines
22 KiB
JavaScript

/* 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,
hasStoredPhoto: false,
};
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,
hasStoredPhoto: false,
});
history = [];
$$('#app input').forEach((i) => {
i.value = '';
});
$$('#app select').forEach((sel) => {
sel.selectedIndex = 0;
});
$$('.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 */
/**
* Who you are here to see: type to narrow, then choose from the dropdown. The
* dropdown is a native select, so a tablet gives it a proper full-screen picker
* with its own scrolling, which handles a long staff list better than a page of
* buttons ever did.
*/
function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
const term = String(searchValue || '').trim().toLowerCase();
const matches = term
? hosts.filter(
(h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term)
)
: hosts;
const label = !hosts.length
? 'Nobody has been added yet'
: matches.length === hosts.length
? `Choose one of ${hosts.length}`
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
selectEl.innerHTML =
`<option value="">${escapeHtml(label)}</option>` +
matches
.map(
(h) =>
`<option value="${h.id}">${escapeHtml(h.name)}${h.area ? ` — ${escapeHtml(h.area)}` : ''}</option>`
)
.join('');
selectEl.disabled = matches.length === 0;
selectEl.onchange = () => {
const picked = hosts.find((h) => h.id === Number(selectEl.value));
if (!picked) return;
state.hostId = picked.id;
state.hostName = picked.name;
onPick();
};
if (emptyEl) {
emptyEl.hidden = matches.length > 0;
emptyEl.textContent = hosts.length
? 'No one matches that. Check the spelling, or ask the front desk.'
: 'No one has been added for this site yet. Please see the front desk.';
}
// Typing a name and pressing enter should just work when only one person is left.
selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
}
/** Enter in the filter box picks the person when the filter leaves exactly one. */
function pickOnlyMatch(selectEl) {
const only = selectEl.dataset.only;
if (!only) return false;
selectEl.value = only;
selectEl.dispatchEvent(new Event('change'));
return true;
}
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;
}
const PHOTO_SIZE = 640;
/**
* Takes a square photo, cropped from the centre of whatever shape the camera
* gives us. The preview frame, the saved file and the space on the badge are all
* square, so nothing is stretched and what the visitor sees is what prints.
*/
function capture() {
const video = $('#cam-video');
const canvas = $('#cam-canvas');
const side = Math.min(video.videoWidth, video.videoHeight);
if (!side) return say('The camera is not ready yet. Try again in a moment.');
const sx = (video.videoWidth - side) / 2;
const sy = (video.videoHeight - side) / 2;
canvas.width = PHOTO_SIZE;
canvas.height = PHOTO_SIZE;
canvas.getContext('2d').drawImage(video, sx, sy, side, side, 0, 0, PHOTO_SIZE, PHOTO_SIZE);
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() {
// Called from the review screen, the camera screen, or straight from the host
// list when a recurring visitor already has a photo on file.
const button =
(current === 'photo' && $('#cam-use')) ||
(current === 'review' && $('#do-signin')) ||
null;
if (button) 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 {
if (button) 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 });
}
/**
* Applies the site's colours as CSS variables. Only three are chosen by an admin;
* the shades and the text colours that sit on them are derived server-side so a
* dark logo colour cannot end up with dark text on it.
*/
function applyTheme(theme, banner, align = 'left') {
if (theme) {
const root = document.documentElement.style;
root.setProperty('--deep', theme.brand);
root.setProperty('--deep-dark', theme.brandDark);
root.setProperty('--on-brand', theme.onBrand);
root.setProperty('--exit', theme.signout);
root.setProperty('--exit-dark', theme.signoutDark);
root.setProperty('--on-exit', theme.onSignout);
root.setProperty('--paper', theme.page);
root.setProperty('--card', theme.card);
root.setProperty('--ink', theme.ink);
root.setProperty('--muted', theme.muted);
root.setProperty('--rule', theme.rule);
root.setProperty('--focus', theme.brand);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
}
const img = $('#banner');
const bar = $('#bar');
bar.classList.toggle('align-center', align === 'center');
bar.classList.toggle('align-left', align !== 'center');
if (banner?.url) {
img.src = banner.url;
img.style.maxHeight = `${banner.height}px`;
img.hidden = false;
// The banner carries the branding, so the name beside it would just repeat it.
$('#siteName').hidden = true;
} else {
img.hidden = true;
img.removeAttribute('src');
$('#siteName').hidden = false;
}
}
async function loadSiteContext() {
siteConfig = await api('/api/config');
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
document.title = name;
$('#siteName').textContent = name;
$('#banner').alt = name;
applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign);
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-select'), '', () => show('guest-contact'), $('#host-empty'));
}
$('#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-select'), e.target.value, () => show('guest-contact'), $('#host-empty'))
);
$('#in-host-search').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
pickOnlyMatch($('#host-select'));
}
});
$('[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;
// With a photo already on file there is nothing to pose for: picking a host
// completes the sign in and the pass prints straight away.
state.hasStoredPhoto = Boolean(person.hasPhoto);
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = '';
renderHosts(
$('#freq-host-select'),
'',
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
$('#freq-host-empty')
);
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-select'),
e.target.value,
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
$('#freq-host-empty')
)
);
$('#in-freq-host-search').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
pickOnlyMatch($('#freq-host-select'));
}
});
/* 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 });
})();