Arag / static /widget.js
AuthorBot
Fix 429 rate limits and show friendly errors in widget.
c23e3f9
Raw
History Blame Contribute Delete
20.7 kB
/**
* 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function escAttr(str) {
return String(str || '').replace(/"/g, '&quot;');
}
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();
})();