299 lines
12 KiB
JavaScript
299 lines
12 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. Remove IDs saved by older widget builds so
|
||
// participant identity is never silently restored from localStorage.
|
||
try { localStorage.removeItem(storageKey); } catch {}
|
||
|
||
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' });
|
||
document.head.appendChild(link);
|
||
}
|
||
|
||
addCssOnce();
|
||
|
||
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);
|
||
document.body.appendChild(launcher);
|
||
document.body.appendChild(panel);
|
||
|
||
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 || '');
|
||
}
|
||
return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent((place.name || '') + ' ' + (place.address || ''));
|
||
}
|
||
|
||
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) {
|
||
var card = el('article', { class: 'ebc-place-card' });
|
||
var head = el('div', { class: 'ebc-place-head' });
|
||
head.appendChild(el('div', { class: 'ebc-place-pin', 'aria-hidden': 'true' }, ['⌖']));
|
||
var titleWrap = el('div', { class: 'ebc-place-title-wrap' });
|
||
titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [place.name || '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]));
|
||
|
||
if (Array.isArray(place.tags) && place.tags.length) {
|
||
var tags = el('div', { class: 'ebc-place-tags' });
|
||
place.tags.slice(0, 3).forEach(function (tag) {
|
||
tags.appendChild(el('span', { class: 'ebc-place-tag' }, [String(tag)]));
|
||
});
|
||
card.appendChild(tags);
|
||
}
|
||
|
||
card.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(place), target: '_blank', rel: 'noopener noreferrer' }, ['Open map']));
|
||
list.appendChild(card);
|
||
});
|
||
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) {
|
||
var card = el('article', { class: 'ebc-food-card' });
|
||
var head = el('div', { class: 'ebc-place-head' });
|
||
head.appendChild(el('div', { class: 'ebc-food-icon', 'aria-hidden': 'true' }, ['🍽']));
|
||
var titleWrap = el('div', { class: 'ebc-place-title-wrap' });
|
||
titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [food.name || 'Unnamed restaurant']));
|
||
titleWrap.appendChild(el('div', { class: 'ebc-place-rating' }, [formatRating(food)]));
|
||
head.appendChild(titleWrap);
|
||
card.appendChild(head);
|
||
|
||
if (food.address) card.appendChild(el('p', { class: 'ebc-place-address' }, [food.address]));
|
||
|
||
if (Array.isArray(food.cuisines) && food.cuisines.length) {
|
||
var cuisines = el('div', { class: 'ebc-place-tags' });
|
||
food.cuisines.slice(0, 3).forEach(function (cuisine) {
|
||
cuisines.appendChild(el('span', { class: 'ebc-food-tag' }, [String(cuisine).replace(/_/g, ' ')]));
|
||
});
|
||
card.appendChild(cuisines);
|
||
}
|
||
|
||
card.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(food), target: '_blank', rel: 'noopener noreferrer' }, ['Open map']));
|
||
list.appendChild(card);
|
||
});
|
||
bubble.appendChild(list);
|
||
}
|
||
|
||
function addMessage(role, text, places, foods) {
|
||
var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') });
|
||
if (role === 'assistant' && Array.isArray(foods) && foods.length) appendFoodCards(bubble, text, foods);
|
||
else if (role === 'assistant' && Array.isArray(places) && places.length) appendPlaceCards(bubble, text, places);
|
||
else if (role === 'assistant') appendAssistantContent(bubble, text);
|
||
else bubble.textContent = text;
|
||
messages.appendChild(bubble);
|
||
messages.scrollTop = messages.scrollHeight;
|
||
}
|
||
|
||
function openPanel() {
|
||
panel.classList.add('ebc-open');
|
||
input.focus();
|
||
}
|
||
function closePanel() {
|
||
panel.classList.remove('ebc-open');
|
||
}
|
||
|
||
launcher.addEventListener('click', function () {
|
||
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
|
||
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';
|
||
var greet = res.json.data.greeting;
|
||
if (greet) addMessage('assistant', greet);
|
||
if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength));
|
||
}
|
||
});
|
||
|
||
function send() {
|
||
var text = String(input.value || '').trim();
|
||
if (!text) return;
|
||
setError(null);
|
||
input.value = '';
|
||
addMessage('user', text);
|
||
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;
|
||
}
|
||
addMessage(
|
||
'assistant',
|
||
(res.json.data && res.json.data.reply) ? res.json.data.reply : '…',
|
||
res.json.data && Array.isArray(res.json.data.places) ? res.json.data.places : [],
|
||
res.json.data && Array.isArray(res.json.data.foods) ? res.json.data.foods : []
|
||
);
|
||
// 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();
|
||
});
|
||
})();
|
||
|