/* ============================================================ Stackless — floating chat widget Connects to the n8n chat workflow at: https://n8nmac.apphub.co.za/webhook/34f4684f-a603-4207-a8a9-75746a345463 Self-contained: injects DOM + scoped styles on first load. Survives the workflow returning 5xx — surfaces a friendly fallback with a "message a human" path instead of crashing. ============================================================ */ (function () { if (window.__stacklessChatLoaded) return; window.__stacklessChatLoaded = true; var WEBHOOK = 'https://n8nmac.apphub.co.za/webhook/34f4684f-a603-4207-a8a9-75746a345463'; var BRAND = { teal: '#1DB897', coral: '#F76857', ink: '#212A40', inkDeep: '#1C2439', paper: '#F4F2EC', surface: '#FFFFFF', border: '#E5E2D8', muted: '#6E7387' }; var STORE_KEY = 'stackless:chat:session'; var POS_KEY = 'stackless:chat:open'; var HISTORY_KEY = 'stackless:chat:history'; // ---- session id (persists per browser) ---- function getSession() { try { var s = localStorage.getItem(STORE_KEY); if (!s) { s = 'sess-' + (crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2, 10)); localStorage.setItem(STORE_KEY, s); } return s; } catch (_) { return 'sess-anon'; } } // ---- styles (scoped under .cw-*) ---- var css = '' + '.cw-root{position:fixed;right:20px;bottom:20px;z-index:1000;font-family:Inter,-apple-system,BlinkMacSystemFont,system-ui,sans-serif;color:' + BRAND.ink + ';}' + '.cw-launcher{width:58px;height:58px;border-radius:50%;background:' + BRAND.ink + ';border:0;box-shadow:0 12px 30px -10px rgba(28,36,57,.45),0 0 0 1px rgba(255,255,255,.04) inset;cursor:pointer;display:grid;place-items:center;transition:transform .2s ease,background .2s ease,box-shadow .2s ease;color:#fff;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}' + '.cw-launcher:hover{transform:translateY(-2px);box-shadow:0 18px 38px -10px rgba(28,36,57,.55);}' + '.cw-launcher:focus-visible{outline:2px solid ' + BRAND.teal + ';outline-offset:3px;}' + '.cw-launcher svg{width:24px;height:24px;}' + '.cw-launcher .cw-pulse{position:absolute;width:10px;height:10px;border-radius:50%;background:' + BRAND.coral + ';top:6px;right:6px;box-shadow:0 0 0 0 rgba(247,104,87,.6);animation:cw-pulse 2s infinite;}' + '@keyframes cw-pulse{0%{box-shadow:0 0 0 0 rgba(247,104,87,.55);}70%{box-shadow:0 0 0 12px rgba(247,104,87,0);}100%{box-shadow:0 0 0 0 rgba(247,104,87,0);}}' + '.cw-panel{position:fixed;right:20px;bottom:92px;width:min(380px,calc(100vw - 32px));height:min(560px,calc(100vh - 120px));max-height:calc(100vh - 120px);background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:20px;box-shadow:0 30px 80px -30px rgba(28,36,57,.35);display:flex;flex-direction:column;overflow:hidden;opacity:0;transform:translateY(12px) scale(.98);transform-origin:bottom right;pointer-events:none;transition:opacity .2s ease,transform .2s ease;}' + '.cw-panel.is-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}' + '.cw-head{display:flex;align-items:center;gap:12px;padding:14px 16px;background:' + BRAND.ink + ';color:#fff;}' + '.cw-avatar{width:34px;height:34px;border-radius:10px;background:' + BRAND.surface + ';display:grid;place-items:center;flex:none;}' + '.cw-avatar svg{width:22px;height:22px;}' + '.cw-meta{flex:1;min-width:0;}' + '.cw-title{font:600 14.5px/1.2 "Inter Tight",-apple-system,system-ui,sans-serif;letter-spacing:-.01em;}' + '.cw-status{display:flex;align-items:center;gap:6px;margin-top:3px;font:500 11px/1 "JetBrains Mono",ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.7);}' + '.cw-dot{width:7px;height:7px;border-radius:50%;background:' + BRAND.teal + ';box-shadow:0 0 0 3px rgba(29,184,151,.25);}' + '.cw-head-btn{background:transparent;border:0;color:rgba(255,255,255,.7);cursor:pointer;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;transition:background .15s,color .15s;}' + '.cw-head-btn:hover{background:rgba(255,255,255,.1);color:#fff;}' + '.cw-head-btn svg{width:16px;height:16px;}' + '.cw-messages{flex:1;overflow-y:auto;padding:18px 16px;background:' + BRAND.paper + ';display:flex;flex-direction:column;gap:10px;scrollbar-width:thin;scrollbar-color:' + BRAND.border + ' transparent;}' + '.cw-messages::-webkit-scrollbar{width:6px;}' + '.cw-messages::-webkit-scrollbar-thumb{background:' + BRAND.border + ';border-radius:3px;}' + '.cw-msg{max-width:84%;padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.5;word-wrap:break-word;white-space:pre-wrap;animation:cw-in .2s ease;}' + '@keyframes cw-in{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:translateY(0);}}' + '.cw-msg--bot{background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';color:' + BRAND.ink + ';border-bottom-left-radius:4px;align-self:flex-start;}' + '.cw-msg--user{background:' + BRAND.ink + ';color:#fff;border-bottom-right-radius:4px;align-self:flex-end;}' + '.cw-msg--err{background:#fff5f3;border:1px solid #f7c8bf;color:#a8341f;border-bottom-left-radius:4px;align-self:flex-start;}' + '.cw-msg a{color:' + BRAND.teal + ';text-decoration:underline;text-underline-offset:2px;}' + '.cw-msg--user a{color:#a4f0d6;}' + '.cw-typing{align-self:flex-start;background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:16px;border-bottom-left-radius:4px;padding:12px 16px;display:flex;gap:4px;}' + '.cw-typing span{width:7px;height:7px;border-radius:50%;background:' + BRAND.muted + ';animation:cw-typing 1.2s infinite;}' + '.cw-typing span:nth-child(2){animation-delay:.15s;}' + '.cw-typing span:nth-child(3){animation-delay:.3s;}' + '@keyframes cw-typing{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-3px);}}' + '.cw-quick{padding:0 16px 12px;background:' + BRAND.paper + ';display:flex;flex-wrap:wrap;gap:6px;}' + '.cw-quick.is-dismissed{display:none;}' + '.cw-quick button{background:' + BRAND.surface + ';border:1px solid ' + BRAND.border + ';border-radius:999px;padding:7px 12px;font:500 12.5px/1 Inter,system-ui,sans-serif;color:' + BRAND.ink + ';cursor:pointer;transition:background .15s,border-color .15s,color .15s;}' + '.cw-quick button:hover{background:' + BRAND.ink + ';color:#fff;border-color:' + BRAND.ink + ';}' + '.cw-form{display:flex;gap:8px;align-items:flex-end;padding:12px 14px;border-top:1px solid ' + BRAND.border + ';background:' + BRAND.surface + ';}' + '.cw-form textarea{flex:1;resize:none;border:1px solid ' + BRAND.border + ';border-radius:14px;padding:10px 12px;font:400 16px/1.5 Inter,system-ui,sans-serif;color:' + BRAND.ink + ';background:' + BRAND.surface + ';outline:none;min-height:42px;max-height:120px;transition:border-color .15s,box-shadow .15s;touch-action:manipulation;}' + '.cw-form textarea:focus{border-color:' + BRAND.teal + ';box-shadow:0 0 0 3px rgba(29,184,151,.15);}' + '.cw-form textarea::placeholder{color:' + BRAND.muted + ';}' + '.cw-send{width:44px;height:44px;border-radius:12px;background:' + BRAND.coral + ';color:#fff;border:0;cursor:pointer;display:grid;place-items:center;flex:none;transition:background .15s,transform .12s;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}' + '.cw-send:hover{background:#e25645;}' + '.cw-send:active{transform:translateY(1px);}' + '.cw-send:disabled{opacity:.5;cursor:not-allowed;}' + '.cw-send svg{width:18px;height:18px;}' + '.cw-foot{padding:8px 16px 12px;background:' + BRAND.surface + ';border-top:1px solid ' + BRAND.border + ';font:500 11px/1.4 "JetBrains Mono",ui-monospace,Menlo,monospace;letter-spacing:.04em;color:' + BRAND.muted + ';text-align:center;}' + '.cw-foot a{color:' + BRAND.ink + ';text-decoration:underline;text-underline-offset:2px;}' + '@media (max-width:640px){' + '.cw-root{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));}' + '.cw-launcher{width:56px;height:56px;}' + '.cw-panel{right:0;left:0;top:0;bottom:0;width:100%;height:100vh;height:100dvh;max-height:100dvh;border-radius:0;transform:translateY(100%);transform-origin:bottom center;}' + '.cw-panel.is-open{transform:translateY(0);}' + '.cw-panel::before{content:"";position:absolute;top:max(8px,env(safe-area-inset-top,0px));left:50%;transform:translateX(-50%);width:36px;height:4px;background:rgba(255,255,255,.32);border-radius:2px;z-index:2;pointer-events:none;}' + '.cw-head{padding-top:calc(16px + env(safe-area-inset-top,0px));}' + '.cw-form{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px));}' + '.cw-foot{padding-bottom:calc(10px + env(safe-area-inset-bottom,0px));}' + '.cw-title{font-size:15.5px;}' + '.cw-msg{font-size:15px;}' + '.cw-quick button{padding:9px 14px;font-size:13px;}' + '}' + '@media (prefers-reduced-motion:reduce){' + '.cw-launcher,.cw-panel,.cw-msg{transition:none;animation:none;}' + '.cw-pulse{animation:none;}' + '}'; var style = document.createElement('style'); style.setAttribute('data-cw', 'stackless'); style.textContent = css; document.head.appendChild(style); // ---- icons (inline, currentColor) ---- var ICON_CHAT = ''; var ICON_CLOSE = ''; var ICON_SEND = ''; var ICON_RESET = ''; // brand mark: three slanted capsules + coral dot (matches the logo) var ICON_BRAND = ''; // ---- DOM scaffold ---- var root = document.createElement('div'); root.className = 'cw-root'; root.innerHTML = '' + '' + ''; 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); } })();