feat(fusion_clock): NFC kiosk — enrollment, manager page, sounds, lock, profile photos

Kiosk work across this session (19.0.3.6.0 -> 19.0.3.10.0):
- Program-from-unknown-tap: amber prompt -> Manager PIN -> pick/create employee
  -> binds the captured UID (no re-tap). Reassign moves a card between employees.
- Manager page (gear, when unlocked): search employees + tag status; assign/re-tag,
  clear tag, archive employee, + new employee. Server-gated by the enroll password.
- Screen lock: kiosk starts locked (tap-only); Unlock -> Manager PIN, Lock button;
  PIN remembered for the session so the gear never re-prompts.
- Sounds: pleasant + loud sine chimes (rising in / descending out) + a low "denied"
  tone for wrong/unknown taps. Gated by fusion_clock.enable_sounds.
- Guided profile-photo capture for employees with no picture (clock-in or enroll):
  live camera + oval face guide -> capture -> preview -> save to hr.employee.
- PIN no longer re-renders per digit; centered result card; 12h time; clock-out shows
  "Worked Xh Ym this shift"; modern clock idle icon; faster animations/result timers;
  session keep-alive so the kiosk login never expires.
- New endpoints: create_employee, clear_tag, delete_employee (archive), verify_pin,
  save_profile_photo; enroll gains force-reassign.
- Docs: fusion_clock is now developed in Claude Code (dropped Cursor references).

Spec/plan under fusion_clock/docs/superpowers/. Deployed live on entech
(odoo-entech / LXC 111 on pve-worker5), v19.0.3.10.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-30 17:21:33 -04:00
parent 2a16f80d8d
commit 55898dd1d4
10 changed files with 1002 additions and 84 deletions

View File

