diff --git a/chat-widget.js b/chat-widget.js new file mode 100644 index 0000000..b600911 --- /dev/null +++ b/chat-widget.js @@ -0,0 +1,376 @@ +/* ============================================================ + 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;}' + + '.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 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 14px/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;}' + + '.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:42px;height:42px;border-radius:12px;background:' + BRAND.coral + ';color:#fff;border:0;cursor:pointer;display:grid;place-items:center;flex:none;transition:background .15s,transform .12s;}' + + '.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:520px){' + + '.cw-root{right:12px;bottom:12px;}' + + '.cw-panel{right:12px;left:12px;bottom:84px;width:auto;height:calc(100vh - 100px);border-radius:18px;}' + + '.cw-launcher{width:54px;height:54px;}' + + '}' + + '@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; + input.value = q; + autoSize(); + form.requestSubmit(); + }); + quick.appendChild(b); + }); + } + + 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)'; + setTimeout(function () { input.focus(); }, 200); + try { localStorage.setItem(POS_KEY, '1'); } catch (_) {} + } + + 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 (_) {} + } + + 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 (_) {} + 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; + 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(); + try { if (localStorage.getItem(POS_KEY) === '1') open(); } catch (_) {} +})(); diff --git a/contact.html b/contact.html index fd0ca30..1ebacc8 100644 --- a/contact.html +++ b/contact.html @@ -577,5 +577,6 @@ }); + diff --git a/index.html b/index.html index c2c949b..f3c280a 100644 --- a/index.html +++ b/index.html @@ -1115,5 +1115,6 @@ })(); + diff --git a/pricing.html b/pricing.html index 4a5e73a..a8f47e6 100644 --- a/pricing.html +++ b/pricing.html @@ -632,5 +632,6 @@ })(); +