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