PageSourceSearch

https://quix.io/js/chat.js

js quix.io collected 2026-10-01 10:46:17 UTC 11,354 bytes, 282 lines download raw bytes

1// The chat box is an overlay, not part of the page: the hero's "Ask AI" CTA
2// (any [data-ask-ai] trigger) opens it fixed in the centre of the screen, where
3// it stays put while the page scrolls behind it. Nothing loads or animates until
4// it's opened; the chat itself is an iframe created on the first message.
5document.addEventListener('DOMContentLoaded', function () {
6  const card = document.querySelector('.feature-card');
7  if (!card) return;
8  const triggers = document.querySelectorAll('[data-ask-ai]');
9  const featureText = document.querySelector('.feature-card__text');
10  const input = document.querySelector('.feature-card__input');
11  const submitBtn = document.querySelector('.feature-card__submit');
12  const messages = document.querySelector('.feature-card__messages');
13  const choiceYes = document.querySelector('.feature-card__choice--yes');
14  const choiceNo = document.querySelector('.feature-card__choice--no');
15
16  // TODO: switch to prod URL when ready
17  const CHAT_URL = 'https://quixai-chat-quixers-quixaiwebsiteai-prod.az-france-0.app.quix.io/embed_papaya';
18  const CHAT_ORIGIN = new URL(CHAT_URL).origin;
19  const HAS_PREV_KEY = 'quixai_had_chat';
20  let chatIframe = null;
21  let probeIframe = null;
22  let closeTimer = null;
23  let lastTrigger = null;
24
25  // ── Intro placeholder variant ────────────────────────────────────────────────
26  // Show one of these at random as the input placeholder and record which the
27  // visitor saw with their first message: it rides along on the quixai-chat-send
28  // postMessage as promptVariant, and the chat backend stores it per session for
29  // the admin view. The empty string is the deliberate "blank" variant.
30  var INTRO_PLACEHOLDERS = ['Ask me about Quix'];
31  var promptVariantShown = null;
32
33  function pickPromptVariant() {
34    promptVariantShown = INTRO_PLACEHOLDERS[Math.floor(Math.random() * INTRO_PLACEHOLDERS.length)];
35    input.placeholder = promptVariantShown;
36  }
37
38  // ── Typewriter placeholder animation ────────────────────────────────────────
39
40  let typingAbort = null;
41
42  function sleep(ms, signal) {
43    return new Promise(function (resolve, reject) {
44      if (signal.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; }
45      const t = setTimeout(resolve, ms);
46      signal.addEventListener('abort', function () {
47        clearTimeout(t);
48        reject(new DOMException('Aborted', 'AbortError'));
49      }, { once: true });
50    });
51  }
52
53  async function runPlaceholderLoop(groups, signal) {
54    if (!groups.length) return;
55    const typeSpeed = 30, deleteSpeed = 20;
56    const pauseFull = 1500, pauseFullFirst = 3000, pauseEmpty = 400;
57    let isFirst = true;
58    try {
59      while (!signal.aborted) {
60        for (const group of groups) {
61          let current = '';
62          for (const ch of group.prefix) {
63            current += ch; featureText.textContent = current;
64            await sleep(typeSpeed, signal);
65          }
66          const prefixLen = current.length;
67          for (const phrase of group.phrases) {
68            for (const ch of phrase) {
69              current += ch; featureText.textContent = current;
70              await sleep(typeSpeed, signal);
71            }
72            await sleep(isFirst ? pauseFullFirst : pauseFull, signal);
73            isFirst = false;
74            while (current.length > prefixLen) {
75              current = current.slice(0, -1); featureText.textContent = current;
76              await sleep(deleteSpeed, signal);
77            }
78            await sleep(pauseEmpty, signal);
79          }
80          while (current.length > 0) {
81            current = current.slice(0, -1); featureText.textContent = current;
82            await sleep(deleteSpeed, signal);
83          }
84          await sleep(pauseEmpty, signal);
85        }
86      }
87    } catch (err) {
88      if (err.name !== 'AbortError') throw err;
89    }
90  }
91
92  var FALLBACK_PROMPTS = [{
93    prefix: '',
94    phrases: [
95      'Create dynamic test plans that adapt in real time',
96      'Find and analyse any test data, instantly',
97      'Monitor tests, live',
98      'Optimise hardware performance',
99      'Detect anomalies as they happen'
100    ]
101  }];
102
103  function startTyping() {
104    stopTyping();
105    const controller = new AbortController();
106    typingAbort = controller;
107    fetch('js/chat-prompts.json')
108      .then(function (r) { return r.ok ? r.json() : null; })
109      .then(function (groups) {
110        if (controller.signal.aborted) return;
111        runPlaceholderLoop(Array.isArray(groups) ? groups : FALLBACK_PROMPTS, controller.signal);
112      })
113      .catch(function () {
114        if (!controller.signal.aborted) {
115          runPlaceholderLoop(FALLBACK_PROMPTS, controller.signal);
116        }
117      });
118  }
119
120  function stopTyping() {
121    if (typingAbort) { typingAbort.abort(); typingAbort = null; }
122    featureText.textContent = '';
123  }
124
125  // ── Iframe management ────────────────────────────────────────────────────────
126
127  function loadIframe(initialMessage, freshStart) {
128    if (chatIframe) {
129      if (initialMessage) {
130        chatIframe.contentWindow.postMessage(
131          { type: 'quixai-chat-send', message: initialMessage, promptVariant: promptVariantShown },
132          CHAT_ORIGIN
133        );
134      }
135      return;
136    }
137    chatIframe = document.createElement('iframe');
138    chatIframe.src = CHAT_URL;
139    chatIframe.setAttribute('allowtransparency', 'true');
140    chatIframe.setAttribute('allow', 'clipboard-write');
141    messages.appendChild(chatIframe);
142
143    chatIframe.addEventListener('load', function () {
144      if (freshStart) {
145        chatIframe.contentWindow.postMessage({ type: 'quixai-chat-fresh-start' }, CHAT_ORIGIN);
146      } else if (initialMessage) {
147        chatIframe.contentWindow.postMessage(
148          { type: 'quixai-chat-send', message: initialMessage, promptVariant: promptVariantShown },
149          CHAT_ORIGIN
150        );
151      }
152    });
153  }
154
155  function expandAndLoad(initialMessage, freshStart) {
156    card.classList.remove('has-previous');
157    card.classList.add('is-expanded');
158    document.body.classList.add('chat-expanded');
159    loadIframe(initialMessage, freshStart);
160  }
161
162  // ── Open / close ─────────────────────────────────────────────────────────────
163
164  function openChat() {
165    if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
166    if (card.classList.contains('is-open')) return;
167    card.classList.add('is-open');
168    triggers.forEach(function (t) { t.setAttribute('aria-expanded', 'true'); });
169    pickPromptVariant();
170
171    // Previous-session detection runs per open, not per page load: the visitor may
172    // have chatted, closed the box and come back to it without a reload.
173    if (localStorage.getItem(HAS_PREV_KEY)) {
174      stopTyping();
175      featureText.textContent = 'Continue your previous conversation?';
176      card.classList.add('has-previous');
177    } else {
178      card.classList.remove('has-previous');
179      startTyping();
180      // Desktop only — autofocus on mobile throws up the keyboard over the box.
181      if (window.innerWidth > 1300) input.focus();
182    }
183  }
184
185  function closeChat() {
186    if (!card.classList.contains('is-open')) return;
187    card.classList.remove('is-open');
188    document.body.classList.remove('chat-expanded');
189    triggers.forEach(function (t) { t.setAttribute('aria-expanded', 'false'); });
190    localStorage.removeItem(HAS_PREV_KEY);
191    if (lastTrigger) { lastTrigger.focus(); lastTrigger = null; }
192
193    // Reset only once the fade has finished, so the 1s shrink back to the small
194    // intro box happens out of sight.
195    closeTimer = setTimeout(function () {
196      closeTimer = null;
197      card.classList.remove('is-expanded');
198      card.classList.remove('has-previous');
199      stopTyping();
200      if (chatIframe) { chatIframe.remove(); chatIframe = null; }
201    }, 300);
202  }
203
204  triggers.forEach(function (t) {
205    t.addEventListener('click', function () {
206      lastTrigger = t;
207      openChat();
208    });
209  });
210
211  document.addEventListener('keydown', function (e) {
212    if (e.key === 'Escape') closeChat();
213  });
214
215  // Click away to dismiss — but only the intro box. Once the live chat is up, the
216  // ✕ (or Escape) is the way out: a stray click on the page behind shouldn't throw
217  // away a conversation in progress. Clicks inside the iframe don't reach here.
218  document.addEventListener('click', function (e) {
219    if (!card.classList.contains('is-open')) return;
220    if (card.classList.contains('is-expanded')) return;
221    if (card.contains(e.target)) return;
222    if (e.target.closest && e.target.closest('[data-ask-ai]')) return;
223    closeChat();
224  });
225
226  // ── Choice buttons ───────────────────────────────────────────────────────────
227
228  choiceYes.addEventListener('click', function () {
229    localStorage.setItem(HAS_PREV_KEY, '1');
230    expandAndLoad(null, false);
231  });
232
233  choiceNo.addEventListener('click', function () {
234    localStorage.removeItem(HAS_PREV_KEY);
235    card.classList.remove('has-previous');
236    startTyping();
237    pickPromptVariant();
238    // Clear the server session via a hidden probe iframe (avoids CORS)
239    probeIframe = document.createElement('iframe');
240    probeIframe.src = CHAT_URL;
241    probeIframe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;border:none;visibility:hidden;';
242    document.body.appendChild(probeIframe);
243    probeIframe.addEventListener('load', function () {
244      probeIframe.contentWindow.postMessage({ type: 'quixai-chat-fresh-start' }, CHAT_ORIGIN);
245    });
246  });
247
248  // ── Normal submit ────────────────────────────────────────────────────────────
249
250  function handleSubmit() {
251    const text = input.value.trim();
252    if (!text) return;
253    stopTyping();
254    input.value = '';
255    localStorage.setItem(HAS_PREV_KEY, '1');
256    expandAndLoad(text, false);
257  }
258
259  input.addEventListener('keydown', function (e) {
260    if (e.key === 'Enter') handleSubmit();
261  });
262  submitBtn.addEventListener('click', handleSubmit);
263
264  var closeBtn = document.querySelector('.feature-card__close-btn');
265  if (closeBtn) {
266    closeBtn.addEventListener('click', closeChat);
267  }
268
269  // ── Messages from iframe ─────────────────────────────────────────────────────
270
271  window.addEventListener('message', function (event) {
272    if (event.origin !== CHAT_ORIGIN || !event.data) return;
273
274    if (event.data.type === 'quixai-chat-clear') {
275      closeChat();
276    } else if (event.data.type === 'quixai-chat-fresh-start-done') {
277      if (probeIframe) { probeIframe.remove(); probeIframe = null; }
278    } else if (event.data.type === 'quixai-chat-started') {
279      localStorage.setItem(HAS_PREV_KEY, '1');
280    }
281  });
282});

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.