Public Access
606 lines
18 KiB
JavaScript
606 lines
18 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 = '';
|
|
});
|
|
$$('.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) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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() {
|
|
// 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 });
|
|
}
|
|
|
|
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;
|
|
// 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-list'), '', () =>
|
|
state.hasStoredPhoto ? submitSignIn() : 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, () =>
|
|
state.hasStoredPhoto ? submitSignIn() : 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 });
|
|
})();
|