@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
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 · <a href="https://xuda.ai/legal/terms" target="_blank" rel="noopener">Terms & 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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
// 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…</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…</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…</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…</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>