1/** 2 * TicketPayGo AI Chat Widget 3 * Vanilla JS â loads public settings from /api/settings, sends messages to /api/chat 4 */ 5(function () { 6 'use strict'; 7 8 var TPGChat = { 9 settings: null, 10 lang: 'en', 11 isOpen: false, 12 isLoading: false, 13 privacyAccepted: false, 14 sessionId: null, 15 sessionMessages: [], 16 17 getLang: function () { 18 try { 19 var stored = localStorage.getItem('tpg-lang'); 20 if (stored === 'nl') return 'nl'; 21 if (stored === 'de') return 'de'; 22 } catch (e) {} 23 var params = new URLSearchParams(window.location.search); 24 if (params.get('lang') === 'nl') return 'nl'; 25 if (params.get('lang') === 'de') return 'de'; 26 if (window.location.pathname.startsWith('/nl')) return 'nl'; 27 return 'en'; 28 }, 29 30 getGreeting: function () { 31 var s = this.settings; 32 if (this.lang === 'nl' && s.greetingNl) return s.greetingNl; 33 if (this.lang === 'de' && s.greetingDe) return s.greetingDe; 34 return s.greeting || 'Hi! How can I help you?'; 35 }, 36 37 getSuggestedQuestions: function () { 38 var s = this.settings; 39 if (this.lang === 'nl' && s.suggestedQuestionsNl && s.suggestedQuestionsNl.length) { 40 return s.suggestedQuestionsNl; 41 } 42 if (this.lang === 'de' && s.suggestedQuestionsDe && s.suggestedQuestionsDe.length) { 43 return s.suggestedQuestionsDe; 44 } 45 return s.suggestedQuestions || []; 46 }, 47 48 init: function () { 49 var self = this; 50 this.lang = this.getLang(); 51 fetch('/api/settings') 52 .then(function (r) { return r.json(); }) 53 .then(function (cfg) { 54 if (!cfg || !cfg.enabled) return; 55 self.settings = cfg; 56 self.injectStyles(); 57 self.render(); 58 self.bindEvents(); 59 setTimeout(function () { 60 var bubble = document.getElementById('tpgc-bubble'); 61 if (bubble) bubble.classList.add('tpgc-visible'); 62 }, 2500); 63 // React to live language switches 64 window.addEventListener('tpg-language-change', function (e) { 65 self.lang = (e.detail && e.detail.lang) || 'en'; 66 // Update the greeting bubble text live 67 var bubbleInner = document.getElementById('tpgc-bubble-inner'); 68 if (bubbleInner) { 69 var newGreeting = self.getGreeting(); 70 // Replace text node (last child) without removing the close button 71 var closeBtn = bubbleInner.querySelector('#tpgc-bubble-close'); 72 bubbleInner.textContent = ''; 73 if (closeBtn) bubbleInner.appendChild(closeBtn); 74 bubbleInner.appendChild(document.createTextNode(' ' + newGreeting)); 75 } 76 }); 77 }) 78 .catch(function (e) { console.warn('TPGChat: settings load failed', e); }); 79 }, 80 81 esc: function (str) { 82 var d = document.createElement('div'); 83 d.appendChild(document.createTextNode(String(str))); 84 return d.innerHTML; 85 }, 86 87 linkify: function (str) { 88 var escaped = this.esc(str); 89 // Convert markdown [text](url) links (absolute and relative) 90 escaped = escaped.replace( 91 /\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g, 92 '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline;word-break:break-all;">$1</a>' 93 ); 94 // Convert remaining raw https:// URLs not already inside an href 95 escaped = escaped.replace( 96 /(href=")?(https?:\/\/[^\s<>"']+)/g, 97 function (match, prefix, url) { 98 if (prefix) return match; 99 return '<a href="' + url + '" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline;word-break:break-all;">' + url + '</a>'; 100 } 101 ); 102 return escaped; 103 }, 104 105 injectStyles: function () { 106 var c = this.settings.primaryColor || '#0a4974'; 107 var style = document.createElement('style'); 108 style.id = 'tpgc-styles'; 109 style.textContent = [ 110 '@keyframes tpgcFadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}', 111 '@keyframes tpgcSlideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}', 112 '@keyframes tpgcBounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)}}', 113 '#tpgc-root *{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}', 114 '#tpgc-btn{position:fixed;z-index:9999;width:56px;height:56px;border-radius:16px;background:' + c + ';border:none;cursor:pointer;box-shadow:0 4px 20px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;transition:transform .2s,box-shadow .2s}', 115 '#tpgc-btn:hover{transform:scale(1.08);box-shadow:0 6px 28px rgba(0,0,0,.28)}', 116 '#tpgc-bubble{position:fixed;z-index:9998;max-width:270px;opacity:0;pointer-events:none;transition:opacity .3s,transform .3s;transform:translateY(6px)}', 117 '#tpgc-bubble.tpgc-visible{opacity:1;pointer-events:auto;transform:translateY(0)}', 118 '#tpgc-bubble-inner{background:' + c + ';color:#fff;border-radius:14px;padding:12px 16px;font-size:13px;font-weight:500;line-height:1.45;box-shadow:0 4px 18px rgba(0,0,0,.16);position:relative;cursor:pointer}', 119 '#tpgc-bubble-close{position:absolute;top:-8px;right:-8px;width:20px;height:20px;background:#fff;border:none;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.15);padding:0}', 120 '#tpgc-window{position:fixed;z-index:9998;width:min(380px,calc(100vw - 24px));height:min(560px,calc(100vh - 110px));background:#fff;border-radius:20px;box-shadow:0 8px 40px rgba(0,0,0,.18);display:none;flex-direction:column;overflow:hidden}', 121 '#tpgc-window.tpgc-open{display:flex;animation:tpgcSlideUp .28s ease-out}', 122 '#tpgc-header{background:' + c + ';padding:14px 18px;display:flex;align-items:center;gap:12px;flex-shrink:0}', 123 '#tpgc-header-avatar{width:44px;height:44px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden}', 124 '#tpgc-header-avatar img{width:100%;height:100%;object-fit:cover;border-radius:50%}', 125 126 '#tpgc-header-info{flex:1}', 127 '#tpgc-header-name{color:#fff;font-weight:700;font-size:15px;line-height:1.2}', 128 '#tpgc-header-status{color:rgba(255,255,255,.82);font-size:11px;margin-top:1px}', 129 '#tpgc-minimize{background:none;border:none;cursor:pointer;padding:4px;border-radius:8px;display:flex;align-items:center;justify-content:center;opacity:.85;transition:opacity .15s,background .15s}', 130 '#tpgc-minimize:hover{opacity:1;background:rgba(255,255,255,.15)}', 131 '#tpgc-messages{flex:1;overflow-y:auto;padding:14px;background:#f8f9fa;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth}', 132 '.tpgc-msg{display:flex;animation:tpgcFadeUp .2s ease-out}', 133 '.tpgc-msg-user{justify-content:flex-end}', 134 '.tpgc-msg-assistant{justify-content:flex-start}', 135 '.tpgc-bubble-user{background:#e5e7eb;color:#111827;border-radius:16px 16px 4px 16px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:82%;word-break:break-word}', 136 '.tpgc-bubble-assistant{background:' + c + ';color:#fff;border-radius:16px 16px 16px 4px;padding:9px 13px;font-size:13.5px;line-height:1.5;max-width:82%;word-break:break-word}', 137 '#tpgc-suggestions{display:flex;flex-direction:column;gap:7px;margin-top:2px}', 138 '.tpgc-suggestion{text-align:left;padding:9px 13px;border-radius:11px;border:1.5px solid ' + c + ';background:#fff;color:' + c + ';font-size:13px;font-weight:500;cursor:pointer;transition:background .15s;font-family:inherit}', 139 '.tpgc-suggestion:hover{background:#f0f7ff}', 140 '#tpgc-typing{display:flex;justify-content:flex-start}', 141 '#tpgc-typing-inner{background:' + c + ';border-radius:16px 16px 16px 4px;padding:11px 14px;display:flex;gap:4px;align-items:center}', 142 '.tpgc-dot{width:7px;height:7px;background:#fff;border-radius:50%;animation:tpgcBounce 1.1s infinite}', 143 '#tpgc-privacy{background:#1f2937;color:#fff;padding:14px 16px;text-align:center;flex-shrink:0}', 144 '#tpgc-privacy p{margin:0 0 10px;font-size:12px;line-height:1.5;color:#d1d5db}', 145 '#tpgc-privacy a{color:#93c5fd}', 146 '#tpgc-privacy-btn{background:' + c + ';color:#fff;border:none;border-radius:8px;padding:8px 22px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;transition:opacity .15s}', 147 '#tpgc-privacy-btn:hover{opacity:.88}', 148 '#tpgc-input-area{padding:10px 14px 12px;background:#fff;border-top:1px solid #e5e7eb;flex-shrink:0;display:none}', 149 '#tpgc-form{display:flex;gap:8px;align-items:center}', 150 '#tpgc-input{flex:1;padding:10px 15px;border:1.5px solid #e5e7eb;border-radius:24px;font-size:14px;outline:none;transition:border-color .15s;font-family:inherit;color:#111827;background:#fff}', 151 '#tpgc-input:focus{border-color:' + c + '}', 152 '#tpgc-send{width:40px;height:40px;border-radius:50%;background:' + c + ';border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .15s,opacity .15s}', 153 '#tpgc-send:hover{transform:scale(1.1)}', 154 '#tpgc-send:disabled{opacity:.45;cursor:not-allowed;transform:none}', 155 '#tpgc-powered{text-align:center;margin-top:6px;font-size:11px;color:#9ca3af}', 156 '#tpgc-powered a{color:#9ca3af;text-decoration:none;display:inline-flex;align-items:center;gap:4px;justify-content:center;transition:color .15s}', 157 '#tpgc-powered a:hover{color:#6b7280}', 158 '#tpgc-powered a:hover img{opacity:1!important}', 159 '#tpgc-notif{position:absolute;top:-4px;right:-4px;width:12px;height:12px;background:#3b82f6;border-radius:50%;border:2px solid #fff}', 160 ].join('\n'); 161 document.head.appendChild(style); 162 }, 163 164 render: function () { 165 var s = this.settings; 166 var isRight = s.position !== 'bottom-left'; 167 var posBtn = isRight ? 'right:20px' : 'left:20px'; 168 var posWin = isRight ? 'right:20px' : 'left:20px'; 169 var posBubble = isRight ? 'right:20px' : 'left:20px'; 170 171 var root = document.createElement('div'); 172 root.id = 'tpgc-root'; 173 174 var greeting = this.getGreeting(); 175 var lang = this.lang; 176 var privacyText = lang === 'nl' 177 ? 'Door deze chat te gebruiken, ga je akkoord met AI-verwerking van je berichten. Zie ons <a href="/privacy-policy" target="_blank">privacybeleid</a>.' 178 : lang === 'de' 179 ? 'Durch die Nutzung dieses Chats stimmst du der KI-Verarbeitung deiner Nachrichten zu. Siehe unsere <a href="/privacy-policy" target="_blank">Datenschutzrichtlinie</a>.' 180 : 'By using this chat, you consent to AI processing of your messages. See our <a href="/privacy-policy" target="_blank">privacy policy</a>.'; 181 var privacyBtn = lang === 'nl' ? 'Ik ga akkoord' : lang === 'de' ? 'Ich stimme zu' : 'I Accept'; 182 var inputPlaceholder = lang === 'nl' ? 'Typ je berichtâ¦' : lang === 'de' ? 'Nachricht eingebenâ¦' : 'Type your message...'; 183 184 // Greeting bubble 185 root.innerHTML += [ 186 '<div id="tpgc-bubble" style="bottom:88px;' + posBubble + '">', 187 ' <div id="tpgc-bubble-inner">', 188 ' <button id="tpgc-bubble-close" aria-label="Dismiss">', 189 ' <svg width="8" height="8" viewBox="0 0 8 8" fill="none"><path d="M1 1l6 6M7 1L1 7" stroke="#555" stroke-width="1.5" stroke-linecap="round"/></svg>', 190 ' </button>', 191 ' ' + this.esc(greeting), 192 ' </div>', 193 '</div>', 194 ].join(''); 195 196 // Toggle button 197 root.innerHTML += [ 198 '<button id="tpgc-btn" aria-label="Open chat" style="bottom:20px;' + posBtn + '">', 199 ' <span id="tpgc-icon-chat" style="display:flex;align-items:center;justify-content:center;position:relative;">', 200 ' <img src="/assets/chat-balloon.svg" alt="Chat" width="28" height="28" style="filter:brightness(0) invert(1);">', 201 ' <span id="tpgc-notif"></span>', 202 ' </span>', 203 ' <span id="tpgc-icon-close" style="display:none;align-items:center;justify-content:center;">', 204 ' <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>', 205 ' </span>', 206 '</button>', 207 ].join(''); 208 209 // Chat window 210 root.innerHTML += [ 211 '<div id="tpgc-window" style="bottom:88px;' + posWin + '" role="dialog" aria-label="Chat with ' + this.esc(s.assistantName || 'TPG Assistant') + '">', 212 213 // Header 214 ' <div id="tpgc-header">', 215 ' <div id="tpgc-header-avatar">', 216 ' <img src="' + this.esc(s.avatarUrl || '/assets/logo.svg') + '" alt="' + this.esc(s.assistantName || 'TPG Assistant') + '">', 217 ' </div>', 218 ' <div id="tpgc-header-info">', 219 ' <div id="tpgc-header-name">' + this.esc(s.assistantName || 'TPG Assistant') + '</div>', 220 ' <div id="tpgc-header-status">Online</div>', 221 ' </div>', 222 ' <button id="tpgc-minimize" aria-label="Minimize chat">', 223 ' <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"><path d="M18 15l-6-6-6 6"/></svg>', 224 ' </button>', 225 ' </div>', 226 227 // Messages 228 ' <div id="tpgc-messages" role="log" aria-live="polite"></div>', 229 230 // Privacy notice 231 ' <div id="tpgc-privacy">', 232 ' <p>' + privacyText + '</p>', 233 ' <button id="tpgc-privacy-btn">' + privacyBtn + '</button>', 234 ' </div>', 235
236 // Input 237 ' <div id="tpgc-input-area">', 238 ' <form id="tpgc-form" autocomplete="off">', 239 ' <input id="tpgc-input" type="text" placeholder="' + inputPlaceholder + '" maxlength="500" />', 240 241 ' <button type="submit" id="tpgc-send" aria-label="Send">', 242 ' <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>', 243 ' </button>', 244 ' </form>', 245 ' <div id="tpgc-powered"><a href="https://upkyk.com/assistant-ai/" target="_blank" rel="noopener"><span>Powered by</span><img src="/assets/upkyk-icon-grey.svg" alt="Upkyk" style="opacity:.7;vertical-align:middle;width:48px;height:20px;display:inline-block;"></a></div>', 246 ' </div>', 247 248 '</div>', 249 ].join(''); 250 251 document.body.appendChild(root); 252 253 // Add initial greeting message 254 this.appendMessage('assistant', greeting, true); 255 }, 256 257 bindEvents: function () { 258 var self = this; 259 260 document.getElementById('tpgc-btn').addEventListener('click', function () { 261 self.isOpen ? self.close() : self.open(); 262 }); 263 264 document.getElementById('tpgc-minimize').addEventListener('click', function () { 265 self.close(); 266 }); 267 268 document.getElementById('tpgc-bubble-close').addEventListener('click', function () { 269 var b = document.getElementById('tpgc-bubble'); 270 if (b) { b.classList.remove('tpgc-visible'); b.style.display = 'none'; } 271 }); 272 273 document.getElementById('tpgc-bubble').addEventListener('click', function (e) { 274 if (e.target.closest('#tpgc-bubble-close')) return; 275 self.open(); 276 }); 277 278 document.getElementById('tpgc-privacy-btn').addEventListener('click', function () { 279 self.privacyAccepted = true; 280 document.getElementById('tpgc-privacy').style.display = 'none'; 281 document.getElementById('tpgc-input-area').style.display = 'block'; 282 document.getElementById('tpgc-input').focus(); 283 }); 284 285 document.getElementById('tpgc-form').addEventListener('submit', function (e) { 286 e.preventDefault(); 287 var input = document.getElementById('tpgc-input'); 288 var text = input.value.trim(); 289 if (text && !self.isLoading) { 290 input.value = ''; 291 self.send(text); 292 } 293 }); 294 }, 295 296 open: function () { 297 this.isOpen = true; 298 document.getElementById('tpgc-window').classList.add('tpgc-open'); 299 document.getElementById('tpgc-icon-chat').style.display = 'none'; 300 document.getElementById('tpgc-icon-close').style.display = 'flex'; 301 var bubble = document.getElementById('tpgc-bubble'); 302 if (bubble) { bubble.classList.remove('tpgc-visible'); bubble.style.display = 'none'; } 303 this.scrollBottom(); 304 if (this.privacyAccepted) document.getElementById('tpgc-input').focus(); 305 }, 306 307 close: function () { 308 this.isOpen = false; 309 document.getElementById('tpgc-window').classList.remove('tpgc-open'); 310 document.getElementById('tpgc-icon-chat').style.display = 'flex'; 311 document.getElementById('tpgc-icon-close').style.display = 'none'; 312 }, 313 314 appendMessage: function (role, text, withSuggestions) { 315 var msgs = document.getElementById('tpgc-messages'); 316 if (!msgs) return; 317 318 var wrap = document.createElement('div'); 319 wrap.className = 'tpgc-msg tpgc-msg-' + role; 320 var bubble = document.createElement('div'); 321 bubble.className = role === 'user' ? 'tpgc-bubble-user' : 'tpgc-bubble-assistant'; 322 if (role === 'assistant') { 323 bubble.innerHTML = this.linkify(text); 324 } else { 325 bubble.textContent = text; 326 } 327 wrap.appendChild(bubble); 328 msgs.appendChild(wrap); 329 330 // Suggested questions after first assistant message 331 if (withSuggestions && role === 'assistant' && this.getSuggestedQuestions().length) { 332 this.renderSuggestions(msgs); 333 } 334 335 this.scrollBottom(); 336 }, 337 338 renderSuggestions: function (container) { 339 var self = this; 340 var wrap = document.createElement('div'); 341 wrap.id = 'tpgc-suggestions';
342 (self.getSuggestedQuestions()).forEach(function (q) { 343 var btn = document.createElement('button'); 344 btn.className = 'tpgc-suggestion'; 345 btn.textContent = q; 346 btn.addEventListener('click', function () { 347 if (!self.privacyAccepted) return; 348 var s = document.getElementById('tpgc-suggestions'); 349 if (s) s.remove(); 350 self.send(q); 351 }); 352 wrap.appendChild(btn); 353 }); 354 container.appendChild(wrap); 355 this.scrollBottom(); 356 }, 357 358 showTyping: function () { 359 var msgs = document.getElementById('tpgc-messages'); 360 if (!msgs) return; 361 var el = document.createElement('div'); 362 el.id = 'tpgc-typing'; 363 el.innerHTML = '<div id="tpgc-typing-inner">' + 364 '<span class="tpgc-dot" style="animation-delay:0ms"></span>' + 365 '<span class="tpgc-dot" style="animation-delay:180ms"></span>' + 366 '<span class="tpgc-dot" style="animation-delay:360ms"></span>' + 367 '</div>'; 368 msgs.appendChild(el); 369 this.scrollBottom(); 370 }, 371 372 hideTyping: function () { 373 var el = document.getElementById('tpgc-typing'); 374 if (el) el.remove(); 375 }, 376 377 scrollBottom: function () { 378 var msgs = document.getElementById('tpgc-messages'); 379 if (msgs) msgs.scrollTop = msgs.scrollHeight; 380 }, 381 382 send: function (text) { 383 var self = this; 384 if (this.isLoading) return; 385 386 var sugg = document.getElementById('tpgc-suggestions'); 387 if (sugg) sugg.remove(); 388 389 this.appendMessage('user', text); 390 this.sessionMessages.push({ role: 'user', content: text }); 391 this.isLoading = true; 392 393 var sendBtn = document.getElementById('tpgc-send'); 394 if (sendBtn) sendBtn.disabled = true; 395 396 this.showTyping(); 397 398 fetch('/api/chat/', { 399 method: 'POST', 400 headers: { 'Content-Type': 'application/json' }, 401 body: JSON.stringify({ messages: this.sessionMessages, sessionId: this.sessionId }), 402 }) 403 .then(function (r) { return r.json(); }) 404 .then(function (data) { 405 self.hideTyping(); 406 var reply = data.response || 'Sorry, something went wrong. Please try again.'; 407 self.appendMessage('assistant', reply); 408 self.sessionMessages.push({ role: 'assistant', content: reply }); 409 if (data.sessionId) self.sessionId = data.sessionId; 410 }) 411 .catch(function () { 412 self.hideTyping(); 413 var errMsg = self.lang === 'nl' 414 ? 'Sorry, er is een fout opgetreden. Probeer het opnieuw.' 415 : self.lang === 'de' 416 ? 'Entschuldigung, ein Fehler ist aufgetreten. Bitte versuche es erneut.' 417 : 'Sorry, an error occurred. Please try again.'; 418 self.appendMessage('assistant', errMsg); 419 }) 420 .finally(function () { 421 self.isLoading = false; 422 if (sendBtn) sendBtn.disabled = false; 423 document.getElementById('tpgc-input').focus(); 424 }); 425 }, 426 }; 427 428 if (document.readyState === 'loading') { 429 document.addEventListener('DOMContentLoaded', function () { TPGChat.init(); }); 430 } else { 431 TPGChat.init(); 432 } 433})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.