'
+ '';
document.body.appendChild(root);
var launcher = root.querySelector('.cw-launcher');
var panel = root.querySelector('.cw-panel');
var closeBtn = root.querySelector('.cw-close');
var resetBtn = root.querySelector('.cw-reset');
var messages = root.querySelector('.cw-messages');
var quick = root.querySelector('.cw-quick');
var form = root.querySelector('.cw-form');
var input = root.querySelector('.cw-input');
var sendBtn = root.querySelector('.cw-send');
var QUICK_PROMPTS = [
'What can you build?',
'How much does a website cost?',
'What is Rent-an-AI-Agent?',
'How long does a project take?'
];
// ---- state ----
var history = [];
try { history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]') || []; } catch (_) { history = []; }
var isOpen = false;
var inFlight = false;
function saveHistory() {
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-50))); } catch (_) {}
}
function renderQuick() {
quick.innerHTML = '';
QUICK_PROMPTS.forEach(function (q) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = q;
b.addEventListener('click', function () {
if (inFlight) return;
// Dismiss the recommendation row the moment the user picks one
// — otherwise it sits over the chat for the rest of the session.
quick.classList.add('is-dismissed');
input.value = q;
autoSize();
form.requestSubmit();
});
quick.appendChild(b);
});
}
// Hide the "what I can build" recommendation row once the user has
// actually started a conversation. It returns on reset.
function dismissQuick() {
quick.classList.add('is-dismissed');
}
function restoreQuick() {
quick.classList.remove('is-dismissed');
}
function escape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
});
}
// Allow http(s) links inside bot messages; everything else stays escaped.
function linkify(escaped) {
return escaped.replace(/(https?:\/\/[^\s<]+)/g, function (url) {
return '' + url + '';
});
}
function addMessage(role, text, opts) {
opts = opts || {};
var div = document.createElement('div');
div.className = 'cw-msg cw-msg--' + role;
div.innerHTML = linkify(escape(text));
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
if (role === 'user' || role === 'bot') {
history.push({ role: role, text: text, t: Date.now() });
saveHistory();
}
return div;
}
function addError(text) {
var div = document.createElement('div');
div.className = 'cw-msg cw-msg--err';
div.innerHTML = linkify(escape(text));
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
function showTyping() {
var el = document.createElement('div');
el.className = 'cw-typing';
el.setAttribute('aria-label', 'Assistant is typing');
el.innerHTML = '';
messages.appendChild(el);
messages.scrollTop = messages.scrollHeight;
return el;
}
function renderHistory() {
messages.innerHTML = '';
if (!history.length) {
addMessage('bot', "hey — i'm the stackless assistant. ask me what we build, what it costs, or how it works.");
return;
}
history.forEach(function (m) { addMessage(m.role, m.text); });
}
function open() {
if (isOpen) return;
isOpen = true;
panel.classList.add('is-open');
panel.setAttribute('aria-hidden', 'false');
launcher.setAttribute('aria-expanded', 'true');
launcher.style.transform = 'scale(.9)';
try { localStorage.setItem(POS_KEY, '1'); } catch (_) {}
// Lock background scroll so the page behind the panel doesn't move
try {
var scrollY = window.scrollY || window.pageYOffset || 0;
panel.dataset.lockedScroll = String(scrollY);
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = '-' + scrollY + 'px';
document.body.style.left = '0';
document.body.style.right = '0';
} catch (_) {}
// Only auto-focus on desktop — on mobile that would summon the keyboard
// before the user has even seen the panel.
var isMobile = window.matchMedia('(max-width: 640px)').matches;
if (!isMobile) {
setTimeout(function () { input.focus(); }, 200);
}
}
function close() {
if (!isOpen) return;
isOpen = false;
panel.classList.remove('is-open');
panel.setAttribute('aria-hidden', 'true');
launcher.setAttribute('aria-expanded', 'false');
launcher.style.transform = '';
try { localStorage.setItem(POS_KEY, '0'); } catch (_) {}
// Release background scroll and restore the scroll position
try {
var y = parseInt(panel.dataset.lockedScroll || '0', 10) || 0;
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, y);
delete panel.dataset.lockedScroll;
} catch (_) {}
// Clear any keyboard-driven panel resizing
panel.style.height = '';
panel.style.top = '';
}
function toggle() { isOpen ? close() : open(); }
function reset() {
history = [];
try { localStorage.removeItem(HISTORY_KEY); } catch (_) {}
try {
var s = localStorage.getItem(STORE_KEY);
localStorage.removeItem(STORE_KEY);
if (s) localStorage.setItem(STORE_KEY, 'sess-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
} catch (_) {}
restoreQuick();
renderHistory();
}
// ---- webhook call ----
function extractReply(data) {
if (data == null) return '';
if (typeof data === 'string') return data;
if (Array.isArray(data)) {
for (var i = 0; i < data.length; i++) {
var v = extractReply(data[i]);
if (v) return v;
}
return '';
}
var keys = ['output', 'text', 'response', 'message', 'reply', 'answer', 'content', 'result'];
for (var k = 0; k < keys.length; k++) {
if (typeof data[keys[k]] === 'string' && data[keys[k]].trim()) return data[keys[k]];
}
// Sometimes n8n wraps it: { data: { output: "..." } } or { json: { output: "..." } }
if (data.data) { var d = extractReply(data.data); if (d) return d; }
if (data.json) { var j = extractReply(data.json); if (j) return j; }
if (typeof data.message === 'object') { var m = extractReply(data.message); if (m) return m; }
return '';
}
function callWebhook(text) {
var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 25000);
return fetch(WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ chatInput: text, sessionId: getSession(), message: text }),
signal: ctrl ? ctrl.signal : undefined
})
.then(function (res) {
clearTimeout(timer);
var ct = (res.headers.get('content-type') || '').toLowerCase();
var parse = ct.indexOf('application/json') >= 0 ? res.json() : res.text();
return parse.then(function (body) { return { ok: res.ok, status: res.status, body: body }; });
});
}
function send(text) {
text = (text || '').trim();
if (!text || inFlight) return;
inFlight = true;
sendBtn.disabled = true;
// First real send — the suggestion row has done its job, remove it so
// it doesn't sit over the conversation for the rest of the session.
dismissQuick();
addMessage('user', text);
var typing = showTyping();
callWebhook(text)
.then(function (r) {
typing.remove();
if (!r.ok) {
var detail = (r.body && (r.body.message || r.body.error)) ? ' (' + (r.body.message || r.body.error) + ')' : '';
addError("our assistant is having a moment — try again, or tap 'talk to a human' below" + detail + '.');
return;
}
var reply = extractReply(r.body);
if (!reply) {
addError("didn't catch a reply from the assistant. try again, or message us directly on contact.html.");
return;
}
addMessage('bot', reply);
})
.catch(function (err) {
typing.remove();
var msg = (err && err.name === 'AbortError')
? "that took longer than expected. try again, or message us on contact.html."
: "couldn't reach the assistant. check your connection, or message us on contact.html.";
addError(msg);
})
.then(function () {
inFlight = false;
sendBtn.disabled = false;
input.focus();
});
}
// ---- input behaviour ----
function autoSize() {
input.style.height = 'auto';
input.style.height = Math.min(120, input.scrollHeight) + 'px';
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var v = input.value;
input.value = '';
autoSize();
send(v);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
input.addEventListener('input', autoSize);
launcher.addEventListener('click', toggle);
closeBtn.addEventListener('click', close);
resetBtn.addEventListener('click', function () { reset(); input.focus(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen) close();
});
// ---- boot ----
renderHistory();
renderQuick();
// Only auto-reopen the panel on desktop — on mobile this would slam a
// fullscreen overlay over the page on every reload.
try {
if (localStorage.getItem(POS_KEY) === '1' && !window.matchMedia('(max-width: 640px)').matches) open();
} catch (_) {}
// ---- visual viewport (mobile keyboard handling) ----
// When the soft keyboard opens on mobile, shrink the panel to the visible
// viewport so the input stays above the keyboard. The panel is full-screen
// on mobile, so we just resize it to the visible height.
if (window.visualViewport) {
var vv = window.visualViewport;
function handleVV() {
if (!isOpen) return;
if (!window.matchMedia('(max-width: 640px)').matches) return;
// vv.height shrinks when the keyboard appears; vv.offsetTop rises.
panel.style.height = vv.height + 'px';
panel.style.top = vv.offsetTop + 'px';
// Keep the latest message visible above the keyboard.
var wasAtBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
if (wasAtBottom) {
requestAnimationFrame(function () { messages.scrollTop = messages.scrollHeight; });
}
}
vv.addEventListener('resize', handleVV);
vv.addEventListener('scroll', handleVV);
}
})();