/* @odoo-module */
// NFC Clock Kiosk — Web NFC + camera + state machine.
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
// element #nfc_kiosk_root only exists on that page, so the module is
// inert elsewhere).
(function() {
"use strict";
const root = document.getElementById("nfc_kiosk_root");
if (!root) return; // not on the kiosk page
const stateContainer = document.getElementById("nfc_state_container");
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)
// ──────────────────────────────────────────────────────────────
let _debugOverlayEl = null;
function debugLog(msg) {
try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {}
if (!debugEnabled) return;
if (!_debugOverlayEl) {
_debugOverlayEl = document.createElement("div");
_debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;";
document.body.appendChild(_debugOverlayEl);
}
const line = document.createElement("div");
const ts = new Date().toLocaleTimeString();
line.textContent = "[" + ts + "] " + msg;
_debugOverlayEl.appendChild(line);
while (_debugOverlayEl.childNodes.length > 40) {
_debugOverlayEl.removeChild(_debugOverlayEl.firstChild);
}
_debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight;
}
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
// ──────────────────────────────────────────────────────────────
// Dominant-hue extraction from company logo
// Sets the CSS variable --nfc-h on so SCSS can interpolate
// the entire palette from the brand color. Falls back to default
// (220 = aurora-blue) if no logo or extraction fails.
// ──────────────────────────────────────────────────────────────
function rgbToHue(r, g, b) {
const rN = r / 255, gN = g / 255, bN = b / 255;
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
const d = max - min;
if (d === 0) return null; // grayscale, no hue info
let h;
if (max === rN) h = ((gN - bN) / d) % 6;
else if (max === gN) h = (bN - rN) / d + 2;
else h = (rN - gN) / d + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
return h;
}
function extractDominantHue(img) {
try {
const c = document.createElement("canvas");
const w = c.width = Math.min(img.naturalWidth, 200);
const h = c.height = Math.min(img.naturalHeight, 200);
const ctx = c.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 128) continue; // skip transparent
const red = data[i], green = data[i + 1], blue = data[i + 2];
const lum = (red + green + blue) / 3;
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
if (range < 25) continue; // skip near-grays
r += red; g += green; b += blue; count++;
}
if (count < 50) {
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
return null;
}
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
const hue = rgbToHue(avgR, avgG, avgB);
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
return hue;
} catch (e) {
debugLog("hue extraction failed: " + e.message);
return null;
}
}
function applyBrandHue(hue) {
if (hue == null) return;
document.documentElement.style.setProperty("--nfc-h", String(hue));
}
const logoImg = document.getElementById("nfc_company_logo");
if (logoImg) {
const tryExtract = () => {
const hue = extractDominantHue(logoImg);
applyBrandHue(hue);
};
if (logoImg.complete && logoImg.naturalWidth) {
tryExtract();
} else {
logoImg.addEventListener("load", tryExtract);
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
}
} else {
debugLog("no company logo on page; using default hue");
}
// ──────────────────────────────────────────────────────────────
// State machine
// ──────────────────────────────────────────────────────────────
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
let currentState = STATE.SETUP;
function setState(next, payload) {
currentState = next;
if (next === STATE.IDLE) renderIdle();
else if (next === STATE.PROCESSING) renderProcessing();
else if (next === STATE.RESULT) renderResult(payload);
else if (next === STATE.ENROLL) renderEnroll(payload);
}
// ──────────────────────────────────────────────────────────────
// Rendering helpers
// ──────────────────────────────────────────────────────────────
function renderIdle() {
stateContainer.innerHTML = `
Tap your card to clock in or out
`;
}
function renderProcessing() {
stateContainer.innerHTML = `
Reading card
`;
}
// ──────────────────────────────────────────────────────────────
// 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 (G–E–C) — 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 (C–E–G) — 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 = `
${escapeHtml(payload.message || "Error")}
`;
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";
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 = `Worked ${h}h ${m}m this shift
`;
}
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
stateContainer.innerHTML = `
${escapeHtml(payload.employee_name)}
${action} at ${time}
${hoursLine}
`;
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);
}
}
// ──────────────────────────────────────────────────────────────
// Enroll Mode
// ──────────────────────────────────────────────────────────────
let enrollPassword = "";
let enrollSelectedEmployee = null;
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
let enrollIdleTimer = null;
function resetEnrollIdleTimer() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = setTimeout(() => {
// 60s of inactivity in Enroll Mode → exit
exitEnrollMode();
}, 60000);
}
function exitEnrollMode() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = null;
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 = `
${escapeHtml(opts.title || "Enter PIN")}
${[1,2,3,4,5,6,7,8,9].map(n => `${n} `).join("")}
⌫
0
OK
Cancel
`;
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 = `
⚠
This card isn't programmed yet
Program it now, or ask a manager.
Program this card
Cancel
`;
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") {
mountPinPad({
title: "Manager PIN",
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
onCancel: exitEnrollMode,
});
return;
}
if (phase === "manager") {
stateContainer.innerHTML = `
`;
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 = `Connection error.
`; return; }
if (!emps.length) { listEl.innerHTML = `No employees found.
`; return; }
listEl.innerHTML = emps.map(e => {
const tag = e.card_uid
? `● ${escapeHtml(e.card_uid)} `
: `○ no tag `;
const actions = (confirmDeleteId === e.id)
? `Confirm delete
Cancel `
: `${e.card_uid ? "Re-tag" : "Assign"}
📷 Photo
${e.card_uid ? `Clear tag ` : ""}
Delete `;
return `
${escapeHtml(e.name)} ${escapeHtml(e.department || "")} ${tag}
${actions}
`;
}).join("");
listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
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 === "photo") {
openPhotoCapture(id, btn.dataset.name, () => renderEnroll({ phase: "manager" }));
} 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();
}
}));
}
searchEl.addEventListener("input", () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, 200);
});
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
refresh();
return;
}
if (phase === "employee") {
stateContainer.innerHTML = `
`;
const searchEl = document.getElementById("enroll_search");
const listEl = document.getElementById("enroll_list");
let debounceTimer = null;
searchEl.addEventListener("input", () => {
resetEnrollIdleTimer();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
listEl.innerHTML = (result.employees || []).map(e =>
`${escapeHtml(e.name)} · ${escapeHtml(e.department || "")}
`
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
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 = `
`;
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 = `
Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card
⌐■
Hold the card to the back of the tablet
Cancel
`;
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "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 = `
${msg}
${ok && payload.employee_id ? `📷 Take photo ` : ""}
Enroll another
Done
`;
if (ok && 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;
pendingEnrollUid = null;
renderEnroll({ phase: ok ? "employee" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
// 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" });
}
}
// 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 = `
Reassign card?
This card belongs to ${escapeHtml(existingName || "another employee")} . Move it to ${escapeHtml(empName)} ?
Cancel
Move
`;
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 (kioskLocked || currentState !== STATE.IDLE) return;
enrollSelectedEmployee = null;
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 => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
// ──────────────────────────────────────────────────────────────
// Clock display (centered top: time with AM/PM + date)
// ──────────────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
let hours = now.getHours();
const ampm = hours >= 12 ? "PM" : "AM";
hours = hours % 12;
if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock
const hh = String(hours).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
const timeEl = document.getElementById("nfc_clock_time");
const dateEl = document.getElementById("nfc_clock_date");
if (timeEl) {
// Render hh:mm + AM/PM as separate spans so SCSS can style them differently
timeEl.innerHTML = `${hh}:${mm}${ampm} `;
}
if (dateEl) dateEl.textContent = dateStr;
}
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
// ──────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────
// Web NFC reader
// ──────────────────────────────────────────────────────────────
let ndefReader = null;
let nfcReady = false;
async function startNfcReader() {
debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window));
if (!("NDEFReader" in window)) {
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
}
ndefReader = new NDEFReader();
debugLog("startNfcReader: ndefReader created, calling scan()...");
await ndefReader.scan();
debugLog("startNfcReader: scan() resolved ✓");
ndefReader.addEventListener("reading", onNfcReading);
ndefReader.addEventListener("readingerror", (ev) => {
debugLog("readingerror event fired");
console.warn("[nfc-kiosk] reading error; reader still active");
});
nfcReady = true;
debugLog("startNfcReader: listeners attached, nfcReady=true");
}
// ──────────────────────────────────────────────────────────────
// USB HID keyboard-wedge listener (works alongside Web NFC).
// Most USB NFC readers in HID mode type the UID as keystrokes and
// end with Enter. We buffer chars until Enter arrives (or 500ms
// pause), then route the UID through the same flow Web NFC uses.
//
// Critical: this listener fires the same handleTap()/_onEnrollTap()
// codepath as Web NFC, so penalty + photo + activity log all work
// identically regardless of which reader produced the UID.
// ──────────────────────────────────────────────────────────────
let _hidBuffer = "";
let _hidLastKeyAt = 0;
let _hidFlushTimer = null;
const HID_RESET_MS = 500; // pause longer than this resets the buffer
const HID_FLUSH_MS = 600; // if no Enter arrives, flush this long after last char
const HID_MIN_LEN = 4; // shortest plausible UID
const HID_CHAR_RE = /^[0-9A-Fa-f:\-]$/; // hex digits + common separators
function _flushHidBuffer() {
const uid = _hidBuffer.trim().toUpperCase();
_hidBuffer = "";
if (_hidFlushTimer) { clearTimeout(_hidFlushTimer); _hidFlushTimer = null; }
if (uid.length < HID_MIN_LEN) {
debugLog("HID flush: too short, ignored (" + JSON.stringify(uid) + ")");
return;
}
debugLog("HID flush: uid=" + uid + " state=" + currentState);
if (currentState === STATE.ENROLL) {
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
} else if (currentState === STATE.IDLE) {
handleTap(uid);
} else {
debugLog(" → IGNORED: state=" + currentState);
}
}
// ──────────────────────────────────────────────────────────────
// Local wedge daemon SSE listener.
//
// If a `wedge.py` daemon is running on this machine (used for
// ACR122U / PC/SC readers that can't emit keystrokes themselves),
// it exposes a Server-Sent Events stream at
// http://localhost:8765/events that pushes each detected UID.
//
// Chrome treats http://localhost as a secure origin, so an HTTPS
// kiosk page can connect to it without mixed-content blocking.
// No keystroke injection, no Accessibility permission needed,
// no focused-window dependency.
//
// Routes the UID through the same handleTap()/_onEnrollTap() flow
// as Web NFC and USB HID — so photo, penalty, activity log all
// fire identically.
// ──────────────────────────────────────────────────────────────
const WEDGE_SSE_URL = "http://localhost:8765/events";
let _wedgeEs = null;
function startWedgeSseListener() {
try {
_wedgeEs = new EventSource(WEDGE_SSE_URL);
_wedgeEs.addEventListener("message", (ev) => {
const uid = (ev.data || "").trim().toUpperCase();
if (!uid) return;
debugLog("wedge SSE: " + uid + " state=" + currentState);
if (currentState === STATE.ENROLL) {
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap &&
window.__nfcKiosk._onEnrollTap(uid);
} else if (currentState === STATE.IDLE) {
handleTap(uid);
} else {
debugLog(" → IGNORED: state=" + currentState);
}
});
_wedgeEs.addEventListener("open", () => {
debugLog("wedge SSE: connected to " + WEDGE_SSE_URL);
});
_wedgeEs.addEventListener("error", () => {
// EventSource auto-reconnects; this fires on every
// dropped connection. Log first occurrence only.
if (!_wedgeEs._loggedError) {
debugLog("wedge SSE: connection error (daemon may not be running) — will auto-retry");
_wedgeEs._loggedError = true;
}
});
debugLog("startWedgeSseListener: subscribed to " + WEDGE_SSE_URL);
} catch (e) {
debugLog("startWedgeSseListener: failed to start — " + e.message);
}
}
function startUsbHidListener() {
document.addEventListener("keydown", (e) => {
// Don't capture keystrokes inside form inputs — preserves
// typing in enroll-mode search box, etc.
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
return;
}
// Don't fight the existing Ctrl+Shift+T mock-tap shortcut.
if (e.ctrlKey || e.metaKey || e.altKey) return;
const now = Date.now();
if (now - _hidLastKeyAt > HID_RESET_MS) {
_hidBuffer = "";
}
_hidLastKeyAt = now;
if (e.key === "Enter") {
e.preventDefault();
_flushHidBuffer();
return;
}
if (HID_CHAR_RE.test(e.key)) {
_hidBuffer += e.key;
// Fallback flush if the reader doesn't emit Enter
if (_hidFlushTimer) clearTimeout(_hidFlushTimer);
_hidFlushTimer = setTimeout(_flushHidBuffer, HID_FLUSH_MS);
}
});
debugLog("startUsbHidListener: listening for HID keystrokes ✓");
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const rawSerial = event.serialNumber || "";
const uid = rawSerial.toUpperCase();
const recCount = (event.message && event.message.records) ? event.message.records.length : 0;
debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState);
if (!uid) {
debugLog(" → IGNORED: empty serialNumber");
return;
}
if (currentState === STATE.ENROLL) {
debugLog(" → routing to _onEnrollTap");
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
return;
}
if (currentState !== STATE.IDLE) {
debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")");
return;
}
debugLog(" → calling handleTap(" + uid + ")");
handleTap(uid);
}
async function handleTap(uid) {
debugLog("handleTap: uid=" + uid);
setState(STATE.PROCESSING);
let photoB64 = "";
try {
photoB64 = await capturePhoto();
debugLog("handleTap: photo captured, size=" + photoB64.length);
} catch (e) {
debugLog("handleTap: photo capture failed: " + e.message);
console.warn("[nfc-kiosk] camera capture failed", e);
}
try {
debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap...");
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200));
if (result.error === "debounce") {
setState(STATE.IDLE);
return;
}
if (result.error === "card_unknown") {
renderUnknownCard(uid);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
debugLog("handleTap: POST failed: " + e.message);
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
}
}
async function postJson(url, params) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
});
const json = await res.json();
return json.result || {};
}
// ──────────────────────────────────────────────────────────────
// Camera
// ──────────────────────────────────────────────────────────────
let cameraStream = null;
const videoEl = document.getElementById("nfc_camera_feed");
const canvasEl = document.getElementById("nfc_camera_canvas");
async function startCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera not supported on this browser/device.");
}
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
videoEl.srcObject = cameraStream;
await videoEl.play();
}
async function capturePhoto() {
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
const w = videoEl.videoWidth;
const h = videoEl.videoHeight;
canvasEl.width = w;
canvasEl.height = h;
const ctx = canvasEl.getContext("2d");
ctx.drawImage(videoEl, 0, 0, w, h);
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 = `
Use this photo?
Retake
Use photo
`;
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 = `
Take ${escapeHtml(employeeName)}'s photo
Center the face in the oval
Skip
Capture
`;
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
// visibilitychange when the page comes back to the foreground.
// ──────────────────────────────────────────────────────────────
let wakeLock = null;
async function acquireWakeLock() {
if (!("wakeLock" in navigator)) {
debugLog("wakeLock: API not supported on this browser");
return;
}
if (wakeLock) {
debugLog("wakeLock: already held, skipping");
return;
}
try {
wakeLock = await navigator.wakeLock.request("screen");
debugLog("wakeLock: acquired ✓ (screen will stay on)");
wakeLock.addEventListener("release", () => {
debugLog("wakeLock: released by browser/OS");
wakeLock = null;
});
} catch (e) {
debugLog("wakeLock: request failed: " + (e && e.message));
}
}
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState === "visible") {
debugLog("visibility: visible — re-acquiring wakeLock");
await acquireWakeLock();
} else {
debugLog("visibility: " + document.visibilityState);
}
});
// ──────────────────────────────────────────────────────────────
// Setup wizard activation
// ──────────────────────────────────────────────────────────────
const setupBtn = document.getElementById("nfc_setup_start");
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;
try {
await startNfcReader();
webNfcOk = true;
debugLog("setup: Web NFC ready ✓");
} catch (webNfcErr) {
debugLog("setup: Web NFC unavailable, continuing with USB HID — " + webNfcErr.message);
}
// USB HID listener: no permission needed, works on any platform.
startUsbHidListener();
// Local wedge daemon SSE listener (for ACR122U / PC/SC readers).
startWedgeSseListener();
// Camera: best-effort unless photoRequired forces it.
try {
await startCamera();
debugLog("setup: camera ready ✓");
} catch (camErr) {
debugLog("setup: camera failed: " + camErr.message);
if (photoRequired) {
// Only THIS path is a hard fail. Use the existing error
// render to keep DOM patterns consistent with the rest
// of this file.
stateContainer.innerHTML = `
Setup failed
${escapeHtml(camErr.message)}
Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.
`;
return;
}
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: ✓");
});
}
// ──────────────────────────────────────────────────────────────
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
// ──────────────────────────────────────────────────────────────
if (debugEnabled) {
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
e.preventDefault();
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
if (!uid) return;
localStorage.setItem("nfc_mock_uid", uid);
if (currentState === STATE.ENROLL) {
_onEnrollTap(uid.toUpperCase());
} else if (currentState === STATE.IDLE) {
handleTap(uid.toUpperCase());
}
}
});
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
}
window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
};
})();