Files
site/apps/web/public/wesp/js/kiosk-pairing.js
T

286 lines
11 KiB
JavaScript

(function () {
const LOG = "[WespKioskPairing]";
const API = {
status: "/api/kiosk/status",
accessLink: "/api/kiosk/access-link",
setupAvailable: "/api/kiosk/setup-available",
};
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isLocalhostContext() {
return LOCALHOST_HOSTS.has((window.location.hostname || "").toLowerCase());
}
function ensureModalStyles() {
const criticalId = "wespKioskPairCriticalCss";
if (!document.getElementById(criticalId)) {
const style = document.createElement("style");
style.id = criticalId;
style.textContent =
"#kioskPairModal{display:none;position:fixed;inset:0;z-index:5000;padding:8px;background:rgba(0,0,0,.55);overflow:hidden}" +
"#kioskPairModal.kiosk-pair-open{display:flex;align-items:stretch;justify-content:center;height:100vh;height:100dvh}" +
"body.kiosk-pair-modal-open{overflow:hidden!important}" +
"#kioskPairModal .kiosk-pair-panel{display:flex;flex-direction:column;width:min(480px,100%);height:100%;max-height:100%;min-height:0;overflow:hidden;box-sizing:border-box}" +
"#kioskPairModal .kiosk-pair-body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch}" +
"#kioskPairModal .kiosk-pair-actions{flex:0 0 auto;display:flex;flex-wrap:wrap;gap:6px}";
document.head.appendChild(style);
}
if (document.getElementById("wespKioskModalsCss")) return;
if (window.WespKioskTheme && typeof window.WespKioskTheme.ensureModalStyles === "function") {
window.WespKioskTheme.ensureModalStyles();
return;
}
const link = document.createElement("link");
link.id = "wespKioskModalsCss";
link.rel = "stylesheet";
link.href = "/static/css/kiosk-modals.css";
document.head.appendChild(link);
}
function createModal() {
ensureModalStyles();
const modal = document.createElement("div");
modal.id = "kioskPairModal";
modal.innerHTML = `
<div class="kiosk-pair-panel" role="dialog" aria-modal="true" aria-labelledby="kioskPairTitle">
<div class="kiosk-pair-header">
<h3 id="kioskPairTitle">Привязать терминал</h3>
<p class="kiosk-pair-subtitle">Start URL для Fully Kiosk Browser</p>
</div>
<div class="kiosk-pair-body">
<div class="kiosk-pair-steps">
1) Скопируйте ссылку или отсканируйте QR.<br/>
2) Fully Kiosk → Settings → Start URL → вставьте ссылку → Save.<br/>
3) Перезапустите Fully Kiosk.
</div>
<div id="kioskPairLoading" class="kiosk-pair-loading">
<span class="kiosk-pair-spinner"></span>
<span>Загрузка ссылки...</span>
</div>
<div class="kiosk-pair-qr-wrap">
<img id="kioskPairQr" class="kiosk-pair-qr" alt="QR Start URL" />
</div>
<p id="kioskPairUrl" class="kiosk-pair-url"></p>
<p id="kioskPairStatus" class="kiosk-pair-status"></p>
</div>
<div class="kiosk-pair-actions">
<button id="kioskPairCopy" type="button" class="kiosk-pair-btn kiosk-pair-btn--primary">Скопировать ссылку</button>
<button id="kioskPairRefresh" type="button" class="kiosk-pair-btn kiosk-pair-btn--secondary">Обновить ссылку</button>
<button id="kioskPairClose" type="button" class="kiosk-pair-btn kiosk-pair-btn--ghost">Закрыть</button>
</div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function createBlockOverlay() {
const el = document.createElement("div");
el.id = "kioskBlockedOverlay";
el.style.cssText =
"display:none;position:fixed;inset:0;background:rgba(10,10,10,.78);z-index:4500;color:#fff;align-items:center;justify-content:center;text-align:center;padding:20px;";
el.innerHTML = `
<div style="max-width:520px">
<h2 style="margin:0 0 10px 0">Терминал не привязан</h2>
<p style="margin:0;line-height:1.4">Обратитесь к администратору для настройки терминала.</p>
</div>
`;
document.body.appendChild(el);
return el;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
console.warn(LOG, url, response.status, data.message || data);
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
}
function setup(opts) {
const options = opts || {};
const modal = createModal();
const block = createBlockOverlay();
const qrImg = modal.querySelector("#kioskPairQr");
const qrWrap = modal.querySelector(".kiosk-pair-qr-wrap");
const loadingEl = modal.querySelector("#kioskPairLoading");
const urlEl = modal.querySelector("#kioskPairUrl");
const statusEl = modal.querySelector("#kioskPairStatus");
const copyBtn = modal.querySelector("#kioskPairCopy");
const refreshBtn = modal.querySelector("#kioskPairRefresh");
const closeBtn = modal.querySelector("#kioskPairClose");
let currentStartUrl = "";
let canIssueAccessLink = isLocalhostContext();
function isEnforcePairedOnly(data) {
if (!data || typeof data !== "object") return true;
return data.enforce_paired_only !== false;
}
async function refreshSetupAvailability() {
if (isLocalhostContext()) {
canIssueAccessLink = true;
return true;
}
try {
const data = await fetchJson(API.setupAvailable);
canIssueAccessLink = !!data.can_issue_access_link;
} catch (e) {
console.warn(LOG, "setup-available error", e);
canIssueAccessLink = false;
}
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.style.display = canIssueAccessLink ? "" : "none";
}
}
return canIssueAccessLink;
}
async function checkStatus() {
if (isLocalhostContext()) {
block.style.display = "none";
if (typeof options.onPaired === "function") options.onPaired({ paired: true, localhost: true });
return true;
}
try {
const data = await fetchJson(API.status);
const enforce = isEnforcePairedOnly(data);
const paired = !!data.paired || !enforce;
console.debug(LOG, "/api/kiosk/status", {
paired: !!data.paired,
enforce_paired_only: enforce,
device_id: data.device_id,
status: data.status,
});
block.style.display = paired ? "none" : "flex";
if (paired && typeof options.onPaired === "function") options.onPaired(data);
if (!paired && typeof options.onUnpaired === "function") options.onUnpaired(data);
return paired;
} catch (e) {
console.warn(LOG, "checkStatus error", e);
block.style.display = "flex";
if (typeof options.onUnpaired === "function") options.onUnpaired({});
return false;
}
}
async function loadAccessLink(refresh) {
if (!canIssueAccessLink) {
statusEl.textContent = "Выдача Start URL доступна только администратору или с localhost.";
statusEl.style.color = "#ff9f9f";
return;
}
loadingEl.style.display = "flex";
qrWrap.classList.remove("kiosk-pair-qr-visible");
qrImg.removeAttribute("src");
statusEl.textContent = refresh ? "Обновление ссылки..." : "Загрузка ссылки...";
try {
const data = await fetchJson(API.accessLink, {
method: refresh ? "POST" : "GET",
headers: refresh ? { "Content-Type": "application/json" } : undefined,
body: refresh ? JSON.stringify({ refresh: true }) : undefined,
});
currentStartUrl = data.start_url || data.pair_url || "";
console.info(LOG, "access-link OK", { start_url: currentStartUrl.slice(0, 80) });
const qrSrc = data.qr_image_url || "";
await new Promise((resolve) => {
const done = () => {
qrImg.onload = null;
qrImg.onerror = null;
resolve();
};
qrImg.onload = done;
qrImg.onerror = done;
qrImg.src = qrSrc;
});
loadingEl.style.display = "none";
qrWrap.classList.add("kiosk-pair-qr-visible");
qrImg.style.display = "block";
urlEl.textContent = currentStartUrl;
statusEl.textContent = "Постоянная ссылка. Вставьте её в Fully Kiosk → Start URL.";
statusEl.style.color = "";
} catch (e) {
loadingEl.style.display = "none";
statusEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось получить ссылку") || "Не удалось получить ссылку";
statusEl.style.color = "#ff9f9f";
}
}
async function openModal() {
if (!canIssueAccessLink) {
window.alert("Выдача Start URL доступна только администратору или при открытии с localhost.");
return;
}
modal.classList.add("kiosk-pair-open");
document.body.classList.add("kiosk-pair-modal-open");
loadAccessLink(false);
}
function closeModal() {
modal.classList.remove("kiosk-pair-open");
document.body.classList.remove("kiosk-pair-modal-open");
}
async function copyStartUrl() {
if (!currentStartUrl) {
statusEl.textContent = "Сначала дождитесь загрузки ссылки";
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(currentStartUrl);
} else {
const ta = document.createElement("textarea");
ta.value = currentStartUrl;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
statusEl.textContent = "Ссылка скопирована";
statusEl.style.color = "#9fd89f";
} catch (e) {
statusEl.textContent = "Не удалось скопировать — выделите ссылку вручную";
statusEl.style.color = "#ff9f9f";
}
}
async function refreshLink() {
if (!window.confirm("Старая ссылка перестанет работать. Обновить Start URL?")) {
return;
}
await loadAccessLink(true);
}
copyBtn.addEventListener("click", copyStartUrl);
refreshBtn.addEventListener("click", refreshLink);
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal();
});
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.addEventListener("click", (e) => {
e.preventDefault();
openModal();
});
}
}
refreshSetupAvailability().then(function () {
checkStatus();
});
return { checkStatus, openModal };
}
window.WespKioskPairing = { setup };
})();