PageSourceSearch

https://ticketpaygo.io/js/ai-chat.js?v=1

js ticketpaygo.io collected 2026-09-25 23:08:49 UTC 21,247 bytes, 433 lines download raw bytes

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.