Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-01 15:45:47 +10:00
parent a7564b9033
commit cccba98d97
5 changed files with 92 additions and 22 deletions
+2
View File
@@ -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 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. 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 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 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 what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type
+16
View File
@@ -353,3 +353,19 @@ a:focus-visible {
border: 0; border: 0;
visibility: hidden; 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); }
+10 -2
View File
@@ -63,7 +63,11 @@
<div class="rail" data-rail></div> <div class="rail" data-rail></div>
<h2>Who are you here to see?</h2> <h2>Who are you here to see?</h2>
<label class="field"> <label class="field">
<span>Start typing a name</span> <span>Pick from the list</span>
<select id="host-select" class="picker"></select>
</label>
<label class="field">
<span>Or start typing a name</span>
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson"> <input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
</label> </label>
<div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div> <div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div>
@@ -162,7 +166,11 @@
<section class="screen" id="screen-freq-host"> <section class="screen" id="screen-freq-host">
<h2 id="freq-greeting">Who are you here to see?</h2> <h2 id="freq-greeting">Who are you here to see?</h2>
<label class="field"> <label class="field">
<span>Start typing a name</span> <span>Pick from the list</span>
<select id="freq-host-select" class="picker"></select>
</label>
<label class="field">
<span>Or start typing a name</span>
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search"> <input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
</label> </label>
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div> <div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div>
+54 -14
View File
@@ -150,6 +150,9 @@ function resetState() {
$$('#app input').forEach((i) => { $$('#app input').forEach((i) => {
i.value = ''; i.value = '';
}); });
$$('#app select').forEach((sel) => {
sel.selectedIndex = 0;
});
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false')); $$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
$('#check-number-field').hidden = true; $('#check-number-field').hidden = true;
$('#check-continue').hidden = true; $('#check-continue').hidden = true;
@@ -182,16 +185,51 @@ tickClock();
/* --------------------------------------------------------------- hosts */ /* --------------------------------------------------------------- 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 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; : 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 =
`<option value="">${escapeHtml(placeholder)}</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) choose(picked.id, picked.name);
};
}
if (!matches.length) { if (!matches.length) {
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`; listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`;
return; return;
} }
listEl.innerHTML = matches listEl.innerHTML = matches
.slice(0, 60) .slice(0, 60)
.map( .map(
@@ -202,11 +240,7 @@ function renderHosts(listEl, searchValue, onPick) {
) )
.join(''); .join('');
listEl.querySelectorAll('[data-host-id]').forEach((btn) => { listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName));
state.hostId = Number(btn.dataset.hostId);
state.hostName = btn.dataset.hostName;
onPick();
});
}); });
} }
@@ -413,7 +447,7 @@ async function loadSiteContext() {
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site'; change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
hosts = await api('/api/hosts'); hosts = await api('/api/hosts');
renderHosts($('#host-list'), '', () => show('guest-contact')); renderHosts($('#host-list'), '', () => show('guest-contact'), $('#host-select'));
} }
$('#change-site').addEventListener('click', chooseSite); $('#change-site').addEventListener('click', chooseSite);
@@ -448,7 +482,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
}); });
$('#in-host-search').addEventListener('input', (e) => $('#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', () => { $('[data-next="guest-contact"]').addEventListener('click', () => {
@@ -522,8 +556,11 @@ $('#do-freq-auth').addEventListener('click', async () => {
state.hasStoredPhoto = Boolean(person.hasPhoto); state.hasStoredPhoto = Boolean(person.hasPhoto);
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`; $('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = ''; $('#in-freq-host-search').value = '';
renderHosts($('#freq-host-list'), '', () => renderHosts(
state.hasStoredPhoto ? submitSignIn() : show('photo') $('#freq-host-list'),
'',
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
$('#freq-host-select')
); );
show('freq-host'); show('freq-host');
} catch (err) { } catch (err) {
@@ -535,8 +572,11 @@ $('#do-freq-auth').addEventListener('click', async () => {
}); });
$('#in-freq-host-search').addEventListener('input', (e) => $('#in-freq-host-search').addEventListener('input', (e) =>
renderHosts($('#freq-host-list'), e.target.value, () => renderHosts(
state.hasStoredPhoto ? submitSignIn() : show('photo') $('#freq-host-list'),
e.target.value,
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
$('#freq-host-select')
) )
); );
+10 -6
View File
@@ -86,9 +86,9 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
const pad = unit * 0.07; const pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115)); const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062)); 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 // Square, matching the crop taken at the kiosk. Sized so that even a long name
// badge than the old 4:3 frame, so a square photo still leaves room for the name. // wrapping onto two lines still fits above the detail rows on a 62 x 90 mm label.
const photoWidth = portrait ? unit * 0.46 : unit * 0.42; const photoWidth = portrait ? unit * 0.52 : unit * 0.5;
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere // 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. // 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; padding: ${pad}mm;
display: flex; display: flex;
flex-direction: ${portrait ? 'column' : 'row'}; flex-direction: ${portrait ? 'column' : 'row'};
align-items: ${portrait ? 'center' : 'stretch'}; align-items: center;
${portrait ? 'justify-content: center;' : ''}
text-align: ${portrait ? 'center' : 'left'}; text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm; gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif; font-family: "Segoe UI", Arial, Helvetica, sans-serif;
@@ -137,11 +138,12 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
width: ${photoWidth}mm; width: ${photoWidth}mm;
height: ${photoWidth}mm; height: ${photoWidth}mm;
flex: 0 0 auto; flex: 0 0 auto;
align-self: center;
object-fit: cover; object-fit: cover;
border: 0.3mm solid #000; border: 0.3mm solid #000;
} }
.body { .body {
flex: 1 1 auto; flex: ${portrait ? '0 1 auto' : '1 1 auto'};
min-width: 0; min-width: 0;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -166,7 +168,9 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.rows { .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; padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm; font-size: ${bodySize}mm;
line-height: 1.35; line-height: 1.35;