Eventbuzz_Concierge/public/embed/concierge-widget.js

1238 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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;
}
function appendFormattedText(parent, text) {
var s = String(text || '');
if (!s) return parent;
var re = /\*\*([^*]+)\*\*/g;
var last = 0;
var m;
while ((m = re.exec(s))) {
if (m.index > last) {
parent.appendChild(document.createTextNode(s.slice(last, m.index)));
}
parent.appendChild(el('strong', null, [m[1]]));
last = m.index + m[0].length;
}
if (last < s.length) {
parent.appendChild(document.createTextNode(s.slice(last)));
}
return parent;
}
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=23',
}),
);
}
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' });
var intro = el('div', { class: 'ebc-intro' });
appendFormattedText(intro, text);
bubble.appendChild(intro);
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) {
var intro = el('div', { class: 'ebc-intro' });
appendFormattedText(intro, text);
bubble.appendChild(intro);
}
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);
}
})();
})();