- 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>
154 lines
5.8 KiB
JavaScript
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 = "";
|
|
});
|
|
})();
|