(function () { var cfg = window.EB_CONCIERGE || {}; var botId = cfg.botId; var apiBase = String(cfg.apiBase || '').replace(/\/$/, ''); if (!botId || !apiBase) { console.error('[EventBuzz Concierge] Missing window.EB_CONCIERGE.botId or apiBase'); return; } var sessionId = null; var busy = false; var quickReplies = []; var quickRepliesEl = null; var typingEl = null; var voiceEnabled = false; var voiceState = 'idle'; var voiceRoom = null; var voiceConnecting = false; var seenTranscriptIds = {}; var remoteAudioEls = []; var FEEDBACK_THANKS_UP = 'Thanks — glad that was helpful!'; var FEEDBACK_THANKS_DOWN = 'Thanks for the feedback. We’ll use it to improve.'; var DEFAULT_ACCENT = 'rgb(13, 148, 136)'; function el(tag, attrs, children) { var n = document.createElement(tag); if (attrs) { Object.keys(attrs).forEach(function (k) { n.setAttribute(k, attrs[k]); }); } (children || []).forEach(function (c) { n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); return n; } var embedOrigin = apiBase.replace(/\/api$/, ''); function addCssOnce() { if (document.getElementById('ebc-css')) return; document.head.appendChild( el('link', { id: 'ebc-css', rel: 'stylesheet', href: embedOrigin + '/embed/concierge-widget.css?v=22', }), ); } function parseAccentRgb(input) { var s = String(input || '').trim(); if (!s) return null; var hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); if (hex) { var h = hex[1]; if (h.length === 3) { h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; } return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), }; } var rgb = s.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+\s*)?\)$/i); if (rgb) { var r = Number(rgb[1]); var g = Number(rgb[2]); var b = Number(rgb[3]); if (r <= 255 && g <= 255 && b <= 255) return { r: r, g: g, b: b }; } return null; } function mixWithWhite(channel, amount) { return Math.round(channel + (255 - channel) * amount); } function applyAccentColor(color) { var parsed = parseAccentRgb(color) || parseAccentRgb(DEFAULT_ACCENT); if (!parsed || !root) return; var r = parsed.r; var g = parsed.g; var b = parsed.b; root.style.setProperty('--ebc-accent', 'rgb(' + r + ', ' + g + ', ' + b + ')'); root.style.setProperty('--ebc-accent-rgb', r + ', ' + g + ', ' + b); root.style.setProperty( '--ebc-user', 'rgb(' + mixWithWhite(r, 0.88) + ', ' + mixWithWhite(g, 0.88) + ', ' + mixWithWhite(b, 0.88) + ')', ); } addCssOnce(); var root = el('div', { id: 'eventbuzz-concierge-root' }); applyAccentColor(cfg.accentColor || cfg.accent || DEFAULT_ACCENT); var launcherLogo = el('img', { class: 'ebc-launcher-logo', src: embedOrigin + '/embed/logo.png', alt: '', width: '36', height: '36', }); var launcherLabel = el('span', { class: 'ebc-launcher-label' }, ['ask me']); var launcher = el( 'button', { class: 'ebc-launcher', type: 'button', 'aria-label': 'Ask me' }, [launcherLogo, launcherLabel], ); var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Your virtual concierge' }); var header = el('div', { class: 'ebc-header' }); var headerBrand = el('div', { class: 'ebc-header-brand' }); var headerLogo = el('img', { class: 'ebc-header-logo', src: embedOrigin + '/embed/logo.png', alt: '', width: '32', height: '32', }); var title = el('div', { class: 'ebc-title' }, ['Your virtual concierge']); headerBrand.appendChild(headerLogo); headerBrand.appendChild(title); var headerActions = el('div', { class: 'ebc-header-actions' }); var endVoiceBtn = el( 'button', { class: 'ebc-end-voice ebc-hidden', type: 'button', 'aria-label': 'End voice', title: 'End voice', }, ['End voice'], ); var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close chat' }, ['×']); headerActions.appendChild(endVoiceBtn); headerActions.appendChild(closeBtn); header.appendChild(headerBrand); header.appendChild(headerActions); var messages = el('div', { class: 'ebc-messages' }); var errorEl = el('div', { class: 'ebc-error' }); var inputBar = el('div', { class: 'ebc-inputbar' }); var input = el('input', { class: 'ebc-input', placeholder: 'Ask a question…', maxlength: '800', }); // WhatsApp-style paper-plane send icon var sendIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sendIcon.setAttribute('class', 'ebc-send-icon'); sendIcon.setAttribute('viewBox', '0 0 24 24'); sendIcon.setAttribute('width', '22'); sendIcon.setAttribute('height', '22'); sendIcon.setAttribute('aria-hidden', 'true'); var sendPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); sendPath.setAttribute('fill', 'currentColor'); sendPath.setAttribute( 'd', 'M1.101 21.757L23.8 12.028 1.101 2.3l.011 7.912 13.623 1.816-13.623 1.817-.011 7.912z', ); sendIcon.appendChild(sendPath); var sendBtn = el('button', { class: 'ebc-send', type: 'button', 'aria-label': 'Send', title: 'Send', }); sendBtn.appendChild(sendIcon); var micIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); micIcon.setAttribute('viewBox', '0 0 24 24'); micIcon.setAttribute('width', '20'); micIcon.setAttribute('height', '20'); micIcon.setAttribute('aria-hidden', 'true'); var micPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); micPath.setAttribute('fill', 'currentColor'); micPath.setAttribute( 'd', 'M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.93V21h2v-3.07A7 7 0 0 0 19 11h-2z', ); micIcon.appendChild(micPath); var micBtn = el('button', { class: 'ebc-mic ebc-hidden', type: 'button', 'aria-label': 'Start voice', title: 'Start voice', }); micBtn.appendChild(micIcon); inputBar.appendChild(input); inputBar.appendChild(micBtn); inputBar.appendChild(sendBtn); panel.appendChild(header); panel.appendChild(messages); panel.appendChild(errorEl); panel.appendChild(inputBar); root.appendChild(panel); root.appendChild(launcher); document.body.appendChild(root); function setError(text) { errorEl.textContent = text || ''; } var EVENT_TZ = 'Asia/Kolkata'; function formatWhen(iso, timeZone) { if (!iso) return ''; try { return new Date(iso).toLocaleString('en-IN', { timeZone: timeZone || EVENT_TZ, weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } catch (e) { return String(iso); } } function formatTimeOnly(iso, timeZone) { if (!iso) return ''; try { return new Date(iso).toLocaleTimeString('en-IN', { timeZone: timeZone || EVENT_TZ, hour: 'numeric', minute: '2-digit', hour12: true, }); } catch (e) { return ''; } } function sameDayInTz(startIso, endIso, timeZone) { try { var opts = { timeZone: timeZone || EVENT_TZ, year: 'numeric', month: '2-digit', day: '2-digit' }; return ( new Date(startIso).toLocaleDateString('en-CA', opts) === new Date(endIso).toLocaleDateString('en-CA', opts) ); } catch (e) { return false; } } function formatTimeRange(startIso, endIso, timeZone) { var tz = timeZone || EVENT_TZ; if (!startIso && !endIso) return ''; if (startIso && endIso) { try { if (sameDayInTz(startIso, endIso, tz)) { return formatWhen(startIso, tz) + ' – ' + formatTimeOnly(endIso, tz); } return formatWhen(startIso, tz) + ' – ' + formatWhen(endIso, tz); } catch (e) { return formatWhen(startIso, tz); } } return formatWhen(startIso || endIso, tz); } /** Format topic duration for cards: "5" / "5 min" → "5m"; leave other values as-is. */ function formatTopicDuration(duration) { var raw = String(duration == null ? '' : duration).trim(); if (!raw) return ''; var match = raw.match(/^(\d+(?:\s*\+\s*\d+)?)\s*(m|min|mins|minutes)?$/i); if (match) return match[1].replace(/\s+/g, '') + 'm'; return raw; } /** Topic line: "Title (5m)" and optional " · Role". */ function formatTopicLine(t) { if (!t) return ''; var title = String(t.title || '').trim(); if (!title) return ''; var dur = formatTopicDuration(t.duration); var role = String(t.role || '').trim(); var line = title; if (dur) line += ' (' + dur + ')'; if (role) line += ' · ' + role; return line; } function buildCtaCard(data) { var title = String((data && data.title) || 'Register').trim() || 'Register'; var action = title; var url = String((data && data.url) || '').trim(); var card = el('article', { class: 'ebc-cta-card' }); if (!url) return card; card.appendChild( el( 'a', { class: 'ebc-cta-btn', href: url, target: '_blank', rel: 'noopener noreferrer', }, [action], ), ); return card; } function buildFileCard(data) { var card = el('article', { class: 'ebc-file-card' }); var title = String((data && data.title) || 'Download').trim() || 'Download'; var url = String((data && data.url) || '').trim(); var mediaType = String((data && data.mediaType) || '').toLowerCase(); card.appendChild(el('div', { class: 'ebc-file-title' }, [title])); if (url && mediaType === 'image') { var img = el('img', { class: 'ebc-file-preview', src: url, alt: title, loading: 'lazy', }); card.appendChild(img); } else if (url && mediaType === 'pdf') { card.appendChild( el('div', { class: 'ebc-file-meta' }, ['PDF document']), ); } if (url) { var actions = el('div', { class: 'ebc-file-actions' }); if (mediaType === 'image' || mediaType === 'pdf') { actions.appendChild( el( 'a', { class: 'ebc-file-btn ebc-file-view', href: url, target: '_blank', rel: 'noopener noreferrer', }, [mediaType === 'pdf' ? 'Open PDF' : 'View'], ), ); } actions.appendChild( el( 'a', { class: 'ebc-file-btn ebc-file-download', href: url, target: '_blank', rel: 'noopener noreferrer', download: title, }, ['Download'], ), ); card.appendChild(actions); } return card; } function buildSessionCard(data) { var card = el('article', { class: 'ebc-session-card' }); card.appendChild(el('div', { class: 'ebc-session-title' }, [data.title || 'Session'])); var range = data.timeRangeLocal || formatTimeRange(data.startTime, data.endTime, data.timeZone || EVENT_TZ); if (range) { card.appendChild(el('div', { class: 'ebc-session-meta ebc-session-time' }, [range])); } var trackBits = []; if (data.track) trackBits.push(data.track); if (data.trackHall) trackBits.push(data.trackHall); if (trackBits.length) { card.appendChild( el('div', { class: 'ebc-session-meta ebc-session-track' }, ['Track: ' + trackBits.join(' · ')]), ); } if (data.location) { card.appendChild(el('div', { class: 'ebc-session-meta' }, ['Location: ' + data.location])); } if (Array.isArray(data.speakers) && data.speakers.length) { card.appendChild( el('div', { class: 'ebc-session-speakers' }, ['Speakers: ' + data.speakers.slice(0, 4).join(', ')]), ); } if (Array.isArray(data.topics) && data.topics.length) { var topicsWrap = el('div', { class: 'ebc-session-topics' }); topicsWrap.appendChild(el('div', { class: 'ebc-session-topics-label' }, ['Topics'])); var ul = el('ul', { class: 'ebc-session-topics-list' }); data.topics.slice(0, 8).forEach(function (t) { var line = formatTopicLine(t); if (line) ul.appendChild(el('li', {}, [line])); }); topicsWrap.appendChild(ul); card.appendChild(topicsWrap); } if (Array.isArray(data.resources) && data.resources.length) { var links = el('div', { class: 'ebc-session-resources' }); data.resources.slice(0, 4).forEach(function (r) { if (!r || !r.url) return; links.appendChild( el( 'a', { class: 'ebc-session-resource', href: String(r.url), target: '_blank', rel: 'noopener noreferrer', }, [String(r.name || 'Download')], ), ); }); if (links.childNodes.length) card.appendChild(links); } return card; } function buildPlaceCard(data, opts) { opts = opts || {}; var isHotel = opts.kind === 'accommodation'; var card = el('article', { class: 'ebc-place-card' + (isHotel ? ' ebc-accommodation-card' : ''), }); if (isHotel) { card.appendChild(el('div', { class: 'ebc-place-badge' }, ['Hotel'])); } card.appendChild(el('div', { class: 'ebc-place-title' }, [data.name || (isHotel ? 'Stay' : 'Place')])); if (data.address) { card.appendChild(el('div', { class: 'ebc-place-meta' }, [String(data.address)])); } var bits = []; if (data.rating != null && data.rating !== '') bits.push('★ ' + String(data.rating)); if (data.distance != null && data.distance !== '') { var m = Number(data.distance); bits.push(Number.isFinite(m) ? (m >= 1000 ? (m / 1000).toFixed(1) + ' km' : Math.round(m) + ' m') : String(data.distance)); } if (bits.length) card.appendChild(el('div', { class: 'ebc-place-meta' }, [bits.join(' · ')])); if (data.mapsUrl) { card.appendChild( el( 'a', { class: 'ebc-place-link', href: String(data.mapsUrl), target: '_blank', rel: 'noopener noreferrer', }, ['Open map'], ), ); } if (isHotel && data.website) { card.appendChild( el( 'a', { class: 'ebc-place-link', href: String(data.website), target: '_blank', rel: 'noopener noreferrer', }, ['Website'], ), ); } return card; } function buildSpeakerCard(data) { var card = el('article', { class: 'ebc-speaker-card' }); var head = el('div', { class: 'ebc-speaker-head' }); if (data.photo) { head.appendChild( el('img', { class: 'ebc-speaker-photo', src: String(data.photo), alt: String(data.name || 'Speaker'), width: '48', height: '48', }), ); } var info = el('div', { class: 'ebc-speaker-info' }); info.appendChild(el('div', { class: 'ebc-speaker-name' }, [data.name || 'Speaker'])); var roleBits = []; if (data.speakerRole) roleBits.push(String(data.speakerRole)); if (data.title) roleBits.push(String(data.title)); if (data.organizationName) roleBits.push(String(data.organizationName)); if (roleBits.length) { info.appendChild(el('div', { class: 'ebc-speaker-meta' }, [roleBits.join(' · ')])); } head.appendChild(info); card.appendChild(head); if (data.bio) { var bio = String(data.bio); if (bio.length > 280) bio = bio.slice(0, 277) + '…'; card.appendChild(el('div', { class: 'ebc-speaker-bio' }, [bio])); } var links = el('div', { class: 'ebc-speaker-links' }); [ { key: 'linkedin', label: 'LinkedIn' }, { key: 'twitter', label: 'Twitter/X' }, { key: 'website', label: 'Website' }, ].forEach(function (item) { var href = data[item.key]; if (!href) return; links.appendChild( el( 'a', { class: 'ebc-speaker-link', href: String(href), target: '_blank', rel: 'noopener noreferrer', }, [item.label], ), ); }); if (links.childNodes.length) card.appendChild(links); return card; } function buildExhibitorCard(data) { var card = el('article', { class: 'ebc-exhibitor-card' }); var head = el('div', { class: 'ebc-exhibitor-head' }); if (data.logo) { head.appendChild( el('img', { class: 'ebc-exhibitor-logo', src: String(data.logo), alt: String(data.name || 'Exhibitor'), width: '48', height: '48', }), ); } var info = el('div', { class: 'ebc-exhibitor-info' }); info.appendChild(el('div', { class: 'ebc-exhibitor-name' }, [data.name || 'Exhibitor'])); var metaBits = []; if (data.boothNumber) metaBits.push('Booth ' + String(data.boothNumber)); if (data.hall) metaBits.push(String(data.hall)); if (data.category) metaBits.push(String(data.category)); if (metaBits.length) { info.appendChild(el('div', { class: 'ebc-exhibitor-meta' }, [metaBits.join(' · ')])); } head.appendChild(info); card.appendChild(head); if (data.shortDescription) { var desc = String(data.shortDescription); if (desc.length > 220) desc = desc.slice(0, 217) + '…'; card.appendChild(el('div', { class: 'ebc-exhibitor-desc' }, [desc])); } var links = el('div', { class: 'ebc-exhibitor-links' }); [ { key: 'website', label: 'Website' }, { key: 'linkedin', label: 'LinkedIn' }, { key: 'twitter', label: 'Twitter/X' }, { key: 'instagram', label: 'Instagram' }, ].forEach(function (item) { var href = data[item.key]; if (!href) return; links.appendChild( el( 'a', { class: 'ebc-exhibitor-link', href: String(href), target: '_blank', rel: 'noopener noreferrer', }, [item.label], ), ); }); if (links.childNodes.length) card.appendChild(links); return card; } function buildTicketQrCard(data) { var card = el('article', { class: 'ebc-ticket-qr-card' }); card.appendChild(el('div', { class: 'ebc-ticket-qr-title' }, [data.name || 'Your ticket'])); var metaBits = []; if (data.ticketName) metaBits.push(String(data.ticketName)); if (data.status) metaBits.push(String(data.status)); if (metaBits.length) { card.appendChild(el('div', { class: 'ebc-ticket-qr-meta' }, [metaBits.join(' · ')])); } if (data.imageUrl) { card.appendChild( el('img', { class: 'ebc-ticket-qr-img', src: String(data.imageUrl), alt: 'Check-in QR code', width: '200', height: '200', }), ); card.appendChild( el('div', { class: 'ebc-ticket-qr-hint' }, ['Show this QR at check-in']), ); var downloadHref = data.participantId ? embedOrigin + '/embed/participant-check-in-qr/' + encodeURIComponent(String(data.participantId)) + '.png?download=1' : String(data.imageUrl); var actions = el('div', { class: 'ebc-ticket-qr-actions' }); var downloadBtn = el( 'a', { class: 'ebc-ticket-qr-download', href: downloadHref, download: 'check-in-qr.png', target: '_blank', rel: 'noopener noreferrer', }, ['Download QR'], ); var emailBtn = el( 'button', { class: 'ebc-ticket-qr-email', type: 'button' }, ['Send me Email'], ); emailBtn.addEventListener('click', function () { if (busy) return; clearQuickReplies(); input.value = 'Email me my ticket QR code'; sendMessage(); }); actions.appendChild(downloadBtn); actions.appendChild(emailBtn); card.appendChild(actions); } return card; } function scrollToElementTop(targetEl) { if (!targetEl || !messages.contains(targetEl)) return; var pad = 8; var delta = targetEl.getBoundingClientRect().top - messages.getBoundingClientRect().top - pad; messages.scrollTop += delta; } function showTyping() { hideTyping(); typingEl = el('div', { class: 'ebc-msg bot ebc-typing', 'aria-live': 'polite', 'aria-label': 'Concierge is typing', }); var dots = el('div', { class: 'ebc-typing-dots' }); dots.appendChild(el('span', { class: 'ebc-typing-dot' })); dots.appendChild(el('span', { class: 'ebc-typing-dot' })); dots.appendChild(el('span', { class: 'ebc-typing-dot' })); typingEl.appendChild(dots); messages.appendChild(typingEl); messages.scrollTop = messages.scrollHeight; } function hideTyping() { if (typingEl && typingEl.parentNode) { typingEl.parentNode.removeChild(typingEl); } typingEl = null; } function addEphemeralBotMessage(text) { var bubble = el('div', { class: 'ebc-msg bot ebc-ephemeral' }); bubble.appendChild(document.createTextNode(String(text || ''))); messages.appendChild(bubble); } function buildFeedbackFooter(messageId) { var footer = el('div', { class: 'ebc-feedback' }); footer.appendChild( el('span', { class: 'ebc-feedback-label' }, ['Was this reply useful?']), ); var actions = el('div', { class: 'ebc-feedback-actions' }); var upBtn = el( 'button', { class: 'ebc-feedback-btn', type: 'button', 'aria-label': 'Yes, useful', title: 'Useful', }, ['👍'], ); var downBtn = el( 'button', { class: 'ebc-feedback-btn', type: 'button', 'aria-label': 'Not useful', title: 'Not useful', }, ['👎'], ); function lockButtons() { upBtn.disabled = true; downBtn.disabled = true; } async function submitFeedback(satisfied) { if (!sessionId || !messageId) return; lockButtons(); if (footer.parentNode) footer.parentNode.removeChild(footer); addEphemeralBotMessage(satisfied ? FEEDBACK_THANKS_UP : FEEDBACK_THANKS_DOWN); try { await api('/public/concierge/' + encodeURIComponent(botId) + '/feedback', { method: 'POST', body: { sessionId: sessionId, messageId: messageId, satisfied: satisfied }, }); } catch (err) { // UI already updated; feedback persist is best-effort console.warn('[EventBuzz Concierge] feedback failed:', err && err.message); } } upBtn.addEventListener('click', function () { submitFeedback(true); }); downBtn.addEventListener('click', function () { submitFeedback(false); }); actions.appendChild(upBtn); actions.appendChild(downBtn); footer.appendChild(actions); return footer; } function addMessage(role, text, opts) { opts = opts || {}; var cards = Array.isArray(opts.cards) ? opts.cards : []; var scrollMode = opts.scroll || 'bottom'; var bubble = el('div', { class: 'ebc-msg ' + (role === 'user' ? 'user' : 'bot') + (cards.length ? ' ebc-has-cards' : '') + (opts.ephemeral ? ' ebc-ephemeral' : ''), }); if (role === 'bot' || role === 'assistant') { if (text) { bubble.appendChild( el('div', { class: cards.length || opts.messageId ? 'ebc-intro' : '' }, [String(text)]), ); } if (cards.length) { var list = el('div', { class: 'ebc-cards-list' }); cards.forEach(function (card) { if (!card) return; var data = card.data || {}; if (card.type === 'session') list.appendChild(buildSessionCard(data)); else if (card.type === 'speaker') list.appendChild(buildSpeakerCard(data)); else if (card.type === 'exhibitor') list.appendChild(buildExhibitorCard(data)); else if (card.type === 'file') list.appendChild(buildFileCard(data)); else if (card.type === 'cta') list.appendChild(buildCtaCard(data)); else if (card.type === 'ticket_qr') { list.appendChild(buildTicketQrCard(data)); } else if (card.type === 'accommodation') { list.appendChild(buildPlaceCard(data, { kind: 'accommodation' })); } else if (card.type === 'place' || data.placeId || (data.name && data.address && !data.sessionId)) { list.appendChild(buildPlaceCard(data)); } }); if (list.childNodes.length) bubble.appendChild(list); } else if (!text) { bubble.appendChild(document.createTextNode('')); } if (opts.messageId) { bubble.appendChild(buildFeedbackFooter(String(opts.messageId))); } } else { bubble.appendChild(document.createTextNode(String(text || ''))); } messages.appendChild(bubble); if (scrollMode === 'bottom') { messages.scrollTop = messages.scrollHeight; } return bubble; } function clearQuickReplies() { if (quickRepliesEl && quickRepliesEl.parentNode) { quickRepliesEl.parentNode.removeChild(quickRepliesEl); } quickRepliesEl = null; } function renderQuickReplies(items) { clearQuickReplies(); if (!items || !items.length) return; quickRepliesEl = el('div', { class: 'ebc-quick-replies', role: 'group', 'aria-label': 'Suggested questions', }); items.forEach(function (item) { var label = String((item && item.label) || '').trim(); var message = String((item && item.message) || label).trim(); if (!label || !message) return; var chip = el('button', { class: 'ebc-chip', type: 'button' }, [label]); chip.addEventListener('click', function () { if (busy) return; clearQuickReplies(); input.value = message; sendMessage(); }); quickRepliesEl.appendChild(chip); }); if (!quickRepliesEl.childNodes.length) { quickRepliesEl = null; return; } messages.appendChild(quickRepliesEl); messages.scrollTop = messages.scrollHeight; } function setOpen(open) { if (open) root.classList.add('ebc-open'); else root.classList.remove('ebc-open'); } function setVoiceState(next) { voiceState = next; root.classList.remove( 'ebc-voice-idle', 'ebc-voice-connecting', 'ebc-voice-listening', 'ebc-voice-speaking', 'ebc-voice-error', ); root.classList.add('ebc-voice-' + next); micBtn.classList.toggle('ebc-hidden', !voiceEnabled); micBtn.classList.toggle('is-active', next === 'listening' || next === 'speaking' || next === 'connecting'); micBtn.classList.toggle('is-listening', next === 'listening'); micBtn.classList.toggle('is-speaking', next === 'speaking'); micBtn.classList.toggle('is-connecting', next === 'connecting'); micBtn.disabled = next === 'connecting' || busy; var live = next === 'listening' || next === 'speaking' || next === 'connecting'; micBtn.setAttribute('aria-label', live ? 'End voice' : 'Start voice'); micBtn.title = live ? 'End voice' : 'Start voice'; endVoiceBtn.classList.toggle('ebc-hidden', !live); if (next === 'listening') input.setAttribute('placeholder', 'Listening… you can still type'); else if (next === 'speaking') input.setAttribute('placeholder', 'Concierge is speaking…'); else if (next === 'connecting') input.setAttribute('placeholder', 'Starting voice…'); else input.setAttribute('placeholder', 'Ask a question…'); } function rememberTranscript(id) { if (!id) return false; if (seenTranscriptIds[id]) return true; seenTranscriptIds[id] = true; return false; } function appendVoiceTranscript(role, text, id) { var cleaned = String(text || '').trim(); if (!cleaned) return; if (rememberTranscript(id || role + ':' + cleaned)) return; addMessage(role === 'user' ? 'user' : 'bot', cleaned); } function loadLivekitClient() { if (window.LivekitClient) return Promise.resolve(window.LivekitClient); if (window.livekit) return Promise.resolve(window.livekit); if (window.__ebcLivekitPromise) return window.__ebcLivekitPromise; window.__ebcLivekitPromise = new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/livekit-client@2.15.14/dist/livekit-client.umd.js'; script.async = true; script.onload = function () { var lib = window.LivekitClient || window.livekit; if (!lib || !lib.Room) { reject(new Error('Voice client failed to load')); return; } resolve(lib); }; script.onerror = function () { window.__ebcLivekitPromise = null; reject(new Error('Could not load voice client')); }; document.head.appendChild(script); }); return window.__ebcLivekitPromise; } function clearRemoteAudio() { remoteAudioEls.forEach(function (el) { try { if (el && el.srcObject) el.srcObject = null; if (el && el.parentNode) el.parentNode.removeChild(el); } catch (e) { /* ignore */ } }); remoteAudioEls = []; } function attachRemoteAudio(track) { if (!track) return; var kind = track.kind || (track.source && String(track.source)); if (kind && String(kind).toLowerCase() !== 'audio') return; var el = track.attach ? track.attach() : null; if (!el) return; el.autoplay = true; el.playsInline = true; el.setAttribute('playsinline', 'true'); el.style.display = 'none'; document.body.appendChild(el); remoteAudioEls.push(el); var play = el.play && el.play(); if (play && play.catch) play.catch(function () {}); } async function stopVoice() { var room = voiceRoom; voiceRoom = null; voiceConnecting = false; clearRemoteAudio(); if (room) { try { await room.disconnect(); } catch (e) { /* ignore */ } } setVoiceState('idle'); } async function startVoice() { if (!voiceEnabled || voiceConnecting || voiceState === 'connecting') return; if (voiceRoom) { await stopVoice(); return; } setError(''); voiceConnecting = true; setVoiceState('connecting'); try { var Livekit = await loadLivekitClient(); var granted = await api('/public/concierge/' + encodeURIComponent(botId) + '/voice/token', { method: 'POST', body: { sessionId: sessionId || undefined }, }); sessionId = granted.sessionId || sessionId; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('This browser cannot access the microphone'); } await navigator.mediaDevices.getUserMedia({ audio: true }).then(function (preview) { preview.getTracks().forEach(function (track) { track.stop(); }); }); var MicSource = Livekit.Track && Livekit.Track.Source && Livekit.Track.Source.Microphone; var room = new Livekit.Room({ adaptiveStream: false, dynacast: false, audioCaptureDefaults: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); room.on(Livekit.RoomEvent.Disconnected, function () { if (voiceRoom === room) { voiceRoom = null; voiceConnecting = false; setVoiceState('idle'); } }); if (Livekit.RoomEvent.MediaDevicesError) { room.on(Livekit.RoomEvent.MediaDevicesError, function (err) { setError((err && err.message) || 'Microphone error'); }); } room.on(Livekit.RoomEvent.ActiveSpeakersChanged, function (speakers) { if (voiceRoom !== room || voiceState === 'connecting') return; var localId = room.localParticipant && room.localParticipant.identity; var agentSpeaking = (speakers || []).some(function (p) { return p && p.identity && p.identity !== localId; }); setVoiceState(agentSpeaking ? 'speaking' : 'listening'); }); room.on(Livekit.RoomEvent.TranscriptionReceived, function (segments, participant) { if (voiceRoom !== room) return; var localId = room.localParticipant && room.localParticipant.identity; var isUser = participant && participant.identity === localId; (segments || []).forEach(function (seg) { if (!seg || !seg.final) return; appendVoiceTranscript(isUser ? 'user' : 'bot', seg.text, seg.id); }); }); room.on(Livekit.RoomEvent.TrackSubscribed, function (track, publication, participant) { console.info('[ebc-voice] subscribed', track && track.kind, participant && participant.identity); attachRemoteAudio(track); }); if (Livekit.RoomEvent.LocalTrackPublished) { room.on(Livekit.RoomEvent.LocalTrackPublished, function (pub) { console.info('[ebc-voice] mic published', pub && pub.trackSid, pub && pub.source); }); } await room.connect(granted.url, granted.token); if (typeof room.startAudio === 'function') { await room.startAudio().catch(function () {}); } room.remoteParticipants.forEach(function (participant) { participant.trackPublications.forEach(function (pub) { if (pub && pub.track) attachRemoteAudio(pub.track); }); }); var micPub = null; if (typeof room.localParticipant.setMicrophoneEnabled === 'function') { micPub = await room.localParticipant.setMicrophoneEnabled(true); } if (!micPub && typeof Livekit.createLocalAudioTrack === 'function') { var micTrack = await Livekit.createLocalAudioTrack({ echoCancellation: true, noiseSuppression: true, autoGainControl: true, }); micPub = await room.localParticipant.publishTrack(micTrack); } var published = micPub || (MicSource != null && room.localParticipant.getTrackPublication && room.localParticipant.getTrackPublication(MicSource)); if (!published) { throw new Error('Microphone did not publish. Allow mic access and try again.'); } voiceRoom = room; voiceConnecting = false; setVoiceState('listening'); console.info('[ebc-voice] connected', granted.roomName, 'mic', published.trackSid || true); } catch (err) { voiceConnecting = false; voiceRoom = null; setVoiceState('error'); setError(err.message || 'Could not start voice'); } } function toggleVoice() { if (voiceState === 'connecting') return; if (voiceRoom || voiceState === 'listening' || voiceState === 'speaking') { stopVoice(); return; } startVoice(); } function setBusy(next) { busy = next; input.disabled = next; sendBtn.disabled = next; micBtn.disabled = next || voiceState === 'connecting'; if (quickRepliesEl) { Array.prototype.forEach.call(quickRepliesEl.querySelectorAll('.ebc-chip'), function (chip) { chip.disabled = next; }); } Array.prototype.forEach.call( root.querySelectorAll('.ebc-ticket-qr-email'), function (btn) { btn.disabled = next; }, ); } async function api(path, options) { var res = await fetch(apiBase + path, { method: (options && options.method) || 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: options && options.body ? JSON.stringify(options.body) : undefined, }); var json = await res.json().catch(function () { return null; }); if (!res.ok || !json || json.success === false) { var msg = (json && json.error && json.error.message) || (json && json.message) || 'Request failed (' + res.status + ')'; throw new Error(msg); } return json.data; } async function loadConfig() { var data = await api('/public/concierge/' + encodeURIComponent(botId) + '/config'); // Embed accentColor wins; otherwise use bot config, then default applyAccentColor(cfg.accentColor || cfg.accent || data.accentColor || DEFAULT_ACCENT); if (data.greeting) addMessage('bot', data.greeting); quickReplies = Array.isArray(data.quickReplies) ? data.quickReplies : []; renderQuickReplies(quickReplies); if (data.maxMessageLength) input.setAttribute('maxlength', String(data.maxMessageLength)); voiceEnabled = Boolean(data.voiceEnabled); setVoiceState('idle'); } async function sendMessage() { var text = String(input.value || '').trim(); if (!text || busy) return; setError(''); input.value = ''; clearQuickReplies(); var userEl = addMessage('user', text, { scroll: 'bottom' }); setBusy(true); showTyping(); try { var data = await api('/public/concierge/' + encodeURIComponent(botId) + '/chat', { method: 'POST', body: { message: text, sessionId: sessionId || undefined }, }); sessionId = data.sessionId || sessionId; hideTyping(); addMessage('bot', data.reply || 'No reply', { cards: data.cards || [], messageId: data.messageId || null, scroll: 'none', }); if (Array.isArray(data.quickReplies) && data.quickReplies.length) { quickReplies = data.quickReplies; renderQuickReplies(quickReplies); } // Keep the question at the top of the viewport so the reply can be read from the start scrollToElementTop(userEl); } catch (err) { hideTyping(); setError(err.message || 'Failed to send'); } finally { setBusy(false); input.focus(); } } async function maybeResume() { try { var params = new URLSearchParams(window.location.search || ''); var ref = String(params.get('session') || params.get('sessionId') || cfg.sessionId || '').trim(); if (!ref) return false; var data = await api( '/public/concierge/' + encodeURIComponent(botId) + '/session/' + encodeURIComponent(ref), ); sessionId = data.sessionId || null; messages.innerHTML = ''; clearQuickReplies(); (data.history || []).forEach(function (m) { addMessage(m.role === 'user' ? 'user' : 'bot', m.text); }); return true; } catch (err) { // Fresh chat if resume fails return false; } } launcher.addEventListener('click', function () { setOpen(true); input.focus(); }); closeBtn.addEventListener('click', function () { setOpen(false); }); sendBtn.addEventListener('click', sendMessage); micBtn.addEventListener('click', toggleVoice); endVoiceBtn.addEventListener('click', function () { stopVoice(); }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); sendMessage(); } }); (async function boot() { try { await loadConfig(); var resumed = await maybeResume(); if (!resumed && quickReplies.length && !quickRepliesEl) { renderQuickReplies(quickReplies); } } catch (err) { setError(err.message || 'Failed to load chatbot'); setOpen(true); } })(); })();