/**
* Kosmelita_AiChat — standalone widget embed for headless storefronts
* (ScandiPWA / PWA Studio / any SPA served from the Magento domain).
*
* Framework-free: no jQuery, no RequireJS, no Magento frontend libraries.
* Embed with a single tag (the module serves this file at a stable URL):
*
* <script src="https://www.eurokos.lt/aichat/widget/script" defer></script>
*
* It bootstraps from /aichat/widget/config (which also establishes the
* session + form key), injects the widget stylesheet, and talks to the same
* aichat/chat/* endpoints as the Magento-layout widget.
*
* Add-to-cart on product cards works on ScandiPWA-style storefronts: the
* embed reads (or creates) the SAME GraphQL cart the PWA uses — the masked
* cart id ScandiPWA keeps in localStorage under "cart_id" (per website code,
* BrowserDatabase envelope) — and calls the standard addProductsToCart
* mutation. The PWA's header badge only refetches on a full page load; the
* embed dispatches an "aichat:cart-changed" DOM event so a one-line theme
* hook can refresh it instantly (see INSTALL.md §8), and the "view cart"
* link performs a full-load navigation so the cart page is always correct.
* Remaining difference from the layout widget: no page-context awareness.
*/
(function () {
'use strict';
if (window.__kosmelitaAichat) { return; }
window.__kosmelitaAichat = true;
var OPEN_KEY = 'aichat_open';
var TRANSCRIPT_KEY = 'aichat_transcript';
var TEASER_KEY = 'aichat_teaser_seen';
var ACTIVITY_KEY = 'aichat_last_activity';
var CONSENT_KEY = 'aichat_photo_consent';
var UNREAD_KEY = 'aichat_unread';
var MAX_STORED = 60;
var SVG_RESET = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
var SVG_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var SVG_ATTACH = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15.5l-5-5-10 10"/></svg>';
var SVG_CAMERA = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h3l2-2.5h6L17 7h3a1.5 1.5 0 0 1 1.5 1.5v10A1.5 1.5 0 0 1 20 20H4a1.5 1.5 0 0 1-1.5-1.5v-10A1.5 1.5 0 0 1 4 7z"/><circle cx="12" cy="13" r="3.4"/></svg>';
var SVG_INFO = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><line x1="12" y1="11" x2="12" y2="16"/><circle cx="12" cy="7.4" r="0.7" fill="currentColor" stroke="none"/></svg>';
var SVG_CART = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="20" r="1.3"/><circle cx="18" cy="20" r="1.3"/><path d="M2 3h3l2.2 11.2a1.6 1.6 0 0 0 1.6 1.3h8.4a1.6 1.6 0 0 0 1.6-1.3L21.5 7H6"/></svg>';
var SVG_CHECK = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10"/></svg>';
var SVG_UP = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M4 21h13.3a2 2 0 0 0 2-1.7l1.3-8A2 2 0 0 0 18 9h-5l1-4.5A2 2 0 0 0 12.2 2L7 10"/></svg>';
var SVG_DOWN = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V3"/><path d="M20 3H6.7a2 2 0 0 0-2 1.7l-1.3 8A2 2 0 0 0 5.3 15h5l-1 4.5A2 2 0 0 0 11.8 22L17 14"/></svg>';
var SVG_SHIELD = '<svg viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path fill="currentColor" d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3Zm-1 14-4-4 1.4-1.4L11 13.2l4.6-4.6L17 10l-6 6Z"/></svg>';
function lsGet(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { window.localStorage.setItem(k, v); } catch (e) { /* ignore */ } }
function ssGet(k) { try { return window.sessionStorage.getItem(k); } catch (e) { return null; } }
function ssSet(k, v) { try { window.sessionStorage.setItem(k, v); } catch (e) { /* ignore */ } }
function el(tag, className, attrs) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'text') { node.textContent = attrs[k]; }
else if (k === 'html') { node.innerHTML = attrs[k]; }
else { node.setAttribute(k, attrs[k]); }
});
}
return node;
}
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text == null ? '' : text;
return d.innerHTML;
}
/** Escape, then render a small safe subset: links, **bold**, *italic*, newlines. */
function renderMessage(text) {
var html = escapeHtml(text);
html = html.replace(/\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g, function (m, label, url) {
return '<a href="' + url + '" target="_blank" rel="noopener nofollow">' + label + '</a>';
});
html = html.replace(/(^|[^"=>])(https?:\/\/[^\s<]+)/g, function (m, pre, url) {
return pre + '<a href="' + url + '" target="_blank" rel="noopener nofollow">' + url + '</a>';
});
html = html.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
return html.replace(/\n/g, '<br>');
}
function timeNow() {
var d = new Date(), h = d.getHours(), m = d.getMinutes();
return ((h % 12) || 12) + ':' + (m < 10 ? '0' + m : m) + ' ' + (h < 12 ? 'AM' : 'PM');
}
function post(url, data) {
var body = new URLSearchParams();
Object.keys(data).forEach(function (k) { body.append(k, data[k] == null ? '' : data[k]); });
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' },
body: body.toString()
}).then(function (r) { return r.json(); });
}
/* ---------------- bootstrap ---------------- */
fetch('/aichat/widget/config', { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); })
.then(function (raw) {
// Resilience: tolerate PHP notices/warnings printed before the
// JSON (e.g. stale generated code right after a deployment) —
// parse from the first brace instead of dying silently.
var start = raw.indexOf('{');
if (start === -1) {
throw new Error('non-JSON response: ' + raw.slice(0, 200));
}
var cfg = JSON.parse(raw.slice(start));
if (!cfg || !cfg.enabled) { return; }
var link = el('link', '', { rel: 'stylesheet', href: cfg.urls.css });
link.addEventListener('load', function () { init(cfg); });
link.addEventListener('error', function () { init(cfg); });
document.head.appendChild(link);
})
.catch(function (e) {
// Loud failure beats an invisibly missing widget.
console.error('[aichat] widget bootstrap failed:', e);
});
/* ---------------- widget ---------------- */
function init(cfg) {
var t = function (key, fallback) { return (cfg.text && cfg.text[key]) || fallback; };
var transcript = [];
try {
var stored = JSON.parse(lsGet(TRANSCRIPT_KEY) || '[]');
if (Array.isArray(stored)) { transcript = stored; }
} catch (e) { /* fresh start */ }
var busy = false;
var pendingImage = null;
var pendingThumb = null;
var consentPendingFile = null;
// Session timeout: wipe a stale local transcript (server side expires independently).
var timeoutMinutes = parseInt(cfg.sessionTimeoutMinutes, 10) || 0;
if (timeoutMinutes > 0 && transcript.length) {
var lastActivity = parseInt(lsGet(ACTIVITY_KEY), 10) || 0;
if (lastActivity && (Date.now() - lastActivity) > timeoutMinutes * 60000) {
transcript = [];
lsSet(TRANSCRIPT_KEY, '[]');
lsSet(OPEN_KEY, '0');
}
}
/* ----- DOM ----- */
var root = el('div', 'aichat-widget');
root.setAttribute('style', cfg.widgetStyle || '');
var launcher = el('button', 'aichat-launcher' + (cfg.logoUrl ? ' has-logo' : ''), {
type: 'button', 'aria-label': t('openChat', 'Open chat assistant')
});
launcher.innerHTML = cfg.logoUrl
? '<img src="' + escapeHtml(cfg.logoUrl) + '" alt=""/>'
: '<span class="aichat-spark">✨</span>';
var unread = el('span', 'aichat-unread');
unread.style.display = 'none';
launcher.appendChild(unread);
root.appendChild(launcher);
var teaser = null;
if (cfg.welcomeMessage) {
teaser = el('div', 'aichat-teaser');
teaser.style.display = 'none';
var teaserClose = el('button', 'aichat-teaser-close', { type: 'button', 'aria-label': t('close', 'Close'), html: '×' });
var teaserText = el('div', 'aichat-teaser-text', { text: cfg.welcomeMessage });
teaser.appendChild(teaserClose);
teaser.appendChild(teaserText);
root.appendChild(teaser);
}
var panel = el('div', 'aichat-panel');
var header = el('div', 'aichat-header');
var id = el('div', 'aichat-id');
var avatarBig = avatar();
var idText = el('span', 'aichat-id-text');
var titleRow = el('span', 'aichat-titlerow');
titleRow.appendChild(el('span', 'aichat-title', { text: cfg.assistantName || '' }));
titleRow.appendChild(el('span', 'aichat-badge', { text: t('ai', 'AI') }));
idText.appendChild(titleRow);
if (cfg.headerSubtitle) { idText.appendChild(el('span', 'aichat-subtitle', { text: cfg.headerSubtitle })); }
id.appendChild(avatarBig);
id.appendChild(idText);
header.appendChild(id);
var actions = el('span', 'aichat-actions');
var resetBtn = el('button', 'aichat-reset', {
type: 'button', title: t('startOver', 'Start over'), 'aria-label': t('startOver', 'Start over'), html: SVG_RESET
});
var closeBtn = el('button', 'aichat-close', {
type: 'button', 'aria-label': t('closeChat', 'Close chat'), html: SVG_CLOSE
});
actions.appendChild(resetBtn);
actions.appendChild(closeBtn);
header.appendChild(actions);
panel.appendChild(header);
var messagesWrap = el('div', 'aichat-messages-wrap');
var messages = el('div', 'aichat-messages', { role: 'log', 'aria-live': 'polite' });
var scrollHint = el('div', 'aichat-scroll-more', { 'aria-hidden': 'true', html: '<span>▾</span>' });
messagesWrap.appendChild(messages);
messagesWrap.appendChild(scrollHint);
panel.appendChild(messagesWrap);
var consentBox = null, preview = null, fileInput = null, cameraInput = null;
if (cfg.visionEnabled) {
consentBox = el('div', 'aichat-photo-consent');
consentBox.style.display = 'none';
consentBox.appendChild(el('p', '', { text: cfg.visionConsentText || '' }));
var consentActions = el('div', 'aichat-photo-consent-actions');
var consentAccept = el('button', 'aichat-consent-accept', { type: 'button', text: t('agree', 'I agree') });
var consentCancel = el('button', 'aichat-consent-cancel', { type: 'button', text: t('cancel', 'Cancel') });
consentActions.appendChild(consentAccept);
consentActions.appendChild(consentCancel);
consentBox.appendChild(consentActions);
panel.appendChild(consentBox);
preview = el('div', 'aichat-attach-preview');
preview.style.display = 'none';
preview.appendChild(el('img', '', { alt: '', src: '' }));
var previewRemove = el('button', '', { type: 'button', 'aria-label': t('removePhoto', 'Remove photo'), html: '×' });
preview.appendChild(previewRemove);
panel.appendChild(preview);
}
var form = el('form', 'aichat-input');
if (cfg.visionEnabled) {
fileInput = el('input', '', { type: 'file', accept: 'image/*', hidden: 'hidden' });
cameraInput = el('input', '', { type: 'file', accept: 'image/*', capture: 'environment', hidden: 'hidden' });
var attachBtn = el('button', 'aichat-attach', { type: 'button', title: t('attach', 'Attach a photo'), 'aria-label': t('attach', 'Attach a photo'), html: SVG_ATTACH });
var cameraBtn = el('button', 'aichat-attach', { type: 'button', title: t('takePhoto', 'Take a photo'), 'aria-label': t('takePhoto', 'Take a photo'), html: SVG_CAMERA });
form.appendChild(fileInput);
form.appendChild(cameraInput);
form.appendChild(attachBtn);
form.appendChild(cameraBtn);
attachBtn.addEventListener('click', function () { fileInput.click(); });
cameraBtn.addEventListener('click', function () { cameraInput.click(); });
}
var textarea = el('textarea', '', { rows: '1', placeholder: cfg.inputPlaceholder || '', 'aria-label': t('message', 'Message') });
var sendBtn = el('button', '', { type: 'submit', 'aria-label': t('send', 'Send'), html: '➤' });
sendBtn.setAttribute('data-aichat-send', '');
form.appendChild(textarea);
form.appendChild(sendBtn);
panel.appendChild(form);
if (cfg.disclaimer) {
var disc = el('div', 'aichat-disclaimer', { html: SVG_SHIELD });
disc.appendChild(el('span', '', { text: cfg.disclaimer }));
panel.appendChild(disc);
}
root.appendChild(panel);
document.body.appendChild(root);
/* ----- excluded URLs on SPA route changes ----- */
function pathExcluded() {
var path = ('/' + window.location.pathname.replace(/^\/+|\/+$/g, '')).toLowerCase();
return (cfg.excludedUrls || []).some(function (pattern) {
pattern = String(pattern).toLowerCase();
if (pattern.indexOf('://') !== -1) {
try { pattern = new URL(pattern).pathname; } catch (e) { /* keep as-is */ }
}
pattern = pattern.replace(/\/+$/, '');
if (!pattern) { return false; }
if (pattern.indexOf('*') !== -1) {
var rx = new RegExp(pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'));
return rx.test(path);
}
return path.indexOf(pattern) !== -1;
});
}
function applyExclusion() {
root.style.display = pathExcluded() ? 'none' : '';
}
['pushState', 'replaceState'].forEach(function (fn) {
var orig = window.history[fn];
window.history[fn] = function () {
var out = orig.apply(this, arguments);
window.dispatchEvent(new Event('aichat:nav'));
return out;
};
});
window.addEventListener('popstate', applyExclusion);
window.addEventListener('aichat:nav', applyExclusion);
applyExclusion();
/* ----- state helpers ----- */
function touchActivity() { lsSet(ACTIVITY_KEY, String(Date.now())); }
function isOpen() { return root.classList.contains('aichat-open'); }
function updateUnreadBadge() {
var count = parseInt(lsGet(UNREAD_KEY), 10) || 0;
if (count > 0 && !isOpen()) {
unread.textContent = count > 9 ? '9+' : String(count);
unread.style.display = '';
} else {
unread.style.display = 'none';
}
}
function markUnread() {
lsSet(UNREAD_KEY, String((parseInt(lsGet(UNREAD_KEY), 10) || 0) + 1));
updateUnreadBadge();
}
function dismissTeaser() {
ssSet(TEASER_KEY, '1');
if (teaser) { teaser.style.display = 'none'; }
}
function setOpen(open) {
root.classList.toggle('aichat-open', open);
lsSet(OPEN_KEY, open ? '1' : '0');
if (open) {
dismissTeaser();
lsSet(UNREAD_KEY, '0');
updateUnreadBadge();
textarea.focus();
scrollDown();
} else {
updateUnreadBadge();
}
}
function scrollDown() { messages.scrollTop = messages.scrollHeight; }
function nearBottom() {
return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 40;
}
function updateScrollHint() {
scrollHint.classList.toggle('is-visible', !nearBottom());
}
function scrollToRow(row) {
messages.scrollTop = Math.max(0, row.offsetTop - 6);
}
function record(entry) {
transcript.push(entry);
if (transcript.length > MAX_STORED) { transcript = transcript.slice(-MAX_STORED); }
lsSet(TRANSCRIPT_KEY, JSON.stringify(transcript));
}
/* ----- PWA cart (shared GraphQL cart) ----- */
// ScandiPWA's BrowserDatabase envelope: {data, expiration, createdAt}.
function bdGet(key) {
try {
var o = JSON.parse(window.localStorage.getItem(key) || '');
if (o.expiration && Date.now() - o.createdAt > o.expiration * 1000) { return null; }
return o.data;
} catch (e) { return null; }
}
function bdSet(key, data) {
try {
window.localStorage.setItem(key, JSON.stringify({ data: data, createdAt: Date.now() }));
} catch (e) { /* ignore */ }
}
function pwaWebsiteCode() { return window.website_code || cfg.websiteCode || 'base'; }
function pwaAuthToken() { return bdGet('auth_token') || null; }
// The PWA's cart id: localStorage "cart_id" keyed by website code.
// A customer-token cart id is only valid while the customer token
// exists — mirror ScandiPWA's own getCartId() rules exactly.
function pwaGetCartId() {
var tokens = bdGet('cart_id') || {};
var entry = tokens[pwaWebsiteCode()];
if (!entry) { return null; }
if (entry.isCustomerToken && !pwaAuthToken()) { return null; }
return entry.token || null;
}
function pwaSetCartId(token) {
var tokens = bdGet('cart_id') || {};
tokens[pwaWebsiteCode()] = { token: token, isCustomerToken: !!pwaAuthToken() };
bdSet('cart_id', tokens);
}
function gql(query) {
var headers = { 'Content-Type': 'application/json', Store: cfg.storeCode || 'default' };
var token = pwaAuthToken();
if (token) { headers.Authorization = 'Bearer ' + token; }
return fetch(cfg.graphqlUrl || '/graphql', {
method: 'POST',
credentials: 'same-origin',
headers: headers,
body: JSON.stringify({ query: query })
}).then(function (r) { return r.json(); });
}
// Resolve the cart to add into: the PWA's stored cart, else the
// signed-in customer's cart, else a fresh guest cart — which is then
// stored back in ScandiPWA's own format so the PWA adopts it too.
var ensureCartPending = null;
function ensureCartId() {
var id = pwaGetCartId();
if (id) { return Promise.resolve(id); }
if (ensureCartPending) { return ensureCartPending; }
var q = pwaAuthToken() ? '{ customerCart { id } }' : 'mutation { createEmptyCart }';
ensureCartPending = gql(q).then(function (r) {
var created = r && r.data && (r.data.createEmptyCart || (r.data.customerCart && r.data.customerCart.id));
if (created) { pwaSetCartId(created); }
ensureCartPending = null;
return created || null;
}, function (e) { ensureCartPending = null; throw e; });
return ensureCartPending;
}
function addToPwaCart(sku, qty) {
return ensureCartId().then(function (cartId) {
if (!cartId) { throw new Error('no cart'); }
var q = 'mutation { addProductsToCart(cartId: ' + JSON.stringify(cartId)
+ ', cartItems: [{sku: ' + JSON.stringify(sku)
+ ', quantity: ' + (parseInt(qty, 10) > 0 ? parseInt(qty, 10) : 1)
+ '}]) { cart { total_quantity } user_errors { message } } }';
return gql(q).then(function (r) {
var d = r && r.data && r.data.addProductsToCart;
var userErrors = (d && d.user_errors) || [];
if (!d || userErrors.length) {
throw new Error(userErrors.length ? userErrors[0].message : 'add failed');
}
// Let the PWA refresh its cart state instantly when the
// theme installed the one-line hook (INSTALL.md §8);
// without it the badge catches up on the next full load.
try {
document.dispatchEvent(new CustomEvent('aichat:cart-changed', { detail: { sku: sku } }));
if (typeof window.__aichatCartSync === 'function') { window.__aichatCartSync(); }
} catch (e) { /* ignore */ }
return d.cart && d.cart.total_quantity;
});
});
}
/* ----- renderers ----- */
function avatar() {
var a = el('span', 'aichat-avatar' + (cfg.logoUrl ? ' has-logo' : ''));
a.innerHTML = cfg.logoUrl
? '<img src="' + escapeHtml(cfg.logoUrl) + '" alt=""/>'
: '<span class="aichat-spark">✨</span>';
return a;
}
function linkTarget() { return cfg.linkNewTab === false ? '_self' : '_blank'; }
/* Invalidated when the conversation is reset mid-flight, so a pending
reply can never leak into a fresh conversation. */
var sendGen = 0;
function formatPrice(value, currency) {
return Number(value).toFixed(2) + (currency ? ' ' + currency : '');
}
function cardsEl(products) {
var wrap = el('div', 'aichat-cards');
products.forEach(function (p) {
var card = el('div', 'aichat-card');
var main = el('a', 'aichat-card-main', { target: linkTarget(), rel: 'noopener', href: p.url || '#' });
var img = el('span', 'aichat-card-img' + (p.image ? '' : ' aichat-card-noimg'));
if (p.image) { img.style.backgroundImage = 'url("' + p.image + '")'; }
main.appendChild(img);
main.appendChild(el('span', 'aichat-card-name', { text: p.name || p.sku }));
if (typeof p.price === 'number' && p.price > 0) {
var priceEl = el('span', 'aichat-card-price');
priceEl.appendChild(el('span', '', { text: formatPrice(p.price, p.currency) }));
// Discounts sell: strike the old price and badge the cut.
if (typeof p.old_price === 'number' && p.old_price > p.price) {
priceEl.appendChild(el('del', 'aichat-card-oldprice', { text: formatPrice(p.old_price, p.currency) }));
if (p.discount_percent) {
priceEl.appendChild(el('span', 'aichat-card-discount', { text: '-' + p.discount_percent + '%' }));
}
}
main.appendChild(priceEl);
}
if (p.rating && p.rating.stars) {
var full = Math.round(p.rating.stars);
main.appendChild(el('span', 'aichat-card-rating', {
text: '★★★★★'.slice(0, full) + '☆☆☆☆☆'.slice(0, 5 - full)
+ ' ' + p.rating.stars + ' (' + p.rating.count + ' ' + t('reviewsSuffix', 'reviews') + ')'
}));
}
if (p.label) { main.appendChild(el('span', 'aichat-card-tag', { text: p.label })); }
card.appendChild(main);
if (p.sku) {
var acts = el('div', 'aichat-card-actions');
// Add-to-cart on the PWA's own shared GraphQL cart. Only
// when the server says the product is directly addable
// (simple/virtual + in stock) — configurables deep-link.
if (cfg.cartButtons && p.add_to_cart) {
var cart = el('button', 'aichat-card-btn aichat-card-cart', {
type: 'button',
title: t('addToCart', 'Add to cart'),
'aria-label': t('addToCart', 'Add to cart'),
html: '<span class="aichat-plus">+</span>' + SVG_CART
});
cart.setAttribute('data-sku', p.sku);
acts.appendChild(cart);
}
var info = el('button', 'aichat-card-btn aichat-card-info', {
type: 'button', title: t('moreInfo', 'More info'), 'aria-label': t('moreInfo', 'More info'), html: SVG_INFO
});
info.setAttribute('data-sku', p.sku);
info.setAttribute('data-name', p.name || p.sku);
acts.appendChild(info);
card.appendChild(acts);
}
wrap.appendChild(card);
});
return wrap;
}
function chipsEl(items, starter) {
var wrap = el('div', 'aichat-chips' + (starter ? ' aichat-starters' : ''));
items.forEach(function (c) {
var chip = el('button', 'aichat-chip', { type: 'button', text: c.label });
chip.setAttribute('data-send', starter ? c.prompt : c.message);
// Failswitch: the customer's bubble shows the label they
// tapped, never the internal message behind it.
chip.setAttribute('data-display', c.label);
wrap.appendChild(chip);
});
return wrap;
}
function compareEl(items) {
var table = el('table', 'aichat-compare-table');
function addRow(cls, builder) {
var tr = el('tr', 'aichat-cmp-' + cls);
items.forEach(function (p) {
var td = el('td');
td.appendChild(builder(p));
tr.appendChild(td);
});
table.appendChild(tr);
}
addRow('img', function (p) {
var a = el('a', '', { target: linkTarget(), rel: 'noopener', href: p.url || '#' });
var img = el('span', 'aichat-cmp-img');
if (p.image) { img.style.backgroundImage = 'url("' + p.image + '")'; }
a.appendChild(img);
return a;
});
addRow('name', function (p) {
return el('a', '', { target: linkTarget(), rel: 'noopener', href: p.url || '#', text: p.name || p.sku });
});
addRow('price', function (p) {
return el('span', '', { text: typeof p.price === 'number' ? formatPrice(p.price, p.currency) : '' });
});
addRow('rating', function (p) {
return el('span', '', { text: p.rating_stars ? '★ ' + p.rating_stars + ' (' + p.rating_count + ')' : '—' });
});
addRow('stock', function (p) {
return el('span', p.in_stock ? 'is-in' : 'is-out', {
text: p.in_stock ? t('inStock', 'In stock') : t('outOfStock', 'Out of stock')
});
});
addRow('desc', function (p) { return el('span', '', { text: p.description || '' }); });
var wrap = el('div', 'aichat-compare');
wrap.appendChild(table);
return wrap;
}
function feedbackEl(entry) {
var fb = el('div', 'aichat-feedback');
fb.setAttribute('data-mid', entry.message_id);
var up = el('button', 'aichat-fb aichat-fb-up' + (entry.feedback === 1 ? ' is-active' : ''), {
type: 'button', 'aria-label': t('helpful', 'Helpful'), html: SVG_UP
});
var down = el('button', 'aichat-fb aichat-fb-down' + (entry.feedback === -1 ? ' is-active' : ''), {
type: 'button', 'aria-label': t('notHelpful', 'Not helpful'), html: SVG_DOWN
});
fb.appendChild(up);
fb.appendChild(down);
return fb;
}
function typeInto(bubble, text, finalHtml) {
var parts = String(text).split(/(\s+)/), i = 0, shown = '';
(function step() {
if (i >= parts.length) {
bubble.innerHTML = finalHtml;
if (nearBottom()) { scrollDown(); }
updateScrollHint();
return;
}
shown += parts[i++];
bubble.textContent = shown;
if (nearBottom()) { scrollDown(); }
updateScrollHint();
setTimeout(step, 16);
})();
}
function renderEntry(entry, typing, anchorTop) {
var isUser = entry.role === 'user';
var row = el('div', 'aichat-row aichat-row-' + (isUser ? 'user' : 'assistant'));
if (!isUser) { row.appendChild(avatar()); }
var col = el('div', 'aichat-col');
var hasText = isUser || entry.role === 'error' || (entry.content && entry.content.trim() !== '');
if (hasText) {
var bubble = el('div', 'aichat-msg aichat-msg-' + (isUser ? 'user' : 'assistant')
+ (entry.role === 'error' ? ' aichat-error' : ''));
if (typing && !isUser && entry.content) {
col.appendChild(bubble);
typeInto(bubble, entry.content, renderMessage(entry.content));
} else {
bubble.innerHTML = isUser ? escapeHtml(entry.content) : renderMessage(entry.content);
if (isUser && entry.imageThumb) {
var photo = el('img', 'aichat-msg-photo', { alt: '', src: entry.imageThumb });
bubble.insertBefore(photo, bubble.firstChild);
}
col.appendChild(bubble);
}
}
if (entry.products && entry.products.length) { col.appendChild(cardsEl(entry.products)); }
if (entry.compare && entry.compare.length > 1) { col.appendChild(compareEl(entry.compare)); }
if (entry.chips && entry.chips.length) { col.appendChild(chipsEl(entry.chips, false)); }
if (entry.ts) {
var meta = el('div', 'aichat-meta');
meta.innerHTML = escapeHtml(entry.ts) + (isUser ? ' <span class="aichat-ticks">✓✓</span>' : '');
col.appendChild(meta);
}
if (!isUser && entry.role === 'assistant' && cfg.feedbackEnabled && entry.message_id) {
col.appendChild(feedbackEl(entry));
}
row.appendChild(col);
messages.appendChild(row);
if (anchorTop) { scrollToRow(row); } else { scrollDown(); }
updateScrollHint();
return row;
}
function renderAll() {
messages.innerHTML = '';
if (transcript.length) {
transcript.forEach(function (e) { renderEntry(e); });
} else {
if (cfg.welcomeMessage) {
renderEntry({ role: 'assistant', content: cfg.welcomeMessage });
}
if (cfg.starterPrompts && cfg.starterPrompts.length) {
messages.appendChild(starterBlock(cfg.starterPrompts));
}
}
scrollDown();
}
/* Starter chips + (when vision is on) a photo hint chip that opens the
picker directly — the feature is invisible unless something says it
exists. */
function starterBlock(chips) {
var wrap = chipsEl(chips, true);
if (cfg.visionEnabled && fileInput) {
var photo = el('button', 'aichat-chip aichat-chip-photo', {
type: 'button', text: t('photoChip', '📷 Send a photo — I will find the product')
});
photo.setAttribute('data-action', 'photo');
wrap.appendChild(photo);
}
return wrap;
}
/* Context-aware starters: ask the server what page the customer is on
(via the SPA path) and swap the generic chips for specific ones.
Best-effort — the generic chips are already rendered and stay if
this fails or returns nothing new. */
function refreshStarters() {
if (transcript.length || !cfg.urls.starters) { return; }
fetch(cfg.urls.starters + '?path=' + encodeURIComponent(window.location.pathname.replace(/^\//, '')), {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json(); }).then(function (res) {
if (!res || !res.chips || !res.chips.length || transcript.length) { return; }
var current = messages.querySelector('.aichat-starters');
if (current) {
current.parentNode.replaceChild(starterBlock(res.chips), current);
}
}).catch(function () { /* generic starters remain */ });
}
/* ----- photos ----- */
function resizeImage(file, maxDim, quality, cb) {
var img = new Image();
img.onload = function () {
try {
var scale = Math.min(1, maxDim / Math.max(img.width, img.height));
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.width * scale));
canvas.height = Math.max(1, Math.round(img.height * scale));
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
cb(canvas.toDataURL('image/jpeg', quality));
} catch (e) { cb(null); }
URL.revokeObjectURL(img.src);
};
img.onerror = function () { URL.revokeObjectURL(img.src); cb(null); };
img.src = URL.createObjectURL(file);
}
function setPendingImage(file) {
if (!file || !/^image\//.test(file.type)) { return; }
resizeImage(file, 1280, 0.85, function (dataUrl) {
if (!dataUrl) { return; }
pendingImage = dataUrl;
resizeImage(file, 96, 0.7, function (thumb) {
pendingThumb = thumb;
if (preview) {
preview.querySelector('img').setAttribute('src', thumb || dataUrl);
preview.style.display = '';
}
});
});
}
function clearPendingImage() {
pendingImage = null;
pendingThumb = null;
if (preview) {
preview.style.display = 'none';
preview.querySelector('img').setAttribute('src', '');
}
if (fileInput) { fileInput.value = ''; }
if (cameraInput) { cameraInput.value = ''; }
}
function offerImage(file) {
var needsConsent = cfg.visionConsentText && lsGet(CONSENT_KEY) !== '1';
if (!needsConsent) {
setPendingImage(file);
return;
}
consentPendingFile = file;
if (consentBox) { consentBox.style.display = ''; }
}
/* ----- send ----- */
function send(message, display) {
if (busy || (!message && !pendingImage)) { return; }
busy = true;
touchActivity();
var starters = messages.querySelector('.aichat-starters');
if (starters) { starters.remove(); }
var image = pendingImage;
var userEntry = { role: 'user', content: display || message, ts: timeNow() };
if (pendingThumb) { userEntry.imageThumb = pendingThumb; }
clearPendingImage();
record(userEntry);
renderEntry(userEntry);
var typingRow = el('div', 'aichat-row aichat-row-assistant');
typingRow.appendChild(avatar());
var typingCol = el('div', 'aichat-col', {
html: '<div class="aichat-msg aichat-msg-assistant aichat-typing"><span></span><span></span><span></span></div>'
});
typingRow.appendChild(typingCol);
messages.appendChild(typingRow);
scrollDown();
// Staged waiting notes: a product answer takes two model passes
// plus catalog work — silence reads as "broken", a narrated wait
// reads as "working". Texts are translatable via widget config.
var waitNote = el('div', 'aichat-wait-note');
var waitTimers = [
setTimeout(function () {
waitNote.textContent = t('waitSearching', 'Searching the catalog…');
typingCol.appendChild(waitNote);
scrollDown();
}, 3500),
setTimeout(function () {
waitNote.textContent = t('waitAlmost', 'One moment — comparing the options…');
}, 12000)
];
function clearWait() { waitTimers.forEach(clearTimeout); }
var myGen = ++sendGen;
function firePost() {
return post(cfg.urls.send, {
message: message,
image: image || '',
form_key: cfg.formKey,
client: 'pwa',
pwa_cart_id: pwaGetCartId() || ''
});
}
var formKeyRetried = false;
firePost().then(function handleRes(res) {
// Session expired mid-conversation: silently refresh the form
// key from the config endpoint and retry ONCE.
if (res && res.code === 'invalid_form_key' && !formKeyRetried) {
formKeyRetried = true;
return fetch(cfg.urls ? '/aichat/widget/config' : '/aichat/widget/config', {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json(); }).then(function (fresh) {
if (fresh && fresh.formKey) { cfg.formKey = fresh.formKey; }
return firePost().then(handleRes);
});
}
if (myGen !== sendGen) { return; } // conversation was reset
clearWait();
typingRow.remove();
touchActivity();
if (res && res.cart_adds && res.cart_adds.length) {
executeCartAdds(res.cart_adds);
}
var hasProducts = res && res.products && res.products.length;
var hasChips = res && res.chips && res.chips.length;
var hasCompare = res && res.compare && res.compare.length > 1;
if (res && (res.reply || hasProducts || hasChips || hasCompare)) {
var entry = {
role: 'assistant', content: res.reply || '', ts: timeNow(),
products: res.products || [], chips: res.chips || [],
compare: res.compare || [],
message_id: res.message_id || null
};
record(entry);
renderEntry(entry, !!cfg.streamEnabled, true);
if (!isOpen()) { markUnread(); }
} else {
var err = { role: 'error', content: (res && res.error) || t('genericError', 'Something went wrong.'), ts: timeNow() };
record(err);
renderEntry(err);
}
}).catch(function () {
clearWait();
if (myGen !== sendGen) { return; }
typingRow.remove();
var err = { role: 'error', content: t('networkError', 'Network error. Please try again.'), ts: timeNow() };
record(err);
renderEntry(err);
}).then(function () { if (myGen === sendGen) { busy = false; } });
}
/* Conversational add-to-cart on the PWA: the server VALIDATED the add
(stock, options) and handed it to us — we perform it against the
PWA's own GraphQL cart and confirm inline. */
function executeCartAdds(adds) {
// Sequential: parallel adds raced guest-cart creation.
adds.reduce(function (chain, add) {
return chain.then(function () {
return addToPwaCart(add.sku, add.qty || 1).then(function () {
var note = el('div', 'aichat-cart-toast');
note.appendChild(el('span', '', {
text: '✓ ' + t('cartAddedToast', 'Added to your cart:') + ' ' + (add.name || add.sku)
}));
note.appendChild(el('a', 'aichat-card-viewcart', {
href: cfg.cartUrl || '/cart', text: t('viewCart', 'View cart')
}));
messages.appendChild(note);
scrollDown();
}).catch(function () {
var err = { role: 'error', content: t('addFailed', 'Could not add to cart. Please try from the product page.'), ts: timeNow() };
record(err);
renderEntry(err);
});
});
}, Promise.resolve());
}
/* ----- events ----- */
launcher.addEventListener('click', function () { setOpen(true); refreshStarters(); });
closeBtn.addEventListener('click', function () { setOpen(false); });
if (teaser) {
teaser.querySelector('.aichat-teaser-close').addEventListener('click', function (e) {
e.stopPropagation();
dismissTeaser();
});
teaser.querySelector('.aichat-teaser-text').addEventListener('click', function () { setOpen(true); });
}
resetBtn.addEventListener('click', function () {
sendGen++; // orphan any in-flight reply
busy = false; // and unlock the composer
transcript = [];
lsSet(TRANSCRIPT_KEY, '[]');
renderAll();
post(cfg.urls.reset, { form_key: cfg.formKey });
});
form.addEventListener('submit', function (e) {
e.preventDefault();
var message = textarea.value.trim();
if (message || pendingImage) {
textarea.value = '';
send(message);
}
});
textarea.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event('submit', { cancelable: true }));
}
});
messages.addEventListener('scroll', updateScrollHint);
scrollHint.querySelector('span').addEventListener('click', scrollDown);
// Delegated clicks inside the message area (chips, info buttons, feedback).
messages.addEventListener('click', function (e) {
var chip = e.target.closest('.aichat-chip');
if (chip) {
if (chip.getAttribute('data-action') === 'photo') {
if (fileInput) { fileInput.click(); }
return;
}
var m = chip.getAttribute('data-send');
if (m) { send(m, chip.getAttribute('data-display')); }
return;
}
var cartBtn = e.target.closest('.aichat-card-cart');
if (cartBtn) {
e.preventDefault();
if (cartBtn.disabled || cartBtn.classList.contains('is-added')) { return; }
var cartSku = cartBtn.getAttribute('data-sku');
if (!cartSku) { return; }
cartBtn.disabled = true;
addToPwaCart(cartSku).then(function () {
cartBtn.disabled = false;
cartBtn.classList.add('is-added');
cartBtn.innerHTML = SVG_CHECK;
cartBtn.title = t('added', 'Added to cart');
// "View cart" is a PLAIN link (not the SPA router), so it
// full-loads the app and the PWA cart is always correct
// even without the theme sync hook.
var actsWrap = cartBtn.closest('.aichat-card');
if (actsWrap && !actsWrap.querySelector('.aichat-card-viewcart')) {
var view = el('a', 'aichat-card-viewcart', {
href: cfg.cartUrl || '/cart', text: t('viewCart', 'View cart')
});
actsWrap.appendChild(view);
}
}).catch(function () {
cartBtn.disabled = false;
cartBtn.title = t('addFailed', 'Could not add to cart. Please try from the product page.');
cartBtn.classList.add('is-error');
setTimeout(function () { cartBtn.classList.remove('is-error'); }, 2500);
});
return;
}
var info = e.target.closest('.aichat-card-info');
if (info) {
e.preventDefault();
var sku = info.getAttribute('data-sku'), name = info.getAttribute('data-name');
if (sku) {
var tmpl = cfg.infoPrompt || 'Tell me more about {name} (SKU {sku})';
send(tmpl.replace(/\{name\}/g, name || '').replace(/\{sku\}/g, sku).trim());
}
return;
}
var fb = e.target.closest('.aichat-fb');
if (fb) {
var wrap = fb.closest('.aichat-feedback');
var mid = parseInt(wrap.getAttribute('data-mid'), 10);
if (!mid) { return; }
var value = fb.classList.contains('is-active') ? 0 : (fb.classList.contains('aichat-fb-up') ? 1 : -1);
wrap.querySelectorAll('.aichat-fb').forEach(function (b) { b.classList.remove('is-active'); });
if (value === 1) { wrap.querySelector('.aichat-fb-up').classList.add('is-active'); }
else if (value === -1) { wrap.querySelector('.aichat-fb-down').classList.add('is-active'); }
transcript.forEach(function (en) { if (en.message_id === mid) { en.feedback = value || null; } });
lsSet(TRANSCRIPT_KEY, JSON.stringify(transcript));
post(cfg.urls.feedback, { message_id: mid, value: value, form_key: cfg.formKey });
var parent = wrap.parentNode;
parent.querySelectorAll('.aichat-fb-comment, .aichat-fb-thanks').forEach(function (n) { n.remove(); });
if (value === -1) {
var cf = el('form', 'aichat-fb-comment');
cf.setAttribute('data-mid', mid);
var inp = el('input', '', { type: 'text', maxlength: '500', placeholder: t('fbCommentPlaceholder', 'What went wrong? (optional)') });
cf.appendChild(inp);
cf.appendChild(el('button', '', { type: 'submit', text: t('fbCommentSend', 'Send') }));
wrap.parentNode.insertBefore(cf, wrap.nextSibling);
cf.addEventListener('submit', function (ev) {
ev.preventDefault();
var comment = inp.value.trim();
if (comment) {
post(cfg.urls.feedback, { message_id: mid, value: -1, comment: comment, form_key: cfg.formKey });
}
var thanks = el('div', 'aichat-fb-thanks', { text: t('fbThanks', 'Thank you for the feedback!') });
cf.parentNode.replaceChild(thanks, cf);
});
inp.focus();
scrollDown();
}
}
});
if (cfg.visionEnabled) {
[fileInput, cameraInput].forEach(function (input) {
input.addEventListener('change', function () {
if (this.files && this.files[0]) { offerImage(this.files[0]); }
});
});
preview.querySelector('button').addEventListener('click', clearPendingImage);
consentBox.querySelector('.aichat-consent-accept').addEventListener('click', function () {
lsSet(CONSENT_KEY, '1');
var file = consentPendingFile;
consentPendingFile = null;
consentBox.style.display = 'none';
if (file) { setPendingImage(file); }
});
consentBox.querySelector('.aichat-consent-cancel').addEventListener('click', function () {
consentPendingFile = null;
consentBox.style.display = 'none';
if (fileInput) { fileInput.value = ''; }
if (cameraInput) { cameraInput.value = ''; }
});
}
// Click outside closes the panel (mousedown decides inside/outside — a
// clicked chip may already be removed from the DOM by click time).
var pointerDownInside = false;
document.addEventListener('mousedown', function (e) {
pointerDownInside = root.contains(e.target);
});
document.addEventListener('click', function () {
if (isOpen() && !pointerDownInside) { setOpen(false); }
});
/* ----- go ----- */
renderAll();
if (lsGet(OPEN_KEY) === '1') { setOpen(true); refreshStarters(); }
if (teaser && ssGet(TEASER_KEY) !== '1' && !isOpen() && !transcript.length) {
setTimeout(function () {
if (ssGet(TEASER_KEY) !== '1' && !isOpen()) {
teaser.style.display = '';
teaser.classList.add('is-in');
}
}, 1200);
}
updateUnreadBadge();
}
})();