Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+10
-2
@@ -63,7 +63,11 @@
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Who are you here to see?</h2>
|
||||
<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">
|
||||
</label>
|
||||
<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">
|
||||
<h2 id="freq-greeting">Who are you here to see?</h2>
|
||||
<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">
|
||||
</label>
|
||||
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div>
|
||||
|
||||
+54
-14
@@ -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 =
|
||||
`<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) {
|
||||
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(
|
||||
@@ -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')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
+10
-6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user