1class FlycastChatWidget extends HTMLElement { 2 constructor() { 3 super(); 4 this.attachShadow({ mode: 'open' }); 5 this.isOpen = false; 6 this.messages = []; 7 this.leadCaptured = false; 8 } 9 10 connectedCallback() { 11 this.apiUrl = this.getAttribute('src') || ''; 12 this.render(); 13 this.attachEventListeners(); 14 } 15 16 render() { 17 const styles = ` 18 <style> 19 * { 20 margin: 0; 21 padding: 0; 22 box-sizing: border-box; 23 } 24 25 :host { 26 position: fixed; 27 bottom: 24px; 28 right: 24px; 29 z-index: 999999; 30 font-family: 'Inter', Arial, sans-serif; 31 } 32 33 .chat-button { 34 width: 60px; 35 height: 60px; 36 border-radius: 16px; 37 background: #FFB100; 38 border: none; 39 cursor: pointer; 40 box-shadow: 0 4px 12px rgba(255, 177, 0, 0.3); 41 display: flex; 42 align-items: center; 43 justify-content: center; 44 transition: all 0.3s ease; 45 } 46 47 .chat-button:hover { 48 transform: scale(1.05); 49 box-shadow: 0 6px 16px rgba(255, 177, 0, 0.4); 50 } 51 52 .chat-button img { 53 width: 32px; 54 height: 32px; 55 } 56 57 .chat-window { 58 position: fixed; 59 bottom: 100px; 60 right: 24px; 61 width: 400px; 62 max-width: calc(100vw - 48px); 63 height: 600px; 64 max-height: calc(100vh - 150px); 65 background: #FFFFFF; 66 border-radius: 16px; 67 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); 68 display: flex; 69 flex-direction: column; 70 opacity: 0; 71 transform: translateY(20px); 72 pointer-events: none; 73 transition: all 0.3s ease; 74 } 75 76 .chat-window.open { 77 opacity: 1; 78 transform: translateY(0); 79 pointer-events: all; 80 } 81 82 .chat-header { 83 background: #FFB100; 84 color: #121212; 85 padding: 16px 20px; 86 border-radius: 16px 16px 0 0; 87 display: flex; 88 justify-content: space-between; 89 align-items: center; 90 font-weight: 600; 91 } 92 93 .close-btn { 94 background: none; 95 border: none; 96 color: #121212; 97 cursor: pointer; 98 font-size: 24px; 99 line-height: 1; 100 padding: 0; 101 width: 28px; 102 height: 28px; 103 display: flex; 104 align-items: center; 105 justify-content: center; 106 } 107 108 .chat-messages { 109 flex: 1; 110 overflow-y: auto; 111 padding: 20px; 112 display: flex; 113 flex-direction: column; 114 gap: 12px; 115 } 116 117 .message { 118 max-width: 80%; 119 padding: 12px 16px; 120 border-radius: 16px; 121 line-height: 1.5; 122 color: #121212; 123 font-size: 14px; 124 white-space: pre-wrap; 125 } 126 127 .message.user { 128 background: #FFB100; 129 align-self: flex-end; 130 margin-left: auto; 131 } 132 133 .message.assistant { 134 background: #F5F5F5; 135 align-self: flex-start; 136 } 137 138 .chat-input-wrapper { 139 padding: 16px 20px; 140 border-top: 1px solid #E5E5E5; 141 display: flex; 142 gap: 8px; 143 } 144 145 .chat-input { 146 flex: 1; 147 padding: 12px 16px; 148 border: 1px solid #E5E5E5; 149 border-radius: 16px; 150 font-family: inherit; 151 font-size: 14px; 152 outline: none; 153 transition: border-color 0.2s; 154 } 155 156 .chat-input:focus { 157 border-color: #FFB100; 158 } 159 160 .send-btn { 161 background: #FFB100; 162 color: #121212; 163 border: none; 164 padding: 12px 20px; 165 border-radius: 16px; 166 cursor: pointer; 167 font-weight: 600; 168 font-size: 14px; 169 transition: all 0.2s; 170 } 171 172 .send-btn:hover { 173 background: #E5A000; 174 } 175 176 .send-btn:disabled { 177 opacity: 0.5;
178 cursor: not-allowed; 179 } 180 181 .lead-form-overlay { 182 position: absolute; 183 top: 0; 184 left: 0; 185 right: 0; 186 bottom: 0; 187 background: rgba(255, 255, 255, 0.98); 188 display: none; 189 flex-direction: column; 190 padding: 24px; 191 z-index: 10; 192 border-radius: 16px; 193 } 194 195 .lead-form-overlay.show { 196 display: flex; 197 } 198 199 .lead-form-title { 200 font-size: 20px; 201 font-weight: 700; 202 color: #121212; 203 margin-bottom: 8px; 204 } 205 206 .lead-form-subtitle { 207 font-size: 14px; 208 color: #666; 209 margin-bottom: 24px; 210 } 211 212 .form-group { 213 margin-bottom: 16px; 214 } 215 216 .form-label { 217 display: block; 218 font-size: 13px; 219 font-weight: 600; 220 color: #121212; 221 margin-bottom: 6px; 222 } 223 224 .form-input { 225 width: 100%; 226 padding: 12px 16px; 227 border: 1px solid #E5E5E5; 228 border-radius: 16px; 229 font-family: inherit; 230 font-size: 14px; 231 outline: none; 232 transition: border-color 0.2s; 233 } 234 235 .form-input:focus { 236 border-color: #FFB100; 237 } 238 239 .form-input.error { 240 border-color: #ff4444; 241 } 242 243 .form-error { 244 color: #ff4444; 245 font-size: 12px; 246 margin-top: 4px; 247 display: none; 248 } 249 250 .form-error.show { 251 display: block; 252 } 253 254 .form-actions { 255 display: flex; 256 gap: 8px; 257 margin-top: 24px; 258 } 259 260 .form-btn { 261 flex: 1; 262 padding: 12px 20px; 263 border: none; 264 border-radius: 16px; 265 font-weight: 600; 266 font-size: 14px; 267 cursor: pointer; 268 transition: all 0.2s; 269 } 270 271 .form-btn-primary { 272 background: #FFB100; 273 color: #121212; 274 } 275 276 .form-btn-primary:hover { 277 background: #E5A000; 278 } 279 280 .form-btn-secondary { 281 background: #F5F5F5; 282 color: #121212; 283 } 284 285 .form-btn-secondary:hover { 286 background: #E5E5E5; 287 } 288 289 @media (max-width: 480px) { 290 .chat-window { 291 width: calc(100vw - 24px); 292 right: 12px; 293 bottom: 90px; 294 } 295 296 :host { 297 right: 12px; 298 bottom: 12px; 299 } 300 } 301 </style> 302 `; 303 304 const html = ` 305 ${styles} 306 <button class="chat-button" id="toggleBtn" aria-label="Open chat"> 307 <span style="font-size:28px; line-height:1;">ð¬</span> 308 </button> 309 <div class="chat-window" id="chatWindow"> 310 <div class="chat-header"> 311 <span>Flycast Assistant</span> 312 <button class="close-btn" id="closeBtn">Ã</button> 313 </div> 314 <div class="chat-messages" id="messages"></div> 315 <div class="chat-input-wrapper"> 316 <input type="text" class="chat-input" id="chatInput" placeholder="Type your message..." /> 317 <button class="send-btn" id="sendBtn">Send</button> 318 </div> 319 <div class="lead-form-overlay" id="leadForm"> 320 <div class="lead-form-title">Continue the conversation</div> 321 <div class="lead-form-subtitle">Help us serve you better by sharing a few details</div> 322 <form id="leadCaptureForm"> 323 <div class="form-group"> 324 <label class="form-label">First Name *</label> 325 <input type="text" class="form-input" id="firstName" required /> 326 <div class="form-error" id="firstNameError">Please enter your first name</div> 327 </div> 328 <div class="form-group"> 329 <label class="form-label">Email *</label> 330 <input type="email" class="form-input" id="email" required /> 331 <div class="form-error" id="emailError">Please enter a valid email</div> 332 </div> 333 <div class="form-group"> 334 <label class="form-label">Mobile *</label> 335 <input type="tel" class="form-input" id="mobile" required /> 336 <div class="form-error" id="mobileError">Please enter a valid mobile number</div> 337 </div> 338 <div class="form-group"> 339 <label class="form-label">Website *</label> 340 <input type="text" class="form-input" id="website" placeholder="Website (e.g. www.flycastmedia.co.uk)" required /> 341 <div class="form-error" id="websiteError">Please enter your website (any format is fine)</div> 342 </div> 343 <div class="form-actions"> 344 <button type="button" class="form-btn form-btn-secondary" id="skipBtn">Skip</button> 345 <button type="submit" class="form-btn form-btn-primary">Continue</button> 346 </div> 347 </form> 348 </div> 349 </div> 350 `; 351 352 this.shadowRoot.innerHTML = html; 353 } 354 355 attachEventListeners() { 356 const toggleBtn = this.shadowRoot.getElementById('toggleBtn'); 357 const closeBtn = this.shadowRoot.getElementById('closeBtn'); 358 const sendBtn = this.shadowRoot.getElementById('sendBtn'); 359 const chatInput = this.shadowRoot.getElementById('chatInput'); 360 const leadForm = this.shadowRoot.getElementById('leadCaptureForm'); 361 const skipBtn = this.shadowRoot.getElementById('skipBtn'); 362 363 toggleBtn.addEventListener('click', () => this.toggleChat()); 364 closeBtn.addEventListener('click', () => this.toggleChat()); 365 sendBtn.addEventListener('click', () => this.sendMessage()); 366 chatInput.addEventListener('keypress', (e) => { 367 if (e.key === 'Enter') this.sendMessage(); 368 }); 369 leadForm.addEventListener('submit', (e) => this.handleLeadSubmit(e)); 370 skipBtn.addEventListener('click', () => this.skipLeadForm()); 371 } 372 373 toggleChat() { 374 this.isOpen = !this.isOpen; 375 const chatWindow = this.shadowRoot.getElementById('chatWindow'); 376 if (this.isOpen) { 377 chatWindow.classList.add('open'); 378 if (this.messages.length === 0) { 379 this.addMessage('assistant', 'Hello! I\'m your Flycast Media assistant. How can I help you today?'); 380 } 381 } else { 382 chatWindow.classList.remove('open'); 383 } 384 } 385 386 addMessage(role, content) { 387 const trimmedContent = typeof content === 'string' ? content.trim() : content; 388 389 // Special handling: if assistant sends SHOW_LEAD_FORM 390 if (role === 'assistant' && trimmedContent === 'SHOW_LEAD_FORM') { 391 // If we've already captured lead details, don't show the form again 392 if (this.leadCaptured) { 393 const fallbackMessage = "I'm here to keep helping. What would you like to know more about?"; 394 const messagesContainer = this.shadowRoot.getElementById('messages'); 395 const messageDiv = document.createElement('div'); 396 messageDiv.className = 'message assistant'; 397 messageDiv.textContent = fallbackMessage; 398 messagesContainer.appendChild(messageDiv); 399 messagesContainer.scrollTop = messagesContainer.scrollHeight; 400 this.messages.push({ role: 'assistant', content: fallbackMessage }); 401 } else { 402 this.showLeadForm(); 403 } 404 return; 405 } 406 407 const formattedContent = 408 role === 'assistant' ? this.formatAssistantMessage(trimmedContent) : trimmedContent; 409 410 const messagesContainer = this.shadowRoot.getElementById('messages'); 411 const messageDiv = document.createElement('div'); 412 messageDiv.className = `message ${role}`; 413 414 if (role === 'assistant') { 415 // Assistant messages can contain basic HTML (links, line breaks) 416 messageDiv.innerHTML = formattedContent; 417 } else { 418 // User messages are always treated as plain text for safety 419 messageDiv.textContent = formattedContent; 420 } 421 422 messagesContainer.appendChild(messageDiv); 423 messagesContainer.scrollTop = messagesContainer.scrollHeight; 424 425 this.messages.push({ role, content: formattedContent }); 426 } 427 428 formatAssistantMessage(content) { 429 const text = typeof content === 'string' ? content : String(content ?? ''); 430 431 // Ensure the core service list is always clear and numbered on separate lines 432 const hasServiceList = 433 text.includes('Investor lead generation') && 434 text.includes('Reputation management'); 435 436 if (hasServiceList) { 437 const servicesBlock = 438 'We offer the following core services for financial firms, including hedge funds:\n\n' + 439 '1. Investor lead generation\n\n' + 440 '2. Financial website design\n\n' + 441 '3. AI automation consultancy\n\n' + 442 '4. Pitch Decks\n\n' + 443 '5. Search engine optimisation\n\n' + 444 '6. Financial PR and social media\n\n' + 445 '7. Financial branding\n\n' + 446 '8. Reputation management\n\n' + 447 'Would you like more detail about any of these? (Just type the number)'; 448 449 return this.formatTextWithLinksAndLineBreaks(servicesBlock); 450 } 451 452 return this.formatTextWithLinksAndLineBreaks(text); 453 } 454 455 formatTextWithLinksAndLineBreaks(text) { 456 // Basic HTML-escaping to avoid accidental HTML injection 457 let safe = text 458 .replace(/&/g, '&') 459 .replace(/</g, '<') 460 .replace(/>/g, '>'); 461 462 // Make plain URLs clickable 463 const urlRegex = /(https?:\/\/[^\s]+)/g; 464 safe = safe.replace(urlRegex, (url) => { 465 return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`; 466 }); 467 468 // Convert line breaks to <br> for display 469 return safe.replace(/\n/g, '<br />'); 470 } 471 472 473 474 async sendMessage() { 475 const chatInput = this.shadowRoot.getElementById('chatInput'); 476 const sendBtn = this.shadowRoot.getElementById('sendBtn'); 477 const message = chatInput.value.trim(); 478 479 if (!message) return; 480 481 chatInput.value = ''; 482 sendBtn.disabled = true; 483 484 this.addMessage('user', message); 485 486 try { 487 // Get page content 488 const pageContent = document.body.innerText.substring(0, 3000); 489 490 const response = await fetch(this.apiUrl, { 491 method: 'POST', 492 headers: { 493 'Content-Type': 'application/json', 494 }, 495 body: JSON.stringify({ 496 messages: this.messages, 497 pageContent: pageContent, 498 }), 499 }); 500 501 if (!response.ok) { 502 throw new Error('Failed to get response'); 503 } 504 505 const reader = response.body.getReader(); 506 const decoder = new TextDecoder(); 507 let assistantMessage = ''; 508 let messageDiv = null; 509 510 while (true) { 511 const { done, value } = await reader.read(); 512 if (done) break; 513 514 const chunk = decoder.decode(value); 515 const lines = chunk.split('\n'); 516 517 for (const line of lines) { 518 if (line.startsWith('data: ')) { 519 const data = line.slice(6); 520 if (data === '[DONE]') continue; 521 522 try { 523 const parsed = JSON.parse(data); 524 const content = parsed.choices?.[0]?.delta?.content; 525 526 if (content) { 527 assistantMessage += content; 528 529 // If the model is streaming out SHOW_LEAD_FORM, don't render it as text 530 if (assistantMessage.trim() === 'SHOW_LEAD_FORM') { 531 if (messageDiv) { 532 messageDiv.remove(); 533 messageDiv = null; 534 } 535 continue; 536 } 537 538 if (!messageDiv) { 539 const messagesContainer = this.shadowRoot.getElementById('messages'); 540 messageDiv = document.createElement('div'); 541 messageDiv.className = 'message assistant'; 542 messagesContainer.appendChild(messageDiv); 543 } 544
545 const formatted = this.formatAssistantMessage(assistantMessage); 546 messageDiv.innerHTML = formatted; 547 const messagesContainer = this.shadowRoot.getElementById('messages'); 548 messagesContainer.scrollTop = messagesContainer.scrollHeight; 549 } 550 } catch (e) { 551 // Ignore JSON parse errors for incomplete chunks 552 } 553 } 554 } 555 } 556 557 // Final handling for assistant message 558 if (assistantMessage.trim() === 'SHOW_LEAD_FORM') { 559 // If lead already captured, show fallback message instead 560 if (this.leadCaptured) { 561 const fallbackMessage = "I'm here to keep helping. What would you like to know more about?"; 562 if (!messageDiv) { 563 const messagesContainer = this.shadowRoot.getElementById('messages'); 564 messageDiv = document.createElement('div'); 565 messageDiv.className = 'message assistant'; 566 messagesContainer.appendChild(messageDiv); 567 } 568 messageDiv.textContent = fallbackMessage; 569 this.messages.push({ role: 'assistant', content: fallbackMessage }); 570 } else { 571 this.showLeadForm(); 572 } 573 } else if (assistantMessage) { 574 this.messages.push({ role: 'assistant', content: assistantMessage }); 575 } 576 } catch (error) { 577 console.error('Error:', error); 578 this.addMessage('assistant', 'Sorry, I encountered an error. Please try again.'); 579 } finally { 580 sendBtn.disabled = false; 581 } 582 } 583 584 showLeadForm() { 585 const leadForm = this.shadowRoot.getElementById('leadForm'); 586 leadForm.classList.add('show'); 587 } 588 589 hideLeadForm() { 590 const leadForm = this.shadowRoot.getElementById('leadForm'); 591 leadForm.classList.remove('show'); 592 } 593 594 skipLeadForm() { 595 this.leadCaptured = true; 596 this.hideLeadForm(); 597 } 598 599 validateEmail(email) { 600 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); 601 } 602 603 validatePhone(phone) { 604 return /^[\d\s\+\-\(\)]+$/.test(phone) && phone.replace(/\D/g, '').length >= 10; 605 } 606 607 validateURL(url) { 608 if (!url) return true; // Optional field 609 try { 610 new URL(url); 611 return true; 612 } catch { 613 return false; 614 } 615 } 616 617 handleLeadSubmit(e) { 618 e.preventDefault(); 619 620 const firstName = this.shadowRoot.getElementById('firstName').value.trim(); 621 const email = this.shadowRoot.getElementById('email').value.trim(); 622 const mobile = this.shadowRoot.getElementById('mobile').value.trim(); 623 const website = this.shadowRoot.getElementById('website').value.trim(); 624 625 let hasError = false; 626 627 // Validate first name 628 if (!firstName) { 629 this.showError('firstName', 'Please enter your first name'); 630 hasError = true; 631 } else { 632 this.hideError('firstName'); 633 } 634 635 // Validate email 636 if (!email || !this.validateEmail(email)) { 637 this.showError('email', 'Please enter a valid email'); 638 hasError = true; 639 } else { 640 this.hideError('email'); 641 } 642 643 // Validate mobile 644 if (!mobile || !this.validatePhone(mobile)) { 645 this.showError('mobile', 'Please enter a valid mobile number'); 646 hasError = true; 647 } else { 648 this.hideError('mobile'); 649 } 650 651 // Validate website (required but flexible format) 652 if (!website) { 653 this.showError('website', 'Please enter your website (any format is fine)'); 654 hasError = true; 655 } else { 656 this.hideError('website'); 657 } 658 659 if (hasError) return; 660 661 // Store lead data 662 this.leadData = { firstName, email, mobile, website }; 663 this.leadCaptured = true; 664 this.hideLeadForm(); 665 666 // Send email notification immediately 667 try { 668 fetch('https://splsmxscimffahjfwswe.supabase.co/functions/v1/send-lead-notification', { 669 method: 'POST', 670 headers: { 671 'Content-Type': 'application/json', 672 }, 673 body: JSON.stringify({ 674 firstName, 675 email, 676 mobile, 677 website, 678 transcript: this.messages, 679 }), 680 }).catch(() => { 681 // Fail silently â email should not break chat experience 682 }); 683 } catch (err) { 684 // Ignore network errors 685 } 686 687 // Fire webhook to Make.com for research automation 688 try { 689 fetch('https://hook.eu2.make.com/l2us4fgu1exoye50potj3etox1vg6juq', { 690 method: 'POST', 691 headers: { 692 'Content-Type': 'application/json', 693 }, 694 body: JSON.stringify({ 695 source: 'flycast-chat-widget', 696 firstName, 697 email, 698 mobile, 699 website, 700 transcript: this.messages, 701 }), 702 }).catch(() => { 703 // Fail silently â webhook should not break chat experience 704 }); 705 } catch (err) { 706 // Ignore network errors 707 } 708 709 // Add confirmation message 710 this.addMessage( 711 'assistant', 712 `Thanks, ${firstName}
712! Someone from our team will contact you shortly.\n\nWould you like to keep chatting with the assistant in the meantime? (Y/N)` 713 ); 714 } 715 716 showError(fieldId, message) { 717 const input = this.shadowRoot.getElementById(fieldId); 718 const error = this.shadowRoot.getElementById(`${fieldId}Error`); 719 input.classList.add('error'); 720 error.textContent = message; 721 error.classList.add('show'); 722 } 723 724 hideError(fieldId) { 725 const input = this.shadowRoot.getElementById(fieldId); 726 const error = this.shadowRoot.getElementById(`${fieldId}Error`); 727 input.classList.remove('error'); 728 error.classList.remove('show'); 729 } 730} 731 732customElements.define('flycast-chat-widget', FlycastChatWidget);
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.