@@ -15,6 +15,20 @@
const photoRequired = root.dataset.photoRequired === "1";
const debugEnabled = root.dataset.debugEnabled === "1";
const locationConfigured = root.dataset.locationConfigured === "1";
const soundsEnabled = root.dataset.soundsEnabled === "1";
// On a known device (set up before) the browser already remembers camera/NFC
// permission, so slim the prompt to a simple resume tap.
try {
if (localStorage.getItem("nfc_setup_done") === "1") {
const _h2 = document.querySelector(".nfc-kiosk__setup h2");
const _p = document.querySelector(".nfc-kiosk__setup p");
const _btn = document.getElementById("nfc_setup_start");
if (_h2) _h2.textContent = "Fusion Clock Kiosk";
if (_p) _p.textContent = "Tap to resume.";
if (_btn) _btn.textContent = "Tap to resume";
}
} catch (e) {}
// ──────────────────────────────────────────────────────────────
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
@@ -140,8 +154,10 @@
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
stroke="currentColor" stroke-width="4" fill="none"/>
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
rx="11" fill="currentColor"/>
<g class="nfc-chip" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="100" cy="100" r="34"/>
<polyline points="100,80 100,100 116,108"/>
</g>
</svg>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
@@ -157,11 +173,67 @@
`;
}
// ──────────────────────────────────────────────────────────────
// Clock sounds (Web Audio — synthesized, loud + distinct in/out).
// AudioContext is created/resumed on the setup tap (a user gesture),
// after which it can play on each clock event.
// ──────────────────────────────────────────────────────────────
let _audioCtx = null;
function unlockAudio() {
try {
if (!_audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) _audioCtx = new AC();
}
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
} catch (e) { debugLog("audio: unlock failed " + e.message); }
}
function _note(freq, startAt, dur, peak, type) {
const osc = _audioCtx.createOscillator();
const g = _audioCtx.createGain();
osc.type = type || "sine";
osc.frequency.setValueAtTime(freq, startAt);
g.gain.setValueAtTime(0.0001, startAt);
g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click)
g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay
osc.connect(g); g.connect(_audioCtx.destination);
osc.start(startAt); osc.stop(startAt + dur + 0.04);
}
function playClockSound(action) {
if (!soundsEnabled || !_audioCtx) return;
try {
if (_audioCtx.state === "suspended") _audioCtx.resume();
const t = _audioCtx.currentTime;
if (action === "clock_out") {
// warm descending major triad (GEC) — a pleasant "goodbye"
_note(783.99, t, 0.20, 0.6, "sine"); // G5
_note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5
_note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5
} else {
// bright ascending major triad (CEG) — a cheerful "welcome"
_note(523.25, t, 0.18, 0.6, "sine"); // C5
_note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5
_note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5
}
} catch (e) { debugLog("audio: play failed " + e.message); }
}
// Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime.
function playErrorSound() {
if (!soundsEnabled || !_audioCtx) return;
try {
if (_audioCtx.state === "suspended") _audioCtx.resume();
const t = _audioCtx.currentTime;
_note(311.13, t, 0.20, 0.55, "triangle"); // Eb4
_note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong")
} catch (e) { debugLog("audio: play failed " + e.message); }
}
function renderResult(payload) {
const isError = payload && payload.error;
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
if (isError) {
playErrorSound();
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__result-text">
@@ -169,25 +241,36 @@
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 4000);
setTimeout(() => setState(STATE.IDLE), 3000);
} else {
playClockSound(payload.action);
const avatar = payload.employee_avatar_url || "";
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
const hours = payload.action === "clock_out" && payload.net_hours_today
? `${payload.net_hours_today.toFixed(1)}h today`
: "";
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
let hoursLine = "";
if (payload.action === "clock_out") {
const mins = Math.round((payload.net_hours_today || 0) * 60);
const h = Math.floor(mins / 60);
const m = mins % 60;
hoursLine = `<div class="hours">Worked ${h}h ${m}m this shift</div>`;
}
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.employee_name)}</div>
<div class="action">${action} at ${time}</div>
${hours ? `<div class="hours">${hours}</div>` : ""}
${hoursLine}
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 3000);
setTimeout(() => {
if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) {
openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE));
} else {
setState(STATE.IDLE);
}
}, 1800);
}
}
@@ -196,6 +279,7 @@
// ──────────────────────────────────────────────────────────────
let enrollPassword = "";
let enrollSelectedEmployee = null;
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
let enrollIdleTimer = null;
function resetEnrollIdleTimer() {
@@ -209,60 +293,175 @@
function exitEnrollMode() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = null;
enrollPassword = "";
if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt)
enrollSelectedEmployee = null;
pendingEnrollUid = null;
setState(STATE.IDLE);
}
// Fixed PIN pad: renders the panel ONCE, then mutates only the masked
// display on each press — no innerHTML rebuild, no replayed entrance
// animation. (Fixes the per-digit "screen refresh" bug.)
function mountPinPad(opts) {
let pin = "";
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>${escapeHtml(opts.title || "Enter PIN")}</h2>
<div class="pin-display" id="nfc_pin_display"></div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="nfc_pin_cancel">Cancel</button>
</div>
</div>
</div>
`;
const displayEl = stateContainer.querySelector("#nfc_pin_display");
const paint = () => { displayEl.textContent = "•".repeat(pin.length); };
paint();
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") { pin = pin.slice(0, -1); paint(); }
else if (n === "ok") { if (pin.length) opts.onOk(pin); }
else { pin += n; paint(); }
});
});
stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel);
}
// Reactive flow: an unknown card was tapped — offer to program it now.
function renderUnknownCard(uid) {
playErrorSound();
currentState = STATE.RESULT; // block taps while this prompt is up
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
const autoCancel = setTimeout(() => {
if (currentState === STATE.RESULT) setState(STATE.IDLE);
}, 8000);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel nfc-kiosk__unknown" style="text-align:center">
<div class="unknown-icon">⚠</div>
<h2>This card isn't programmed yet</h2>
<p style="color:var(--nfc-text-muted)">Program it now, or ask a manager.</p>
<div class="actions" style="justify-content:center">
<button class="confirm" id="uc_program">Program this card</button>
<button class="cancel" id="uc_cancel">Cancel</button>
</div>
</div>
</div>
`;
stateContainer.querySelector("#uc_program").addEventListener("click", () => {
clearTimeout(autoCancel);
pendingEnrollUid = uid;
enrollSelectedEmployee = null;
// If a manager already unlocked, skip the PIN; otherwise ask for it.
setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" });
});
stateContainer.querySelector("#uc_cancel").addEventListener("click", () => {
clearTimeout(autoCancel);
setState(STATE.IDLE);
});
}
function renderEnroll(payload) {
const phase = (payload && payload.phase) || "password";
resetEnrollIdleTimer();
if (phase === "password") {
const masked = "•".repeat(enrollPassword.length);
mountPinPad({
title: "Manager PIN",
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
onCancel: exitEnrollMode,
});
return;
}
if (phase === "manager") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
<h2>Manage employees</h2>
<input class="employee-search" id="mgr_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="mgr_list"></div>
<div class="actions" style="justify-content:space-between">
<button class="confirm" id="mgr_new">+ New employee</button>
<button class="cancel" id="mgr_close">Close</button>
</div>
</div>
</div>
`;
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", async () => {
const searchEl = document.getElementById("mgr_search");
const listEl = document.getElementById("mgr_list");
let confirmDeleteId = null;
let debounceTimer = null;
async function refresh() {
resetEnrollIdleTimer();
let emps = [];
try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; }
catch (e) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">Connection error.</div>`; return; }
if (!emps.length) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">No employees found.</div>`; return; }
listEl.innerHTML = emps.map(e => {
const tag = e.card_uid
? `<span class="m-tag m-tag--on">● ${escapeHtml(e.card_uid)}</span>`
: `<span class="m-tag">○ no tag</span>`;
const actions = (confirmDeleteId === e.id)
? `<button class="m-btn m-danger" data-act="delok" data-id="${e.id}">Confirm delete</button>
<button class="m-btn" data-act="delno" data-id="${e.id}">Cancel</button>`
: `<button class="m-btn" data-act="assign" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${e.card_uid ? "Re-tag" : "Assign"}</button>
${e.card_uid ? `<button class="m-btn" data-act="clear" data-id="${e.id}">Clear tag</button>` : ""}
<button class="m-btn m-danger" data-act="del" data-id="${e.id}">Delete</button>`;
return `<div class="manager-row">
<div class="m-info"><span class="m-name">${escapeHtml(e.name)}</span><small class="m-dept">${escapeHtml(e.department || "")}</small> ${tag}</div>
<div class="m-actions">${actions}</div>
</div>`;
}).join("");
listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
else if (n === "ok") {
if (enrollPassword.length === 0) return;
renderEnroll({ phase: "search" });
return;
const id = parseInt(btn.dataset.id, 10);
const act = btn.dataset.act;
if (act === "assign") {
enrollSelectedEmployee = { id, name: btn.dataset.name };
pendingEnrollUid = null;
renderEnroll({ phase: "tap" });
} else if (act === "clear") {
try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
refresh();
} else if (act === "del") {
confirmDeleteId = id; refresh();
} else if (act === "delno") {
confirmDeleteId = null; refresh();
} else if (act === "delok") {
try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
confirmDeleteId = null; refresh();
}
else enrollPassword += n;
renderEnroll({ phase: "password" });
});
}));
}
searchEl.addEventListener("input", () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, 200);
});
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
refresh();
return;
}
if (phase === "search") {
if (phase === "employee") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<h2>Who is this card for?</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<div class="actions" style="justify-content:space-between">
<button class="confirm" id="enroll_new">+ New employee</button>
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
@@ -281,17 +480,61 @@
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
renderEnroll({ phase: "tap" });
chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name });
});
});
}, 200);
});
searchEl.focus();
document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "new_employee") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>New employee</h2>
<input class="employee-search" id="new_emp_name" placeholder="Full name…" autocomplete="off"/>
<div class="enroll-msg" id="new_emp_msg"></div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="new_emp_back">Back</button>
<button class="confirm" id="new_emp_create">Create &amp; assign</button>
</div>
</div>
</div>
`;
const nameEl = document.getElementById("new_emp_name");
const msgEl = document.getElementById("new_emp_msg");
nameEl.addEventListener("input", resetEnrollIdleTimer);
nameEl.focus();
const doCreate = async () => {
resetEnrollIdleTimer();
const nm = nameEl.value.trim();
if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; }
msgEl.textContent = "Creating…";
let res;
try {
res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword });
} catch (e) {
msgEl.textContent = "No connection. Try again.";
return;
}
if (res.error) {
msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN."
: res.error === "invalid_name" ? "Enter a valid name."
: "Could not create employee.";
return;
}
chooseEmployee({ id: res.employee_id, name: res.employee_name });
};
document.getElementById("new_emp_create").addEventListener("click", doCreate);
nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); });
document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" }));
return;
}
if (phase === "tap") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
@@ -312,52 +555,163 @@
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "Wrong password. Try again."
? "Wrong Manager PIN. Try again."
: payload.error === "card_already_assigned"
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
: `Enroll failed: ${escapeHtml(payload.error)}`);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<h2 style="color:${ok ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
<div class="actions" style="justify-content:center">
${ok && payload.needs_photo && payload.employee_id ? `<button class="confirm" id="enroll_photo">📷 Take photo</button>` : ""}
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
if (ok && payload.needs_photo && payload.employee_id) {
document.getElementById("enroll_photo").addEventListener("click", () => {
openPhotoCapture(payload.employee_id, payload.employee_name, () => {
if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode();
});
});
}
document.getElementById("enroll_another").addEventListener("click", () => {
enrollSelectedEmployee = null;
renderEnroll({ phase: ok ? "search" : "password" });
pendingEnrollUid = null;
renderEnroll({ phase: ok ? "employee" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: enrollSelectedEmployee.id,
card_uid: uid,
enroll_password: enrollPassword,
});
renderEnroll({ phase: "result", ...result });
// Existing employee picked → if we already hold a tapped UID, bind it now
// (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step.
function chooseEmployee(emp) {
if (pendingEnrollUid) {
doEnroll(emp.id, emp.name, pendingEnrollUid, false);
} else {
enrollSelectedEmployee = emp;
renderEnroll({ phase: "tap" });
}
}
// ⚙ button → enter Enroll Mode
// Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already
// held by someone else triggers a reassign confirm rather than a hard error.
async function doEnroll(empId, empName, uid, force) {
resetEnrollIdleTimer();
let result;
try {
result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force,
});
} catch (e) {
renderEnroll({ phase: "result", employee_name: empName, error: "network" });
return;
}
if (result.error === "card_already_assigned" && !force) {
renderReassignConfirm(empId, empName, uid, result.existing_employee);
return;
}
renderEnroll({ phase: "result", employee_name: empName, ...result });
}
function renderReassignConfirm(empId, empName, uid, existingName) {
resetEnrollIdleTimer();
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Reassign card?</h2>
<p style="color:var(--nfc-text-muted)">This card belongs to <b>${escapeHtml(existingName || "another employee")}</b>. Move it to <b>${escapeHtml(empName)}</b>?</p>
<div class="actions" style="justify-content:center">
<button class="cancel" id="ra_cancel">Cancel</button>
<button class="confirm" id="ra_move">Move</button>
</div>
</div>
</div>
`;
stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true));
stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" }));
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false);
}
// ⚙ button → enter Enroll Mode (only when unlocked)
const settingsBtn = document.getElementById("nfc_settings_btn");
if (settingsBtn) {
settingsBtn.addEventListener("click", () => {
if (currentState !== STATE.IDLE) return;
enrollPassword = "";
if (kioskLocked || currentState !== STATE.IDLE) return;
enrollSelectedEmployee = null;
setState(STATE.ENROLL, { phase: "password" });
pendingEnrollUid = null;
// Already unlocked → reuse that PIN, open the manager page (no re-prompt).
setState(STATE.ENROLL, { phase: "manager" });
});
}
// ──────────────────────────────────────────────────────────────
// Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden.
// A manager long-presses the bottom-right corner and enters the Manager
// PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after
// inactivity. Unlock is in-memory only, so every reload starts locked.
// ──────────────────────────────────────────────────────────────
let kioskLocked = true;
let relockTimer = null;
const lockBtn = document.getElementById("nfc_lock_btn");
const unlockBtn = document.getElementById("nfc_unlock_btn");
function applyLockState() {
if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex";
if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex";
if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none";
}
function armRelock() {
if (relockTimer) clearTimeout(relockTimer);
relockTimer = setTimeout(() => {
relockTimer = null;
if (currentState === STATE.IDLE) lockKiosk();
else armRelock(); // don't re-lock mid-enroll
}, 120000);
}
function lockKiosk() {
kioskLocked = true;
enrollPassword = ""; // forget the PIN → unlocking requires it again
if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; }
applyLockState();
}
function unlockKiosk() {
kioskLocked = false;
applyLockState();
armRelock();
}
if (lockBtn) lockBtn.addEventListener("click", lockKiosk);
function openUnlockPin() {
currentState = STATE.RESULT; // block card taps during PIN entry
mountPinPad({
title: "Manager PIN — unlock",
onOk: async (pin) => {
let ok = false;
try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {}
if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); }
else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; }
},
onCancel: () => setState(STATE.IDLE),
});
}
if (unlockBtn) unlockBtn.addEventListener("click", () => {
if (kioskLocked && currentState === STATE.IDLE) openUnlockPin();
});
applyLockState(); // start locked
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
@@ -387,6 +741,9 @@
updateClock();
setInterval(updateClock, 1000);
// Keep-alive: refresh the session every 4 min so the kiosk login never expires.
setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000);
// ──────────────────────────────────────────────────────────────
// Setup wizard
// ──────────────────────────────────────────────────────────────
@@ -577,6 +934,10 @@
setState(STATE.IDLE);
return;
}
if (result.error === "card_unknown") {
renderUnknownCard(uid);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
debugLog("handleTap: POST failed: " + e.message);
@@ -624,6 +985,82 @@
return canvasEl.toDataURL("image/jpeg", 0.7);
}
// ──────────────────────────────────────────────────────────────
// Guided profile-photo capture (for employees with no picture).
// Live camera + oval face guide → Capture → preview → Use/Retake →
// saves a centered 512² square to the employee's profile image.
// ──────────────────────────────────────────────────────────────
function _captureProfileSquare(video) {
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return "";
const side = Math.min(vw, vh);
const sx = (vw - side) / 2, sy = (vh - side) / 2;
const c = document.createElement("canvas");
c.width = 512; c.height = 512;
c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512);
return c.toDataURL("image/jpeg", 0.85);
}
function openPhotoCapture(employeeId, employeeName, onDone) {
currentState = STATE.RESULT; // block card taps during capture
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
const finish = () => { if (onDone) onDone(); };
if (!cameraStream) { finish(); return; } // no camera → skip silently
let captured = "";
const renderPreview = () => {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__photo-panel">
<h2>Use this photo?</h2>
<div class="nfc-photo-stage">
<img class="nfc-photo-preview" src="${captured}"/>
<div class="nfc-photo-guide"></div>
</div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="photo_retake">Retake</button>
<button class="confirm" id="photo_use">Use photo</button>
</div>
</div>
</div>
`;
document.getElementById("photo_retake").addEventListener("click", renderLive);
document.getElementById("photo_use").addEventListener("click", async () => {
try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {}
finish();
});
};
function renderLive() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__photo-panel">
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
<div class="nfc-photo-stage" id="photo_stage">
<div class="nfc-photo-guide"></div>
<div class="nfc-photo-hint">Center the face in the oval</div>
</div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="photo_skip">Skip</button>
<button class="confirm" id="photo_capture">Capture</button>
</div>
</div>
</div>
`;
const stage = document.getElementById("photo_stage");
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.playsInline = true;
v.className = "nfc-photo-video";
v.srcObject = cameraStream;
stage.insertBefore(v, stage.firstChild);
v.play().catch(() => {});
document.getElementById("photo_capture").addEventListener("click", () => {
captured = _captureProfileSquare(v);
if (captured) renderPreview(); else finish();
});
document.getElementById("photo_skip").addEventListener("click", finish);
}
renderLive();
}
// ──────────────────────────────────────────────────────────────
// Wake Lock — keeps the screen on while the kiosk page is active.
// Released automatically on tab close/navigation; re-acquired on
@@ -668,6 +1105,7 @@
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
debugLog("setup button clicked");
unlockAudio(); // user gesture → unlock Web Audio for clock sounds
// Try Web NFC, but don't fail if absent — USB HID reader is a
// first-class alternative (works on desktops/iOS too).
let webNfcOk = false;
@@ -704,6 +1142,7 @@
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {}
setState(STATE.IDLE);
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
});

