feat(fusion_clock): PIN kiosk — polished photo-tile + PIN clock (opt-in)

A proper shared-device PIN kiosk for clients who don't want NFC: photo-tile grid
(+search) -> tap -> PIN (or first-use create) -> optional master-gated selfie ->
clock, in the NFC kiosk's dark glass + brand-gradient style. Built as an Odoo 19
Interaction; new pin_kiosk.scss (scoped); reworked clock_kiosk.py
(search +avatar/has_pin, verify_pin needs_setup, set_pin, clock via kiosk location).
Drops the redundant kiosk_pin_required (PIN always required); relabels the company
kiosk location; adds a PIN-kiosk app icon. Opt-in via enable_kiosk (off by default).
HttpCase tests added. Bump 19.0.4.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 21:25:32 -04:00
parent b61e159e6f
commit a5ec79013a
13 changed files with 672 additions and 393 deletions

View File

@@ -1,242 +1,288 @@
/** @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 FusionClockKiosk extends Interaction {
static selector = "#fclk-kiosk";
export class PinKiosk extends Interaction {
static selector = "#pin_kiosk_root";
setup() {
this.selectedEmployeeId = 0;
this.resetTimer = null;
this.searchTimeout = null;
const pinAttr = this.el.dataset.pinRequired;
this.pinRequired = pinAttr === "true" || pinAttr === "True";
this._startClock();
this._bindEvents();
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();
}
_startClock() {
const el = document.getElementById("fclk-kiosk-time");
if (!el) return;
const update = () => {
el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
update();
setInterval(update, 1000);
}
_bindEvents() {
const queryInput = document.getElementById("fclk-kiosk-query");
if (queryInput) {
queryInput.addEventListener("input", (e) => this._onSearch(e.target.value));
}
const backBtn = document.getElementById("fclk-kiosk-back-btn");
if (backBtn) {
backBtn.addEventListener("click", () => this._resetKiosk());
}
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
if (clockBtn) {
clockBtn.addEventListener("click", () => this._onClock());
}
}
_resetKiosk() {
const search = document.getElementById("fclk-kiosk-search");
const pin = document.getElementById("fclk-kiosk-pin");
const result = document.getElementById("fclk-kiosk-result");
const error = document.getElementById("fclk-kiosk-error");
const query = document.getElementById("fclk-kiosk-query");
const results = document.getElementById("fclk-kiosk-results");
const pinInput = document.getElementById("fclk-kiosk-pin-input");
if (search) search.style.display = "";
if (pin) pin.style.display = "none";
if (result) result.style.display = "none";
if (error) error.style.display = "none";
if (query) query.value = "";
if (results) results.innerHTML = "";
if (pinInput) pinInput.value = "";
this.selectedEmployeeId = 0;
if (this.resetTimer) clearTimeout(this.resetTimer);
}
_showError(msg) {
const el = document.getElementById("fclk-kiosk-error");
if (el) {
el.textContent = msg;
el.style.display = "";
}
}
_onSearch(value) {
if (this.searchTimeout) clearTimeout(this.searchTimeout);
const q = value.trim();
if (q.length < 2) {
const container = document.getElementById("fclk-kiosk-results");
if (container) container.innerHTML = "";
return;
}
this.searchTimeout = setTimeout(async () => {
try {
const resp = await fetch("/fusion_clock/kiosk/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }),
});
const data = await resp.json();
const employees = (data.result || {}).employees || [];
const container = document.getElementById("fclk-kiosk-results");
if (!container) return;
container.innerHTML = "";
for (const emp of employees) {
const item = document.createElement("a");
item.href = "#";
item.className = "list-group-item list-group-item-action d-flex justify-content-between";
const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary";
const statusText = emp.is_checked_in ? "In" : "Out";
item.innerHTML =
`<span>${emp.name} <small class="text-muted">${emp.department}</small></span>` +
`<span class="badge ${statusBadge}">${statusText}</span>`;
item.addEventListener("click", (e) => {
e.preventDefault();
this._selectEmployee(emp);
});
container.appendChild(item);
}
} catch {
this._showError("Search failed.");
}
}, 300);
}
_selectEmployee(emp) {
this.selectedEmployeeId = emp.id;
const nameEl = document.getElementById("fclk-kiosk-emp-name");
if (nameEl) nameEl.textContent = emp.name;
const searchEl = document.getElementById("fclk-kiosk-search");
const pinEl = document.getElementById("fclk-kiosk-pin");
const errorEl = document.getElementById("fclk-kiosk-error");
if (searchEl) searchEl.style.display = "none";
if (pinEl) pinEl.style.display = "";
if (errorEl) errorEl.style.display = "none";
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
if (clockBtn) {
clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In";
clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success");
}
}
async _onClock() {
if (!this.selectedEmployeeId) return;
const btn = document.getElementById("fclk-kiosk-clock-btn");
if (btn) btn.disabled = true;
const pinInput = document.getElementById("fclk-kiosk-pin-input");
const pin = pinInput ? pinInput.value : "";
if (this.pinRequired && pin.length === 0) {
this._showError("Please enter your PIN.");
if (btn) btn.disabled = false;
return;
}
toggleFullscreen() {
try {
if (this.pinRequired) {
const vResp = await fetch("/fusion_clock/kiosk/verify_pin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "call",
params: { employee_id: this.selectedEmployeeId, pin },
}),
});
const vData = await vResp.json();
if (vData.result && vData.result.error) {
this._showError(vData.result.error);
if (btn) btn.disabled = false;
return;
}
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);
}
let lat = 0;
let lng = 0;
try {
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 10000,
enableHighAccuracy: true,
});
});
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch {
// Native GPS unavailable -- try IP geolocation
}
if (lat === 0 && lng === 0) {
try {
const ipResp = await fetch("https://ipapi.co/json/");
if (ipResp.ok) {
const ipData = await ipResp.json();
if (ipData.latitude && ipData.longitude) {
lat = ipData.latitude;
lng = ipData.longitude;
}
}
} catch {
// IP geolocation also unavailable
}
}
// ---- 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);
}
const resp = await fetch("/fusion_clock/kiosk/clock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "call",
params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng },
}),
});
const data = await resp.json();
const result = data.result || {};
if (result.error) {
this._showError(result.error);
if (btn) btn.disabled = false;
return;
}
const pinEl = document.getElementById("fclk-kiosk-pin");
const resultEl = document.getElementById("fclk-kiosk-result");
if (pinEl) pinEl.style.display = "none";
if (resultEl) resultEl.style.display = "";
const msgEl = document.getElementById("fclk-kiosk-result-msg");
if (msgEl) {
const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning";
let html = `<div style="font-size:3rem"><i class="fa ${icon}"></i></div>`;
html += `<div class="mt-2">${result.message || "Done"}</div>`;
if (result.net_hours !== undefined) {
html += `<div class="text-muted mt-1">Net hours: ${result.net_hours}h</div>`;
}
msgEl.innerHTML = html;
}
this.resetTimer = setTimeout(() => this._resetKiosk(), 10000);
} catch {
this._showError("Operation failed.");
// ---- 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);
}
if (btn) btn.disabled = false;
}
// ---- 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 =
'<div class="pin-kiosk__av"></div><div class="pin-kiosk__name"></div>' +
'<div class="pin-kiosk__sub"></div><div class="pin-kiosk__dots"></div>' +
'<div class="pin-kiosk__err"></div><div class="pin-kiosk__pad"></div>' +
'<button class="pin-kiosk__cancel">✕ Cancel</button>';
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 =
'<div class="pin-kiosk__check"></div><div class="name"></div>' +
'<div class="action"></div><div class="meta"></div>';
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 = '<video autoplay="autoplay" playsinline="playsinline"></video><div class="guide"></div><div class="countdown"></div>';
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.kiosk", FusionClockKiosk);
registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk);

View File

@@ -0,0 +1,129 @@
// PIN Clock Kiosk — premium glass + animated mesh, always-dark.
// Mirrors nfc_kiosk.scss; scoped under :has(#pin_kiosk_root) so it never leaks
// to other frontend pages. Brand hue --pk-h is set by JS from the company
// logo's dominant color; all colors interpolate from it via HSL.
:root {
--pk-h: 168;
--pk-bg: #0b0d10;
--pk-text: #ffffff;
--pk-text-muted: #9ba3ad;
--pk-success: #18a957;
--pk-error: #d9374e;
}
html:has(#pin_kiosk_root) {
overflow: hidden; height: 100%;
body { overflow: hidden; height: 100%; margin: 0; padding: 0;
background: var(--pk-bg) !important; color: var(--pk-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
}
.pin-kiosk {
position: fixed; inset: 0; width: 100vw; height: 100vh;
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
padding: 1.25rem 2rem 2rem; box-sizing: border-box; user-select: none;
-webkit-tap-highlight-color: transparent; overflow: hidden; background: var(--pk-bg);
&::before { content: ""; position: absolute; inset: -15%;
background:
radial-gradient(circle at 20% 30%, hsla(var(--pk-h), 75%, 40%, 0.55) 0%, transparent 45%),
radial-gradient(circle at 80% 20%, hsla(calc(var(--pk-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
radial-gradient(circle at 70% 75%, hsla(calc(var(--pk-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
radial-gradient(circle at 15% 85%, hsla(calc(var(--pk-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
filter: blur(60px) saturate(140%); animation: pk-mesh 28s ease-in-out infinite alternate; z-index: 0; }
&::after { content: ""; position: absolute; inset: 0;
background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.45) 100%); z-index: 1; pointer-events: none; }
> * { position: relative; z-index: 2; }
}
@keyframes pk-mesh {
0% { transform: translate(0, 0) rotate(0) scale(1); }
50% { transform: translate(3%, -2%) rotate(2deg) scale(1.05); }
100% { transform: translate(-3%, 3%) rotate(-1deg) scale(0.98); }
}
// Header chrome
.pin-kiosk__logo { max-height: 56px; max-width: 240px; object-fit: contain;
background: rgba(255,255,255,0.95); padding: 0.55rem 1rem; border-radius: 0.9rem;
border: 2px solid hsla(var(--pk-h), 85%, 72%, 0.95);
box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 0 26px hsla(var(--pk-h), 90%, 60%, 0.5); }
.pin-kiosk__clock { margin-top: 0.5rem; font-size: 2.1rem; font-weight: 300; font-variant-numeric: tabular-nums;
letter-spacing: -0.02em; text-shadow: 0 2px 12px rgba(0,0,0,0.4);
.ampm { font-size: 0.9rem; font-weight: 500; color: var(--pk-text-muted); margin-left: 0.3rem; } }
.pin-kiosk__date { font-size: 0.8rem; color: var(--pk-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; }
// Search
.pin-kiosk__search { margin: 1rem 0 0.85rem; width: 92%; max-width: 440px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 999px;
padding: 0.7rem 1.2rem; color: var(--pk-text); font-size: 1rem; outline: none;
&::placeholder { color: var(--pk-text-muted); }
&:focus { border-color: hsl(var(--pk-h), 80%, 55%); } }
// Tile grid
.pin-kiosk__grid { flex: 1; min-height: 0; overflow-y: auto; width: 100%; max-width: 1100px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.85rem; align-content: start; padding-bottom: 1rem; }
.pin-kiosk__tile { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.85rem 0.4rem;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 1rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.25); cursor: pointer; transition: transform 120ms ease, background 150ms ease;
&:hover, &:active { background: rgba(255,255,255,0.1); transform: translateY(-2px); } }
.pin-kiosk__tile-av { width: 60px; height: 60px; border-radius: 50%; background-size: cover; background-position: center;
display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; color: #fff;
background-color: hsl(var(--pk-h), 55%, 42%);
border: 2px solid rgba(255,255,255,0.25); box-shadow: 0 6px 16px rgba(0,0,0,0.35); }
.pin-kiosk__tile-nm { font-size: 0.8rem; text-align: center; line-height: 1.15; color: #e7ebf0; max-width: 100px; }
// Bottom chrome
.pin-kiosk__location { position: absolute; bottom: 1.5rem; left: 1.5rem; font-size: 0.85rem; color: var(--pk-text-muted);
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); padding: 0.5rem 1rem; border-radius: 999px; }
.pin-kiosk__settings { position: absolute; bottom: 1.5rem; right: 1.5rem; width: 2.75rem; height: 2.75rem; border-radius: 50%;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); color: var(--pk-text-muted);
display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; }
// Glass overlay (PIN pad / setup / result), centered
.pin-kiosk__overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); padding: 2rem; animation: pk-fade 200ms ease-out; }
@keyframes pk-fade { from { opacity: 0; } to { opacity: 1; } }
%pk-glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(24px) saturate(160%); -webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 20px 60px rgba(0,0,0,0.5); border-radius: 1.5rem; }
.pin-kiosk__panel { @extend %pk-glass; padding: 1.75rem 2rem; width: 90%; max-width: 360px;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
.pin-kiosk__av { width: 64px; height: 64px; border-radius: 50%; background-size: cover; background-position: center;
display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 700; color: #fff;
background-color: hsl(var(--pk-h), 60%, 45%); border: 2px solid rgba(255,255,255,0.25); }
.pin-kiosk__name { font-size: 1.25rem; font-weight: 600; }
.pin-kiosk__sub { font-size: 0.85rem; color: var(--pk-text-muted); margin-top: -0.3rem; }
.pin-kiosk__dots { display: flex; gap: 0.85rem; margin: 0.5rem 0; }
.pin-kiosk__dot { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid hsla(var(--pk-h),80%,70%,0.8);
&.on { background: hsl(var(--pk-h),80%,65%); border-color: hsl(var(--pk-h),80%,65%); } }
.pin-kiosk__pad { display: grid; grid-template-columns: repeat(3, 4rem); gap: 0.6rem; }
.pin-kiosk__key { height: 3.25rem; border-radius: 0.85rem; background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12); color: var(--pk-text); font-size: 1.4rem; font-weight: 300; cursor: pointer;
display: flex; align-items: center; justify-content: center;
&:active { transform: scale(0.95); background: rgba(255,255,255,0.14); }
&.ok { background: hsl(var(--pk-h),80%,45%); border-color: transparent; } }
.pin-kiosk__cancel { margin-top: 0.3rem; color: var(--pk-text-muted); font-size: 0.85rem; cursor: pointer; background: none; border: none; }
.pin-kiosk__err { min-height: 1.1rem; color: var(--pk-error); font-size: 0.9rem; }
.pin-kiosk__panel.shake { animation: pk-shake 350ms ease-in-out; }
@keyframes pk-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-10px)} 40%{transform:translateX(10px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
// Result card
.pin-kiosk__result { @extend %pk-glass; padding: 2.25rem 3rem; display: flex; flex-direction: column; align-items: center;
gap: 0.6rem; text-align: center; width: 90%; max-width: 420px;
border-color: rgba(24,169,87,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(24,169,87,0.35);
&--error { border-color: rgba(217,55,78,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 60px rgba(217,55,78,0.3); } }
.pin-kiosk__check { width: 74px; height: 74px; border-radius: 50%; background: rgba(24,169,87,0.18);
border: 2px solid rgba(24,169,87,0.6); display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #34d399; }
.pin-kiosk__result .name { font-size: 1.6rem; font-weight: 600; }
.pin-kiosk__result .action { font-size: 1.2rem; color: #34d399; font-weight: 500; }
.pin-kiosk__result .meta { font-size: 0.9rem; color: var(--pk-text-muted); }
// Photo capture (oval guide, mirrors the NFC kiosk)
.pin-kiosk__photo { @extend %pk-glass; padding: 1.5rem; width: 86%; max-width: 540px; text-align: center;
.stage { position: relative; aspect-ratio: 3 / 4; height: 56vh; max-height: 480px; margin: 0 auto; border-radius: 1rem; overflow: hidden; background: #000; }
video, img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
video { transform: scaleX(-1); }
.guide { position: absolute; top: 47%; left: 50%; width: 64%; aspect-ratio: 3 / 4; 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); }
.countdown { position: absolute; top: 47%; left: 50%; transform: translate(-50%, -50%); font-size: 5rem; font-weight: 200; color: #fff; text-shadow: 0 2px 24px rgba(0,0,0,0.85); } }
@media (prefers-reduced-motion: reduce) {
.pin-kiosk::before, .pin-kiosk__panel.shake, .pin-kiosk__result { animation: none; }
}