PageSourceSearch

https://flycast-chat-float.lovable.app/flycast-widget.js

js flycast-chat-float.lovable.app collected 2026-10-03 03:31:33 UTC 21,537 bytes, 732 lines download raw bytes

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, '&amp;')
459      .replace(/</g, '&lt;')
460      .replace(/>/g, '&gt;');
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.