/** @odoo-module **/ // Fusion Clock PIN Kiosk — tap your photo, enter a PIN, clock in/out. // Built as an Odoo 19 public Interaction. Employee-derived strings are always // inserted via textContent (never interpolated into innerHTML) to avoid XSS. import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; export class PinKiosk extends Interaction { static selector = "#pin_kiosk_root"; setup() { this.grid = this.el.querySelector("#pin_kiosk_grid"); this.searchEl = this.el.querySelector("#pin_kiosk_search"); this.stage = this.el.querySelector("#pin_state_container"); this.photoRequired = this.el.dataset.photo === "1"; this.soundsOn = this.el.dataset.sounds === "1"; this.employees = []; this.filtered = []; this.pinBuf = ""; this.startClock(); this.initBrandHue(); this.searchEl.addEventListener("input", () => this.onSearch()); const gear = this.el.querySelector("#pin_kiosk_settings"); if (gear) gear.addEventListener("click", () => this.toggleFullscreen()); this._load(); } toggleFullscreen() { try { if (document.fullscreenElement) document.exitFullscreen(); else if (this.el.requestFullscreen) this.el.requestFullscreen().catch(() => {}); } catch (e) { /* unsupported */ } } destroy() { if (this._clockTimer) clearInterval(this._clockTimer); if (this._stream) this._stream.getTracks().forEach((t) => t.stop()); } async _load() { const res = await rpc("/fusion_clock/kiosk/search", { query: "" }); this.employees = res.employees || []; this.filtered = this.employees; this.renderGrid(); } // ---- brand hue (mirrors fusion_clock_nfc_kiosk.js) ---- rgbToHue(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; if (d === 0) return null; let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h = Math.round(h * 60); if (h < 0) h += 360; return h; } extractHue(img) { try { const w = Math.min(img.naturalWidth, 200), h = Math.min(img.naturalHeight, 200); if (!w || !h) return null; const c = document.createElement("canvas"); c.width = w; c.height = h; const ctx = c.getContext("2d", { willReadFrequently: true }); ctx.drawImage(img, 0, 0, w, h); const data = ctx.getImageData(0, 0, w, h).data; let rs = 0, gs = 0, bs = 0, n = 0; for (let i = 0; i < data.length; i += 4) { if (data[i + 3] < 128) continue; const r = data[i], g = data[i + 1], b = data[i + 2]; const lum = (r + g + b) / 3; if (lum > 235 || lum < 25) continue; if (Math.max(r, g, b) - Math.min(r, g, b) < 25) continue; rs += r; gs += g; bs += b; n++; } if (n < 50) return null; return this.rgbToHue(Math.round(rs / n), Math.round(gs / n), Math.round(bs / n)); } catch (e) { return null; } } initBrandHue() { const img = this.el.querySelector("#pin_kiosk_logo"); if (!img) return; const apply = () => { const hue = this.extractHue(img); if (hue != null) document.documentElement.style.setProperty("--pk-h", String(hue)); }; if (img.complete && img.naturalWidth) apply(); else img.addEventListener("load", apply); } // ---- clock ---- startClock() { const tick = () => { const d = new Date(); let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0"); const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; const clock = this.el.querySelector("#pin_kiosk_clock"); clock.textContent = `${h}:${m}`; const span = document.createElement("span"); span.className = "ampm"; span.textContent = ap; clock.appendChild(span); this.el.querySelector("#pin_kiosk_date").textContent = d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); }; tick(); this._clockTimer = setInterval(tick, 1000); } // ---- grid ---- initials(name) { return (name || "").split(" ").filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join(""); } onSearch() { const q = this.searchEl.value.trim().toLowerCase(); this.filtered = q ? this.employees.filter((e) => e.name.toLowerCase().includes(q)) : this.employees; this.renderGrid(); } renderGrid() { this.grid.replaceChildren(); for (const emp of this.filtered) { const tile = document.createElement("div"); tile.className = "pin-kiosk__tile"; const av = document.createElement("div"); av.className = "pin-kiosk__tile-av"; if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`; else av.textContent = this.initials(emp.name); const nm = document.createElement("div"); nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name; tile.append(av, nm); tile.addEventListener("click", () => this.onTile(emp)); this.grid.appendChild(tile); } } // ---- PIN / first-use setup ---- onTile(emp) { this.current = emp; this.pinBuf = ""; this.attempts = 0; this._newPin = null; this.showPin(emp, emp.has_pin ? "Enter your PIN" : "Create a PIN", !emp.has_pin, false); } showPin(emp, sub, isSetup, confirming) { this.isSetup = isSetup; this.confirming = confirming; this.pinBuf = ""; this.stage.replaceChildren(); const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const panel = document.createElement("div"); panel.className = "pin-kiosk__panel"; panel.innerHTML = '
' + '
' + '
' + ''; const av = panel.querySelector(".pin-kiosk__av"); if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`; else av.textContent = this.initials(emp.name); panel.querySelector(".pin-kiosk__name").textContent = emp.name; panel.querySelector(".pin-kiosk__sub").textContent = confirming ? "Re-enter to confirm" : sub; const pad = panel.querySelector(".pin-kiosk__pad"); for (const k of ["1", "2", "3", "4", "5", "6", "7", "8", "9", "⌫", "0", "✓"]) { const b = document.createElement("button"); b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : ""); b.textContent = k; b.addEventListener("click", () => this.onKey(k)); pad.appendChild(b); } panel.querySelector(".pin-kiosk__cancel").addEventListener("click", () => this.reset()); ov.appendChild(panel); this.stage.appendChild(ov); this._panel = panel; this.renderDots(); } renderDots() { const dots = this._panel.querySelector(".pin-kiosk__dots"); dots.replaceChildren(); const len = Math.max(4, this.pinBuf.length); for (let i = 0; i < len; i++) { const d = document.createElement("span"); d.className = "pin-kiosk__dot" + (i < this.pinBuf.length ? " on" : ""); dots.appendChild(d); } } err(msg) { this._panel.querySelector(".pin-kiosk__err").textContent = msg; this._panel.classList.add("shake"); setTimeout(() => this._panel.classList.remove("shake"), 360); } onKey(k) { if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; } if (k === "✓") { this.submitPin(); return; } if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); } } async submitPin() { const emp = this.current, pin = this.pinBuf; if (pin.length < 4) return this.err("PIN must be at least 4 digits"); if (this.isSetup && !this.confirming) { this._newPin = pin; return this.showPin(emp, "Create a PIN", true, true); } try { if (this.isSetup && this.confirming) { if (pin !== this._newPin) { this.pinBuf = ""; this.renderDots(); return this.err("PINs didn't match"); } const r = await rpc("/fusion_clock/kiosk/set_pin", { employee_id: emp.id, pin }); if (r.error) return this.err("Couldn't save PIN"); return this.afterPin(emp); } const v = await rpc("/fusion_clock/kiosk/verify_pin", { employee_id: emp.id, pin }); if (v.success) return this.afterPin(emp); this.attempts++; this.pinBuf = ""; this.renderDots(); if (this.attempts >= 3) return this.reset(); this.err("Wrong PIN — try again"); } catch (e) { this.pinBuf = ""; this.renderDots(); this.err("Connection error — try again"); } } // ---- photo (optional) then clock ---- async afterPin(emp) { let photo = ""; if (this.photoRequired) { try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; } } let r; try { r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo }); } catch (e) { r = { error: "Connection error" }; } this.showResult(emp, r); } showResult(emp, r) { this.stage.replaceChildren(); const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const card = document.createElement("div"); const success = !!(r && r.success); card.className = "pin-kiosk__result" + (success ? "" : " pin-kiosk__result--error"); card.innerHTML = '
' + '
'; const check = card.querySelector(".pin-kiosk__check"); if (success) { check.textContent = "✓"; card.querySelector(".action").textContent = r.action === "clock_out" ? "Clocked Out" : "Clocked In"; card.querySelector(".meta").textContent = r.message || ""; if (this.soundsOn) this.beep(); } else { check.textContent = "!"; check.style.cssText = "color:#f87171;background:rgba(217,55,78,.18);border-color:rgba(217,55,78,.6)"; const act = card.querySelector(".action"); act.textContent = "Couldn't clock"; act.style.color = "#f87171"; card.querySelector(".meta").textContent = (r && r.error) || "Try again"; } card.querySelector(".name").textContent = emp.name; ov.appendChild(card); this.stage.appendChild(ov); setTimeout(() => this.reset(), 3000); } beep() { try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(); o.frequency.value = 880; o.connect(a.destination); o.start(); o.stop(a.currentTime + 0.12); } catch (e) { /* no audio */ } } // ---- camera capture (oval guide + 3s countdown; mirrors NFC kiosk) ---- capturePhoto(emp) { return new Promise(async (resolve, reject) => { let stream; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); } catch (e) { return reject(e); } this._stream = stream; this.stage.replaceChildren(); const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; const panel = document.createElement("div"); panel.className = "pin-kiosk__photo"; const h2 = document.createElement("h2"); h2.textContent = emp.name; panel.appendChild(h2); const stage = document.createElement("div"); stage.className = "stage"; stage.innerHTML = '
'; panel.appendChild(stage); ov.appendChild(panel); this.stage.appendChild(ov); const video = stage.querySelector("video"); video.srcObject = stream; const cd = stage.querySelector(".countdown"); let n = 3; cd.textContent = String(n); const timer = setInterval(() => { n--; if (n > 0) { cd.textContent = String(n); return; } clearInterval(timer); const c = document.createElement("canvas"); c.width = video.videoWidth || 480; c.height = video.videoHeight || 640; c.getContext("2d").drawImage(video, 0, 0, c.width, c.height); stream.getTracks().forEach((t) => t.stop()); this._stream = null; resolve(c.toDataURL("image/jpeg", 0.8)); }, 1000); }); } reset() { if (this._stream) { this._stream.getTracks().forEach((t) => t.stop()); this._stream = null; } this.stage.replaceChildren(); this.pinBuf = ""; this.current = null; this._newPin = null; this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid(); rpc("/fusion_clock/kiosk/search", { query: "" }).then((res) => { this.employees = res.employees || []; this.filtered = this.employees; }); } } registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk);