EventBuzz_Backend/public/embed/concierge-widget.js

511 lines
19 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 = (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();
});
})();