From cccba98d97b2ec8b201b1965dcf948b1bdaee9fc Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 1 Sep 2026 15:45:47 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- README.md | 2 ++ public/css/kiosk.css | 16 +++++++++++ public/index.html | 12 ++++++-- public/js/kiosk.js | 68 +++++++++++++++++++++++++++++++++++--------- src/sites.js | 16 +++++++---- 5 files changed, 92 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 3c0fb89..2b114cc 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ into a hidden frame and prints it — one label, no dialog on most kiosk setups. the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text. +The photo is square, matching the crop taken at the kiosk, and sits vertically centred. + Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type diff --git a/public/css/kiosk.css b/public/css/kiosk.css index ca6a2c3..bbd569d 100644 --- a/public/css/kiosk.css +++ b/public/css/kiosk.css @@ -353,3 +353,19 @@ a:focus-visible { border: 0; visibility: hidden; } + +/* --------------------------------------------------- host dropdown */ + +.picker { + width: 100%; + padding: 15px 14px; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--card); + color: var(--ink); + font: inherit; + font-size: 20px; + /* Tall enough to be a comfortable touch target on a tablet. */ + min-height: 58px; +} +.picker:disabled { color: var(--muted); } diff --git a/public/index.html b/public/index.html index cf67ed3..d056748 100644 --- a/public/index.html +++ b/public/index.html @@ -63,7 +63,11 @@

Who are you here to see?

+
@@ -162,7 +166,11 @@

Who are you here to see?

+
diff --git a/public/js/kiosk.js b/public/js/kiosk.js index 1c1b08f..16b82ae 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -150,6 +150,9 @@ function resetState() { $$('#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; @@ -182,16 +185,51 @@ tickClock(); /* --------------------------------------------------------------- hosts */ -function renderHosts(listEl, searchValue, onPick) { - const term = searchValue.trim().toLowerCase(); +/** + * Two ways to say who you are visiting, because neither suits everyone: a dropdown + * for someone who wants to browse a short list, and a filter box for someone who + * knows the name and would rather type it. The filter narrows both, so the two + * never disagree about what is on offer. + */ +function renderHosts(listEl, searchValue, onPick, selectEl = null) { + const term = String(searchValue || '').trim().toLowerCase(); const matches = term - ? hosts.filter((h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term)) + ? hosts.filter( + (h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term) + ) : hosts; + const choose = (id, name) => { + state.hostId = Number(id); + state.hostName = name; + onPick(); + }; + + if (selectEl) { + const placeholder = + matches.length === hosts.length + ? `${hosts.length} ${hosts.length === 1 ? 'person' : 'people'} — choose a name` + : `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`; + 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) choose(picked.id, picked.name); + }; + } + 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( @@ -202,11 +240,7 @@ function renderHosts(listEl, searchValue, onPick) { ) .join(''); listEl.querySelectorAll('[data-host-id]').forEach((btn) => { - btn.addEventListener('click', () => { - state.hostId = Number(btn.dataset.hostId); - state.hostName = btn.dataset.hostName; - onPick(); - }); + btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName)); }); } @@ -413,7 +447,7 @@ async function loadSiteContext() { change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site'; hosts = await api('/api/hosts'); - renderHosts($('#host-list'), '', () => show('guest-contact')); + renderHosts($('#host-list'), '', () => show('guest-contact'), $('#host-select')); } $('#change-site').addEventListener('click', chooseSite); @@ -448,7 +482,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => { }); $('#in-host-search').addEventListener('input', (e) => - renderHosts($('#host-list'), e.target.value, () => show('guest-contact')) + renderHosts($('#host-list'), e.target.value, () => show('guest-contact'), $('#host-select')) ); $('[data-next="guest-contact"]').addEventListener('click', () => { @@ -522,8 +556,11 @@ $('#do-freq-auth').addEventListener('click', async () => { 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') + renderHosts( + $('#freq-host-list'), + '', + () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), + $('#freq-host-select') ); show('freq-host'); } catch (err) { @@ -535,8 +572,11 @@ $('#do-freq-auth').addEventListener('click', async () => { }); $('#in-freq-host-search').addEventListener('input', (e) => - renderHosts($('#freq-host-list'), e.target.value, () => - state.hasStoredPhoto ? submitSignIn() : show('photo') + renderHosts( + $('#freq-host-list'), + e.target.value, + () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), + $('#freq-host-select') ) ); diff --git a/src/sites.js b/src/sites.js index 54010c5..58e98b0 100644 --- a/src/sites.js +++ b/src/sites.js @@ -86,9 +86,9 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { const pad = unit * 0.07; const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115)); const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062)); - // Square, matching the crop taken at the kiosk. Slightly smaller on a portrait - // badge than the old 4:3 frame, so a square photo still leaves room for the name. - const photoWidth = portrait ? unit * 0.46 : unit * 0.42; + // Square, matching the crop taken at the kiosk. Sized so that even a long name + // wrapping onto two lines still fits above the detail rows on a 62 x 90 mm label. + const photoWidth = portrait ? unit * 0.52 : unit * 0.5; // Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere // else it prints as grey, so it is off unless the site opts in. @@ -126,7 +126,8 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { padding: ${pad}mm; display: flex; flex-direction: ${portrait ? 'column' : 'row'}; - align-items: ${portrait ? 'center' : 'stretch'}; + align-items: center; + ${portrait ? 'justify-content: center;' : ''} text-align: ${portrait ? 'center' : 'left'}; gap: ${unit * 0.05}mm; font-family: "Segoe UI", Arial, Helvetica, sans-serif; @@ -137,11 +138,12 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { width: ${photoWidth}mm; height: ${photoWidth}mm; flex: 0 0 auto; + align-self: center; object-fit: cover; border: 0.3mm solid #000; } .body { - flex: 1 1 auto; + flex: ${portrait ? '0 1 auto' : '1 1 auto'}; min-width: 0; width: 100%; display: flex; @@ -166,7 +168,9 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = { overflow-wrap: anywhere; } .rows { - margin-top: auto; + /* Portrait badges centre the whole block; wider ones push the detail rows to + the bottom edge, which is where the eye expects them beside a photo. */ + margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'}; padding-top: ${unit * 0.04}mm; font-size: ${bodySize}mm; line-height: 1.35;