/* 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) => `` ).join(''); el.innerHTML = `${bars}Step ${step} of ${TOTAL_STEPS}`; } 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 = `` + matches .map( (h) => `` ) .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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ? `
No sites are set up yet. An admin needs to add one first.
`; } else { list.innerHTML = sites .map( (s) => `` ) .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) { 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('--rule', theme.rule); root.setProperty('--focus', theme.brand); document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand); } const img = $('#banner'); 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); 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) => `` ) .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 }); })();