511 lines
19 KiB
JavaScript
511 lines
19 KiB
JavaScript
(function () {
|
||
var cfg = window.EB_CONCIERGE || {};
|
||
var botId = cfg.botId;
|
||
var apiBase = (cfg.apiBase || '').replace(/\/$/, '');
|
||
if (!botId || !apiBase) {
|
||
// eslint-disable-next-line no-console
|
||
console.error('[EventBuzz Concierge] Missing window.EB_CONCIERGE.botId or apiBase');
|
||
return;
|
||
}
|
||
|
||
var storageKey = 'ebc_session_' + botId;
|
||
var sessionId = null;
|
||
// A page refresh starts a new chat unless ?session=<WebChatSession id> is present.
|
||
// Remove IDs saved by older widget builds so participant identity is never
|
||
// silently restored from localStorage.
|
||
try { localStorage.removeItem(storageKey); } catch {}
|
||
|
||
function readSessionRefFromUrl() {
|
||
try {
|
||
var params = new URLSearchParams(window.location.search || '');
|
||
return String(params.get('session') || params.get('sessionId') || cfg.sessionId || '').trim();
|
||
} catch (e) {
|
||
return String(cfg.sessionId || '').trim();
|
||
}
|
||
}
|
||
|
||
var resumeSessionRef = readSessionRefFromUrl();
|
||
|
||
function el(tag, attrs, children) {
|
||
var n = document.createElement(tag);
|
||
if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]);
|
||
if (children) children.forEach(function (c) { n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
||
return n;
|
||
}
|
||
|
||
function addCssOnce() {
|
||
if (document.getElementById('ebc-css')) return;
|
||
var link = el('link', { id: 'ebc-css', rel: 'stylesheet', href: apiBase.replace(/\/api$/, '') + '/embed/concierge-widget.css?v=5' });
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
addCssOnce();
|
||
|
||
var root = el('div', { id: 'eventbuzz-concierge-root', 'data-eventbuzz-concierge': 'root' });
|
||
var launcher = el('button', { class: 'ebc-launcher', type: 'button', 'aria-label': 'Chat with event concierge' }, ['Chat']);
|
||
var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Event concierge chat' });
|
||
var header = el('div', { class: 'ebc-header' });
|
||
var title = el('div', { class: 'ebc-title' }, ['Event Concierge']);
|
||
var headerActions = el('div', { class: 'ebc-header-actions' });
|
||
var endBtn = el('button', { class: 'ebc-end', type: 'button', 'aria-label': 'End chat' }, ['End chat']);
|
||
var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close' }, ['×']);
|
||
header.appendChild(title);
|
||
headerActions.appendChild(endBtn);
|
||
headerActions.appendChild(closeBtn);
|
||
header.appendChild(headerActions);
|
||
|
||
var messages = el('div', { class: 'ebc-messages' });
|
||
var error = 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' });
|
||
var sendBtn = el('button', { class: 'ebc-send', type: 'button' }, ['Send']);
|
||
inputBar.appendChild(input);
|
||
inputBar.appendChild(sendBtn);
|
||
|
||
panel.appendChild(header);
|
||
panel.appendChild(messages);
|
||
panel.appendChild(error);
|
||
panel.appendChild(inputBar);
|
||
root.appendChild(launcher);
|
||
root.appendChild(panel);
|
||
document.body.appendChild(root);
|
||
|
||
function ensureOnTop() {
|
||
if (!root.parentNode) {
|
||
document.body.appendChild(root);
|
||
return;
|
||
}
|
||
if (document.body.lastElementChild !== root) {
|
||
document.body.appendChild(root);
|
||
}
|
||
}
|
||
|
||
ensureOnTop();
|
||
if (typeof MutationObserver !== 'undefined') {
|
||
var topTimer = null;
|
||
var observer = new MutationObserver(function () {
|
||
if (topTimer) return;
|
||
topTimer = setTimeout(function () {
|
||
topTimer = null;
|
||
ensureOnTop();
|
||
}, 50);
|
||
});
|
||
observer.observe(document.body, { childList: true });
|
||
} else {
|
||
setInterval(ensureOnTop, 1000);
|
||
}
|
||
|
||
function setError(msg) {
|
||
if (!msg) {
|
||
error.style.display = 'none';
|
||
error.textContent = '';
|
||
return;
|
||
}
|
||
error.style.display = 'block';
|
||
error.textContent = msg;
|
||
}
|
||
|
||
function parseNumberedRecommendations(text) {
|
||
var source = String(text || '').trim();
|
||
var markerPattern = /(?:^|\s)(\d{1,2})[.)]\s+/g;
|
||
var markers = [];
|
||
var match;
|
||
|
||
while ((match = markerPattern.exec(source)) !== null) {
|
||
markers.push({
|
||
number: match[1],
|
||
markerStart: match.index,
|
||
contentStart: markerPattern.lastIndex
|
||
});
|
||
}
|
||
|
||
if (markers.length < 2) return null;
|
||
|
||
var intro = source.slice(0, markers[0].markerStart).trim();
|
||
var items = markers.map(function (marker, index) {
|
||
var nextMarker = markers[index + 1];
|
||
return {
|
||
number: marker.number,
|
||
text: source.slice(marker.contentStart, nextMarker ? nextMarker.markerStart : source.length).trim()
|
||
};
|
||
}).filter(function (item) { return item.text; });
|
||
|
||
return items.length >= 2 ? { intro: intro, items: items } : null;
|
||
}
|
||
|
||
function appendAssistantContent(bubble, text) {
|
||
var parsed = parseNumberedRecommendations(text);
|
||
if (!parsed) {
|
||
bubble.textContent = text;
|
||
return;
|
||
}
|
||
|
||
bubble.classList.add('ebc-rich');
|
||
if (parsed.intro) {
|
||
bubble.appendChild(el('p', { class: 'ebc-intro' }, [parsed.intro]));
|
||
}
|
||
|
||
var list = el('div', { class: 'ebc-card-list' });
|
||
parsed.items.forEach(function (item) {
|
||
var card = el('div', { class: 'ebc-card' });
|
||
card.appendChild(el('div', { class: 'ebc-card-number' }, [item.number]));
|
||
card.appendChild(el('div', { class: 'ebc-card-text' }, [item.text]));
|
||
list.appendChild(card);
|
||
});
|
||
bubble.appendChild(list);
|
||
}
|
||
|
||
function formatRating(place) {
|
||
if (place.rating === null || place.rating === undefined || place.rating === '') return 'Rating not available';
|
||
var label = String(place.rating);
|
||
if (place.userRatingsTotal !== null && place.userRatingsTotal !== undefined) {
|
||
label += ' (' + String(place.userRatingsTotal) + ' reviews)';
|
||
}
|
||
return label;
|
||
}
|
||
|
||
function mapsUrl(place) {
|
||
if (place.location && isFinite(Number(place.location.lat)) && isFinite(Number(place.location.lng))) {
|
||
return 'https://www.google.com/maps/search/?api=1&query=' +
|
||
encodeURIComponent(String(place.location.lat) + ',' + String(place.location.lng)) +
|
||
'&query_place_id=' + encodeURIComponent(place.placeId || place.foodId || place.hotelId || '');
|
||
}
|
||
return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent((place.name || '') + ' ' + (place.address || ''));
|
||
}
|
||
|
||
// Only allow http/https links to reach the DOM; anything else is neutralized.
|
||
function safeUrl(url) {
|
||
var s = String(url || '').trim();
|
||
return /^https?:\/\//i.test(s) ? s : '';
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return '';
|
||
var d = new Date(value);
|
||
if (isNaN(d.getTime())) return '';
|
||
try {
|
||
return d.toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
|
||
} catch (e) {
|
||
return d.toISOString();
|
||
}
|
||
}
|
||
|
||
function priceLevelLabel(level) {
|
||
var n = Number(level);
|
||
if (!isFinite(n) || n <= 0) return '';
|
||
return new Array(Math.min(4, n) + 1).join('₹');
|
||
}
|
||
|
||
function buildPlaceCard(place, opts) {
|
||
opts = opts || {};
|
||
var card = el('article', { class: 'ebc-place-card' });
|
||
var head = el('div', { class: 'ebc-place-head' });
|
||
head.appendChild(el('div', { class: opts.iconClass || 'ebc-place-pin', 'aria-hidden': 'true' }, [opts.icon || '⌖']));
|
||
var titleWrap = el('div', { class: 'ebc-place-title-wrap' });
|
||
titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [place.name || opts.fallbackName || 'Unnamed place']));
|
||
titleWrap.appendChild(el('div', { class: 'ebc-place-rating' }, [formatRating(place)]));
|
||
head.appendChild(titleWrap);
|
||
card.appendChild(head);
|
||
|
||
if (place.address) card.appendChild(el('p', { class: 'ebc-place-address' }, [place.address]));
|
||
|
||
var priceLabel = priceLevelLabel(place.priceLevel);
|
||
if (priceLabel) card.appendChild(el('div', { class: 'ebc-place-price' }, [priceLabel]));
|
||
|
||
var tags = Array.isArray(opts.tags) ? opts.tags : (Array.isArray(place.tags) ? place.tags : []);
|
||
if (tags.length) {
|
||
var tagWrap = el('div', { class: 'ebc-place-tags' });
|
||
tags.slice(0, 3).forEach(function (tag) {
|
||
tagWrap.appendChild(el('span', { class: opts.tagClass || 'ebc-place-tag' }, [String(tag).replace(/_/g, ' ')]));
|
||
});
|
||
card.appendChild(tagWrap);
|
||
}
|
||
|
||
var links = el('div', { class: 'ebc-place-links' });
|
||
links.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(place), target: '_blank', rel: 'noopener noreferrer' }, ['Open map']));
|
||
var site = safeUrl(place.website);
|
||
if (site) links.appendChild(el('a', { class: 'ebc-place-map', href: site, target: '_blank', rel: 'noopener noreferrer' }, ['Website']));
|
||
card.appendChild(links);
|
||
return card;
|
||
}
|
||
|
||
function buildFoodCard(food) {
|
||
return buildPlaceCard(food, {
|
||
icon: '🍽', iconClass: 'ebc-food-icon',
|
||
fallbackName: 'Unnamed restaurant',
|
||
tags: Array.isArray(food.cuisines) ? food.cuisines : [],
|
||
tagClass: 'ebc-food-tag',
|
||
});
|
||
}
|
||
|
||
function buildAccommodationCard(hotel) {
|
||
return buildPlaceCard(hotel, { icon: '🏨', iconClass: 'ebc-hotel-icon', fallbackName: 'Unnamed stay' });
|
||
}
|
||
|
||
function buildTicketCard(t) {
|
||
var card = el('article', { class: 'ebc-ticket-card' });
|
||
var head = el('div', { class: 'ebc-ticket-head' });
|
||
head.appendChild(el('h3', { class: 'ebc-place-name' }, [t.name || 'Ticket']));
|
||
if (t.price) head.appendChild(el('span', { class: 'ebc-ticket-price' }, [String(t.price)]));
|
||
card.appendChild(head);
|
||
if (t.description) card.appendChild(el('p', { class: 'ebc-place-address' }, [t.description]));
|
||
if (Array.isArray(t.keyPoints) && t.keyPoints.length) {
|
||
var ul = el('ul', { class: 'ebc-ticket-points' });
|
||
t.keyPoints.slice(0, 5).forEach(function (kp) {
|
||
ul.appendChild(el('li', {}, [String(kp)]));
|
||
});
|
||
card.appendChild(ul);
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function buildActionCard(opts) {
|
||
var card = el('article', { class: 'ebc-action-card' });
|
||
if (opts.title) card.appendChild(el('div', { class: 'ebc-action-title' }, [opts.title]));
|
||
if (opts.note) card.appendChild(el('div', { class: 'ebc-meta' }, [opts.note]));
|
||
var url = safeUrl(opts.url);
|
||
if (url) {
|
||
card.appendChild(el('a', { class: 'ebc-action-btn', href: url, target: '_blank', rel: 'noopener noreferrer' }, [opts.label || 'Open']));
|
||
} else if (opts.label) {
|
||
card.appendChild(el('span', { class: 'ebc-action-btn ebc-action-disabled' }, [opts.label]));
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function buildSessionJoinCard(s) {
|
||
var card = el('article', { class: 'ebc-session-card' });
|
||
card.appendChild(el('h3', { class: 'ebc-place-name' }, [s.title || 'Session']));
|
||
var when = formatDateTime(s.startTime);
|
||
if (when) card.appendChild(el('div', { class: 'ebc-meta' }, [when]));
|
||
var url = safeUrl(s.url);
|
||
if (url) card.appendChild(el('a', { class: 'ebc-action-btn', href: url, target: '_blank', rel: 'noopener noreferrer' }, ['Join session']));
|
||
return card;
|
||
}
|
||
|
||
// Dispatch a single {type, data} card to its renderer. Unknown types render
|
||
// as a minimal titled note so new backend card types degrade gracefully.
|
||
function renderCard(card) {
|
||
var type = card && card.type;
|
||
var data = (card && card.data) || {};
|
||
switch (type) {
|
||
case 'food': return buildFoodCard(data);
|
||
case 'place': return buildPlaceCard(data);
|
||
case 'accommodation': return buildAccommodationCard(data);
|
||
case 'ticket': return buildTicketCard(data);
|
||
case 'registration': return buildActionCard({
|
||
title: data.open === false ? 'Registration closed' : 'Registration',
|
||
note: data.deadline ? ('Deadline: ' + formatDateTime(data.deadline)) : '',
|
||
url: data.open === false ? '' : data.url,
|
||
label: data.label || (data.open === false ? 'Registration closed' : 'Register'),
|
||
});
|
||
case 'checkin': return buildActionCard({
|
||
title: data.method === 'web' ? 'Online check-in' : 'Check-in',
|
||
url: data.url,
|
||
label: data.label || 'Check in',
|
||
});
|
||
case 'join': return buildActionCard({
|
||
title: 'Join online',
|
||
url: data.url,
|
||
label: data.label || 'Join online',
|
||
});
|
||
case 'session_join': return buildSessionJoinCard(data);
|
||
default:
|
||
if (data && (data.name || data.title || data.label)) {
|
||
return buildActionCard({ title: data.name || data.title || '', url: data.url, label: data.label });
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function appendCards(bubble, text, cards) {
|
||
bubble.classList.add('ebc-rich', 'ebc-cards');
|
||
if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text]));
|
||
var list = el('div', { class: 'ebc-cards-list' });
|
||
cards.forEach(function (card) {
|
||
var node = renderCard(card);
|
||
if (node) list.appendChild(node);
|
||
});
|
||
if (list.childNodes.length) bubble.appendChild(list);
|
||
}
|
||
|
||
function appendPlaceCards(bubble, text, places) {
|
||
bubble.classList.add('ebc-rich', 'ebc-places');
|
||
if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text]));
|
||
var list = el('div', { class: 'ebc-place-list' });
|
||
places.forEach(function (place) { list.appendChild(buildPlaceCard(place)); });
|
||
bubble.appendChild(list);
|
||
}
|
||
|
||
function appendFoodCards(bubble, text, foods) {
|
||
bubble.classList.add('ebc-rich', 'ebc-foods');
|
||
if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text]));
|
||
var list = el('div', { class: 'ebc-food-list' });
|
||
foods.forEach(function (food) { list.appendChild(buildFoodCard(food)); });
|
||
bubble.appendChild(list);
|
||
}
|
||
|
||
function scrollAnchorToTop(anchorEl) {
|
||
if (!anchorEl || !messages.contains(anchorEl)) {
|
||
messages.scrollTop = messages.scrollHeight;
|
||
return;
|
||
}
|
||
// Align the user's last message to the top of the visible transcript so the
|
||
// reply can be read from the start without scrolling up.
|
||
var delta = anchorEl.getBoundingClientRect().top - messages.getBoundingClientRect().top;
|
||
messages.scrollTop += delta;
|
||
}
|
||
|
||
function addMessage(role, text, opts) {
|
||
opts = opts || {};
|
||
var cards = Array.isArray(opts.cards) ? opts.cards : [];
|
||
var places = Array.isArray(opts.places) ? opts.places : [];
|
||
var foods = Array.isArray(opts.foods) ? opts.foods : [];
|
||
var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') });
|
||
if (role === 'assistant' && cards.length) appendCards(bubble, text, cards);
|
||
else if (role === 'assistant' && foods.length) appendFoodCards(bubble, text, foods);
|
||
else if (role === 'assistant' && places.length) appendPlaceCards(bubble, text, places);
|
||
else if (role === 'assistant') appendAssistantContent(bubble, text);
|
||
else bubble.textContent = text;
|
||
messages.appendChild(bubble);
|
||
|
||
if (opts.anchorToTop) {
|
||
// Wait a frame so layout (cards / pre-wrap height) is settled.
|
||
requestAnimationFrame(function () {
|
||
scrollAnchorToTop(opts.anchorToTop);
|
||
});
|
||
} else if (opts.scroll !== false) {
|
||
messages.scrollTop = messages.scrollHeight;
|
||
}
|
||
return bubble;
|
||
}
|
||
|
||
function openPanel() {
|
||
ensureOnTop();
|
||
panel.classList.add('ebc-open');
|
||
input.focus();
|
||
}
|
||
function closePanel() {
|
||
panel.classList.remove('ebc-open');
|
||
}
|
||
|
||
launcher.addEventListener('click', function () {
|
||
ensureOnTop();
|
||
if (panel.classList.contains('ebc-open')) closePanel();
|
||
else openPanel();
|
||
});
|
||
closeBtn.addEventListener('click', closePanel);
|
||
endBtn.addEventListener('click', function () {
|
||
var endingSessionId = sessionId;
|
||
sessionId = null;
|
||
try { localStorage.removeItem(storageKey); } catch {}
|
||
messages.textContent = '';
|
||
addMessage('assistant', 'This chat has ended. Ask a question to start a new session.');
|
||
if (endingSessionId) {
|
||
http('/public/concierge/' + encodeURIComponent(botId) + '/session/end', {
|
||
sessionId: endingSessionId
|
||
}).catch(function () {});
|
||
}
|
||
});
|
||
|
||
function http(path, body) {
|
||
return fetch(apiBase + path, {
|
||
method: body ? 'POST' : 'GET',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
credentials: 'omit'
|
||
}).then(function (r) {
|
||
return r.json().then(function (j) { return { status: r.status, json: j }; });
|
||
});
|
||
}
|
||
|
||
// Load config (and optionally resume a prior WebChatSession from ?session=)
|
||
http('/public/concierge/' + encodeURIComponent(botId) + '/config').then(function (res) {
|
||
if (res.json && res.json.success && res.json.data) {
|
||
title.textContent = res.json.data.eventName ? ('Concierge · ' + res.json.data.eventName) : 'Event Concierge';
|
||
if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength));
|
||
|
||
var greet = res.json.data.greeting;
|
||
if (!resumeSessionRef) {
|
||
if (greet) addMessage('assistant', greet);
|
||
return;
|
||
}
|
||
|
||
return http(
|
||
'/public/concierge/' + encodeURIComponent(botId) + '/session/' + encodeURIComponent(resumeSessionRef)
|
||
).then(function (sessionRes) {
|
||
if (!sessionRes.json || !sessionRes.json.success || !sessionRes.json.data) {
|
||
setError((sessionRes.json && sessionRes.json.error && sessionRes.json.error.message)
|
||
? sessionRes.json.error.message
|
||
: 'Could not load that chat session');
|
||
if (greet) addMessage('assistant', greet);
|
||
return;
|
||
}
|
||
var data = sessionRes.json.data;
|
||
sessionId = data.sessionId || null;
|
||
var history = Array.isArray(data.history) ? data.history : [];
|
||
if (!history.length) {
|
||
if (greet) addMessage('assistant', greet);
|
||
} else {
|
||
history.forEach(function (m) {
|
||
if (!m || !m.text) return;
|
||
addMessage(m.role === 'user' ? 'user' : 'assistant', m.text, { scroll: false });
|
||
});
|
||
messages.scrollTop = messages.scrollHeight;
|
||
openPanel();
|
||
}
|
||
}).catch(function () {
|
||
setError('Could not load that chat session');
|
||
if (greet) addMessage('assistant', greet);
|
||
});
|
||
}
|
||
});
|
||
|
||
function send() {
|
||
var text = String(input.value || '').trim();
|
||
if (!text) return;
|
||
setError(null);
|
||
input.value = '';
|
||
var userBubble = addMessage('user', text, { scroll: false });
|
||
sendBtn.disabled = true;
|
||
|
||
http('/public/concierge/' + encodeURIComponent(botId) + '/chat', {
|
||
sessionId: sessionId || undefined,
|
||
message: text
|
||
}).then(function (res) {
|
||
sendBtn.disabled = false;
|
||
if (!res.json || !res.json.success) {
|
||
setError((res.json && res.json.error && res.json.error.message) ? res.json.error.message : 'Request failed');
|
||
return;
|
||
}
|
||
if (res.json.data && res.json.data.sessionId) {
|
||
sessionId = res.json.data.sessionId;
|
||
}
|
||
var data = res.json.data || {};
|
||
addMessage(
|
||
'assistant',
|
||
data.reply ? data.reply : '…',
|
||
{
|
||
cards: Array.isArray(data.cards) ? data.cards : [],
|
||
places: Array.isArray(data.places) ? data.places : [],
|
||
foods: Array.isArray(data.foods) ? data.foods : [],
|
||
anchorToTop: userBubble,
|
||
}
|
||
);
|
||
// Log detected intent to browser console when backend returns it
|
||
if (res.json.data && res.json.data.intent) {
|
||
// eslint-disable-next-line no-console
|
||
console.info('[EventBuzz Concierge] detected intent:', res.json.data.intent);
|
||
}
|
||
}).catch(function (e) {
|
||
sendBtn.disabled = false;
|
||
setError(e && e.message ? e.message : 'Network error');
|
||
});
|
||
}
|
||
|
||
sendBtn.addEventListener('click', send);
|
||
input.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') send();
|
||
});
|
||
})();
|
||
|