Spaces:
Restarting
Restarting
| /** | |
| * AuthorBot Chat Widget — widget.js | |
| * Vanilla JS, zero dependencies. | |
| * Embeds as a floating chat bubble on any author website. | |
| * | |
| * One-line install (recommended): | |
| * <script src="https://YOUR-BACKEND/static/widget.js" defer | |
| * data-authorbot-token="TOKEN" | |
| * data-authorbot-slug="AUTHOR_ID" | |
| * data-authorbot-api="https://YOUR-BACKEND/api" | |
| * data-authorbot-theme="midnight" | |
| * data-authorbot-position="bottom-right"></script> | |
| */ | |
| (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 = ` | |
| <div id="ab-window" class="ab-hidden"> | |
| <div id="ab-header"> | |
| <div id="ab-avatar">🤖</div> | |
| <div> | |
| <div id="ab-bot-name">Book Advisor</div> | |
| <div id="ab-status">Online</div> | |
| </div> | |
| <button id="ab-close-btn" aria-label="Close chat">×</button> | |
| </div> | |
| <div id="ab-messages" role="log" aria-live="polite"></div> | |
| <div id="ab-input-bar"> | |
| <textarea id="ab-input" rows="1" placeholder="Ask me anything about the books..." aria-label="Chat input"></textarea> | |
| <button id="ab-send" aria-label="Send message">↑</button> | |
| </div> | |
| <div id="ab-branding">Powered by AuthorBot</div> | |
| </div> | |
| <button id="ab-bubble" aria-label="Open book advisor chat">💬</button> | |
| `; | |
| 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 = `<div class="ab-bubble-text">${formatBotText(text)}</div>`; | |
| $messages.appendChild(el); | |
| scrollToBottom(); | |
| } | |
| function addBotMessage(text, links, bookSelector) { | |
| const el = document.createElement('div'); | |
| el.className = 'ab-msg ab-bot'; | |
| let html = `<div class="ab-bubble-text">${formatBotText(text)}</div>`; | |
| if (links && links.length) { | |
| html += `<div class="ab-links">`; | |
| links.forEach(l => { | |
| html += `<a class="ab-link-btn" href="${escAttr(l.url)}" target="_blank" rel="noopener" data-type="${escAttr(l.type)}">${escHtml(l.icon)} ${escHtml(l.label)}</a>`; | |
| }); | |
| html += `</div>`; | |
| } | |
| if (bookSelector && bookSelector.length) { | |
| html += `<div class="ab-book-selector">`; | |
| bookSelector.forEach(b => { | |
| const selected = selectedBookId === b.id; | |
| html += `<button type="button" class="ab-book-btn${selected ? ' ab-selected' : ''}" data-book-id="${escAttr(b.id)}"${selected ? ' disabled' : ''}> | |
| <span class="ab-book-thumb">📖</span> | |
| <div> | |
| <div class="ab-book-title">${escHtml(b.title)}</div> | |
| ${b.tagline ? `<div class="ab-book-tagline">${escHtml(b.tagline)}</div>` : ''} | |
| </div> | |
| </button>`; | |
| }); | |
| html += `</div>`; | |
| } | |
| 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 = `<div class="ab-bubble-text ab-typing"><div class="ab-dot"></div><div class="ab-dot"></div><div class="ab-dot"></div></div>`; | |
| $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,'"').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(); | |
| })(); | |