Files
gogo-telefon/gogo/static/widget.js
Senad Uka 646917c322 M3: chat widget, owner dashboard, super-admin panel
- Embeddable vanilla-JS chat widget (one script tag + tenant public key):
  WebSocket, resumable sessions (24h localStorage token), honeypot + per-IP
  and per-message rate limits, Bosnian UI (§7)
- Session auth (bcrypt + signed cookies), owner accounts + super-admins,
  admin impersonation ('otvori kao salon', §11)
- Owner dashboard (§10, Bosnian): requests with same actions as email,
  call/chat history with transcripts, usage bar; settings for profile,
  working hours (manual + LLM paste-to-parse), services CRUD + 'Zalijepi
  cjenovnik' LLM import with review step, scheduling (Google OAuth free/busy
  read-only + calendar mappings + buffers, partner status), telephony
  (forwarding MMI codes per operator, ring group, worker SIP creds + QR),
  agent voice/price-mode/notes + widget snippet, notifications/SMS templates
- Super-admin panel (§11): tenant CRUD incl. plan/minutes/paid-until,
  partner API config (encrypted secrets), number/SIM registry + assignment,
  connectivity test (live availability + dry-run push), versioned prompt
  template editor with publish/rollback, playground, usage overview, health
- Fixes: WAL + busy_timeout for sqlite tests, no awaits in WS disconnect
  path (deadlock), template publish autoflush bug
- 76 tests green (incl. widget WS e2e booking flow); dashboard, widget and
  admin verified live in a browser against Postgres

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:51:57 +02:00

154 lines
5.8 KiB
JavaScript

/* Gogo Telefon chat widget — embeddable, dependency-free (§7).
*
* Usage on the salon's site:
* <script src="https://app.gogotelefon.ba/widget.js" data-gogo-key="PUBLIC_KEY" async></script>
*/
(function () {
"use strict";
var script = document.currentScript ||
document.querySelector("script[data-gogo-key]");
if (!script) return;
var KEY = script.getAttribute("data-gogo-key");
if (!KEY) return;
var BASE = (function () {
try {
var u = new URL(script.src);
return u.origin;
} catch (e) {
return "";
}
})();
var WS_BASE = BASE.replace(/^http/, "ws");
var STORE = "gogo_chat_" + KEY;
/* ---------- styles ---------- */
var css =
".gogo-bubble{position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;" +
"background:#7c3aed;color:#fff;border:none;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.25);" +
"font-size:26px;z-index:999999;display:flex;align-items:center;justify-content:center}" +
".gogo-panel{position:fixed;bottom:88px;right:20px;width:340px;max-width:calc(100vw - 32px);" +
"height:480px;max-height:calc(100vh - 120px);background:#fff;border-radius:14px;z-index:999999;" +
"box-shadow:0 8px 30px rgba(0,0,0,.3);display:none;flex-direction:column;overflow:hidden;" +
"font-family:system-ui,sans-serif}" +
".gogo-panel.open{display:flex}" +
".gogo-head{background:#7c3aed;color:#fff;padding:12px 16px;font-weight:600;font-size:15px}" +
".gogo-head small{display:block;font-weight:400;opacity:.85;font-size:12px}" +
".gogo-msgs{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px;background:#f7f7fa}" +
".gogo-msg{max-width:82%;padding:8px 12px;border-radius:12px;font-size:14px;line-height:1.45;" +
"white-space:pre-wrap;word-wrap:break-word}" +
".gogo-msg.bot{background:#fff;border:1px solid #e5e5ec;align-self:flex-start;border-bottom-left-radius:4px}" +
".gogo-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:4px}" +
".gogo-msg.typing{color:#888;font-style:italic;background:#fff;border:1px dashed #ddd}" +
".gogo-form{display:flex;border-top:1px solid #e5e5ec;background:#fff}" +
".gogo-form input[type=text]{flex:1;border:0;padding:12px 14px;font-size:14px;outline:none}" +
".gogo-form button{border:0;background:none;color:#7c3aed;font-weight:700;padding:0 16px;cursor:pointer;font-size:14px}" +
".gogo-hp{position:absolute;left:-9999px;opacity:0;height:0;width:0}";
var style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
/* ---------- DOM ---------- */
var bubble = document.createElement("button");
bubble.className = "gogo-bubble";
bubble.setAttribute("aria-label", "Otvori chat");
bubble.textContent = "💬";
var panel = document.createElement("div");
panel.className = "gogo-panel";
panel.innerHTML =
'<div class="gogo-head">Virtuelni asistent<small>Gogo Telefon — odgovaramo odmah</small></div>' +
'<div class="gogo-msgs"></div>' +
'<form class="gogo-form">' +
'<input type="text" placeholder="Upišite poruku…" maxlength="500" autocomplete="off">' +
'<input type="text" class="gogo-hp" name="website" tabindex="-1" autocomplete="off">' +
"<button type=\"submit\">Pošalji</button></form>";
document.body.appendChild(bubble);
document.body.appendChild(panel);
var msgs = panel.querySelector(".gogo-msgs");
var form = panel.querySelector(".gogo-form");
var input = form.querySelector('input[type=text]:not(.gogo-hp)');
var honeypot = form.querySelector(".gogo-hp");
function addMsg(text, who) {
var el = document.createElement("div");
el.className = "gogo-msg " + who;
el.textContent = text;
msgs.appendChild(el);
msgs.scrollTop = msgs.scrollHeight;
return el;
}
var typingEl = null;
function showTyping() {
if (typingEl) return;
typingEl = addMsg("Gogo piše…", "bot typing");
}
function hideTyping() {
if (typingEl) { typingEl.remove(); typingEl = null; }
}
/* ---------- WebSocket ---------- */
var ws = null;
var connected = false;
function connect() {
if (ws) return;
var resume = "";
try { resume = localStorage.getItem(STORE) || ""; } catch (e) {}
ws = new WebSocket(
WS_BASE + "/widget/ws?key=" + encodeURIComponent(KEY) +
(resume ? "&resume=" + encodeURIComponent(resume) : "")
);
ws.onopen = function () { connected = true; };
ws.onmessage = function (ev) {
var data;
try { data = JSON.parse(ev.data); } catch (e) { return; }
if (data.session) {
try { localStorage.setItem(STORE, data.session); } catch (e) {}
}
if (data.type === "greeting") {
addMsg(data.text, "bot");
} else if (data.type === "resume") {
msgs.innerHTML = "";
(data.messages || []).forEach(function (m) {
addMsg(m.text, m.role === "user" ? "user" : "bot");
});
} else if (data.type === "typing") {
showTyping();
} else if (data.type === "message") {
hideTyping();
addMsg(data.text, "bot");
}
};
ws.onclose = function (ev) {
connected = false;
ws = null;
hideTyping();
if (ev.code === 4429) {
addMsg("Previše pokušaja — molimo pokušajte kasnije ili pozovite salon.", "bot");
}
};
}
bubble.addEventListener("click", function () {
panel.classList.toggle("open");
if (panel.classList.contains("open")) {
connect();
input.focus();
}
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var text = input.value.trim();
if (!text || !ws || !connected) return;
addMsg(text, "user");
ws.send(JSON.stringify({ type: "message", text: text, website: honeypot.value }));
input.value = "";
});
})();