1217 lines
39 KiB
JavaScript
1217 lines
39 KiB
JavaScript
(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);
|
||
}
|
||
})();
|
||
})();
|
||
|