1 2 3(function () { 4 5 // --- SETUP: CONSTANTS AND STATE VARIABLES --- 6 7 const currentScript = document.currentScript || (function () { 8 9 const scripts = document.getElementsByTagName('script'); 10 11 return scripts[scripts.length - 1]; 12 13 })(); 14 15 16 17 const urlParams = new URLSearchParams(currentScript.src.split('?')[1] || ''); 18 19 const propertyId = urlParams.get("propertyId") || ""; 20 21 const apiEndpoint = "https://www.furie.ai/furie.php"; 22 23 const globalImgUrl = 'https://www.furie.ai/img/'; 24 25 26 // 1. DEFINE THE DEFAULT PROMO PHRASES 27 const DEFAULT_PROMO_PHRASES = [ 28 "I never sleep.", 29 "I have answers.", 30 "I'll solve ur ?", 31 "I know info.", 32 "Need a pointer?", 33 "Happy to help!", 34 "I share the â¤ï¸!", 35 "I know things.", 36 "I share info.", 37 "I spark convos.", 38 "I find discounts.", 39 "I'm free to use.", 40 "I'll help you out.", 41 "I guide you to savings.", 42 "I spot sales.", 43 "I ease your day.", 44 "I handle it!", 45 "I'm here for you.", 46 "I have ideas.", 47 "Why wait?", 48 "Happy to help!", 49 "Click here now.", 50 "Get Furie⢠free." 51 ]; 52 53 // 2. INITIALIZE THE WORKING LIST WITH DEFAULTS 54 55 const getStorageKey = (key) => `${key}_${propertyId}`; 56 57 let promoPhrases = [...DEFAULT_PROMO_PHRASES]; 58 59 let isProductPlacementEnabled = true; 60 let isCustomHooksActive = false; // Tracks if we are using AI/Custom messages 61 let isLiveChatActive = false; 62 let isProactiveEnabled = false; 63 let isGdprEnabled = false; 64 let privacyPolicyUrl = "#"; 65 let isGdprConsented = localStorage.getItem(getStorageKey("furrieGdprConsent")) === "1"; 66 // If GDPR is on AND they haven't consented yet, we block features 67 let isGdprBlocked = false; 68 let pendingProactivePayload = null; 69 70 let furieSessionId = localStorage.getItem(getStorageKey("furieSessionId")) || null; 71 let liveChatSessionId = null; // Ensure this is defined 72 73// ââ Audio (chirp) & mute state âââââââââââââââââââââââ 74 const agentChirp = new Audio('https://www.furie.ai/sounds/chirp.mp3'); 75 agentChirp.preload = 'auto'; 76 77// Image urls 78const baseImgUrl = `https://www.furie.ai/img/${propertyId}/`; 79const defaultImgUrl = 'https://www.furie.ai/img/default/'; // Fallback URL 80 81const imageUrls = [ 82 baseImgUrl + 'logo.png', 83 baseImgUrl + 'bot.png', 84 baseImgUrl + 'agent.png' 85]; 86 87// Preload the three 88preloadImages(imageUrls); 89 90 91 92// --- PROTECT WIDGET SVGs FROM HOST CSS --- 93const svgProtectionStyle = document.createElement('style'); 94svgProtectionStyle.textContent = ` 95 /* 1. Reset SVG defaults so host CSS doesn't hide them */ 96 #furrie-chatbot button svg { 97 width: 24px !important; 98 height: 24px !important; 99 min-width: 24px !important; 100 min-height: 24px !important; 101 display: block !important; 102 visibility: visible !important; 103 opacity: 1 !important; 104 fill: currentColor !important; /* Force it to use the text color */ 105 } 106 107 /* 2. Ensure paths inherit the fill correctly */ 108 #furrie-chatbot button svg path { 109 fill: currentColor !important; 110 stroke: none !important; /* Prevent external strokes from ruining icons */ 111 opacity: 1 !important; 112 visibility: visible !important; 113 } 114 115 /* 3. Special handling for stroked icons (like the 'mute' line) */ 116 #furrie-chatbot button svg line { 117 stroke: currentColor !important; 118 stroke-width: 2px !important; 119 } 120 121 /* 4. Stop buttons from being squashed by Flexbox */ 122 #furrie-chatbot button { 123 flex-shrink: 0 !important; /* Don't let long titles squash buttons */ 124 box-sizing: border-box !important; 125 } 126 127 #furrie-tts-toggle svg path { 128 fill: currentColor !important; 129 stroke: none !important; 130 opacity: 1 !important; /* Force path opacity to 1, rely on button opacity */ 131 } 132 133 /* Ensure the strikethrough line is visible */ 134 #furrie-tts-toggle svg line { 135 stroke: currentColor !important; 136 stroke-width: 2px !important; 137 opacity: 1 !important; 138 } 139 140 141 142/* --- BUBBLE BASE CLASS --- */
143.furie-chat-bubble-base { 144 padding: 10px 14px !important; 145 word-wrap: break-word !important; 146 /* CRITICAL FIX FOR SPACING: Preserves spaces and line breaks */ 147 white-space: pre-wrap !important; 148 line-height: 1.4em !important; 149 border: 1px solid #EAEFF2 !important; 150 box-shadow: none !important; 151 margin: 0 !important; 152 max-width: 100% !important; 153 font-family: 'Quicksand', sans-serif !important; 154 font-size: 14px !important; 155} 156 157/* --- GUEST BUBBLE (You) --- */ 158.furie-bubble-guest { 159 /* Use CSS Variable for color (set in JS below) */ 160 background-color: var(--furie-guest-bg, #785CC2) !important; 161 color: var(--furie-guest-fg, #ffffff) !important; 162 border-radius: 25px 25px 5px 25px !important; 163 margin-left: auto !important; /* Pushes to right */ 164} 165 166/* --- BOT/AGENT BUBBLE --- */ 167.furie-bubble-bot { 168 background-color: #ffffff !important; 169 color: #444444 !important; 170 border-radius: 25px 25px 25px 5px !important; 171 margin-right: auto !important; /* Pushes to left */ 172} 173`; 174document.head.appendChild(svgProtectionStyle); 175 176 177 178 179// --- CSS ISOLATION & STYLE DEFINITIONS --- 180const furieStyle = document.createElement('style'); 181furieStyle.textContent = ` 182 /* 1. DEFINE VARIABLES */ 183 :root { 184 --furie-guest-bg: #785CC2; 185 --furie-guest-fg: #ffffff; 186 --furie-bot-bg: #ffffff; 187 --furie-bot-fg: #444444; 188 } 189 190 /* 2. BASE BUBBLE STYLE */ 191 .furie-chat-bubble-base { 192 /* Standard spacing */ 193 padding: 10px 14px !important; 194 border-radius: 25px !important; 195 border: 1px solid #EAEFF2 !important; 196 box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; 197 198 /* LAYOUT PROTECTION */ 199 max-width: 85% !important; 200 display: block !important; 201 white-space: pre-wrap !important; 202 word-wrap: break-word !important; 203 min-width: 85%; 204 205 /* TYPOGRAPHY FIXES */ 206 line-height: 1.5 !important; 207 font-family: 'Quicksand', sans-serif !important; 208 209 /* FIX 1: Use 'inherit' so the Text Size Toggle works */ 210 font-size: inherit !important; 211 212 text-align: left !important; 213 letter-spacing: normal !important; 214 margin: 0 !important; 215 box-sizing: border-box !important; 216 217 /* VISIBILITY FIX: Ensure parent is visible so letters can fade in */ 218 opacity: 1 !important; 219 } 220 221 /* 3. VARIANT STYLES */ 222 .furie-bubble-guest { 223 background-color: var(--furie-guest-bg) !important; 224 color: var(--furie-guest-fg) !important; 225 border-bottom-right-radius: 5px !important; 226 margin-left: auto !important; 227 min-width: 85%; 228 } 229 230 .furie-bubble-bot { 231 background-color: var(--furie-bot-bg) !important; 232 color: var(--furie-bot-fg) !important; 233 border-bottom-left-radius: 5px !important; 234 margin-right: auto !important; 235 min-width: 85%; 236 } 237 238 /* 4. ANIMATION LOGIC */ 239 .fade-in-letter { 240 opacity: 0; 241 transition: opacity 0.4s ease-in-out; 242 display: inline !important; 243 white-space: pre-wrap !important; 244 position: static !important; 245 width: auto !important; 246 } 247 248 /* REVEAL STATE (Visible) */ 249 .furie-chat-bubble-base.visible .fade-in-letter { 250 opacity: 1 !important; 251 } 252 253 /* 5. LIST FIXES */ 254 .furie-chat-bubble-base ul, 255 .furie-chat-bubble-base ol { 256 margin: 5px 0 5px 20px !important; 257 padding: 0 !important; 258 list-style: disc !important; 259 } 260 .furie-chat-bubble-base li { 261 display: list-item !important; 262 margin-bottom: 4px !important; 263 } 264 265 /* 6. FIX 2: COMPACT THINKING BUBBLE */ 266 /* This forces the bubble to shrink when showing the dots */ 267 .furie-chat-bubble-base.furie-compact { 268 padding: 6px 14px !important; /* Smaller padding */ 269 min-height: 0 !important; /* Reset height */ 270 height: auto !important; 271 width: fit-content !important; /* Shrink to fit dots */ 272 line-height: 1 !important; 273 } 274 275 /* Reset inner span margins for the dots */ 276 .furie-chat-bubble-base.furie-compact > span { 277 margin: 0 !important; 278 display: inline-flex !important; 279 } 280 281 .furie-dots i { 282 font-style: normal !important; 283 font-weight: 800 !important; 284 margin: 0 2px !important; 285 animation: furieWave 1.0s ease-in-out infinite; 286 display: inline-block !important; 287 } 288 289 @keyframes furieWave { 290 0%,100% { transform: translateY(0); opacity:.35; } 291 50% { transform: translateY(-4px); opacity:1; } 292 } 293`; 294document.head.appendChild(furieStyle); 295 296 297 298 299 300const hexToCss = (c) => (!c ? null : (c.startsWith('#') ? c : `#${c}`)); 301 302// YIQ contrast helper for readable text on custom guest bubble 303function idealTextColor(hex) { 304 const h = (hex || '').replace('#',''); 305 if (!/^[0-9a-fA-F]{6}$/.test(h)) return '#ffffff'; 306 const r = parseInt(h.substr(0,2),16); 307 const g = parseInt(h.substr(2,2),16); 308 const b = parseInt(h.substr(4,2),16); 309 const yiq = (r*299 + g*587 + b*114) / 1000; 310 return yiq >= 128 ? '#000000' : '#ffffff'; 311} 312 313function applyUi(ui) { 314 if (!ui) return; 315 316 317// --- CAPTURE GDPR SETTINGS --- 318 if(ui.gdpr_enabled !== undefined) { 319 isGdprEnabled = (parseInt(ui.gdpr_enabled) === 1); 320 } 321 if(ui.property_privacy_policy_url) { 322 privacyPolicyUrl = ui.property_privacy_policy_url; 323 } 324 325// If the server sends a 1, enable it. Otherwise, keep it false. 326 if (ui.proactive_msgs_toggle !== undefined) { 327 isProactiveEnabled = (parseInt(ui.proactive_msgs_toggle) === 1); 328 } 329 330// --- DYNAMIC POSITIONING LOGIC --- 331if (ui.px_from_bottom !== undefined || ui.px_from_right !== undefined) { 332 333const bottom = parseInt(ui.px_from_bottom !== undefined ? ui.px_from_bottom : 72); 334const right = parseInt(ui.px_from_right !== undefined ? ui.px_from_right : 20); 335 336 337 const containerBottom = bottom + 10; 338 339// 1. Update Launcher Wrapper 340const wrapper = document.getElementById('furrie-launcher-wrapper'); 341if (wrapper) { 342 // A. Apply the database position (while still invisible) 343 wrapper.style.bottom = `${bottom}px`; 344 wrapper.style.right = `${right}px`; 345 346 // B. Trigger the Fade-In Animation 347 // We use a tiny timeout to ensure the DOM has updated the 'bottom' position first 348 requestAnimationFrame(() => { 349 wrapper.style.opacity = "1"; 350 wrapper.style.transform = "translateY(0)"; // Slide up to final position 351 });
352} 353 354const docStyle = document.documentElement.style; 355 356 if (ui.guest_color) { 357 const gb = hexToCss(ui.guest_color); 358 if (gb) { 359 docStyle.setProperty('--furie-guest-bg', gb); 360 docStyle.setProperty('--furie-guest-fg', idealTextColor(gb)); 361 } 362 } 363 364// 2. Update Chat Window Container (same as before) 365const chatContainer = document.getElementById('furrie-chatbot'); 366if (chatContainer) { 367 if (window.innerWidth > 768) { 368 chatContainer.style.bottom = `${bottom + 10}px`; 369 chatContainer.style.right = `${right}px`; 370 chatContainer.style.maxHeight = `calc(100vh - ${bottom + 30}px)`; 371 } 372 } 373} 374 375 376 377 378 379 380// --- PROMO PILL IMAGE LOGIC --- 381// Get the image element 382const pillImg = document.querySelector('#furrie-promo-pill img'); 383 384if (pillImg) { 385 // 1. Determine if the image feature is active (default to true if undefined) 386 const isImageActive = (ui.promo_pill_image_active !== undefined) 387 ? (parseInt(ui.promo_pill_image_active) === 1) 388 : true; 389 390 if (!isImageActive) { 391 // CASE A: Feature is OFF -> Hide the image completely 392 pillImg.style.display = 'none'; 393 } else { 394 // CASE B: Feature is ON -> Ensure visible 395 pillImg.style.display = 'inline-block'; 396 397 if (ui.promo_pill_image_url) { 398 // Sub-case: Custom Image Provided -> Swap SRC and adjust styling 399 pillImg.src = ui.promo_pill_image_url; 400 pillImg.style.height = "20px"; // Larger for custom icons 401 pillImg.style.width = "auto"; 402 pillImg.style.objectFit = "contain"; 403 pillImg.style.marginLeft = "auto"; 404 } else { 405 // Sub-case: Active, but no custom image -> Revert to Default Logo 406 // Note: globalImgUrl is defined at the top of your script 407 pillImg.src = globalImgUrl + 'furie_logo.png'; 408 pillImg.style.height = "20px"; // Standard size 409 pillImg.style.width = "auto"; 410 pillImg.style.borderRadius = "0px;"; 411 pillImg.style.objectFit = "contain"; 412 pillImg.style.marginLeft = "auto"; 413 } 414 } 415} 416 417 418 419 420 421// --- PROMO PILL VISIBILITY TOGGLE --- 422 // Use the new DB field 'promo_pops_toggle'. Default to 1 (true) if undefined. 423 const showPill = (ui.promo_pops_toggle !== undefined) ? (ui.promo_pops_toggle == 1) : 1; 424 425 // Update global state variable (used elsewhere in rotation logic) 426 isProductPlacementEnabled = showPill; 427 428 if (typeof promoPill !== 'undefined') { 429 // Hide or Show based on database value 430 promoPill.style.display = showPill ? 'flex' : 'none'; 431 } 432 433 if (ui.promo_pill_image_url) { 434 const pillImg = document.querySelector('#furrie-promo-pill img'); 435 if (pillImg) { 436 pillImg.src = ui.promo_pill_image_url; 437 // Reset styles specific to the logo to ensure custom icon fits well 438 pillImg.style.height = "14px"; // Slightly larger for custom icons 439 pillImg.style.width = "auto"; 440 pillImg.style.objectFit = "contain"; 441 } 442 } 443 444 445 446 447 // Title text/content 448 const headerTitleEl = document.getElementById('furrie-title'); 449 if (headerTitleEl) { 450 if (ui.chat_header_name) headerTitleEl.textContent = ui.chat_header_name; 451 const hdrTxt = hexToCss(ui.header_text_color) || '#555555'; 452 headerTitleEl.style.setProperty('color', hdrTxt, 'important'); 453 } 454 455 // Header/container background 456 const headerBar = container.querySelector('div[style*="justify-content: space-between"]'); 457 if (ui.header_background_color) { 458 const bg = hexToCss(ui.header_background_color); 459 if (bg) { 460 container.style.backgroundColor = bg; 461 if (headerBar) headerBar.style.backgroundColor = bg; 462 } 463 } 464 465 466 467const bubbleFadeStyle = document.createElement('style'); 468bubbleFadeStyle.textContent = ` 469 .chat-bubble { 470 opacity: 0 !important; 471 transform: translateY(6px) !important; 472 transition: opacity 0.5s ease, transform 0.5s ease !important; 473 474 } 475 .chat-bubble ul { 476 margin: 0 !important; 477 padding-left: 20px !important; 478} 479 480.chat-bubble li { 481 margin: 0 !important; 482 padding: 0 !important; 483 list-style: none !important; 484 line-height: 1.2 !important; 485} 486 .chat-bubble.visible { 487 opacity: 1 !important; 488 transform: translateY(0) !important; 489 } 490 491 492/* Hide native bullets; weâll provide our own animated marker */ 493#furrie-messages ul !important, 494#furrie-messages ol { list-style: none !important; padding-left: 18px !important; } 495#furrie-messages li { position: relative !important; } 496 497`; 498document.head.appendChild(bubbleFadeStyle); 499 500 501 502const letterFadeStyle = document.createElement('style'); 503letterFadeStyle.textContent = `
504 /* Each letter will be a span with these base properties */ 505 .fade-in-letter { 506 opacity: 0; 507 transition: opacity 0.4s ease-in-out; 508 } 509 510 /* When the parent bubble gets the 'visible' class, the letters fade in */ 511 /* The transition-delay on each span will stagger the effect */ 512 .bubble.visible .fade-in-letter { 513 opacity: 1 !important; 514 } 515`; 516document.head.appendChild(letterFadeStyle); 517 518 // Chat window border 519 if (ui.border_color) { 520 const bc = hexToCss(ui.border_color); 521 const msg = document.getElementById('furrie-messages'); 522 if (msg && bc) { 523 msg.style.borderTop = `3px solid ${bc}`; 524 msg.style.borderBottom = `3px solid ${bc}`; 525 } 526 } 527 528 // Submit (send) button color + readable text 529 if (ui.button_color) { 530 const submitBtn = document.getElementById('furrie-submit') || document.querySelector('#furrie-form button[type="submit"]'); 531 const c = hexToCss(ui.button_color); 532 window.__furie_button_color = c; 533 if (submitBtn && c) { 534 submitBtn.style.setProperty('background-color', idealTextColor(c), 'important'); 535 submitBtn.style.setProperty('color', c, 'important'); 536 } 537 } 538 539if (ui.button_color) { 540 const micBtn = document.getElementById('furrie-mic'); 541 const c = hexToCss(ui.button_color); 542 if (micBtn && c) { 543 micBtn.style.backgroundColor = idealTextColor(c); 544 micBtn.style.color = c; // auto contrast 545 micBtn.style.borderRadius = "50%"; // optional, to match send button 546 } 547} 548 549 // Guest bubble background + auto-contrast 550 if (ui.guest_color) { 551 const gb = hexToCss(ui.guest_color); 552 if (gb) { 553 window.__furie_guest_bubble_bg = gb; 554 window.__furie_guest_bubble_fg = idealTextColor(gb); 555 } 556 } 557 558 // Redirect buttons 559 if (ui.redirect_button_color) { 560 window.__furie_redirect_btn_color = hexToCss(ui.redirect_button_color); 561 } 562 563 564 565 566 // --- FORCE UPDATE BUTTON COLORS --- 567 if (ui.header_text_color) { 568 const textColor = hexToCss(ui.header_text_color); 569 570 // 1. Update TTS Toggle 571 const ttsBtn = document.getElementById('furrie-tts-toggle'); 572 if (ttsBtn) { 573 ttsBtn.style.setProperty('color', textColor, 'important'); 574 // Reset opacity based on active state (stored in local var or check icon) 575 const isReadAloud = localStorage.getItem("furrieReadAloud") === "1"; 576 ttsBtn.style.opacity = isReadAloud ? "1" : "0.7"; 577 } 578 579 // 2. Update Mute Button 580 const muteBtn = document.getElementById('furrie-mute'); 581 if (muteBtn) { 582 muteBtn.style.setProperty('color', textColor, 'important'); 583 } 584 585 // 3. Update Font Toggle 586 const fontBtn = document.getElementById('furrie-font-toggle'); 587 if (fontBtn) { 588 fontBtn.style.setProperty('color', textColor, 'important'); 589 } 590 } 591 592 593} 594 595 596 597 598/** 599 * Smoothly scrolls the chat window to the bottom over a set duration. 600 * @param {number} duration - in ms 601 */ 602function smoothScrollBottom(duration = 700) { 603 const start = messagesDiv.scrollTop; 604 const end = messagesDiv.scrollHeight - messagesDiv.clientHeight; 605 const startTime = performance.now(); 606 607 function step(now) { 608 const progress = Math.min(1, (now - startTime) / duration); 609 // easeInOutQuad 610 const eased = progress < 0.5 611 ? 2 * progress * progress 612 : -1 + (4 - 2 * progress) * progress; 613 614 messagesDiv.scrollTop = start + (end - start) * eased; 615 if (progress < 1) requestAnimationFrame(step); 616 } 617 requestAnimationFrame(step); 618} 619 620 621 622function preloadImages(urls) { 623 urls.forEach(url => { 624 const img = new Image(); 625 img.src = url; 626 }); 627} 628 629 630 631preloadImages(imageUrls); 632 633 634const waveStyle = document.createElement('style'); 635waveStyle.textContent = ` 636 .furie-compact { padding: 2px 2px !important; line-height: 1; padding: 0px; margin-bottom: 10px;} 637 .furie-dots { display:inline-flex; white-space:nowrap; line-height:1; padding:0px; margin-right: 16px; margin-left: 10px; } 638 /* soft pulse so itâs not jarring */ 639 .furie-pulse { animation: furiePulse 1.6s ease-in-out infinite; } 640 @keyframes furiePulse { 641 0%,100% { opacity:.7 } 642 50% { opacity:1 } 643 } 644 .furie-dots i { 645 display:inline-block; 646 font-style:normal; 647 font-weight: 800; 648 opacity:.35; 649 font-size: 16px; 650 animation: furieWave 1.0s ease-in-out infinite; 651 margin: 0 2px; 652 padding: 0px; 653 } 654 @keyframes furieWave { 655 0%,100% { transform: translateY(0); opacity:.35; } 656 50% { transform: translateY(-10px); opacity:1; } 657 } 658`; 659document.head.appendChild(waveStyle); 660 661 662 // Live Chat State 663 664let sessionStatus = 'uninitialized'; // -> 'Browse', 'bot', 'live' 665 666 667 668 let liveChatApiEndpoint = ''; 669 670 let liveChatPropertyId = null; 671 672 let pusherInstance = null; 673 674 let pusherChannel = null; 675 676 let isMuted = (localStorage.getItem('furrieMuted') === '1'); // remember choice 677 agentChirp.muted = isMuted; 678 679 let notificationInterval = null; 680 const originalTitle = document.title; 681 682 // User Info State 683 684let userName = localStorage.getItem("furrieUserName") || ""; 685let userEmail = localStorage.getItem("furrieUserEmail") || ""; 686let existingGuestUid = localStorage.getItem("furrieGuestUid") || null; 687 let infoCollected = localStorage.getItem(getStorageKey("furrieAskedInfo")); 688 689 690 691 692/** 693 * Generates a random, memorable name for a guest. 694 * @returns {string} A name like 'KindFox451' 695 */ 696function generateMemorableName() { 697 698 // ============================ 699 // SAFE ADJECTIVES 700 // ============================ 701 $adjectives = [ 702 703 // 3-Letter (Colors / Qualities) 704 'Red','Blue','Tan','Ash','Bay','Gray','Grey','Ice','Jet','Pink','Gold', 705 'Big','New','Hot','Dry','Wet','Top','Fit','Key','Zen','Apt', 706 707 // 4-Letter Colors 708 'Aqua','Mint','Plum','Ruby','Teal','Jade','Onyx','Rose','Cyan', 709 710 // 4-Letter Qualities (Positive / Neutral) 711 'Kind','Calm','Brave','Good','Nice','Warm','Cool','Rich','Mild','Wild', 712 'Busy','Easy','Fine','Free','Full','Huge','Keen','Neat','Open','Pure', 713 'Real','Soft','Sure','Tall','Thin','Tiny','True','Vast','Wide','Wise', 714 'Deep','Fast','Pale','Bold','Chic','Cozy','Cute','Fair','Fond','Glad', 715 'Hazy','High','Just','Live','Lush','Mega','Meek','Okay','Oval', 716 'Peak','Posh','Prim','Safe','Same','Slim','Solo','Tidy','Twin','Used', 717 'Zany' 718 ]; 719 720 // ============================ 721 // SAFE NOUNS 722 // ============================ 723 $nouns = [ 724 725 // 3-Letter Animals 726 'Fox','Owl','Dog','Cat','Bug','Ant','Ape','Bat','Bee','Elk','Emu','Pig', 727 'Cow','Hen','Ram','Rat','Yak','Cod','Ray','Eel','Fly','Gnu','Jay','Kit', 728 'Koi','Pup', 729 730 // 4-Letter Animals 731 'Lion','Puma','Wolf','Bear','Hawk','Goat','Duck','Frog','Fish','Bird', 732 'Crab','Deer','Dove','Gull','Hare','Lamb','Lark','Mole','Moth','Mule',
733 'Newt','Seal','Swan','Toad','Tuna','Wasp','Wren','Ibex','Kudu','Ling', 734 'Lynx','Boar','Colt','Coot','Crow','Dace','Dodo','Drake','Fawn','Flea', 735 'Fowl','Gnat','Goby','Hake','Hart','Kite','Kiwi','Mako','Mink','Mite', 736 'Oryx','Pony','Pupa','Rook','Sora','Stag','Tahr','Tern','Tick', 737 'Zebu', 738 739 // 3-Letter Nature/Objects (Safe Only) 740 'Sky','Ark','Axe','Bag','Bar','Bed','Bin','Box','Bud','Bus','Can','Cap', 741 'Car','Cog','Cot','Cup','Dam','Den','Dew','Dot','Egg','Era','Fan','Fig', 742 'Fin','Fir','Fog','Fun','Gap','Gas','Gem','Gum','Hat','Hay','Hut','Ice', 743 'Ink','Jar','Jet','Jug','Key','Kit','Lab','Lag','Lap','Lid','Lip','Log', 744 'Lot','Map','Mat','Mop','Mug','Net','Nib','Oak','Oar','Oil','Orb','Ore', 745 'Pad','Pal','Pan','Pea','Peg','Pen','Pet','Pie','Pin','Pit','Pod','Pop', 746 'Pot','Row','Rub','Rug','Run','Rut','Sap','Saw','Sea','Set','Sip','Sit', 747 'Sow','Spa','Spy','Sub','Sum','Sun','Tab','Tag','Tap','Tar','Tea','Ten', 748 'Tie','Tin','Tip','Toe','Ton','Toy','Tub','Tug','Urn','Use','Van','Vat', 749 'Vet','Wax','Web','Win','Yen','Yep','Yet','Zip','Zoo', 750 751 // 4-Letter Nature/Objects (Safe Only) 752 'Aura','Boat','Book','Bush','Cave','Clay','Core','Cove','Cube','Dawn', 753 'Deck','Desk','Dirt','Disk','Dock','Door','Dusk','Dust','Echo','Edge', 754 'Face','Farm','Fire','Flag','Flax','Flow','Foam','Fort','Game','Gate', 755 'Gaze','Gear','Glow','Gold','Golf','Grit','Gulf','Hail','Head','Heat', 756 'Hill','Home','Hope','Hour','Hull','Iron','Jade','Jolt','Jute','Kelp', 757 'Kite','Knot','Lake','Lamp','Lava','Leaf','Link','Loop','Mask','Maze', 758 'Mead','Milk','Mint','Mist','Moon','Moss','Mud','Myth','Nest','Node', 759 'Note','Opal','Path','Peak','Pear','Peat','Pine','Pool','Port','Post', 760 'Pond','Rain','Reef','Rice','Ring','Road','Rock','Rook','Root','Rose', 761 'Ruby','Sand','Seed','Ship','Snow','Soil','Song','Star','Stone','Sun', 762 'Tale','Team','Time','Tree','Tide','Vale','Veil','View','Wave','Way', 763 'Wind','Wood','Yarn','Year','Yurt','Zeal','Zone','Zinc','Acme','Atom', 764 'Axis','Ball','Band','Bank','Base','Beam','Bean','Belt','Bend','Bike', 765 'Bill','Blob','Blog','Bolt','Bond','Bone','Boom','Boot','Bore','Bowl', 766 'Brain','Brass','Brick','Bride','Brim','Bubble','Buck','Bulb','Bulk', 767 'Bunk','Burl','Cafe','Cake','Camp','Cane','Cape','Card','Care','Cart', 768 'Case','Cash','Cask','Cast','Chum','City','Clam','Clan','Claw','Clip', 769 'Club','Clue','Coal','Coat','Coil','Coin','Comb','Cone','Copy','Cord', 770 'Cork','Cost','Crew','Crop','Cure','Curl','Dale','Dart','Dash','Data', 771 'Date','Day','Deal','Deed','Dime','Dice','Dish','Dome','Dream','Dress', 772 'Drop','Drum','Duke','Dune','Duty','Earn','East','Ebb','Fang','Fate', 773 'Feed','Felt','Fern','Film','Fizz','Fjord','Foil','Fold','Folk','Food', 774 'Foot','Ford','Form','Fuel','Fume','Fund','Fury','Fuse','Fuzz','Gale', 775 'Gift','Glass','Glue','Goal','Gong','Gown','Grab','Gram','Grid','Grin', 776 'Grip','Gulp','Gush','Hack','Hall','Hand','Hang','Harp','Heel','Heir', 777 'Helm','Help','Herb','Hero','Hike','Hint','Hive','Hole','Hood','Hook', 778 'Hoop','Horn','Hose','Host','Hunk','Hunt','Hurd','Hymn','Icon','Idea', 779 'Idle','Idol','Inch','Ink','Isle','Item','Jail','Java','Jeep','Jest', 780 'Jewel','Jinx','Join','Joke','Juice','Jump','Jury','Lace','Lack','Lair', 781 'Lane','Lard','Lash','Last','Lath','Lawn','Lead','Leak','Leap','Lego', 782 'Lens','Liar','Lice','Life','Lift','Like','Lime','Line','List','Load', 783 'Loan','Lobe','Lock','Lode','Loom','Lore','Lure','Lute','Lyre', 784 'Mail','Make','Male','Malt','Many','Mark','Mart','Mash','Mass','Mast', 785 'Mate','Meal','Mean','Meat','Melt','Mend','Menu','Mesh','Mess','Mind', 786 'Mine','Mode','Mood','Moor','Mope','More','Muck','Muff','Name','Navy', 787 'Neck','Need','News','Nook','Noon','Nose','Null','Oath','Pace','Pack', 788 'Pact','Page','Pair','Palm','Pane','Pant','Park','Part','Pass','Pawn', 789 'Peak','Pelt','Phon','Pick','Pile','Pill','Pipe','Pith','Plan','Play', 790 'Plot','Plug','Poem','Poet','Pole','Poll','Pomp','Pork','Posh','Puck', 791 'Puff','Pull','Pulp','Pump','Punt','Punk','Pyro','Quad','Quay','Quest', 792 'Quip','Quiz','Race','Raft','Rage','Rail','Rake','Rank','Rant','Rate', 793 'Rays','Read','Rent','Rest','Ride','Rift','Rind','Riot','Robe','Roll',
794 'Rope','Rote','Rove','Rush','Rusk','Sail','Sale','Salt','Sage','Sash', 795 'Scab','Scar','Scent','Seam','Seat','Sect','Self','Send','Sense', 796 'Serf','Sets','Shack','Shade','Shaft','Sham','Shard','Shed','Shin','Shoe', 797 'Shop','Show','Side','Sigh','Sign','Silk','Sill','Silo','Silt','Site', 798 'Size','Skid','Skim','Slab','Slag','Slam','Sled','Slip', 799 'Slog','Slot','Slum','Smog','Snag','Snip','Sock','Soda','Sofa','Solo', 800 'Soot','Sort','Soul','Soup','Span','Spec','Spin','Spot','Spur','Stab', 801 'Stir','Stop','Suds','Suit','Surf','Swab','Swag','Swan','Sway', 802 'Talk','Tank','Tape','Task','Taxi','Tent','Term','Test','Text','Thaw', 803 'Thug','Tile','Tilt','Tire','Tool','Tour','Town','Track','Trade','Trap', 804 'Tray','Trek','Trim','Trio','Trip','Tuba','Tube','Tuck','Tuft','Turf', 805 'Turn','Tusk','Twine','Type','Unit','Urge','User','Vein','Vent','Vest', 806 'Vibe','Vice','Vine','Visa','Volt','Vote','Wade','Wage','Wail','Wait', 807 'Wake','Walk','Wall','Ward','Ware','Wart','Wash','Watt','Week', 808 'Weld','West','Whim','Whip','Wick','Will','Wink','Wisp','Wish', 809 'Wrap','Yank','Yard','Yawn','Yell','Yelp','Yoga','Yoke','Yolk', 810 'Zero','Zest','Zing','Zips' 811 ]; 812 813 814 const randAdj = adjectives[Math.floor(Math.random() * adjectives.length)]; 815 const randNoun = nouns[Math.floor(Math.random() * nouns.length)]; 816 const randNum = Math.floor(Math.random() * 900) + 100; // 100-999 817 818 return randAdj + randNoun + randNum; 819} 820 821 822 823 824function playChirp() { 825 if (isMuted) return; // Only check if muted, remove the visibility check 826 827 try { 828 // pause â rewind â play = âre-triggerâ 829 agentChirp.pause(); 830 agentChirp.currentTime = 0; 831 agentChirp.play(); 832 } catch { 833 // Safari iOS sometimes refuses; fall back to a throw-away clone 834 const clone = agentChirp.cloneNode(); 835 clone.play().catch(()=>{}); 836 } 837} 838 839 840const quicksandLink = document.createElement("link"); 841quicksandLink.href = "https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500;600;700&display=swap"; 842quicksandLink.rel = "stylesheet"; 843document.head.appendChild(quicksandLink); 844 845const style = document.createElement('style'); 846style.textContent = ` 847 #furrie-chatbot * { 848 font-family: 'Quicksand', sans-serif !important; 849 } 850`; 851document.head.appendChild(style); 852 853/** 854 * Notifies the user of a new message by blinking the page title. 855 */ 856function startTitleNotification() { 857 if (notificationInterval) return; // Already notifying 858 859 notificationInterval = setInterval(() => { 860 document.title = (document.title === originalTitle) 861 ? 'New Message!' 862 : originalTitle; 863 }, 500); // Blink every second 864} 865 866/** 867 * Stops the title-blinking notification and restores the original title. 868 */ 869function stopTitleNotification() { 870 clearInterval(notificationInterval); 871 notificationInterval = null; 872 document.title = originalTitle; 873} 874 875 876/** 877 * helper: Grabs visible text from body, stripping scripts/styles 878 * to ensure the AI sees what the user sees. 879 */ 880function getCleanPageText() { 881 if (!document.body) return ""; 882 883 // clone body so we don't affect the live page 884 const clone = document.body.cloneNode(true); 885 886 // remove invisible scripts/styles 887 const scripts = clone.querySelectorAll('script, style, noscript, iframe, svg'); 888 scripts.forEach(el => el.remove()); 889 890 // get text, collapse whitespace, limit to 2500 chars 891 return (clone.innerText || "").replace(/\s+/g, ' ').trim().substring(0, 30000); 892} 893 894 895 896 897 898 899/** 900 * Creates a session on page load with "Browse" status, 901 * retrieves a session ID, and initializes Pusher to listen for agent messages. 902 */ 903async function createOrResumeSession() { 904 try { 905 const existingGuestUid = localStorage.getItem("furrieGuestUid") || null; 906 const cleanContent = getCleanPageText(); 907 908 const response = await fetch(apiEndpoint, { 909 method: "POST", 910 headers: { "Content-Type": "application/json" }, 911 body: JSON.stringify({ 912 action: 'create_session', 913 propertyId: propertyId, 914 existingSessionId: furieSessionId, 915 guestName: userName, 916 guestEmail: userEmail, 917 existingGuestUid, 918 page_url: window.location.href, 919 page_title: document.title, 920 page_content: cleanContent 921 }), 922 });
923 if (!response.ok) throw new Error('Session creation failed'); 924 925 const data = await response.json(); 926 927 // --- 1. SETUP SESSION IDS & PUSHER (Moved Up) --- 928 // We do this FIRST so the socket is listening even if GDPR blocks the UI 929 if (data.guest_uid) { 930 localStorage.setItem("furrieGuestUid", data.guest_uid); 931 } 932 933 if (data.session_id) { 934 furieSessionId = data.session_id; 935 liveChatSessionId = data.session_id; 936 localStorage.setItem(getStorageKey("furieSessionId"), furieSessionId); 937 938 sessionStatus = data.status || 'Browse'; 939 isLiveChatActive = (sessionStatus === 'open'); 940 941 if (data.pusher_key) { 942 liveChatApiEndpoint = data.live_chat_api; 943 liveChatPropertyId = data.property_id; 944 loadPusher(() => initializePusher(data)); 945 } 946 } 947 948 // --- 2. APPLY UI & CHECK GDPR --- 949 if (data.ui) { 950 applyUi(data.ui); 951 952 // --- GDPR BLOCK --- 953 if (isGdprEnabled && !isGdprConsented && !isCustomHooksActive) { 954 isGdprBlocked = true; 955 956 // 1. Hide Promo Pill immediately 957 const pill = document.getElementById('furrie-promo-pill'); 958 if(pill) pill.style.display = 'none'; 959 960 // 2. Wait a few seconds, then Maximize and Show Checkup 961 setTimeout(() => { 962 maximizeBtn(); 963 renderGdprModal(); 964 }, 1000); 965 966 // CRITICAL CHANGE: We do NOT return here anymore. 967 // We just let the code finish, but we wrap the HISTORY rendering below in a check. 968 } 969 // ---------------------- 970 971 promoPhrases = [...DEFAULT_PROMO_PHRASES]; 972 isCustomHooksActive = false; 973 974 // Promo Hooks Logic 975 if (data.ui.promo_hooks && Array.isArray(data.ui.promo_hooks) && data.ui.promo_hooks.length > 0) { 976 promoPhrases = data.ui.promo_hooks; 977 phraseIndex = -1; 978 isCustomHooksActive = true; 979 } 980 } 981 982 983 // --- 3. RENDER HISTORY FROM DB (Conditioned on GDPR) --- 984 // Only render history if we are NOT blocked. 985 // (If blocked, history will load when handleGdprChoice calls this function again). 986 if (!isGdprBlocked && data.db_history && Array.isArray(data.db_history)) { 987 const msgContainer = document.getElementById("furrie-messages"); 988 989 if(msgContainer) msgContainer.innerHTML = ''; 990 991 const cleanHistoryForStorage = data.db_history.map(msg => ({ 992 sender: msg.sender, 993 text: msg.text, 994 avatarUrl: msg.avatarUrl || null 995 })); 996 localStorage.setItem(getStorageKey("furrieChatHistory"), JSON.stringify(cleanHistoryForStorage)); 997 998 let lastMsgSender = null; 999 let lastMsgText = null; 1000
1001 data.db_history.forEach(msg => { 1002 if (msg.sender === lastMsgSender && msg.text === lastMsgText) { 1003 return; 1004 } 1005 lastMsgSender = msg.sender; 1006 lastMsgText = msg.text; 1007 appendMessage(msg.sender, msg.text, false, null, msg.time, true); 1008 }); 1009 1010 const messagesDiv = document.getElementById("furrie-messages"); 1011 if(messagesDiv) messagesDiv.scrollTop = messagesDiv.scrollHeight; 1012 } 1013 1014 } catch (error) { 1015 console.error("Could not establish a Bot session:", error); 1016 const wrapper = document.getElementById('furrie-launcher-wrapper'); 1017 if (wrapper) wrapper.style.display = 'flex'; 1018 } 1019} 1020 1021 1022 1023 1024 // --- UI INITIALIZATION --- 1025 1026 const container = document.createElement("div"); 1027 1028 const defaultBottom = 82; 1029 1030 container.id = "furrie-chatbot"; 1031 1032 Object.assign(container.style, { 1033 1034 position: "fixed", 1035 1036 bottom: `${defaultBottom}px`, 1037 1038 right: "20px", 1039 1040 width: "400px", 1041 1042 height: "700px", 1043 1044 marginTop: "15px", 1045 1046 maxHeight: `calc(100vh - ${defaultBottom + 30}px)`, 1047 1048 maxWidth: "90vw", 1049 1050 zIndex: "2147483647", 1051 1052 boxShadow: "0 4px 12px rgba(0,0,0,0.15)", 1053 1054 borderRadius: "25px", 1055 1056 overflow: "hidden", 1057 1058 color: "#555555", 1059 1060 backgroundColor: "#E6FEFE", 1061 1062 fontFamily: "Quicksand, sans-serif", 1063 1064 transform: "translateY(100%)", 1065 1066 fontSize: "12px", 1067 1068 paddingTop: "7px", 1069 1070 opacity: "0", 1071 1072 transition: "transform 0.3s ease, opacity 0.3s ease", 1073 1074 pointerEvents: "none" 1075 1076 }); 1077 1078 1079 1080 if (window.innerWidth <= 768) { 1081 1082 Object.assign(container.style, { 1083 1084 top: "0", 1085 1086 bottom: "0", 1087 1088 left: "0", 1089 1090 right: "10px", 1091 1092 width: "100vw", 1093 1094 height: "100dvh", 1095 1096 maxWidth: "100%", 1097 1098 maxHeight: "100vh", 1099 1100 borderRadius: "0", 1101 1102 fontFamily: "Quicksand, sans-serif", 1103 1104 boxSizing: "border-box", 1105 1106 fontSize: "14px", 1107 1108 zIndex: "2147483647" 1109 1110 }); 1111 1112 } else { 1113 1114 container.style.height = "600px"; 1115 1116 container.style.maxHeight = "calc(100vh - ${defaultBottom + 30}px)"; 1117 1118 } 1119 1120 1121 1122 container.innerHTML = ` 1123 1124 <div style="display: flex; flex-direction: column; height: 100%; margin-right: 0px;"> 1125 1126<div id="furrie-chat-header" style=" 1127 background-color: #E6FEFE; 1128 padding: 10px !important; 1129 display: flex !important; 1130 justify-content: space-between !important; 1131 align-items: center !important; 1132 box-sizing: border-box !important; 1133 width: 100% !important; 1134"> 1135<img src="${baseImgUrl}logo.png" onerror="this.onerror=null; this.src='${defaultImgUrl}logo.png';" alt="Logo" style="height: 60px; margin-right: 12px; border-radius: 20px;"> 1136 <h4 id="furrie-title" style="margin-left: 2px; margin-right: 5px; font-size: 16px; font-weight: 800;"></h4> 1137 </a> 1138 <div id="furrie-header-controls" style=" 1139 display: flex !important; 1140 left-margin: 20px !important; 1141 align-items: center !important; 1142 gap: 2px !important; 1143 flex-shrink: 0 !important; 1144 margin-right: 2px !important; /* Extra safety buffer */ 1145"> 1146 <button id="furrie-minimize" style="background: none; border: none; font-size: 20px; font-weight: bold; cursor: pointer; color: #555555; margin-right: 10px; margin-left: 5px;">â</button> 1147 </div> 1148</div> 1149 1150 <div id="furrie-messages" style="flex: 1 !important; padding: 10px !important; overflow-y: auto; border-top: 3px solid #5DF7F8 !important; border-bottom: 3px solid #5DF7F8; background-color: #F7F9FB; border-radius: 25px 0px 0px 25px !important;"></div> 1151 1152 <form id="furrie-form" style="display: flex; align-items: center; border-top: 0px solid #ccc; padding: 6px; background: #fff;"> 1153 1154 <input type="text" id="furrie-input" name="nobot"
1155 placeholder="How can I help you?" 1156 autocomplete="off" 1157 autocorrect="off" 1158 autocapitalize="off" 1159 spellcheck="false" 1160 inputmode="text" style="flex: 1; border: none; padding: 8px; font-size: 16px; color: #5AC0DE; outline: none; box-shadow: none; min-height: 36px; max-height: 60px; font-weight: 500 !important;" /> 1161 1162 1163<button id="furrie-mic" type="button" 1164 style="color: white; border: none; padding: 0 12px; margin-left: 6px; border-radius: 999px; cursor: pointer; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; font-size: 18px;"> 1165 <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" 1166 fill="currentColor" viewBox="0 0 16 16"> 1167 <path d="M8 12a3 3 0 0 0 3-3V4a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3z"/> 1168 <path d="M5 10.5a.5.5 0 0 1 .5.5 2.5 2.5 0 0 0 5 0 .5.5 0 0 1 1 0 1169 3.5 3.5 0 0 1-7 0 .5.5 0 0 1 .5-.5z"/> 1170 <path d="M8 14a.5.5 0 0 1 .5.5V16h-1v-1.5A.5.5 0 0 1 8 14z"/> 1171 </svg> 1172</button> 1173 1174<button id="furrie-submit" type="submit" 1175 style="color: white; border: none; padding: 0 12px; margin-left: 6px; border-radius: 999px; cursor: pointer; width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; font-size: 18px;">â¤</button> 1176 1177 </form> 1178 1179 </div>`; 1180 1181 document.body.appendChild(container); 1182 1183 1184 1185// --- 1. CREATE WRAPPER (Anchors the Launcher position) --- 1186// --- 0. INJECT STYLES FOR BUBBLE TRIANGLE --- 1187// We inject a specific style tag here to handle the "speech bubble tail" pseudo-elements 1188const bubbleTailStyle = document.createElement('style'); 1189bubbleTailStyle.textContent = ` 1190 #furrie-promo-pill::after { 1191 content: ''; 1192 position: absolute; 1193 bottom: -6px; /* Hangs off the bottom */ 1194 right: 18px; /* Aligns with the top-right of the launcher */ 1195 width: 0; 1196 height: 0; 1197 border-left: 7px solid transparent; 1198 border-right: 7px solid transparent; 1199 border-top: 7px solid #fff; /* White triangle matching background */ 1200 } 1201 #furrie-promo-pill::before { 1202 content: ''; 1203 position: absolute; 1204 bottom: -8px; /* Slightly lower to create the border effect */ 1205 right: 18px; 1206 width: 0; 1207 height: 0; 1208 border-left: 7px solid transparent; 1209 border-right: 7px solid transparent; 1210 border-top: 7px solid #eee; /* Grey triangle matching border */ 1211 z-index: -1; 1212 } 1213`; 1214document.head.appendChild(bubbleTailStyle); 1215 1216 1217// --- 1. CREATE WRAPPER (Anchors the Launcher position) --- 1218 const launcherWrapper = document.createElement("div"); 1219 launcherWrapper.id = "furrie-launcher-wrapper"; 1220 Object.assign(launcherWrapper.style, { 1221 position: "fixed", 1222 bottom: "72px", 1223 right: "20px", 1224 zIndex: "2147483647", 1225 display: "flex", 1226 flexDirection: "column", 1227 alignItems: "flex-end", // Align pill to right of launcher 1228 pointerEvents: "none", // Let clicks pass through the invisible gap 1229 // --- ANIMATION STYLES --- 1230 opacity: "0", // ...but it is invisible 1231 transform: "translateY(20px)", // ...and starts 20px lower than final spot 1232 transition: "opacity 0.8s ease, transform 0.8s ease" // The "Gentle Fade" settings 1233 }); 1234 document.body.appendChild(launcherWrapper); 1235 1236 // --- 2. LAUNCHER BUTTON (Inside Wrapper) --- 1237 const launcher = document.createElement("div"); 1238 launcher.id = "furrie-launcher"; 1239 Object.assign(launcher.style, { 1240 // Removed fixed positioning (wrapper handles it) 1241 position: "relative", 1242 backgroundColor: "#ffffff", 1243 border: "1px solid #ddd", 1244 borderRadius: "999px", 1245 padding: "6px 12px 6px 6px", 1246 boxShadow: "0 4px 10px rgba(0,0,0,0.1)", 1247 cursor: "pointer", 1248 display: "flex", 1249 alignItems: "center", 1250 maxWidth: "calc(100vw - 40px)", 1251 fontSize: "14px", 1252 fontWeight: "bold", 1253 fontFamily: "Quicksand, sans-serif", 1254 pointerEvents: "auto" // Re-enable clicks 1255 });
1256 1257 launcher.innerHTML = ` 1258 <img src="${baseImgUrl}bot.png" onerror="this.onerror=null; this.src='${defaultImgUrl}bot.png';" alt="Chat Icon" style="height: 40px; margin-left: 6px; margin-right: 4px; border-radius: 15px;"> 1259 <span id="furrie-launcher-text" style="display: inline-block; white-space: nowrap; overflow: hidden; transition: width 1s ease, opacity 1s ease, margin 5s ease; opacity: 1; width: auto; margin-right: 6px; color: #555555;"></span>`; 1260 1261 launcherWrapper.appendChild(launcher); // Append to wrapper 1262 1263 // --- 3. PRODUCT PLACEMENT PILL (Now a Bubble ABOVE Launcher) --- 1264 const promoPill = document.createElement("a"); 1265 promoPill.id = "furrie-promo-pill"; 1266 promoPill.href = "https://www.furie.ai"; 1267 promoPill.target = "_blank"; 1268 Object.assign(promoPill.style, { 1269 position: "absolute", 1270 bottom: "65px", // MOVED: Sits above the launcher now 1271 right: "16px", 1272 height: "auto", 1273 backgroundColor: "#fff", 1274 borderRadius: "20px", // ROUNDER: Bubble shape 1275 padding: "8px 14px", // PLUMPER: More breathing room 1276 boxShadow: "0 4px 12px rgba(0,0,0,0.1)", 1277 textDecoration: "none", 1278 display: "none", 1279 alignItems: "center", 1280 textAlign: "left", 1281 justifyContent: "center", 1282 fontSize: "13px", 1283 color: "#555", 1284 fontFamily: "Quicksand, sans-serif", 1285 cursor: "pointer", 1286 border: "1px solid #eee", 1287 transition: "opacity 0.3s ease", 1288 pointerEvents: "auto", 1289 whiteSpace: "normal", 1290 overflow: "visible", // CRITICAL: Allows the triangle tail to stick out 1291 minWidth: "200px", 1292 maxWidth: "200px" 1293 1294 }); 1295 1296 // Initial HTML with ID on span for targeting 1297 promoPill.innerHTML = ` 1298 <span id="furie-promo-text" style="margin-right:5px; font-weight:600; transition: opacity 0.5s ease; opacity: 1; border-radius: 10px;">ð</span> 1299 <img src="${globalImgUrl}furie_logo.png" 1300 style="height:14px; width:auto; vertical-align:middle; margin-left:auto; border-radius: 10px;"> 1301 `; 1302 launcherWrapper.appendChild(promoPill); 1303 1304 1305 1306 1307 let phraseIndex = 0; 1308 const promoTextSpan = promoPill.querySelector('#furie-promo-text'); 1309 1310// Cycle every few seconds 1311 setInterval(() => { 1312 // Only rotate if the pill is actually visible 1313 if (promoPill.style.display === 'none') return; 1314 1315 // 1. Fade out text 1316 promoTextSpan.style.opacity = '0'; 1317 1318 setTimeout(() => { 1319 // 2. Swap text 1320 phraseIndex = (phraseIndex + 1) % promoPhrases.length; 1321 const rawPhrase = promoPhrases[phraseIndex]; 1322 1323 let displayText = rawPhrase; 1324 let extractedUrl = ""; // Variable to hold the found URL 1325 1326 // 1. If AI/Custom message included a URL, extract it and strip it from display text 1327 const urlRegex = /(https?:\/\/[^\s]+)/g; 1328 const urlMatch = rawPhrase.match(urlRegex); 1329 1330 if (urlMatch && urlMatch[0]) { 1331 extractedUrl = urlMatch[0]; // Save the URL 1332 displayText = rawPhrase.replace(urlMatch[0], '').trim(); // Clean the text 1333 } 1334 1335 // 2. STORE DATA ON THE ELEMENT (This is the missing link) 1336 promoPill.dataset.pendingUrl = extractedUrl; 1337 promoPill.dataset.pendingText = displayText; 1338 1339 // 3. Update cursor behavior 1340 if (isCustomHooksActive) { 1341 // AI/Custom Mode: Don't link out via href, prepare for Chat Open 1342 promoPill.href = "javascript:void(0)"; 1343 promoPill.style.cursor = "pointer"; 1344 } else { 1345 // Default Mode: Keep default Furie link 1346 promoPill.href = "https://www.furie.ai"; 1347 promoPill.style.cursor = "pointer"; 1348 } 1349 1350 // Update visual text 1351 promoTextSpan.textContent = displayText; 1352 1353 // 4. Fade in text 1354 promoTextSpan.style.opacity = '1'; 1355 }, 500); 1356 1357 }, 8000); 1358 1359 launcherWrapper.appendChild(promoPill); // Append to wrapper 1360 1361 1362 // --- DOM ELEMENT REFERENCES --- 1363 1364 const messagesDiv = document.getElementById("furrie-messages"); 1365 1366 const form = document.getElementById("furrie-form"); 1367 1368 const input = document.getElementById("furrie-input"); 1369 1370 const textSpan = document.getElementById("furrie-launcher-text"); 1371 1372 1373 // Text-to-speech 1374function speakText(text) { 1375 try { 1376 // Encode the text so it safely passes through the URL 1377 const encodedText = encodeURIComponent(text.replace(/<[^>]+>/g, "")); 1378 const url = `https://www.furie.ai/furie_tts.php?text=${encodedText}`; 1379 1380 // The browser natively handles streaming playback for URLs 1381 const audio = new Audio(url); 1382 audio.playbackRate = 1.1; 1383 1384 // Play immediately; the browser will buffer the stream naturally 1385 audio.play().catch(e => console.error("Audio play blocked/failed:", e)); 1386 1387 } catch (e) { 1388 console.error("TTS error:", e); 1389 } 1390} 1391 1392 // Turn off autofill/quicktype/spell UI 1393if (form) form.setAttribute('autocomplete', 'off'); 1394 1395if (input) { 1396 input.setAttribute('autocomplete', 'off'); 1397 input.setAttribute('autocorrect', 'off'); 1398 input.setAttribute('autocapitalize', 'off'); 1399 input.setAttribute('spellcheck', 'false'); 1400 1401 // Chrome sometimes keys suggestions by name=; use an uncommon, rotating name 1402 input.setAttribute('name', 'msg_' + Math.random().toString(36).slice(2)); 1403 1404 // Gently hint password managers to ignore 1405 input.setAttribute('data-lpignore', 'true'); // LastPass 1406 input.setAttribute('data-form-type','other'); // 1Password 1407} 1408 1409const listTight = document.createElement('style'); 1410listTight.textContent = ` 1411 #furrie-messages ul { margin: 6px 0; padding-left: 18px; } 1412 #furrie-messages li { margin: 2px 0; } 1413`; 1414document.head.appendChild(listTight); 1415 1416 1417 1418// Speech-to-text mic logic goes here: 1419let isListening = false; 1420let recognition; 1421 1422function startListening() { 1423 if (!('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) { 1424 alert("Speech recognition not supported."); 1425 return; 1426 } 1427 const SR = window.SpeechRecognition || window.webkitSpeechRecognition; 1428 recognition = new SR(); 1429 recognition.lang = "auto"; 1430 recognition.interimResults = false; 1431 recognition.onresult = (e) => { 1432 const transcript = e.results[0][0].transcript; 1433 input.value = transcript; 1434 form.dispatchEvent(new Event("submit", {cancelable:true, bubbles:true})); 1435 }; 1436 recognition.onend = () => { isListening = false; setMicUI(false); }; 1437 recognition.start(); 1438 isListening = true; 1439 setMicUI(true); 1440} 1441 1442function stopListening() { 1443 if (recognition) recognition.stop(); 1444 isListening = false; 1445 setMicUI(false); 1446} 1447 1448function setMicUI(active) { 1449 const micBtn = document.getElementById('furrie-mic'); 1450 if (!micBtn) return; 1451 micBtn.style.opacity = active ? '1' : '0.7'; 1452 micBtn.title = active ? 'Stop listening' : 'Voice input'; 1453} 1454 1455document.getElementById('furrie-mic')?.addEventListener('click', () => { 1456 if (isListening) stopListening(); else startListening(); 1457}); 1458 1459 1460 // --- HELPER FUNCTIONS --- 1461 1462 1463function canTrackBehavior() { 1464 if (!isGdprEnabled) return true; 1465 return isGdprConsented === true; 1466} 1467 1468 1469 1470function renderGdprModal() { 1471 // 1. Clear existing chat messages to show the modal cleanly 1472 messagesDiv.innerHTML = ''; 1473 1474 // 2. Disable Input 1475 input.disabled = true; 1476 input.placeholder = "Please accept privacy policy to chat."; 1477 1478 // 3. Create Container 1479 const modal = document.createElement("div"); 1480 modal.id = "furrie-gdpr-modal"; 1481 Object.assign(modal.style, { 1482 display: "flex", 1483 flexDirection: "column", 1484 justifyContent: "flex-start", 1485 justifyContent: "center", 1486 height: "100%", 1487 padding: "20px", 1488 textAlign: "left", 1489 backgroundColor: "#fff", 1490 color: "#555", 1491 fontSize: "14px", 1492 lineHeight: "1.5" 1493 });
1494 1495 const btnColor = window.__furie_button_color || '#5AC0DE'; 1496 const textColor = idealTextColor(btnColor); 1497 1498 modal.innerHTML = ` 1499 <div style="text-align:center; margin-bottom:15px; margin-top: 150px; font-size: 24px;">ð¡ï¸</div> 1500 <h3 style="margin:0 0 10px 0; text-align:center; font-weight:700;">Privacy Preference Center</h3> 1501 <p style="margin-bottom:15px; font-size:13px;">To provide a helpful browsing, support, and interaction experience, we need your permission to process your data.</p> 1502 1503 <div style="margin-bottom:10px;"> 1504 <strong style="display:block; margin-bottom:2px;">ð¤ AI Processing (Functional)</strong> 1505 <span style="font-size:12px; color:#777;">We share your messages with AI providers (OpenAI) solely to generate accurate responses.</span> 1506 </div> 1507 1508 <div style="margin-bottom:10px;"> 1509 <strong style="display:block; margin-bottom:2px;">ð Analytics & Insights</strong> 1510 <span style="font-size:12px; color:#777;">We anonymously track your browsing activity (pages visited, scroll depth, time on site) on this website to calculate a "Helpfulness Score" and offer relevant assistance.</span> 1511 </div> 1512 1513 <div style="margin-bottom:20px;"> 1514 <strong style="display:block; margin-bottom:2px;">ðª Session Storage</strong> 1515 <span style="font-size:12px; color:#777;">We store a temporary ID in your browser to maintain conversation history across pages.</span> 1516 </div> 1517 1518 <div style="display:flex; flex-direction:column; gap:10px;"> 1519 <button id="furrie-gdpr-accept" style="background-color:${btnColor}; color:${textColor}; border:none; padding:12px; border-radius:25px; font-weight:bold; cursor:pointer;">Accept & Chat</button> 1520 <button id="furrie-gdpr-decline" style="background:none; border:1px solid #ccc; padding:10px; border-radius:25px; cursor:pointer; color:#777;">Decline</button> 1521 </div> 1522 1523 <div style="text-align:center; margin-top:15px;"> 1524 <a href="${privacyPolicyUrl}" target="_blank" style="font-size:12px; color:#999; text-decoration:underline;">Read Privacy Policy</a> 1525 </div> 1526 `; 1527 1528 messagesDiv.appendChild(modal); 1529 1530 // Bind Events 1531 document.getElementById("furrie-gdpr-accept").onclick = () => handleGdprChoice(true); 1532 document.getElementById("furrie-gdpr-decline").onclick = () => handleGdprChoice(false); 1533} 1534 1535async function handleGdprChoice(accepted) { 1536 if (accepted) { 1537 // --- ACCEPTED FLOW --- 1538 1539 // 1. Update State 1540 isGdprConsented = true; 1541 isGdprBlocked = false; 1542 localStorage.setItem(getStorageKey("furrieGdprConsent"), "1"); 1543 1544 // 2. Remove Modal immediately 1545 const modal = document.getElementById("furrie-gdpr-modal"); 1546 if(modal) modal.remove(); 1547 1548 // 3. Unlock Input 1549 input.disabled = false; 1550 input.placeholder = "How can I help you?"; 1551 1552 // 4. Force Show Promo Pill 1553 if (isProductPlacementEnabled) { 1554 const pill = document.getElementById('furrie-promo-pill'); 1555 if(pill) pill.style.display = 'flex'; 1556 } 1557 1558 // 5. Send Consent to DB (Wait for it to finish) 1559 try { 1560 await fetch(apiEndpoint, { 1561 method: "POST", 1562 headers: { "Content-Type": "application/json" }, 1563 body: JSON.stringify({ 1564 action: 'log_consent', 1565 session_id: furieSessionId, 1566 propertyId: propertyId, 1567 status: 'accepted' 1568 }) 1569 }); 1570 } catch(e) { console.error(e); } 1571 1572 1573 1574// Reset timing so dwell calculations start post-consent 1575pageStartTs = performance.now(); 1576 1577// Force a post-consent tracking event 1578sendPageEvent({ 1579 meta: { 1580 gdpr_accepted: true, 1581 forced: true 1582 } 1583}); 1584 1585 1586 // 6. Restart Session 1587 // This will call loadPusher -> initializePusher. 1588 // Once connected, initializePusher will fire sendPageEvent automatically. 1589 createOrResumeSession(); 1590 1591 } else { 1592 // --- DECLINED FLOW --- 1593 // (This remains unchanged from your previous working version) 1594 isCustomHooksActive = false; 1595 promoPhrases = [...DEFAULT_PROMO_PHRASES]; 1596 const pill = document.getElementById('furrie-promo-pill'); 1597 if (pill) { 1598 pill.href = "https://www.furie.ai"; 1599 pill.style.cursor = "pointer"; 1600 delete pill.dataset.pendingUrl; 1601 delete pill.dataset.pendingText; 1602 if (isProductPlacementEnabled) { 1603 pill.style.display = 'flex'; 1604 const txt = pill.querySelector('#furie-promo-text'); 1605 if(txt) txt.textContent = ""; 1606 } 1607 } 1608 minimizeBtn(); 1609 fetch(apiEndpoint, { 1610 method: "POST", 1611 headers: { "Content-Type": "application/json" }, 1612 body: JSON.stringify({ 1613 action: 'log_consent', 1614 session_id: furieSessionId, 1615 propertyId: propertyId, 1616 status: 'declined' 1617 }) 1618 }); 1619 } 1620} 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630/* Get visible text for prompt */ 1631function getVisibleText() { 1632 // Use document.body.innerText directly. 1633 // It gets the readable text of the page without duplicating it for every parent element. 1634 if (!document.body) return ''; 1635 return document.body.innerText || ''; 1636} 1637 1638 1639/** 1640 * Cleans the bot's HTML response to remove unwanted spacing, 1641 * specifically extra <br> tags around <ul> lists. 1642 * @param {string} html - The raw HTML string from the bot. 1643 * @returns {string} - The cleaned HTML string. 1644 */ 1645function sanitizeHtmlResponse(html) { 1646 if (!html || typeof html !== 'string') { 1647 return ''; 1648 } 1649
1650 let cleanedHtml = html; 1651 1652 // 1. Remove any <br> tags immediately before an opening <ul> tag. 1653 cleanedHtml = cleanedHtml.replace(/(?:<br\s*\/?>\s*)+(<ul)/gi, '$1'); 1654 1655 // 2. Remove any <br> tags immediately after a closing </ul> tag. 1656 cleanedHtml = cleanedHtml.replace(/(<\/ul>)(?:<br\s*\/?>\s*)+/gi, '$1'); 1657 1658 // 3. Trim any leading/trailing whitespace or leftover <br> tags from the whole response. 1659 cleanedHtml = cleanedHtml.trim().replace(/^<br\s*\/?>/i, '').replace(/<br\s*\/?>$/i, '').trim(); 1660 1661 return cleanedHtml; 1662} 1663 1664 /** 1665 1666 * FIX #2: Added the missing saveHistory function. 1667 1668 * Saves a message to the browser's local storage. 1669 1670 * @param {string} sender - Who sent the message ('You' or 'Furie'). 1671 1672 * @param {string} text - The message content. 1673 1674 */ 1675 1676 1677 1678function saveHistory(sender, text, avatarUrl = null) { 1679 const historyKey = getStorageKey("furrieChatHistory"); 1680 const currentHistory = JSON.parse(localStorage.getItem(historyKey) || "[]"); 1681 const messageData = { sender, text }; 1682 if (avatarUrl) { 1683 messageData.avatarUrl = avatarUrl; 1684 } 1685 currentHistory.push(messageData); 1686 localStorage.setItem(historyKey, JSON.stringify(currentHistory)); 1687} 1688 1689 1690 1691 1692 1693 1694// === Mute button (bell icon) =============================================== 1695function bellSVG(muted) { 1696 // simple bell with optional slash 1697 return ` 1698 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"> 1699 <path d="M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22Z"></path> 1700 <path d="M18 16v-4.5a6 6 0 1 0-12 0V16l-1.6 1.6a1 1 0 0 0 .7 1.7h14.8a1 1 0 0 0 .7-1.7L18 16Z" fill="currentColor"></path> 1701 ${muted ? `<line x1="3" y1="3" x2="18" y2="18" stroke="currentColor" stroke-width="1" stroke-linecap="round"></line>` : ``} 1702 </svg>`; 1703} 1704 1705 1706 1707function setMuteUI(btn, muted) { 1708 btn.innerHTML = bellSVG(muted); 1709 btn.title = muted ? "Unmute notifications" : "Mute notifications"; 1710 btn.setAttribute("aria-label", btn.title); 1711 btn.setAttribute("aria-pressed", String(muted)); 1712 // Optional visual state 1713 btn.style.opacity = muted ? "0.5" : "1"; 1714} 1715 1716function addMuteButton() { 1717 const controls = document.getElementById('furrie-header-controls'); 1718 if (!controls) return; 1719 1720 const muteBtn = document.createElement("button"); 1721 muteBtn.id = "furrie-mute"; 1722 muteBtn.type = "button"; 1723 Object.assign(muteBtn.style, { 1724 background: "none", 1725 border: "none", 1726 cursor: "pointer", 1727 width: "36px", 1728 height: "36px", 1729 borderRadius: "50%", 1730 display: "inline-flex", 1731 alignItems: "center", 1732 justifyContent: "center", 1733 marginRight: "4px", 1734 color: (window.__furie_header_text_color || "#555555") + " !important" 1735 }); 1736 1737 setMuteUI(muteBtn, isMuted); 1738 1739 muteBtn.addEventListener("click", () => { 1740 isMuted = !isMuted; 1741 agentChirp.muted = isMuted; 1742 localStorage.setItem("furrieMuted", isMuted ? "1" : "0"); 1743 setMuteUI(muteBtn, isMuted); 1744 }); 1745 1746 // Just insert into controls. The old logic using 'header' is removed. 1747 controls.insertBefore(muteBtn, controls.firstChild); 1748} 1749 1750// Call once after DOM is ready and header exists: 1751addMuteButton(); 1752 1753 1754// Read-aloud toggle 1755function addReadAloudButton() { 1756 const controls = document.getElementById('furrie-header-controls'); 1757 if (!controls) return; 1758 1759 const btn = document.createElement("button"); 1760 btn.id = "furrie-tts-toggle"; 1761 btn.type = "button"; 1762 Object.assign(btn.style, { 1763 background: "none", 1764 border: "none", 1765 cursor: "pointer", 1766 width: "36px", 1767 height: "36px", 1768 display: "inline-flex", 1769 alignItems: "center", 1770 justifyContent: "center", 1771 marginRight: "4px", 1772 marginTop: "2px", 1773 color: (window.__furie_header_text_color || "#555555") + " !important" 1774 }); 1775 1776 function renderIcon(on) { 1777 return ` 1778 <svg width="24" height="24" viewBox="0 0 24 24"> 1779 <path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"></path> 1780 <path d="M16 12c0-1.77-.77-3.37-2-4.47v8.94A5.98 5.98 0 0 0 16 12z" fill="currentColor"></path> 1781 ${on ? "" : `<line x1="3" y1="3" x2="18" y2="18" stroke="currentColor" stroke-width="1" stroke-linecap="round"></line>`} 1782 </svg> 1783 `; 1784 } 1785 1786 let readAloudOn = false; 1787 try { 1788 readAloudOn = localStorage.getItem("furrieReadAloud") === "1"; 1789 } catch (e) {} 1790 1791 btn.innerHTML = renderIcon(readAloudOn); 1792 btn.title = "Toggle Read Aloud"; 1793 btn.style.opacity = readAloudOn ? "1" : "0.5"; 1794 1795 btn.addEventListener("click", () => { 1796 readAloudOn = !readAloudOn; 1797 localStorage.setItem("furrieReadAloud", readAloudOn ? "1" : "0"); 1798 btn.innerHTML = renderIcon(readAloudOn); 1799 btn.style.opacity = readAloudOn ? "1" : "0.5"; 1800 });
1801 1802 // Clean insert into controls 1803 controls.insertBefore(btn, controls.firstChild); 1804} 1805 1806addReadAloudButton(); 1807 1808 1809 1810// === Font Size Toggle =============================================== 1811function addFontSizeToggle() { 1812 const controls = document.getElementById('furrie-header-controls'); 1813 if (!controls) return; 1814 1815 const btn = document.createElement("button"); 1816 btn.id = "furrie-font-toggle"; 1817 btn.type = "button"; 1818 Object.assign(btn.style, { 1819 background: "none", 1820 border: "none", 1821 cursor: "pointer", 1822 width: "36px", 1823 height: "36px", 1824 display: "inline-flex", 1825 alignItems: "center", 1826 justifyContent: "center", 1827 marginRight: "6px", 1828 color: (window.__furie_header_text_color || "#555555") + " !important" 1829 }); 1830 btn.innerHTML = `<span style="display:flex;align-items:flex-end;gap:2px;line-height:1;"> 1831 <span style="font-size:18px;font-weight:800;margin-top:2px;">A</span> 1832 <span style="font-size:12px;font-weight:800;">A</span> 1833 </span>`; 1834 btn.title = "Toggle Font Size"; 1835 1836 const sizes = ["12px", "14px", "16px"]; 1837 let currentIndex = parseInt(localStorage.getItem("furrieFontSizeIndex") || "1", 10); 1838 container.style.fontSize = sizes[currentIndex]; 1839 1840 btn.addEventListener("click", () => { 1841 currentIndex = (currentIndex + 1) % sizes.length; 1842 container.style.fontSize = sizes[currentIndex]; 1843 localStorage.setItem("furrieFontSizeIndex", currentIndex.toString()); 1844 }); 1845 1846 // Clean insert into controls 1847 controls.insertBefore(btn, controls.firstChild); 1848} 1849 1850addFontSizeToggle(); 1851 1852// Per-instance auto-scroll that cancels ONLY if the user interacts during this scroll. 1853let currentAutoScroll = null; 1854 1855/** 1856 * Smoothly scroll #furrie-messages to bottom. 1857 * Returns a cancel() function for this specific instance. 1858 */ 1859function scrollToBottom(duration = 600) { 1860 const el = messagesDiv; 1861 // Cancel any existing instance first 1862 if (currentAutoScroll && typeof currentAutoScroll.cancel === 'function') { 1863 currentAutoScroll.cancel(); 1864 } 1865 1866 const start = el.scrollTop; 1867 const end = el.scrollHeight - el.clientHeight; 1868 if (end <= start) return () => {}; 1869 1870 let cancelled = false; 1871 let rafId = null; 1872 const t0 = performance.now(); 1873 1874 const cancel = () => { cancelled = true; cleanup(); }; 1875 1876 // Cancel only on real user input, not programmatic scrolls 1877 const cancelOnWheelTouch = () => cancel(); 1878 const cancelOnPointer = () => cancel(); 1879 const cancelOnKey = (e) => { 1880 // Only keys that actually scroll 1881 if (['PageUp','PageDown','ArrowUp','ArrowDown','Home','End',' '].includes(e.key)) cancel(); 1882 }; 1883 1884 // Attach listeners for *this* instance only 1885 el.addEventListener('wheel', cancelOnWheelTouch, {passive:true}); 1886 el.addEventListener('touchstart', cancelOnWheelTouch, {passive:true}); 1887 el.addEventListener('pointerdown', cancelOnPointer, {passive:true}); 1888 window.addEventListener('keydown', cancelOnKey, {passive:true}); 1889 1890 function cleanup() { 1891 if (rafId) cancelAnimationFrame(rafId); 1892 el.removeEventListener('wheel', cancelOnWheelTouch, {passive:true}); 1893 el.removeEventListener('touchstart', cancelOnWheelTouch, {passive:true}); 1894 el.removeEventListener('pointerdown', cancelOnPointer, {passive:true}); 1895 window.removeEventListener('keydown', cancelOnKey, {passive:true}); 1896 currentAutoScroll = null; 1897 } 1898 1899 function step(now) { 1900 if (cancelled) return; 1901 const t = Math.min(1, (now - t0) / duration); 1902 const eased = t < 0.5 ? 2*t*t : -1 + (4 - 2*t)*t; 1903 el.scrollTop = start + (end - start) * eased; 1904 if (t < 1) { 1905 rafId = requestAnimationFrame(step); 1906 } else { 1907 cleanup(); 1908 } 1909 } 1910 1911 rafId = requestAnimationFrame(step); 1912 currentAutoScroll = { cancel }; 1913 return currentAutoScroll; 1914} 1915 1916 1917/** 1918 * Appends a message to the chat, animating it with a letter-by-letter fade effect. 1919 * Handles complex HTML by traversing the DOM tree. 1920 * @param {string} sender - Who sent the message ('You', 'FurieBot', 'Agent'). 1921 * @param {string} text - The message content, which can be a string of HTML. 1922 * @param {boolean} [animate=true] - Whether to animate the message. 1923 */ 1924function appendMessage(sender, text, animate = true, avatarUrl = null, specificTimestamp = null, skipSave = false) { 1925 1926 // --- 1. Create the Message Wrapper and Avatar --- 1927 const wrap = document.createElement('div'); 1928 Object.assign(wrap.style, { 1929 display: 'flex', 1930 flexDirection: sender === 'You' ? 'row-reverse' : 'row', 1931 alignItems: 'flex-end', 1932 gap: '6px', 1933 margin: '6px 0' 1934 });
1935 1936 const showAvatar = sender === 'FurieBot' || sender === 'Agent'; 1937 if (showAvatar) { 1938 const av = document.createElement('img'); 1939 const isBot = sender === 'FurieBot'; 1940 1941 let primarySrc = ''; 1942 if (isBot) { 1943 primarySrc = `${baseImgUrl}bot.png`; 1944 } else { // It's an Agent 1945 primarySrc = avatarUrl || `${baseImgUrl}agent.png`; 1946 } 1947 const fallbackSrc = isBot ? `${defaultImgUrl}bot.png` : `${defaultImgUrl}agent.png`; 1948 1949 av.src = primarySrc; 1950 av.alt = sender; 1951 av.onerror = function() { 1952 this.onerror = null; 1953 this.src = fallbackSrc; 1954 }; 1955 1956 Object.assign(av.style, { 1957 width: '58px', height: '58px', borderRadius: '50%', 1958 marginRight: sender === 'You' ? '0' : '6px', 1959 marginLeft: sender === 'You' ? '6px' : '0', 1960 objectFit: 'cover', opacity: '0', transition: 'opacity .8s ease' 1961 }); 1962 wrap.appendChild(av); 1963 requestAnimationFrame(() => { av.style.opacity = '1'; }); 1964 } 1965 1966 // --- 2. Create the Bubble and Timestamp --- 1967 const stack = document.createElement('div'); 1968 Object.assign(stack.style, { 1969 display: 'inline-flex', 1970 flexDirection: 'column', 1971 alignItems: sender === 'You' ? 'flex-end' : 'flex-start', 1972 maxWidth: '85%' 1973 }); 1974 1975 const bubble = document.createElement('div'); 1976 1977 // --- NEW CLASS-BASED STYLING (Replaces inline styles) --- 1978 // 1. Add base class for structure and text reset 1979 bubble.classList.add('furie-chat-bubble-base'); 1980 1981 // 2. Add specific class for colors and alignment 1982 if (sender === 'You') { 1983 bubble.classList.add('furie-bubble-guest'); 1984 } else { 1985 bubble.classList.add('furie-bubble-bot'); 1986 } 1987 1988 // (Note: The large Object.assign block for bubble styles is removed 1989 // because .furie-chat-bubble-base now handles it with !important overrides) 1990 1991 const ts = document.createElement('div'); 1992 Object.assign(ts.style, { 1993 fontSize: '11px', color: '#999', marginTop: '4px', 1994 textAlign: sender === 'You' ? 'right' : 'left', 1995 alignSelf: sender === 'You' ? 'flex-end' : 'flex-start', 1996 padding: '0 2px' 1997 }); 1998 1999 if (specificTimestamp) { 2000 ts.textContent = specificTimestamp; 2001 } else { 2002 ts.textContent = new Date().toLocaleString([], { 2003 month: 'short', 2004 day: 'numeric', 2005 hour: 'numeric', 2006 minute: '2-digit' 2007 }); 2008 } 2009 2010 stack.appendChild(bubble); 2011 stack.appendChild(ts); 2012 wrap.appendChild(stack); 2013 messagesDiv.appendChild(wrap); 2014 scrollToBottom(); 2015 2016 requestAnimationFrame(() => { 2017 // If your base CSS handles visibility, this might be redundant but is safe to keep 2018 bubble.classList.add('visible'); 2019 }); 2020 2021 // --- 3. ANIMATION LOGIC --- 2022 // User messages are never animated 2023 if (sender === 'You' || animate === false) { 2024 bubble.innerHTML = sanitizeHtmlResponse(text); 2025 if (sender !== 'You' && !skipSave) saveHistory(sender, text, avatarUrl); 2026 return bubble; 2027 } 2028 2029 // Bot/Agent messages are animated 2030 bubble.innerHTML = sanitizeHtmlResponse(text); 2031 if (sender !== 'You' && !skipSave) saveHistory(sender, text); 2032 2033 2034 // Insert animated bullet spans for each <li> 2035 const listItems = bubble.querySelectorAll('li'); 2036 listItems.forEach(li => { 2037 if (li.firstChild && li.firstChild.classList && li.firstChild.classList.contains('fade-in-letter-bullet')) return; 2038 2039 const bullet = document.createElement('span'); 2040 bullet.className = 'fade-in-letter fade-in-letter-bullet'; 2041 bullet.textContent = ''; 2042 bullet.style.transitionDelay = '0ms'; 2043 2044 li.insertBefore(bullet, li.firstChild); 2045 }); 2046 2047 // Text-to-speech if toggle is on 2048 if ((sender === 'FurieBot' || sender === 'Agent') && localStorage.getItem("furrieReadAloud") === "1") { 2049 speakText(text.replace(/<[^>]+>/g, "")); 2050 } 2051 2052 let letterCount = 0; 2053 const letterDelay = 5; 2054 2055 function wrapTextNodes(node) { 2056 if (node.nodeType === 3) { // Text node 2057 const fragment = document.createDocumentFragment(); 2058 const characters = node.textContent.split(''); 2059
2060 characters.forEach(char => { 2061 const span = document.createElement('span'); 2062 span.className = 'fade-in-letter'; 2063 span.textContent = char; 2064 span.style.transitionDelay = `${letterCount * letterDelay}ms`; 2065 fragment.appendChild(span); 2066 letterCount++; 2067 }); 2068 node.parentNode.replaceChild(fragment, node); 2069 } else if (node.nodeType === 1) { // Element node 2070 Array.from(node.childNodes).forEach(child => wrapTextNodes(child)); 2071 } 2072 } 2073 2074 wrapTextNodes(bubble); 2075 2076 requestAnimationFrame(() => { 2077 bubble.classList.add('visible'); 2078 }); 2079 2080 const totalDuration = letterCount * letterDelay + 500; 2081 scrollToBottom(totalDuration); 2082 2083 return bubble; 2084} 2085 2086let activeBusyRemover = null; 2087 2088const nauticalPhrases = [ 2089 " " 2090]; 2091 2092function randomNauticalPhrase() { 2093 return nauticalPhrases[Math.floor(Math.random() * nauticalPhrases.length)]; 2094} 2095 2096 2097 2098function showBusyIndicator() { 2099 if (activeBusyRemover) activeBusyRemover(); 2100 2101 const bubble = appendMessage('FurieBot', '', false); 2102 2103bubble.classList.add('furie-compact', 'furie-pulse', 'chat-bubble'); 2104 2105// Trigger fade-in next frame 2106requestAnimationFrame(() => { 2107 bubble.classList.add('visible'); 2108}); 2109 2110 // Hide the timestamp row 2111 const stack = bubble.parentElement; 2112 const ts = stack && stack.children && stack.children[1]; 2113 if (ts) ts.style.display = 'none'; 2114 2115 bubble.innerHTML = ` 2116 <span style="display:inline-flex;align-items:center;gap:2px;margin-left:-10px;">
2117 <span class="furie-dots" aria-live="polite" aria-label="Bot is thinking"></span> 2118 </span> 2119 `; 2120 2121 // Create dot wave 2122 const dots = bubble.querySelector('.furie-dots'); 2123 for (let i = 0; i < 3; i++) { 2124 const d = document.createElement('i'); 2125 d.textContent = 'â¢'; 2126 d.style.animationDelay = (i * 0.08) + 's'; 2127 dots.appendChild(d); 2128 } 2129 2130 // Scroll smoothly to bottom so the bubble isnât cut off 2131 scrollToBottom(); 2132 2133 const wrap = stack && stack.parentElement; 2134 const remover = () => { 2135 if (wrap && wrap.remove) wrap.remove(); 2136 activeBusyRemover = null; 2137 scrollToBottom(); 2138 }; 2139 activeBusyRemover = remover; 2140 return remover; 2141} 2142 2143 2144 2145 2146 function loadPusher(callback) { 2147 2148 if (window.Pusher) { 2149 2150 callback(); 2151 2152 return; 2153 2154 } 2155 2156 const script = document.createElement('script'); 2157 2158 script.src = 'https://js.pusher.com/8.2/pusher.min.js'; 2159 2160 script.onload = callback; 2161 2162 script.onerror = () => appendMessage('FurieBot', 'Error: Could not load live chat library.'); 2163 2164 document.head.appendChild(script); 2165 2166 } 2167 2168 2169 2170 function initializePusher(config) { 2171 2172 2173 if (pusherInstance) { 2174 return; // Pusher is already initialized, do nothing. 2175 } 2176 2177 pusherInstance = new Pusher(config.pusher_key, { 2178 2179 cluster: config.pusher_cluster, 2180 2181 encrypted: true 2182 2183 }); 2184 2185 2186 2187pusherChannel = pusherInstance.subscribe(`session-${liveChatSessionId}`); 2188 2189 2190pusherChannel.bind('pusher:subscription_succeeded', () => { 2191 // Send the beacon now that we are ready to hear the reply 2192 sendPageEvent({ meta: { subscription_ready: true } }); 2193 }); 2194 2195 2196 2197 2198 2199 2200 2201pusherChannel.bind('agent-message', data => { 2202 // An agent message always means the chat is live. 2203 if (!isLiveChatActive) { 2204 isLiveChatActive = true; 2205 sessionStatus = 'open'; 2206 // Maximize the window if the agent sends the first message 2207 if (container.style.opacity === "0") { 2208 maximizeBtn(); 2209 } 2210 addEndChatButton(); // 'End Chat' button 2211 } 2212 2213 appendMessage('Agent', data.msg, true, data.agent_avatar_url); // Use the main appendMessage function 2214 playChirp(); 2215 startTitleNotification(); 2216}); 2217 2218 2219 2220 2221 2222 // --- Listen for proactive messages from the bot --- 2223 pusherChannel.bind('proactive-message', data => { 2224 2225 2226 // --- GDPR BLOCK --- 2227 if (isGdprBlocked) { 2228 // Save the message for later instead of ignoring it 2229 pendingProactivePayload = data; 2230 return; 2231 } 2232 // ----------------- 2233 2234 // 1. CHECK TOGGLE 2235 if (!isProactiveEnabled) return; 2236 2237 // Helper: Strip HTML tags and whitespace for "fuzzy" comparison 2238 const normalize = (str) => (str || "").replace(/<[^>]+>/g, "").replace(/\s+/g, "").toLowerCase(); 2239 const incomingNormalized = normalize(data.msg); 2240 2241 // 2. CHECK LOCAL STORAGE FLAG (Session Scoped) 2242 // If we specifically marked this session as having handled THIS proactive event, stop. 2243 // We use a hash or simplified version of the msg to make the key unique to the content. 2244 // This allows *different* proactive messages on other pages, but blocks *this specific one* from repeating. 2245 const msgHash = incomingNormalized.slice(0, 20); // First 20 chars as ID 2246 const storageKey = `furrie_shown_${furieSessionId}_${msgHash}`; 2247 2248 if (localStorage.getItem(storageKey)) { 2249 return; 2250 } 2251 2252 // 3. CHECK CHAT HISTORY (Robust) 2253 const history = JSON.parse(localStorage.getItem("furrieChatHistory") || "[]"); 2254 2255 // Check if ANY message in history (Bot OR Agent) loosely matches the incoming content. 2256 const alreadyInHistory = history.some(msg => { 2257 const isBot = (msg.sender === 'FurieBot' || msg.sender === 'bot' || msg.sender === 'Agent'); 2258 return isBot && normalize(msg.text) === incomingNormalized; 2259 });
2260 2261 if (alreadyInHistory) { 2262 // It's already in the chat log (loaded from DB), so mark it as shown and exit. 2263 localStorage.setItem(storageKey, "1"); 2264 return; 2265 } 2266 2267 // 4. CHECK IF USER HAS CHATTED (Optional but recommended based on your request) 2268 // "Now if the user has chatted... the message repeats" -> This fixes that specific loop. 2269 // If the chat log contains user messages, we generally assume the "opener" is no longer needed. 2270 const userHasChatted = history.some(msg => msg.sender === 'You'); 2271 2272 // If you want to BLOCK proactive messages strictly if the user has already started chatting: 2273 if (userHasChatted) { 2274 localStorage.setItem(storageKey, "1"); // Suppress it 2275 return; 2276 } 2277 2278 // 5. Mark as shown & Display 2279 localStorage.setItem(storageKey, "1"); 2280 2281 appendMessage('FurieBot', data.msg, true, null); 2282 playChirp(); 2283 startTitleNotification(); 2284 2285 if (data.maximize === true && container.style.opacity === "0") { 2286 maximizeBtn(); 2287 } 2288}); 2289 2290 2291 2292 2293pusherChannel.bind('session-closed', () => { 2294 if (!isLiveChatActive) return; 2295 2296 isLiveChatActive = false; 2297 2298 // Lock live-chat UI 2299 input.disabled = true; 2300 input.placeholder = 'Chat has ended.'; 2301 document.getElementById('furrie-form').style.opacity = '0.5'; 2302 document.getElementById('livechat-end-btn')?.remove(); 2303 2304 2305 const btnColor = window.__furie_button_color || '#5AC0DE'; // Use dynamic color with fallback 2306 const textColor = idealTextColor(btnColor); // Calculate contrasting text color 2307 2308 // Show message + CTA in ONE bubble (no typing animation) 2309 const html = ` 2310 <div style="margin:-20px 0 0px 0; line-height:1; font-size:15px; padding-top:20px;">Thanks ð for speaking with us! â¤ï¸ The human agent chat session has ended.</br></br>Continue â¡ï¸ the conversation ð¬ with the AI ð¤ assistant below.</div> 2311 <div style="display:flex;flex-wrap:wrap;"> 2312 2313 <button id="new-convo-btn" 2314 style="background-color:${btnColor};color:${textColor};border:none; 2315 padding:6px 10px;border-radius:25px;cursor:pointer; 2316 font-size:15px;line-height:1;font-weight:600;">Start a Fresh Chat</button> 2317 </div> 2318 `; 2319 2320 appendMessage('FurieBot', html, false); 2321 2322 // Audible cue 2323 playChirp(); 2324}); 2325 2326 } 2327 2328function resumeBotMode() { 2329 // Re-enable bot UI 2330 isLiveChatActive = false; // ensure we're in bot mode 2331 sessionStatus = 'bot'; 2332 input.disabled = false; 2333 input.placeholder = 'How can I help you?'; 2334 document.getElementById('furrie-form').style.opacity = '1'; 2335 2336 // Tell backend this browser is back to bot mode (helps agent dashboard) 2337 if (furieSessionId) { 2338 fetch(apiEndpoint, { 2339 method: 'POST', 2340 headers: { 'Content-Type': 'application/json' }, 2341 body: JSON.stringify({ 2342 action: 'update_status', 2343 session_id: furieSessionId, 2344 new_status: 'bot' 2345 }) 2346 }).catch(()=>{}); 2347 } 2348 2349 appendMessage('FurieBot', "I'm back on-line. How can I help? ð§"); 2350 input.focus(); 2351} 2352 2353function startNewConversation() { 2354 // Clear local bot history only (keeps live-chat history in your DB) 2355 localStorage.removeItem(getStorageKey('furrieChatHistory')); 2356 2357 // Also clear name/email prompt flag if you want to re-ask: 2358 // localStorage.removeItem('furrieAskedInfo'); 2359 2360 // Optionally create a brand-new bot session ID: 2361 // This will ask backend to create a new 'Browse' session 2362 fetch(apiEndpoint, { 2363 method: 'POST', 2364 headers: { 'Content-Type': 'application/json' }, 2365 body: JSON.stringify({ 2366 action: 'create_session', 2367 propertyId: propertyId, 2368 existingSessionId: null, 2369 guestName: userName || 'Guest', 2370 guestEmail: userEmail || '' 2371 }) 2372 }) 2373 .then(r => r.json()) 2374 .then(data => { 2375 if (data && data.session_id) { 2376 furieSessionId = data.session_id; 2377 localStorage.setItem(getStorageKey('furieSessionId'), furieSessionId); 2378 } 2379 }) 2380 .catch(()=>{}); 2381 2382 // Reset UI like resume 2383 input.disabled = false; 2384 input.placeholder = 'How can I help you?'; 2385 document.getElementById('furrie-form').style.opacity = '1'; 2386 sessionStatus = 'bot'; 2387 isLiveChatActive = false; 2388 2389 // Visual reset: divider and greeting 2390 appendMessage('FurieBot', 'Starting a new conversation. How can I help?'); 2391 input.focus(); 2392} 2393 2394// Single delegated listener for both buttons 2395messagesDiv.addEventListener('click', (e) => { 2396 const t = e.target; 2397 if (!t) return; 2398 if (t.id === 'resume-bot-btn') { 2399 resumeBotMode(); 2400 } else if (t.id === 'new-convo-btn') { 2401 startNewConversation(); 2402 } 2403}); 2404 2405function appendAgentMessage(sender, text) { 2406 const bubble = appendMessage('Agent', text, false); // No typing animation 2407 if (bubble) { 2408 bubble.style.backgroundColor = '#fff'; 2409 bubble.style.borderRadius = '25px 25px 25px 5px'; 2410
2411 // Fade-in effect 2412 bubble.style.opacity = '0'; 2413 bubble.style.transition = 'opacity .8s ease'; 2414 requestAnimationFrame(() => { 2415 bubble.style.opacity = '1'; 2416 }); 2417 } 2418} 2419 2420 2421 2422 function startLiveChat(handoffData) { 2423 2424 isLiveChatActive = true; 2425 2426 liveChatSessionId = handoffData.session_id; 2427 2428 liveChatApiEndpoint = handoffData.live_chat_api; 2429 2430 liveChatPropertyId = handoffData.property_id; 2431 2432 loadPusher(() => initializePusher(handoffData)); 2433 2434 addEndChatButton(); 2435 2436 } 2437 2438 2439 2440function addEndChatButton() { 2441 const controls = document.getElementById('furrie-header-controls'); 2442 if (!controls) return; 2443 2444 // Prevent duplicates 2445 if (document.getElementById('livechat-end-btn')) return; 2446 2447 const endButton = document.createElement('button'); 2448 endButton.id = 'livechat-end-btn'; 2449 endButton.textContent = 'End'; 2450 2451 Object.assign(endButton.style, { 2452 background: '#de712c', 2453 color: 'white', 2454 border: 'none', 2455 padding: '4px 10px', // Adjusted padding for better fit 2456 borderRadius: '15px', 2457 cursor: 'pointer', 2458 fontWeight: 'bold', 2459 fontSize: '11px',
2460 marginRight: '6px', 2461 flexShrink: '0' 2462 }); 2463 2464 endButton.onclick = () => { 2465 if (confirm('Are you sure you want to end this chat?')) { 2466 fetch(liveChatApiEndpoint, { 2467 method: 'POST', 2468 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 2469 body: new URLSearchParams({ 2470 action: 'close-session', 2471 session_id: liveChatSessionId, 2472 property_id: liveChatPropertyId 2473 }) 2474 }).catch(err => console.error('Failed to end session:', err)); 2475 } 2476 }; 2477 2478 // Insert at the very start of the controls (left of the other icons) 2479 controls.insertBefore(endButton, controls.firstChild); 2480} 2481 2482 2483 2484async function promptForUserInfo() { 2485 return new Promise(resolve => { 2486 const wrapper = document.createElement("div"); 2487 wrapper.style.opacity = "0"; 2488 wrapper.style.transition = "opacity 1.5s ease"; 2489 wrapper.style.margin = "0px"; 2490 2491 2492 const btnColor = window.__furie_button_color || '#5AC0DE'; // Use dynamic color with fallback 2493 const textColor = idealTextColor(btnColor); // Calculate contrasting text color 2494 2495 wrapper.innerHTML = ` 2496 <input type="text" id="guest-name" placeholder="Your Name ð·ï¸" style="margin-top: 10px; margin-bottom: 5px; width: 70%; padding: 6px; border: 1px solid #ccc; border-radius: 25px;" /> 2497 <input type="email" id="guest-email" placeholder="Your Email @" style="margin-top: 2px; margin-bottom: 15px; width: 70%; padding: 6px; border: 1px solid #ccc; border-radius: 25px;" /> 2498 <button id="guest-submit" style="background-color: ${btnColor}; color: ${textColor}; border: none; padding: 8px 12px; border-radius: 25px; cursor: pointer; font-weight: bold;">â¤ï¸</button><br><br><span style="font-size:0.6em">I am AI-powered and can make mistakes. By continuing, you agree this chat may be logged for ticket tracking, training, or other purposes. Do not type anything here you would not share in public. By continuing, you agree to our Privacy Policy and Terms and Conditions of Use.</span><br><br>`; 2499 2500 2501 messagesDiv.appendChild(wrapper); 2502 wrapper.scrollIntoView({ behavior: "smooth", block: "end" }); 2503 requestAnimationFrame(() => (wrapper.style.opacity = "1")); 2504 2505 const nameField = wrapper.querySelector("#guest-name"); 2506 const emailField = wrapper.querySelector("#guest-email"); 2507 const submitButton = wrapper.querySelector("#guest-submit"); 2508 2509 [nameField, emailField].forEach(field => { 2510 field.addEventListener("keydown", (event) => { 2511 if (event.key === "Enter") { 2512 event.preventDefault(); 2513 submitButton.click(); 2514 } 2515 }); 2516 }); 2517 2518 submitButton.onclick = () => { 2519 const nameInput = nameField.value.trim(); 2520 const emailInput = emailField.value.trim(); 2521 if (nameInput) { 2522 userName = nameInput; 2523 localStorage.setItem("furrieUserName", userName); 2524 } 2525 if (emailInput) { 2526 userEmail = emailInput; 2527 localStorage.setItem("furrieUserEmail", userEmail); 2528 } 2529 2530 // --- FIX: Update the server immediately with the new Name/Email --- 2531 if (furieSessionId && (userName || userEmail)) { 2532 fetch(apiEndpoint, { 2533 method: "POST", 2534 headers: { "Content-Type": "application/json" }, 2535 body: JSON.stringify({ 2536 // Use the action that triggers name updates 2537 action: 'create_session', 2538 session_id: furieSessionId, // Existing session 2539 existingSessionId: furieSessionId, 2540 propertyId: propertyId, 2541 guestName: userName, // Send the data we just captured 2542 guestEmail: userEmail 2543 }) 2544 }).catch(err => console.error("Failed to sync name update:", err));
2545 } 2546 // ------------------------------------------------------------------ 2547 2548 localStorage.setItem("furrieAskedInfo", "1"); 2549 wrapper.remove(); 2550 resolve(); 2551 }; 2552 }); 2553} 2554 2555 2556/** 2557 * Displays a confirmation message with 'Yes' and 'No' buttons for live agent handoff. 2558 */ 2559function promptForLiveAgentConfirmation() { 2560 // Get the dynamic color, with a fallback to white if it's not set 2561 const bgColor = window.__furie_button_color || '#ffffff'; 2562 // Automatically calculate the best text color (black or white) for readability 2563 const fgColor = idealTextColor(bgColor); 2564 2565 // Create a wrapper for the confirmation message and buttons 2566 const confirmationWrapper = document.createElement("div"); 2567 Object.assign(confirmationWrapper.style, { 2568 opacity: "0", 2569 transition: "opacity 1.0s ease", 2570 padding: "10px", 2571 textAlign: "center", 2572 backgroundColor: "#fff", 2573 borderRadius: "25px", 2574 margin: "10px 0 22px 0" 2575 }); 2576 2577 const message = document.createElement("p"); 2578 message.textContent = "ðââï¸ð§?"; 2579 message.style.marginBottom = "10px"; 2580 confirmationWrapper.appendChild(message); 2581 2582 const buttonContainer = document.createElement("div"); 2583 confirmationWrapper.appendChild(buttonContainer); 2584 2585 // --- 'Yes' Button --- 2586 const yesButton = document.createElement("button"); 2587 yesButton.textContent = "ð"; 2588 Object.assign(yesButton.style, { 2589 backgroundColor: bgColor, 2590 color: fgColor, 2591 border: "none", 2592 padding: "10px 20px", 2593 borderRadius: "20px", 2594 cursor: "pointer", 2595 fontWeight: "bold", 2596 marginRight: "10px" 2597 }); 2598 2599 yesButton.onclick = () => { 2600 confirmationWrapper.remove(); 2601 appendMessage("You", "ð", false); // Show user's choice in the chat 2602 2603 // This payload uses the special phrase the backend is now looking for 2604 const handoffPayload = { 2605 question: "##CONFIRM_HANDOFF##", 2606 propertyId, 2607 guestname: userName, 2608 guestemail: userEmail, 2609 history: JSON.parse(localStorage.getItem("furrieChatHistory") || "[]") 2610 }; 2611 2612 // We call the main bot request handler to trigger the handoff on the backend 2613 handleBotRequest(handoffPayload); 2614 }; 2615 2616 // --- 'No' Button --- 2617 const noButton = document.createElement("button"); 2618 noButton.textContent = "ð"; 2619 Object.assign(noButton.style, { 2620 backgroundColor: "#9da0b1", // A clear 'no' color 2621 color: "white", 2622 border: "none", 2623 padding: "10px 20px", 2624 borderRadius: "20px", 2625 cursor: "pointer", 2626 fontWeight: "bold" 2627 }); 2628 2629 noButton.onclick = () => { 2630 confirmationWrapper.remove(); 2631 appendMessage("You", "No", false); // Show user's choice 2632 appendMessage("FurieBot", "ð"); 2633 }; 2634 2635 buttonContainer.appendChild(yesButton); 2636 buttonContainer.appendChild(noButton); 2637 2638 messagesDiv.appendChild(confirmationWrapper); 2639 requestAnimationFrame(() => (confirmationWrapper.style.opacity = "1")); 2640 confirmationWrapper.scrollIntoView({ behavior: "smooth", block: "end" }); 2641} 2642 2643 2644 // --- UI EVENT LISTENERS AND MAIN LOGIC --- 2645 2646 2647 2648const minimizeBtn = () => { 2649 container.style.transform = "translateY(100%)"; 2650 container.style.opacity = "0"; 2651 container.style.pointerEvents = "none"; 2652 2653 // Show the entire wrapper (Launcher + Pill) 2654 setTimeout(() => { 2655 launcherWrapper.style.display = "flex"; 2656 }, 300); 2657 }; 2658 2659 const maximizeBtn = () => { 2660 container.style.transform = "translateY(0)"; 2661 container.style.opacity = "1"; 2662 container.style.pointerEvents = "auto"; 2663 2664 // Hide the entire wrapper 2665 launcherWrapper.style.display = "none"; 2666 2667 setTimeout(() => input.focus(), 300); 2668 }; 2669 2670 // --- CLICK LISTENER FOR PILL --- 2671 promoPill.addEventListener("click", (e) => { 2672 // Only hijack the click if we are in AI/Custom mode 2673 if (isCustomHooksActive) { 2674 e.preventDefault(); // Stop any href navigation 2675 maximizeBtn(); // Open the chat window 2676 2677 // 1. Retrieve the data we stored in the loop above 2678 const url = promoPill.dataset.pendingUrl; 2679 const text = promoPill.dataset.pendingText; 2680 2681 // 2. If a URL exists for this specific message, inject the bot message 2682 if (url) { 2683 // Construct the HTML message 2684 const linkMsg = `Click here: <br><a href="${url}" target="_blank" style="text-decoration: underline;
2684 color: inherit; font-weight: 700;">${text}</a><br><br>Or type below.`; 2685 2686 // Inject into chat stream as 'FurieBot' (false = no typing animation) 2687 appendMessage('FurieBot', linkMsg, false); 2688 } 2689 } 2690 // If not custom mode, let it bubble (defaults to furie.ai href) 2691 }); 2692 2693launcher.addEventListener("click", maximizeBtn); 2694const minBtnEl = container.querySelector("#furrie-minimize"); 2695if (minBtnEl) minBtnEl.addEventListener("click", minimizeBtn); 2696 2697 2698 2699 // Launcher text animation 2700 2701 let isShrunk = false; 2702 2703 setTimeout(() => { 2704 2705 if (!textSpan) return; 2706 2707 textSpan.style.width = textSpan.offsetWidth + "px"; 2708 2709 requestAnimationFrame(() => { 2710 2711 setTimeout(() => { 2712 2713 textSpan.style.opacity = "0"; 2714 2715 textSpan.style.width = "0px"; 2716 2717 textSpan.style.marginRight = "2px"; 2718 2719 isShrunk = true; 2720 2721 }, 100); 2722 2723 }); 2724 2725 }, 5000); 2726 2727 // ... (mouseenter/mouseleave for launcher remains the same) ... 2728 2729 launcher.addEventListener("mouseenter", () => { 2730 2731 if (isShrunk) { 2732 2733 const temp = textSpan.cloneNode(true); 2734 2735 Object.assign(temp.style, {position: "absolute", visibility: "hidden", width: "auto", opacity: "0", whiteSpace: "nowrap"}); 2736 2737 document.body.appendChild(temp); 2738 2739 const fullWidth = temp.offsetWidth + "px"; 2740 2741 document.body.removeChild(temp); 2742 2743 textSpan.style.width = fullWidth; 2744 2745 textSpan.style.opacity = "1"; 2746 2747 textSpan.style.marginRight = "8px"; 2748 2749 } 2750 2751 }); 2752 2753 launcher.addEventListener("mouseleave", () => { 2754 2755 if (isShrunk) { 2756 2757 requestAnimationFrame(() => { 2758 2759 textSpan.style.opacity = "0"; 2760 2761 textSpan.style.width = "0px"; 2762 2763 textSpan.style.marginRight = "0"; 2764 2765 }); 2766 2767 } 2768 2769 }); 2770 2771 2772 2773 // Load initial chat history 2774 2775// JSON.parse(localStorage.getItem("furrieChatHistory") || "[]") 2776// .forEach(msg => appendMessage(msg.sender, msg.text, false, msg.avatarUrl)); 2777 2778// messagesDiv.scrollTop = messagesDiv.scrollHeight; 2779 2780 2781 2782 2783 2784 // --- MAIN FORM SUBMISSION LOGIC --- 2785 2786/** 2787 * Reusable function to send a payload to the bot and handle the response. 2788 * This can be called by both the main form and the confirmation buttons. 2789 * @param {object} payload The data to send to the bot API. 2790 */ 2791async function handleBotRequest(payload) { 2792 2793 2794 // The main request to the bot now handles all logic. 2795 try { 2796 2797 2798 2799 const stopBusy = showBusyIndicator(); // <-- show the wave dots 2800 2801 2802 const response = await fetch(apiEndpoint, { 2803 method: "POST", 2804 headers: { "Content-Type": "application/json" }, 2805 body: JSON.stringify(payload), 2806 }); 2807 2808 if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 2809 const data = await response.json(); 2810 stopBusy(); 2811 2812 // Update the local status after the bot has successfully processed the message 2813 if (sessionStatus === 'Browse') { 2814 sessionStatus = 'bot'; 2815 } 2816 if (data.action === 'confirm_live_agent') { 2817 promptForLiveAgentConfirmation(); 2818 return; 2819 } 2820 2821 // Case 2 (NEW): Bot's message triggered a confirmation. 2822 // We must display the bot's message FIRST, then the prompt. 2823 if (data.action === 'answer_and_confirm_handoff') { 2824 const reply = data.answer; 2825 appendMessage("FurieBot", reply); 2826 2827 // Wait for the typing animation to finish, then show the prompt.
2828 const animMs = reply.split(/\s+/).length * 80 + 200; // 80ms/word + buffer 2829 setTimeout(promptForLiveAgentConfirmation, animMs); 2830 2831 // Also handle any redirect buttons that might have been sent 2832 if (data.redirect && Array.isArray(data.buttons)) { 2833 // (This is the same redirect logic from your original code) 2834 const redirectBlock = document.createElement("div"); 2835 redirectBlock.style.opacity = "0"; 2836 redirectBlock.style.transition = "opacity 0.8s ease"; 2837 let html = `<div style="margin-top: 4px; margin-bottom: 10px; margin-left: 22px;">${data.message || ""}</div><div>`; 2838 data.buttons.forEach(btn => { 2839const ctaColor = window.__furie_redirect_btn_color || '#5AC0DE'; 2840html += `<a href="${btn.button_url}" target="_blank" 2841 style="display:inline-block;margin:4px 4px 4px 22px; 2842 background-color:${ctaColor}; color:#fff; padding:8px 14px; 2843 border-radius:25px;text-decoration:none;font-weight:bold;"> 2844 ${btn.button_text} 2845 </a>`; 2846 }); 2847 html += "</div>"; 2848 redirectBlock.innerHTML = html; 2849 setTimeout(() => { 2850 redirectBlock.classList.add('chat-bubble'); // reuse the fade-in style 2851 messagesDiv.appendChild(redirectBlock); 2852 2853 requestAnimationFrame(() => { 2854 redirectBlock.classList.add('visible'); 2855 scrollToBottom(); // gradual scroll to bottom over ~0.8s 2856 }); 2857 2858 // saveHistory("FurieBot", data.message || ""); 2859}, animMs); 2860 } 2861 return; // Processing for this response is complete. 2862 } 2863 2864 // Case 3: User clicked "Yes," and backend is starting the live chat. 2865 if (data.action === 'initiate_handoff') { 2866 appendMessage("FurieBot", data.answer); // Displays "One moment..." 2867 startLiveChat(data.handoff_data); 2868 return; 2869 2870 } 2871 2872 // --- DEFAULT BEHAVIOR --- 2873 // If no special action, just display the bot's answer and any redirect buttons. 2874 const reply = data.answer || "Oops! I had a little hiccup. Please try again."; 2875 appendMessage("FurieBot", reply); 2876 2877if (data.redirect && Array.isArray(data.buttons)) { 2878 // (This is the same redirect logic from your original code) 2879 const redirectBlock = document.createElement("div"); 2880 redirectBlock.style.opacity = "0"; 2881 redirectBlock.style.transition = "opacity 0.8s ease"; 2882 let html = `<div style="margin-top: 4px; margin-left: 22px;">${data.message || ""}</div><div>`; 2883 data.buttons.forEach(btn => { 2884const ctaColor = window.__furie_redirect_btn_color || '#5AC0DE'; 2885html += `<a href="${btn.button_url}" target="_blank" 2886 style="display:inline-block;margin:4px 4px 12px 22px; 2887 background-color:${ctaColor}; color:#fff; padding:8px 14px; 2888 border-radius:25px;text-decoration:none;font-weight:bold;"> 2889 ${btn.button_text} 2890 </a>`; 2891 }); 2892 html += "</div>"; 2893 redirectBlock.innerHTML = html; 2894 const animMs = reply.split(/\s+/).length * 80 + 200; 2895 setTimeout(() => { 2896 messagesDiv.appendChild(redirectBlock); 2897 requestAnimationFrame(() => { 2898 redirectBlock.style.opacity = "1"; 2899 redirectBlock.scrollIntoView({ behavior: "smooth", block: "end" }); 2900 }); 2901 // saveHistory("FurieBot", data.message || ""); 2902 }, animMs); 2903 } 2904 2905 } catch (err) { 2906 stopBusy(); 2907 console.error("Fetch error:", err); 2908 appendMessage("FurieBot", "Sorry, I couldnât find an answer! Please try again."); 2909 } 2910} 2911 2912 2913// --- GuestC --- 2914const trackingEndpoint = "https://www.furie.ai/guest_track.php"; 2915const proactivemessageEndpoint = "https://www.furie.ai/livechat/guest_track.php"; 2916let pageStartTs = performance.now(); 2917 2918 2919 2920function currentUtm() { 2921 const u = new URL(location.href); 2922 const g = x => u.searchParams.get(x) || ""; 2923 return { 2924 utm_source: g("utm_source"), 2925 utm_medium: g("utm_medium"), 2926 utm_campaign: g("utm_campaign"), 2927 utm_term: g("utm_term"),
2928 utm_content: g("utm_content"), 2929 }; 2930} 2931function scrollPct() { 2932 const doc = document.documentElement; 2933 const denom = (doc.scrollHeight - window.innerHeight); 2934 if (denom <= 0) return 0; 2935 return Math.max(0, Math.min(100, Math.round((window.scrollY / denom) * 100))); 2936} 2937 2938function sendPageEvent(extra = {}) { 2939 if (!canTrackBehavior()) return; 2940 if (!furieSessionId) return; 2941 2942 const payload = { 2943 session_id: furieSessionId, 2944 propertyId, 2945 url: location.href.split('#')[0], 2946 title: document.title, 2947 referrer: document.referrer || "", 2948 pagecontents: getCleanPageText().slice(0, 30000), 2949 time_spent_ms: Math.round(performance.now() - pageStartTs), 2950 viewport_w: window.innerWidth, 2951 viewport_h: window.innerHeight, 2952 scroll_pct: scrollPct(), 2953 ...currentUtm(), 2954 meta: extra.meta || null 2955 }; 2956 2957 navigator.sendBeacon(trackingEndpoint, JSON.stringify(payload)); 2958 navigator.sendBeacon(proactivemessageEndpoint, JSON.stringify(payload)); 2959} 2960// on hide/unload 2961document.addEventListener('visibilitychange', () => { 2962 if (document.visibilityState === 'hidden') sendPageEvent(); 2963}); 2964window.addEventListener('pagehide', () => sendPageEvent()); 2965// simple heartbeat (optional): every 5s 2966setInterval(() => sendPageEvent({meta:{heartbeat:true}}), 5000); 2967 2968 2969// --- MAIN FORM SUBMISSION LOGIC --- 2970form.addEventListener("submit", async (e) => { 2971 e.preventDefault(); 2972 const userInput = input.value.trim(); 2973 if (!userInput) return; 2974 2975 saveHistory('You', userInput); 2976 2977 // Live chat mode logic remains unchanged 2978 if (isLiveChatActive) { 2979 appendMessage("You", userInput, false); 2980 input.value = ""; 2981 try { 2982 await fetch(liveChatApiEndpoint, { 2983 method: 'POST', 2984 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 2985 body: new URLSearchParams({ session_id: liveChatSessionId, property_id: liveChatPropertyId, message: userInput, action: 'process-message' }) 2986 }); 2987 } catch (err) { 2988 console.error('Live chat send error:', err); 2989 } 2990 return; 2991 } 2992 2993 // Default Bot Behavior 2994 if (!infoCollected && (!userName || !userEmail)) { 2995 await promptForUserInfo(); 2996 infoCollected = true; 2997 } 2998 2999 appendMessage("You", userInput, false); 3000 input.value = ""; 3001 3002 // Create the payload and call the new refactored function 3003const payload = { 3004 session_id: furieSessionId, 3005 question: userInput, 3006 propertyId, 3007 guestname: userName, 3008 guestemail: userEmail, 3009 history: JSON.parse(localStorage.getItem("furrieChatHistory") || "[]") 3010, 3011 3012 // Visible Text Passthrough: 3013 pagecontents: getVisibleText().slice(0, 500000), // cap if you like 3014 page_url: location.href, 3015 page_title: document.title 3016 }; 3017 3018 await handleBotRequest(payload); 3019}); 3020 3021document.addEventListener('visibilitychange', () => { 3022 // When the user brings the tab back into focus, stop the notification. 3023 if (document.visibilityState === 'visible') { 3024 stopTitleNotification(); 3025 } 3026}); 3027 3028// Only run when DOM is ready so getCleanPageText() can see the body content 3029if (document.readyState === 'loading') { 3030 document.addEventListener('DOMContentLoaded', createOrResumeSession); 3031} else { 3032 createOrResumeSession(); 3033} 3034})();
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.