UNPKG

@xuda.io/runtime-bundle

Version:

The Xuda Runtime Bundle refers to a collection of scripts and libraries packaged together to provide the necessary runtime environment for executing plugins or components in the Xuda platform.

777 lines (741 loc) 36.2 kB
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="referrer" content="no-referrer"> <title>__WIDGET_TITLE__</title> <script src="https://accounts.google.com/gsi/client" async defer></script> <style> *, *::before, *::after { box-sizing: border-box; } html, body { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji", sans-serif; background: #f6f7f9; color: #1f2933; -webkit-font-smoothing: antialiased; } :root { --xu-brand: __WIDGET_COLOR__; --xu-text: #1f2933; --xu-muted: #67737f; --xu-line: #e3e7ec; --xu-bg: #f6f7f9; --xu-card: #ffffff; --xu-bubble-in: var(--xu-brand); --xu-bubble-out: #eef1f5; } .xu-app { display: flex; flex-direction: column; height: 100%; background: var(--xu-card); } .xu-header { background: var(--xu-brand); color: #fff; padding: 10px 14px; display: flex; align-items: center; gap: 12px; } .xu-avatar-wrap { width: 40px; height: 40px; border-radius: 50%; background: rgba(255,255,255,.18); overflow: hidden; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px; color: #fff; } .xu-avatar-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; } .xu-header .xu-title-block { flex: 1; min-width: 0; line-height: 1.2; } .xu-header .xu-title { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .xu-header .xu-subtitle { font-size: 12px; font-weight: 400; opacity: .85; margin-top: 2px; } .xu-online-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #4ade80; margin-right: 6px; vertical-align: middle; } .xu-mode-status { display: inline-flex; align-items: center; gap: 4px; } .xu-mode-status .xu-ai-label, .xu-mode-status .xu-online-label { transition: opacity .15s; } .xu-mode-status .xu-ai-label { display: none; } .xu-app.xu-auto .xu-mode-status .xu-online-label { display: none; } .xu-app.xu-auto .xu-mode-status .xu-ai-label { display: inline-flex; align-items: center; gap: 4px; } .xu-ai-spark { width: 12px; height: 12px; display: inline-block; } .xu-app.xu-auto .xu-online-dot { background: #a78bfa; } .xu-app.xu-auto .xu-header { background: linear-gradient(135deg, var(--xu-brand) 0%, #4f3a78 100%); } .xu-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 16px 6px; } .xu-greeting { background: var(--xu-bg); border: 1px solid var(--xu-line); border-radius: 12px; padding: 12px 14px; font-size: 14px; color: var(--xu-text); margin-bottom: 18px; line-height: 1.45; } .xu-form { display: flex; flex-direction: column; gap: 14px; } .xu-field { display: flex; flex-direction: column; gap: 6px; } .xu-field label { font-size: 12px; font-weight: 500; color: var(--xu-muted); padding-left: 2px; } .xu-field input { border: 1.5px solid var(--xu-line); border-radius: 10px; padding: 11px 13px; font-size: 14px; font-family: inherit; outline: none; transition: border-color .15s, box-shadow .15s; background: #fff; color: var(--xu-text); width: 100%; } .xu-field input:hover { border-color: #c8cfd8; } .xu-field input:focus { border-color: var(--xu-brand); box-shadow: 0 0 0 3px rgba(26,53,87,.12); } .xu-btn { background: var(--xu-brand); color: #fff; border: 0; border-radius: 10px; padding: 13px 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: filter .15s, transform .05s; position: relative; margin-top: 4px; } .xu-btn:hover:not(:disabled) { filter: brightness(1.08); } .xu-btn:active:not(:disabled) { transform: translateY(1px); } .xu-btn:disabled { cursor: progress; filter: brightness(.85); } .xu-btn.xu-loading::after { content: ''; position: absolute; right: 14px; top: 50%; width: 14px; height: 14px; margin-top: -7px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: xu-spin .8s linear infinite; } .xu-error { color: #c0392b; font-size: 12px; min-height: 14px; padding-left: 2px; } .xu-status { text-align: center; padding: 32px 16px; color: var(--xu-muted); font-size: 14px; line-height: 1.5; } .xu-status .xu-spinner { display: inline-block; width: 30px; height: 30px; border: 3px solid var(--xu-line); border-top-color: var(--xu-brand); border-radius: 50%; animation: xu-spin 1s linear infinite; margin-bottom: 12px; } @keyframes xu-spin { to { transform: rotate(360deg); } } .xu-msg { display: flex; flex-direction: column; margin-bottom: 10px; } .xu-msg .xu-bubble { max-width: 78%; padding: 10px 13px; border-radius: 16px; font-size: 14px; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; } .xu-msg.xu-in { align-items: flex-end; } .xu-msg.xu-in .xu-bubble { background: var(--xu-bubble-in); color: #fff; border-bottom-right-radius: 5px; } .xu-msg.xu-out { align-items: flex-start; } .xu-msg.xu-out .xu-bubble { background: var(--xu-bubble-out); color: var(--xu-text); border-bottom-left-radius: 5px; } .xu-msg.xu-rtl .xu-bubble { direction: rtl; text-align: right; } .xu-ts { font-size: 10px; color: var(--xu-muted); margin: 2px 6px 0; opacity: .75; font-variant-numeric: tabular-nums; } .xu-typing { display: none; align-items: center; gap: 4px; background: var(--xu-bubble-out); color: var(--xu-text); padding: 12px 14px; border-radius: 16px; border-bottom-left-radius: 5px; width: max-content; margin-bottom: 10px; } .xu-typing.xu-show { display: inline-flex; } .xu-typing-dot { width: 6px; height: 6px; border-radius: 50%; background: #67737f; animation: xu-dot 1.2s infinite ease-in-out; } .xu-typing-dot:nth-child(2) { animation-delay: .15s; } .xu-typing-dot:nth-child(3) { animation-delay: .3s; } @keyframes xu-dot { 0%, 60%, 100% { opacity: .3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } } .xu-msg .xu-bubble img.xu-att { display: block; max-width: 100%; max-height: 220px; margin-top: 6px; border-radius: 8px; cursor: zoom-in; } .xu-msg.xu-in .xu-bubble img.xu-att { border: 1px solid rgba(255,255,255,.2); } .xu-att-tray { display: none; gap: 6px; padding: 8px 12px 0; flex-wrap: wrap; background: #fff; } .xu-att-tray.xu-show { display: flex; } .xu-att-chip { position: relative; width: 56px; height: 56px; border-radius: 8px; background: #f0f3f7; overflow: hidden; flex: 0 0 auto; } .xu-att-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } .xu-att-chip .xu-att-remove { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #1f2933; color: #fff; border: 2px solid #fff; font-size: 11px; line-height: 14px; text-align: center; cursor: pointer; padding: 0; } .xu-composer-paperclip { background: transparent; color: var(--xu-muted); border: 0; cursor: pointer; padding: 0 6px; height: 40px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; } .xu-composer-paperclip:hover { color: var(--xu-brand); } .xu-composer { border-top: 1px solid var(--xu-line); padding: 10px 12px; display: flex; gap: 6px; background: #fff; align-items: flex-end; } .xu-composer textarea { flex: 1; resize: none; border: 1.5px solid var(--xu-line); border-radius: 10px; padding: 10px 12px; font-size: 14px; font-family: inherit; outline: none; max-height: 110px; min-height: 40px; line-height: 1.4; transition: border-color .15s, box-shadow .15s; } .xu-composer textarea:focus { border-color: var(--xu-brand); box-shadow: 0 0 0 3px rgba(26,53,87,.12); } .xu-composer button { background: var(--xu-brand); color: #fff; border: 0; border-radius: 10px; padding: 0 16px; height: 40px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; } .xu-composer button:hover:not(:disabled) { filter: brightness(1.08); } .xu-composer button:disabled { opacity: .55; cursor: not-allowed; } .xu-footer { text-align: center; font-size: 10px; color: #adb6c0; padding: 6px 0 8px; letter-spacing: .02em; } .xu-footer a { color: inherit; text-decoration: none; } .xu-google-wrap { display: flex; justify-content: center; } .xu-divider { display: flex; align-items: center; gap: 10px; color: var(--xu-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; margin: 4px 0 -4px; } .xu-divider::before, .xu-divider::after { content: ''; flex: 1; height: 1px; background: var(--xu-line); } .xu-email-toggle { background: transparent; color: var(--xu-muted); border: 0; font-size: 12px; cursor: pointer; padding: 6px; text-decoration: underline; text-underline-offset: 3px; align-self: center; } .xu-email-toggle:hover { color: var(--xu-brand); } .xu-email-form { display: none; flex-direction: column; gap: 12px; } .xu-email-form.xu-show { display: flex; } .xu-signup-note { text-align: center; font-size: 11px; color: var(--xu-muted); line-height: 1.4; } .xu-signup-note a { color: inherit; text-decoration: underline; } .xu-success-toast { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; padding: 10px 12px; border-radius: 10px; font-size: 13px; line-height: 1.45; margin-bottom: 12px; display: none; } .xu-success-toast.xu-show { display: block; } .xu-success-toast a { color: #047857; font-weight: 600; } /* Scrollbar */ .xu-body::-webkit-scrollbar { width: 6px; } .xu-body::-webkit-scrollbar-thumb { background: #d6dbe2; border-radius: 3px; } </style> </head> <body> <div class="xu-app __WIDGET_AUTO_CLASS__"> <div class="xu-header"> <div class="xu-avatar-wrap" id="xu-avatar-wrap"><img id="xu-avatar" src="__WIDGET_AVATAR__" alt="" onerror="this.style.display='none';this.parentNode.textContent=(document.getElementById('xu-title-text').textContent||'?').trim().charAt(0).toUpperCase();"></div> <div class="xu-title-block"> <div class="xu-title" id="xu-title-text">__WIDGET_TITLE__</div> <div class="xu-subtitle xu-mode-status" id="xu-subtitle"> <span class="xu-online-label"><span class="xu-online-dot"></span>Online</span> <span class="xu-ai-label"><span class="xu-online-dot"></span><svg class="xu-ai-spark" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l1.7 4.6L18 8.3l-4.3 1.7L12 14.6l-1.7-4.6L6 8.3l4.3-1.7L12 2zm6 10l1 2.5 2.5 1-2.5 1L18 19l-1-2.5-2.5-1L17 14.5 18 12zM5 14l.8 2 2 .8-2 .8L5 19.6l-.8-2-2-.8 2-.8L5 14z"/></svg>AI auto-reply</span> </div> </div> </div> <div class="xu-body" id="xu-body"> <div class="xu-success-toast" id="xu-success-toast"></div> <div class="xu-greeting" data-greeting-template="__WIDGET_GREETING__">__WIDGET_GREETING__</div> <div class="xu-form" id="xu-form"> <div class="xu-google-wrap" id="xu-google-wrap"></div> <div class="xu-signup-note">This will create a free Xuda account &middot; <a href="https://xuda.ai/legal/terms" target="_blank" rel="noopener">Terms &amp; Conditions</a></div> <div class="xu-error" id="xu-form-error"></div> </div> <div id="xu-status" class="xu-status" style="display:none"></div> <div id="xu-thread" style="display:none"></div> <div class="xu-typing" id="xu-typing" aria-label="Typing"><span class="xu-typing-dot"></span><span class="xu-typing-dot"></span><span class="xu-typing-dot"></span><span class="xu-typing-secs" id="xu-typing-secs" style="margin-left:8px;color:#67737f;font-size:11px;font-variant-numeric:tabular-nums"></span></div> </div> <div class="xu-att-tray" id="xu-att-tray"></div> <div class="xu-composer" id="xu-composer" style="display:none"> <input type="file" id="xu-file" accept="image/png,image/jpeg,image/webp,image/gif" multiple style="display:none"> <button class="xu-composer-paperclip" id="xu-paperclip" type="button" aria-label="Attach image"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M21 11l-9.5 9.5a5.5 5.5 0 0 1-7.78-7.78L13.5 3.5a3.75 3.75 0 1 1 5.3 5.3L9 18.5a2 2 0 0 1-2.83-2.83L15 7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> </button> <textarea id="xu-input" rows="1" placeholder="Type a message" aria-label="Message"></textarea> <button id="xu-send" type="button" aria-label="Send"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M3 11.5L20 4l-7.5 17-2.5-7L3 11.5z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg> </button> </div> <div class="xu-footer">powered by <a href="https://xuda.ai" target="_blank" rel="noopener">xuda</a></div> </div> <script> (function(){ var WIDGET_PROFILE_ID = '__WIDGET_PROFILE_ID__'; var CPI_BASE = '__CPI_BASE__'; var GOOGLE_CLIENT_ID = '__GOOGLE_CLIENT_ID__'; var XUDA_DASHBOARD_URL = '__XUDA_DASHBOARD_URL__'; var STORAGE_KEY = 'xuda_widget_' + WIDGET_PROFILE_ID; var POLL_FOCUSED_MS = 3000; var POLL_BLURRED_MS = 15000; var session = null; try { session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null'); } catch(e){} var lastTs = 0; var pollTimer = null; var docHasFocus = true; var $form = document.getElementById('xu-form'); var $status = document.getElementById('xu-status'); var $thread = document.getElementById('xu-thread'); var $composer = document.getElementById('xu-composer'); var $name = document.getElementById('xu-name'); var $email = document.getElementById('xu-email'); var $submit = document.getElementById('xu-submit'); var $err = document.getElementById('xu-form-error'); var $input = document.getElementById('xu-input'); var $send = document.getElementById('xu-send'); var $subtitle = document.getElementById('xu-subtitle'); var $avatar = document.getElementById('xu-avatar'); var $file = document.getElementById('xu-file'); var $paperclip = document.getElementById('xu-paperclip'); var $attTray = document.getElementById('xu-att-tray'); var $googleWrap = document.getElementById('xu-google-wrap'); var $emailToggle = document.getElementById('xu-email-toggle'); var $emailForm = document.getElementById('xu-email-form'); var $divider = document.getElementById('xu-divider'); var $successToast = document.getElementById('xu-success-toast'); var $typing = document.getElementById('xu-typing'); var typingTimer = null; var pendingAttachments = []; // [{ data_uri, filename, size, type }] var renderedIds = new Set(); var pendingOptimisticTexts = []; // [text] of unconfirmed optimistic bubbles awaiting their canonical poll-returned twin function cpi(method, body){ return fetch(CPI_BASE + method, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }).then(function(r){ return r.json(); }); } function showStatus(html){ $form.style.display = 'none'; $thread.style.display = 'none'; $composer.style.display = 'none'; $status.style.display = ''; $status.innerHTML = html; // Wire any "Start over" link that the status HTML may have included. // Clears the persisted widget session and reloads so the visitor gets // the signup form again — useful when a previous team_request is stuck // in 'pending' (e.g. because auto_respond was not wired at signup time). var resetLink = document.getElementById('xu-reset-link'); if (resetLink) { resetLink.addEventListener('click', function(e){ e.preventDefault(); try { sessionStorage.removeItem(STORAGE_KEY); } catch(_){} if (pollTimer) clearInterval(pollTimer); location.reload(); }); } } function showThread(){ $form.style.display = 'none'; $status.style.display = 'none'; $thread.style.display = ''; $composer.style.display = ''; } function escapeHtml(s){ return String(s == null ? '' : s) .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); } // Compact timestamp under each bubble: "HH:MM" for today, "Mon DD · HH:MM" // for past days. Uses the visitor's local timezone via toLocaleTimeString. function formatBubbleTs(ts){ if (!ts) return ''; var d = new Date(ts); if (isNaN(d.getTime())) return ''; var now = new Date(); var sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); if (sameDay) return time; var day = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return day + ' · ' + time; } // Substitute the {visitor_name} placeholder in the persona's greeting with // the signed-in visitor's first name (or strip it cleanly pre-signup so the // sentence still reads naturally). The greeting template is server-rendered // into the `data-greeting-template` attribute — we re-apply on signup so a // returning visitor (session in sessionStorage) sees their name immediately // and a fresh visitor sees the un-personalized variant until they identify. function renderGreeting(visitorFirstName){ var el = document.querySelector('.xu-greeting'); if (!el) return; var template = el.getAttribute('data-greeting-template') || el.textContent || ''; var out; if (visitorFirstName) { // "Hi {visitor_name}, I'm X." → "Hi Ioshka, I'm X." out = template.replace(/\{visitor_name\}/g, visitorFirstName); } else { // Pre-signup: strip {visitor_name} plus the trailing comma+space (or // trailing exclamation+space) and collapse the leftover whitespace so // "Hi {visitor_name}, I'm X." → "Hi, I'm X." out = template .replace(/\{visitor_name\}, /g, '') .replace(/\{visitor_name\}! /g, '') .replace(/\{visitor_name\}/g, ''); } el.textContent = out; } var typingStartTs = 0; var typingCounterTimer = null; var typingErrorShown = false; var $typingSecs = document.getElementById('xu-typing-secs'); function updateTypingSecs(){ if (!$typingSecs || !typingStartTs) return; var s = Math.floor((Date.now() - typingStartTs) / 1000); $typingSecs.textContent = s + 's'; } function showTyping(){ if (!$typing) return; typingErrorShown = false; $typing.classList.add('xu-show'); typingStartTs = Date.now(); if ($typingSecs) $typingSecs.textContent = '0s'; if (typingCounterTimer) clearInterval(typingCounterTimer); typingCounterTimer = setInterval(updateTypingSecs, 1000); var body = document.getElementById('xu-body'); if (body) body.scrollTop = body.scrollHeight; if (typingTimer) clearTimeout(typingTimer); // After 60s with no reply, show "problem detected" message and stop the typing indicator. typingTimer = setTimeout(function(){ if (typingErrorShown) return; typingErrorShown = true; hideTyping(); var html = '<div class="xu-msg xu-out"><div class="xu-bubble">A problem detected, come back later. Sorry for the inconvenience.</div></div>'; $thread.insertAdjacentHTML('beforeend', html); var b = document.getElementById('xu-body'); if (b) b.scrollTop = b.scrollHeight; }, 60000); } function hideTyping(){ if ($typing) $typing.classList.remove('xu-show'); if (typingTimer) { clearTimeout(typingTimer); typingTimer = null; } if (typingCounterTimer) { clearInterval(typingCounterTimer); typingCounterTimer = null; } typingStartTs = 0; if ($typingSecs) $typingSecs.textContent = ''; } function renderAttachments(atts){ if (!atts || !atts.length) return ''; var html = ''; for (var i = 0; i < atts.length; i++) { var src = atts[i].data_uri || atts[i].url || ''; if (!src) continue; html += '<img class="xu-att" src="' + escapeHtml(src) + '" alt="' + escapeHtml(atts[i].filename || '') + '" onclick="window.open(this.src,\'_blank\')">'; } return html; } function renderItems(items){ if (!items || !items.length) return; var html = ''; items.forEach(function(it){ if (it._id && renderedIds.has(it._id)) return; // If this is a poll-returned visitor message that matches a pending optimistic bubble, // the optimistic is already in DOM. Skip duplicate render but advance lastTs. if (!it.__optimistic && it.direction === 'in' && it.text) { var idx = pendingOptimisticTexts.indexOf(it.text); if (idx !== -1) { pendingOptimisticTexts.splice(idx, 1); if (it._id) renderedIds.add(it._id); if (it.date_created_ts && it.date_created_ts > lastTs) lastTs = it.date_created_ts; return; } } if (it._id) renderedIds.add(it._id); var sideClass = it.direction === 'in' ? 'xu-in' : 'xu-out'; var rtl = it.rtl ? ' xu-rtl' : ''; var attHtml = renderAttachments(it.attachments); var bubbleInner = escapeHtml(it.text || '') + attHtml; if (!bubbleInner.trim()) return; var ts = it.date_created_ts || (it.__optimistic ? Date.now() : 0); var tsHtml = ts ? '<div class="xu-ts">' + escapeHtml(formatBubbleTs(ts)) + '</div>' : ''; html += '<div class="xu-msg ' + sideClass + rtl + '"><div class="xu-bubble">' + bubbleInner + '</div>' + tsHtml + '</div>'; // Only advance lastTs from server-issued items so the poll's since_ts filter stays // in the server's clock frame (the client clock may drift ahead of the server). if (!it.__optimistic && it.date_created_ts && it.date_created_ts > lastTs) lastTs = it.date_created_ts; // Any inbound (rep) item ends the typing state if (!it.__optimistic && it.direction === 'out') hideTyping(); }); if (!html) return; $thread.insertAdjacentHTML('beforeend', html); var body = document.getElementById('xu-body'); body.scrollTop = body.scrollHeight; } function renderAttTray(){ if (!pendingAttachments.length) { $attTray.classList.remove('xu-show'); $attTray.innerHTML = ''; return; } var html = ''; for (var i = 0; i < pendingAttachments.length; i++) { html += '<div class="xu-att-chip"><img src="' + escapeHtml(pendingAttachments[i].data_uri) + '" alt=""><button type="button" class="xu-att-remove" data-idx="' + i + '" aria-label="Remove">×</button></div>'; } $attTray.innerHTML = html; $attTray.classList.add('xu-show'); } function readFileAsDataURL(file){ return new Promise(function(resolve, reject){ var r = new FileReader(); r.onload = function(){ resolve(r.result); }; r.onerror = reject; r.readAsDataURL(file); }); } async function pickFiles(files){ var MAX_BYTES = 2_000_000; var MAX_COUNT = 5; var allowed = ['image/png','image/jpeg','image/webp','image/gif']; for (var i = 0; i < files.length; i++) { var f = files[i]; if (!allowed.includes(f.type)) continue; if (f.size > MAX_BYTES) continue; if (pendingAttachments.length >= MAX_COUNT) break; try { var data_uri = await readFileAsDataURL(f); pendingAttachments.push({ data_uri: data_uri, filename: f.name, size: f.size, type: f.type }); } catch (e) {} } renderAttTray(); } function setAssignedInfo(d){ if (d.assigned_avatar && $avatar) { $avatar.src = d.assigned_avatar; $avatar.style.display = ''; } } function startPolling(){ if (pollTimer) clearInterval(pollTimer); var interval = docHasFocus ? POLL_FOCUSED_MS : POLL_BLURRED_MS; pollTimer = setInterval(pollOnce, interval); pollOnce(); } function pollOnce(){ if (!session || !session.widget_token) return; cpi('widget_get_messages', { widget_token: session.widget_token, since_ts: lastTs }) .then(function(r){ if (!r || r.code < 0) return; var d = r.data || {}; if (d.state === 'declined') { if (pollTimer) clearInterval(pollTimer); showStatus('Your request was not accepted.'); return; } if (d.state === 'pending') { showStatus('<div class="xu-spinner"></div><div>Waiting for a representative to accept your request&hellip;</div><div style="margin-top:14px;font-size:12px"><a href="#" id="xu-reset-link" style="color:var(--xu-muted);text-decoration:underline;text-underline-offset:3px">Start over</a></div>'); return; } if (d.state === 'open') { showThread(); if (d.conversation_id) session.conversation_id = d.conversation_id; renderItems(d.items); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); } }) .catch(function(){}); } function showSuccessToast(d){ if (!d || !d.account_active) return; $successToast.innerHTML = 'Welcome! You now have a free <strong>xuda.ai</strong> account. <a href="' + escapeHtml(d.signin_url || XUDA_DASHBOARD_URL) + '" target="_blank">Sign in with Google</a> anytime — no password needed.'; $successToast.classList.add('xu-show'); setTimeout(function(){ $successToast.classList.remove('xu-show'); }, 12000); } function applySignupResponse(d){ session = d; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); setAssignedInfo(session); renderGreeting(session.visitor_first_name || ''); showSuccessToast(d); if (session.state === 'open') { showThread(); } else { showStatus('<div class="xu-spinner"></div><div>Waiting for a representative to accept your request&hellip;</div><div style="margin-top:14px;font-size:12px"><a href="#" id="xu-reset-link" style="color:var(--xu-muted);text-decoration:underline;text-underline-offset:3px">Start over</a></div>'); } startPolling(); } function onGoogleCredential(resp){ if (!resp || !resp.credential) return; cpi('widget_signup_google', { profile_id: WIDGET_PROFILE_ID, id_token: resp.credential, widget_origin: (window.location && window.location.hostname) || '', }).then(function(r){ if (!r || r.code < 0) { $err = document.getElementById('xu-form-error'); if ($err) $err.textContent = (r && r.data) || 'Google sign-in failed.'; return; } applySignupResponse(r.data); }).catch(function(){ var $e = document.getElementById('xu-form-error'); if ($e) $e.textContent = 'Network error. Please try again.'; }); } // Email sign-in has been removed — Google is the only method. If GIS fails // to load we can't fall back to email, so surface a message instead of // leaving the visitor staring at an empty form. function fallbackToEmail(){ var err = document.getElementById('xu-form-error'); if (err) err.textContent = 'Sign-in is temporarily unavailable. Please try again shortly.'; } function initGoogle(){ if (!GOOGLE_CLIENT_ID || !window.google || !window.google.accounts || !window.google.accounts.id) { return fallbackToEmail(); } try { window.google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: onGoogleCredential, ux_mode: 'popup', auto_select: false, }); window.google.accounts.id.renderButton($googleWrap, { theme: 'outline', size: 'large', type: 'standard', shape: 'rectangular', text: 'continue_with', logo_alignment: 'left', width: 280, }); // If Google logs an origin error, the button silently doesn't render. After a short delay, // check if the wrap has children; if not, fall back to the email form so the visitor isn't stuck. setTimeout(function(){ if ($googleWrap && !$googleWrap.firstElementChild) { fallbackToEmail(); } }, 1500); } catch (e) { fallbackToEmail(); } } // Poll for GIS readiness up to ~5s (function waitForGsi(t){ if (window.google && window.google.accounts && window.google.accounts.id) return initGoogle(); if (t > 25) return initGoogle(); // give up and fall through setTimeout(function(){ waitForGsi(t + 1); }, 200); })(0); function submitSignup(){ var name = ($name.value || '').trim(); var email = ($email.value || '').trim(); $err.textContent = ''; if (!name) { $err.textContent = 'Please enter your name.'; return; } if (!email || !/^[\w.\-+]+@[\w\-.]+\.[a-zA-Z]{2,}$/.test(email)) { $err.textContent = 'Please enter a valid email.'; return; } $submit.disabled = true; $submit.classList.add('xu-loading'); cpi('widget_signup', { profile_id: WIDGET_PROFILE_ID, email: email, name: name, widget_origin: (window.location && window.location.hostname) || '', }).then(function(r){ $submit.disabled = false; $submit.classList.remove('xu-loading'); if (!r || r.code < 0) { $err.textContent = (r && r.data) || 'Something went wrong. Please try again.'; return; } applySignupResponse(r.data); }).catch(function(){ $submit.disabled = false; $submit.classList.remove('xu-loading'); $err.textContent = 'Network error. Please try again.'; }); } function sendMessage(){ var text = ($input.value || '').trim(); var atts = pendingAttachments.slice(); if ((!text && !atts.length) || !session || !session.widget_token) return; $send.disabled = true; // Optimistic render with __optimistic flag. Track text so the poll-returned twin is recognized. if (text) pendingOptimisticTexts.push(text); renderItems([{ __optimistic: true, text: text, attachments: atts, direction: 'in', date_created_ts: Date.now() }]); $input.value = ''; $input.style.height = 'auto'; pendingAttachments = []; renderAttTray(); cpi('widget_send_message', { widget_token: session.widget_token, prompt: text, attachments: atts }) .then(function(r){ $send.disabled = false; if (r && r.code === -409) { showStatus('<div class="xu-spinner"></div><div>Waiting for a representative to accept your request&hellip;</div><div style="margin-top:14px;font-size:12px"><a href="#" id="xu-reset-link" style="color:var(--xu-muted);text-decoration:underline;text-underline-offset:3px">Start over</a></div>'); } else if (r && r.code === -401) { sessionStorage.removeItem(STORAGE_KEY); session = null; $form.style.display = ''; showStatus('Your session expired. Please sign in again.'); } else if (r && typeof r.code === 'number' && r.code < 0) { showChatError(); } else { showTyping(); pollOnce(); } }) .catch(function(){ $send.disabled = false; showChatError(); }); } function showChatError(){ hideTyping(); var html = '<div class="xu-msg xu-out"><div class="xu-bubble">A problem detected, come back later. Sorry for the inconvenience.</div></div>'; $thread.insertAdjacentHTML('beforeend', html); var b = document.getElementById('xu-body'); if (b) b.scrollTop = b.scrollHeight; } // Auto-grow textarea $input.addEventListener('input', function(){ $input.style.height = 'auto'; $input.style.height = Math.min($input.scrollHeight, 110) + 'px'; }); // Email sign-in removed — Google-only. (submitSignup + the $name/$email/ // $submit/$emailToggle elements no longer exist, so their wiring is gone.) $send.addEventListener('click', sendMessage); $paperclip.addEventListener('click', function(){ $file.click(); }); $file.addEventListener('change', function(e){ pickFiles(e.target.files); $file.value = ''; }); $attTray.addEventListener('click', function(e){ var btn = e.target.closest('.xu-att-remove'); if (!btn) return; var idx = Number(btn.getAttribute('data-idx')); pendingAttachments.splice(idx, 1); renderAttTray(); }); $input.addEventListener('keydown', function(e){ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); window.addEventListener('focus', function(){ docHasFocus = true; if (session) startPolling(); }); window.addEventListener('blur', function(){ docHasFocus = false; if (session) startPolling(); }); // Initial greeting render. If a returning visitor's session is in // sessionStorage we already know their first name; otherwise show the // un-personalized greeting until they identify via Google/email. renderGreeting(session && session.visitor_first_name); if (session && session.widget_token) { setAssignedInfo(session); if (session.state === 'open') { showThread(); } else { showStatus('<div class="xu-spinner"></div><div>Waiting for a representative to accept your request&hellip;</div><div style="margin-top:14px;font-size:12px"><a href="#" id="xu-reset-link" style="color:var(--xu-muted);text-decoration:underline;text-underline-offset:3px">Start over</a></div>'); } startPolling(); } // ── Site-level login bridge ─────────────────────────────────────────── // The embedding site (e.g. xuda.network) can own the Google sign-in and // pass the resulting id_token down so this widget adopts the same visitor // instead of prompting again. Adoption is lazy (on first open) so we don't // create a contact request for visitors who never engage the chat. The // server still verifies the token (aud must match the same client id), so // a postMessage can only ever supply a legitimate Google credential. (function siteLoginBridge(){ if (window.parent === window) return; // not embedded var siteIdToken = null; var chatOpened = false; var adopting = false; var WDBG = true; function wlog(){ if (WDBG && window.console) { try { console.log.apply(console, ['[xuda-widget v3]'].concat([].slice.call(arguments))); } catch(e){} } } function adopt(){ if (adopting) { wlog('adopt: already adopting'); return; } if (session && session.widget_token) { wlog('adopt: already signed in'); return; } if (!siteIdToken) { wlog('adopt: no siteIdToken yet'); return; } adopting = true; wlog('adopt: calling widget_signup_google with site token'); onGoogleCredential({ credential: siteIdToken }); } window.addEventListener('message', function(ev){ if (ev.source !== window.parent) return; // only the embedding page var d = ev.data; if (!d || typeof d.type !== 'string' || d.type.indexOf('xuda:') !== 0) return; wlog('recv', d.type, 'from parent'); if (d.type === 'xuda:site-session' && d.id_token) { siteIdToken = d.id_token; wlog('stored site token; chatOpened=' + chatOpened); if (chatOpened) adopt(); } else if (d.type === 'xuda:widget-open') { chatOpened = true; // Re-request the session at open time in case the initial ready // handshake was missed (iframe-mount race). The parent replies with // xuda:site-session, which adopts since chatOpened is now true. if (!siteIdToken && !(session && session.widget_token)) { try { window.parent.postMessage({ type: 'xuda:widget-ready' }, '*'); } catch (e) {} } adopt(); } else if (d.type === 'xuda:site-logout') { try { sessionStorage.removeItem(STORAGE_KEY); } catch(_){} try { if (pollTimer) clearInterval(pollTimer); } catch(_){} try { location.reload(); } catch(_){} } }); // Announce readiness so the parent replies with the current site session. wlog('posting widget-ready to parent'); try { window.parent.postMessage({ type: 'xuda:widget-ready' }, '*'); } catch(e){} })(); })(); </script> </body> </html>