/** * AuthorBot Chat Widget — widget.js * Vanilla JS, zero dependencies. * Embeds as a floating chat bubble on any author website. * * One-line install (recommended): * */ (function() { 'use strict'; if (window.__authorBotMounted) return; function readConfig() { const script = document.currentScript; const fromData = script ? { token: script.getAttribute('data-authorbot-token') || '', slug: script.getAttribute('data-authorbot-slug') || '', apiBase: script.getAttribute('data-authorbot-api') || '', theme: script.getAttribute('data-authorbot-theme') || '', position: script.getAttribute('data-authorbot-position') || '', autoOpenDelay: Number(script.getAttribute('data-authorbot-auto-open') || 0), } : {}; return Object.assign({ theme: 'midnight', position: 'bottom-right', autoOpenDelay: 0, apiBase: '', slug: '', token: '', }, window.AuthorBotConfig || {}, fromData); } function mount() { if (window.__authorBotMounted) return; if (!document.body) return; const CONFIG = readConfig(); if (!CONFIG.token) { console.warn('[AuthorBot] No token provided. Widget disabled.'); return; } window.__authorBotMounted = true; // ── Theme Definitions ────────────────────────────────────── const THEMES = { midnight: { bg: '#0d0f1a', surface: '#13161f', border: '#ffffff0f', brand: '#6366f1', text: '#e2e8f0', sub: '#94a3b8' }, ocean: { bg: '#0a1628', surface: '#0f1e38', border: '#ffffff0f', brand: '#0ea5e9', text: '#e0f2fe', sub: '#7dd3fc' }, forest: { bg: '#0a1a0f', surface: '#0f2416', border: '#ffffff0f', brand: '#22c55e', text: '#dcfce7', sub: '#86efac' }, sunset: { bg: '#1a0d0a', surface: '#261510', border: '#ffffff0f', brand: '#f97316', text: '#fff7ed', sub: '#fdba74' }, minimal: { bg: '#ffffff', surface: '#f8fafc', border: '#e2e8f0', brand: '#4f46e5', text: '#1e293b', sub: '#64748b' }, }; const T = THEMES[CONFIG.theme] || THEMES.midnight; // ── State ─────────────────────────────────────────────────── let sessionId = null; let isOpen = false; let isLoading = false; let selectedBookId = null; let books = []; let purchaseUrls = {}; let turnCount = 0; let farewellShown = false; const POS = CONFIG.position.split('-'); const vPos = POS[0]; const hPos = POS[1]; const style = document.createElement('style'); style.textContent = ` #ab-root * { box-sizing: border-box; font-family: -apple-system, 'Inter', BlinkMacSystemFont, sans-serif; } #ab-root { position: fixed; ${vPos}: 24px; ${hPos}: 24px; z-index: 2147483647; display: flex; flex-direction: column; align-items: ${hPos === 'right' ? 'flex-end' : 'flex-start'}; gap: 12px; } #ab-bubble { width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, ${T.brand}, ${T.brand}cc); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 24px; border: none; outline: none; box-shadow: 0 4px 20px ${T.brand}55; transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s; animation: ab-float 4s ease-in-out infinite; } #ab-bubble:hover { transform: scale(1.1); box-shadow: 0 6px 28px ${T.brand}80; } #ab-window { width: 370px; max-height: 560px; background: ${T.bg}; border: 1px solid ${T.border}; border-radius: 20px; display: flex; flex-direction: column; box-shadow: 0 24px 80px rgba(0,0,0,0.6); overflow: hidden; transform-origin: ${hPos === 'right' ? 'bottom right' : 'bottom left'}; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.2s; } #ab-window.ab-hidden { transform: scale(0.85); opacity: 0; pointer-events: none; } #ab-header { background: ${T.surface}; padding: 14px 16px; border-bottom: 1px solid ${T.border}; display: flex; align-items: center; gap: 10px; flex-shrink: 0; } #ab-avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, ${T.brand}, ${T.brand}99); display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; } #ab-bot-name { font-weight: 700; font-size: 14px; color: ${T.text}; } #ab-status { font-size: 11px; color: #22c55e; display: flex; align-items: center; gap: 4px; } #ab-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 6px #22c55e; } #ab-close-btn { margin-left: auto; background: none; border: none; cursor: pointer; color: ${T.sub}; font-size: 18px; line-height: 1; padding: 2px; } #ab-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; scrollbar-width: thin; scrollbar-color: ${T.border} transparent; } .ab-msg { display: flex; flex-direction: column; max-width: 85%; animation: ab-slide-up 0.2s ease; } .ab-msg.ab-bot { align-self: flex-start; } .ab-msg.ab-user { align-self: flex-end; } .ab-bubble-text { padding: 10px 14px; border-radius: 16px; font-size: 13.5px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; } .ab-bot .ab-bubble-text { background: ${T.surface}; border: 1px solid ${T.border}; color: ${T.text}; border-bottom-left-radius: 4px; } .ab-user .ab-bubble-text { background: ${T.brand}; color: #fff; border-bottom-right-radius: 4px; } .ab-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } .ab-link-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; background: ${T.brand}22; color: ${T.brand}; border: 1px solid ${T.brand}44; cursor: pointer; text-decoration: none; transition: all 0.15s; } .ab-link-btn:hover { background: ${T.brand}; color: #fff; } .ab-book-selector { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; } .ab-book-btn { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; cursor: pointer; background: ${T.surface}; border: 1px solid ${T.border}; transition: all 0.15s; font-size: 13px; color: ${T.text}; text-align: left; width: 100%; } .ab-book-btn:hover { border-color: ${T.brand}; background: ${T.brand}15; } .ab-book-btn.ab-selected { border-color: ${T.brand}; background: ${T.brand}15; opacity: 1 !important; } .ab-book-btn:disabled { opacity: 0.45; cursor: default; } .ab-book-thumb { font-size: 20px; flex-shrink: 0; width: 28px; text-align: center; } .ab-book-title { font-weight: 600; font-size: 13px; line-height: 1.3; } .ab-book-tagline { font-size: 11px; opacity: 0.75; margin-top: 2px; line-height: 1.35; } .ab-typing { display: flex; gap: 4px; padding: 12px 14px; } .ab-dot { width: 6px; height: 6px; border-radius: 50%; background: ${T.sub}; animation: ab-bounce 1.2s ease infinite; } .ab-dot:nth-child(2) { animation-delay: 0.2s; } .ab-dot:nth-child(3) { animation-delay: 0.4s; } #ab-input-bar { padding: 12px 14px; border-top: 1px solid ${T.border}; display: flex; gap: 8px; flex-shrink: 0; background: ${T.surface}; } #ab-input { flex: 1; background: ${T.bg}; border: 1px solid ${T.border}; border-radius: 20px; padding: 8px 14px; font-size: 13px; color: ${T.text}; outline: none; resize: none; font-family: inherit; transition: border-color 0.15s; } #ab-input::placeholder { color: ${T.sub}; } #ab-input:focus { border-color: ${T.brand}; } #ab-send { width: 36px; height: 36px; border-radius: 50%; border: none; cursor: pointer; background: linear-gradient(135deg, ${T.brand}, ${T.brand}cc); color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; flex-shrink: 0; align-self: flex-end; } #ab-send:hover { transform: scale(1.1); } #ab-send:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } #ab-branding { text-align: center; padding: 6px; font-size: 10px; color: ${T.sub}; opacity: 0.6; background: ${T.surface}; border-top: 1px solid ${T.border}; } @keyframes ab-float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} } @keyframes ab-slide-up { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} } @keyframes ab-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} } `; (document.head || document.documentElement).appendChild(style); const root = document.createElement('div'); root.id = 'ab-root'; root.innerHTML = `
🤖
Book Advisor
Online
Powered by AuthorBot
`; if (vPos === 'bottom') { root.style.flexDirection = 'column-reverse'; } document.body.appendChild(root); const $window = root.querySelector('#ab-window'); const $messages = root.querySelector('#ab-messages'); const $input = root.querySelector('#ab-input'); const $send = root.querySelector('#ab-send'); const $bubble = root.querySelector('#ab-bubble'); const $close = root.querySelector('#ab-close-btn'); const $botName = root.querySelector('#ab-bot-name'); function openChat() { isOpen = true; $window.classList.remove('ab-hidden'); $bubble.style.display = 'none'; if (!sessionId) initSession(); else $input.focus(); } async function showFarewell() { farewellShown = true; try { const res = await apiPost(`/chat/${CONFIG.slug}/session/farewell`, { session_id: sessionId, selected_book_id: selectedBookId, }, { 'X-Subscription-Token': CONFIG.token }); addBotMessage(res.text, res.links || [], null); } catch (e) { const title = books.find(b => b.id === selectedBookId)?.title || 'this book'; const url = purchaseUrls[selectedBookId]; const links = url ? [{ label: 'Buy Book', url, type: 'purchase', icon: '🛒' }] : []; addBotMessage(`Glad we chatted! If ${title} speaks to you, grab your copy below.`, links, null); } // Keep chat open so the visitor can tap Buy Book; they close with X again } function closeChat() { if (isOpen && farewellShown) { isOpen = false; $window.classList.add('ab-hidden'); $bubble.style.display = ''; return; } if (isOpen && !farewellShown && sessionId && selectedBookId && turnCount >= 1) { showFarewell(); return; } isOpen = false; $window.classList.add('ab-hidden'); $bubble.style.display = ''; } $bubble.addEventListener('click', openChat); $close.addEventListener('click', closeChat); function mapBooks(rawBooks) { purchaseUrls = {}; return (rawBooks || []).map(b => { if (b.purchase_url) purchaseUrls[b.id] = b.purchase_url; return { id: b.id, title: b.title, tagline: b.tagline || '', }; }); } async function initSession() { try { const res = await apiPost(`/chat/${CONFIG.slug}/session/init`, {}, { 'X-Subscription-Token': CONFIG.token }); sessionId = res.session_id; books = mapBooks(res.books); farewellShown = false; $botName.textContent = res.bot_name || 'Book Advisor'; const welcome = res.welcome_message || `Hello!\n\nSelect a book below to ask about it.`; addBotMessage(welcome, [], books.length ? books : null); $input.focus(); } catch (e) { addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null); } } async function sendMessage() { const text = $input.value.trim(); if (!text || isLoading || !sessionId) return; addUserMessage(text); $input.value = ''; resizeInput(); const typingEl = addTypingIndicator(); isLoading = true; $send.disabled = true; turnCount++; try { const res = await apiPost(`/chat/${CONFIG.slug}`, { session_id: sessionId, message: text, selected_book_id: selectedBookId, }, { 'X-Subscription-Token': CONFIG.token }); typingEl.remove(); addBotMessage(res.text, res.links || [], mapBooks(res.book_selector)); if (res.links && res.links.length) { res.links.forEach(l => { if (l.type === 'purchase' && selectedBookId) purchaseUrls[selectedBookId] = l.url; }); } } catch (e) { typingEl.remove(); turnCount = Math.max(0, turnCount - 1); addBotMessage(chatErrorMessage(e, "Sorry, something went wrong. Please try again."), [], null); } finally { isLoading = false; $send.disabled = false; $input.focus(); } } function formatBotText(text) { return escHtml(text); } function addUserMessage(text) { const el = document.createElement('div'); el.className = 'ab-msg ab-user'; el.innerHTML = `
${formatBotText(text)}
`; $messages.appendChild(el); scrollToBottom(); } function addBotMessage(text, links, bookSelector) { const el = document.createElement('div'); el.className = 'ab-msg ab-bot'; let html = `
${formatBotText(text)}
`; if (links && links.length) { html += ``; } if (bookSelector && bookSelector.length) { html += `
`; bookSelector.forEach(b => { const selected = selectedBookId === b.id; html += ``; }); html += `
`; } el.innerHTML = html; el.querySelectorAll('.ab-book-btn').forEach(btn => { btn.addEventListener('click', () => selectBook(btn, bookSelector)); }); el.querySelectorAll('.ab-link-btn').forEach(a => { a.addEventListener('click', () => { try { apiPost(`/chat/${CONFIG.slug}/track-click`, { session_id: sessionId, link_type: a.getAttribute('data-type'), }, { 'X-Subscription-Token': CONFIG.token }).catch(() => {}); } catch {} }); }); $messages.appendChild(el); scrollToBottom(); } async function selectBook(btn, bookSelector) { if (isLoading || btn.disabled) return; const bookId = btn.getAttribute('data-book-id'); const book = bookSelector.find(b => b.id === bookId); if (!book) return; selectedBookId = bookId; addUserMessage(book.title); btn.closest('.ab-book-selector')?.querySelectorAll('.ab-book-btn').forEach(b => { b.disabled = true; b.style.opacity = '0.45'; }); btn.classList.add('ab-selected'); btn.style.opacity = '1'; const typingEl = addTypingIndicator(); isLoading = true; $send.disabled = true; try { const res = await apiPost(`/chat/${CONFIG.slug}`, { session_id: sessionId, message: book.title, selected_book_id: bookId, }, { 'X-Subscription-Token': CONFIG.token }); typingEl.remove(); addBotMessage(res.text, res.links || [], null); turnCount++; if (res.links && res.links.length) { res.links.forEach(l => { if (l.type === 'purchase') purchaseUrls[bookId] = l.url; }); } } catch (e) { typingEl.remove(); addBotMessage(chatErrorMessage(e, "Sorry, something went wrong. Please try again."), [], null); } finally { isLoading = false; $send.disabled = false; $input.focus(); } } function addTypingIndicator() { const el = document.createElement('div'); el.className = 'ab-msg ab-bot'; el.innerHTML = `
`; $messages.appendChild(el); scrollToBottom(); return el; } function resizeInput() { $input.style.height = 'auto'; $input.style.height = Math.min($input.scrollHeight, 100) + 'px'; } $input.addEventListener('input', resizeInput); $input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); $send.addEventListener('click', sendMessage); function scrollToBottom() { requestAnimationFrame(() => { $messages.scrollTop = $messages.scrollHeight; }); } function escHtml(str) { return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } function escAttr(str) { return String(str || '').replace(/"/g, '"'); } async function apiPost(path, body, headers) { const base = CONFIG.apiBase || window.location.origin + '/api'; const res = await fetch(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); if (!res.ok) { let detail = ''; try { const err = await res.json(); detail = err.detail || err.message || ''; } catch (_) { /* non-JSON body */ } const err = new Error(detail || `HTTP ${res.status}`); err.status = res.status; err.detail = detail; throw err; } return res.json(); } function chatErrorMessage(err, fallback) { if (err && err.status === 429) { return err.detail || "You're sending messages quickly — take a short break and try again."; } if (err && err.status === 401) { return "This chat session expired. Please refresh the page."; } if (err && err.status === 413) { return "That message is too long. Try asking one question at a time."; } if (err && err.detail) return err.detail; return fallback || "Sorry, something went wrong. Please try again."; } if (CONFIG.autoOpenDelay > 0) { setTimeout(openChat, CONFIG.autoOpenDelay * 1000); } window.AuthorBot = { open: openChat, close: closeChat, toggle: () => isOpen ? closeChat() : openChat(), }; } function boot() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount); } else { mount(); } } boot(); })();