/* 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 = ''; }); $$('.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 = `
No one matches that. Check the spelling, or ask the front desk.
`; return; } listEl.innerHTML = matches .slice(0, 60) .map( (h) => `` ) .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; } 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 }); } 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) => `` ) .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 }); })();