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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user