View File

@@ -57,6 +57,7 @@ html:has(#nfc_kiosk_root) {
padding: 2rem;
box-sizing: border-box;
user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
background: var(--nfc-bg);
@@ -106,18 +107,19 @@ html:has(#nfc_kiosk_root) {
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
max-height: 52px;
max-width: 220px;
max-height: 64px;
max-width: 260px;
object-fit: contain;
background: rgba(255, 255, 255, 0.20);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
padding: 0.5rem 0.85rem;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.18);
padding: 0.6rem 1.1rem;
border-radius: 1rem;
border: 2px solid hsla(var(--nfc-h), 85%, 72%, 0.95);
box-shadow:
0 6px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
0 8px 28px rgba(0, 0, 0, 0.4),
0 0 30px hsla(var(--nfc-h), 90%, 62%, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
box-sizing: content-box;
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@@ -190,6 +192,29 @@ html:has(#nfc_kiosk_root) {
border-radius: 999px;
}
.nfc-kiosk__lock {
position: absolute;
bottom: 1.5rem;
right: 4.85rem; // sits just left of the ⚙
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: rgba(255,255,255,0.04);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
color: var(--nfc-text-muted);
border: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
font-size: 1.05rem;
display: none; // JS shows it only when unlocked
align-items: center;
justify-content: center;
z-index: 3;
}
// Unlock button sits in the primary bottom-right corner (shown only while locked)
#nfc_unlock_btn { right: 1.5rem; }
.nfc-kiosk__settings {
position: absolute;
bottom: 1.5rem;
@@ -234,7 +259,7 @@ html:has(#nfc_kiosk_root) {
// State container — base fade-in for whatever child renders
// ─────────────────────────────────────────────────────────────────────
#nfc_state_container > * {
animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
animation: nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes nfc-state-in {
@@ -251,6 +276,7 @@ html:has(#nfc_kiosk_root) {
flex-direction: column;
align-items: center;
gap: 2rem;
margin-top: 7rem; // push icon + prompt down so waves clear the clock/time
}
.nfc-kiosk__icon-svg {
@@ -263,6 +289,7 @@ html:has(#nfc_kiosk_root) {
.nfc-chip {
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
transform-origin: center;
transform-box: fill-box;
}
.nfc-wave {
@@ -340,8 +367,10 @@ html:has(#nfc_kiosk_root) {
max-width: 720px;
padding: 2.5rem 3rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
text-align: center;
gap: 1.25rem;
position: relative;
&--success {
@@ -350,7 +379,7 @@ html:has(#nfc_kiosk_root) {
0 20px 60px rgba(0,0,0,0.5),
0 0 80px rgba(24,169,87,0.35),
inset 0 1px 0 rgba(255,255,255,0.1);
animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1);
animation: nfc-success-burst 350ms cubic-bezier(0.16, 1, 0.3, 1);
}
&--error {
@@ -359,7 +388,7 @@ html:has(#nfc_kiosk_root) {
0 20px 60px rgba(0,0,0,0.5),
0 0 60px rgba(217,55,78,0.3),
inset 0 1px 0 rgba(255,255,255,0.1);
animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
animation: nfc-shake 350ms ease-in-out, nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
}
@@ -406,7 +435,7 @@ html:has(#nfc_kiosk_root) {
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.2);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation: nfc-avatar-in 300ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes nfc-avatar-in {
@@ -415,11 +444,11 @@ html:has(#nfc_kiosk_root) {
}
.nfc-kiosk__result-text {
flex: 1;
text-align: center;
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
.action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; }
.hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; }
.hours { font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem; font-weight: 500; }
}
// ─────────────────────────────────────────────────────────────────────
@@ -477,9 +506,11 @@ html:has(#nfc_kiosk_root) {
.nfc-kiosk__enroll-panel {
@extend %nfc-glass;
padding: 2.5rem;
padding: 2rem;
width: 80vw;
max-width: 720px;
max-height: 92vh;
overflow-y: auto;
h2 {
font-size: 1.5rem;
@@ -495,8 +526,8 @@ html:has(#nfc_kiosk_root) {
margin: 1rem 0;
button {
font-size: 2rem;
padding: 1.5rem 0;
font-size: 1.7rem;
padding: 1.1rem 0;
background: rgba(255,255,255,0.05);
color: var(--nfc-text);
border: 1px solid rgba(255,255,255,0.1);
@@ -511,12 +542,12 @@ html:has(#nfc_kiosk_root) {
}
.pin-display {
font-size: 2.5rem;
font-size: 2.2rem;
letter-spacing: 0.5rem;
text-align: center;
margin: 1rem 0 1.5rem;
margin: 0.75rem 0 1rem;
font-variant-numeric: tabular-nums;
min-height: 3rem;
min-height: 2.6rem;
color: hsl(var(--nfc-h), 80%, 70%);
}
@@ -578,6 +609,114 @@ html:has(#nfc_kiosk_root) {
}
}
// Amber accent for the "unknown card → program it" prompt + the inline
// status line in the new-employee form.
.nfc-kiosk__enroll-panel.nfc-kiosk__unknown {
border-color: rgba(224, 168, 62, 0.55);
box-shadow:
0 20px 60px rgba(0,0,0,0.5),
0 0 60px rgba(224, 168, 62, 0.28),
inset 0 1px 0 rgba(255,255,255,0.08);
.unknown-icon { font-size: 3.5rem; line-height: 1; margin-bottom: 0.5rem; color: #e0a83e; }
h2 { color: #e0a83e; }
}
.nfc-kiosk__enroll-panel .enroll-msg {
min-height: 1.4rem;
margin: 0.25rem 0 0.5rem;
color: var(--nfc-error);
font-size: 0.95rem;
text-align: center;
}
// Manager page rows (employee + tag status + per-row actions)
.nfc-kiosk__enroll-panel .manager-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.4rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-wrap: wrap;
.m-info { display: flex; align-items: baseline; gap: 0.5rem; flex: 1; min-width: 12rem; }
.m-name { font-size: 1.05rem; }
.m-dept { color: var(--nfc-text-muted); font-size: 0.8rem; }
.m-tag { font-size: 0.78rem; color: var(--nfc-text-muted); white-space: nowrap; }
.m-tag--on { color: hsl(var(--nfc-h), 70%, 66%); }
.m-actions { display: flex; gap: 0.4rem; flex-shrink: 0; flex-wrap: wrap; }
.m-btn {
font-size: 0.85rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: rgba(255,255,255,0.06);
color: var(--nfc-text);
border: 1px solid rgba(255,255,255,0.1);
cursor: pointer;
&.m-danger { color: #ff8b9a; border-color: rgba(217,55,78,0.45); }
&:active { transform: scale(0.96); }
}
}
// ─────────────────────────────────────────────────────────────────────
// Guided profile-photo capture — live camera with an oval face guide
// ─────────────────────────────────────────────────────────────────────
.nfc-kiosk__photo-panel {
@extend %nfc-glass;
padding: 1.5rem;
width: 80vw;
max-width: 540px;
max-height: 92vh;
overflow-y: auto;
text-align: center;
h2 { font-size: 1.4rem; margin: 0 0 1rem; font-weight: 400; }
}
.nfc-photo-stage {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
max-height: 56vh;
margin: 0 auto;
border-radius: 1rem;
overflow: hidden;
background: #000;
}
.nfc-photo-video,
.nfc-photo-preview {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nfc-photo-video { transform: scaleX(-1); } // mirror for a natural selfie view
.nfc-photo-guide {
position: absolute;
top: 48%;
left: 50%;
width: 60%;
height: 66%;
transform: translate(-50%, -50%);
border: 3px dashed rgba(255, 255, 255, 0.92);
border-radius: 50%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); // dim everything outside the oval
pointer-events: none;
}
.nfc-photo-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0.75rem;
color: #fff;
font-size: 0.95rem;
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.9);
pointer-events: none;
}
// ─────────────────────────────────────────────────────────────────────
// Reduced-motion fallback — respect users who prefer no animation
// ─────────────────────────────────────────────────────────────────────