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.