Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>Support Chat</title> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script> | |
| <style> | |
| :root { | |
| --primary: #6366f1; | |
| --secondary: #4f46e5; | |
| --gradient: linear-gradient(135deg, var(--primary), var(--secondary)); | |
| --bubble-user: var(--gradient); | |
| --bubble-bot: #f1f5f9; | |
| --font-family: 'Inter', -apple-system, sans-serif; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: var(--font-family); | |
| background: #f8fafc; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| } | |
| .chat-container { | |
| width: min(450px, 95vw); | |
| height: min(750px, 92vh); | |
| background: white; | |
| border-radius: 24px; | |
| box-shadow: 0 20px 50px rgba(0,0,0,0.1); | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| border: 1px solid #e2e8f0; | |
| } | |
| .chat-header { | |
| background: var(--gradient); | |
| color: white; | |
| padding: 24px 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| position: relative; | |
| } | |
| .avatar { | |
| width: 48px; height: 48px; | |
| background: rgba(255,255,255,0.25); | |
| border-radius: 16px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 24px; | |
| } | |
| .info h3 { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; } | |
| .info p { font-size: 13px; opacity: 0.9; margin-top: 2px; display: flex; align-items: center; gap: 5px; } | |
| .online-dot { width: 8px; height: 8px; background: #4ade80; border-radius: 50%; } | |
| .new-chat-btn { | |
| margin-left: auto; | |
| background: rgba(255,255,255,0.15); | |
| border: 1px solid rgba(255,255,255,0.2); | |
| border-radius: 10px; | |
| color: white; | |
| font-size: 12px; | |
| padding: 6px 12px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: all 0.2s; | |
| } | |
| .new-chat-btn:hover { background: rgba(255,255,255,0.25); } | |
| .messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| background: #ffffff; | |
| } | |
| .message { display: flex; flex-direction: column; gap: 6px; max-width: 85%; } | |
| .message.bot { align-self: flex-start; } | |
| .message.user { align-self: flex-end; } | |
| .bubble { | |
| padding: 12px 18px; | |
| border-radius: 20px; | |
| font-size: 14.5px; | |
| line-height: 1.6; | |
| position: relative; | |
| } | |
| .message.bot .bubble { background: var(--bubble-bot); color: #1e293b; border-bottom-left-radius: 4px; } | |
| .message.user .bubble { background: var(--bubble-user); color: white; border-bottom-right-radius: 4px; } | |
| /* Markdown overrides */ | |
| .bubble h3 { font-size: 16px; margin: 12px 0 8px; color: var(--primary); } | |
| .bubble ul, .bubble ol { margin-left: 20px; margin-bottom: 10px; padding-left: 4px; } | |
| .bubble code { background: rgba(0,0,0,0.05); padding: 2px 4px; border-radius: 4px; font-family: monospace; } | |
| .bubble pre { background: #1e293b; color: #f8fafc; padding: 12px; border-radius: 12px; margin: 10px 0; overflow-x: auto; } | |
| /* Typing indicator */ | |
| .typing-indicator { | |
| display: flex; align-items: center; gap: 4px; | |
| padding: 14px 18px; | |
| background: var(--bubble-bot); | |
| border-radius: 20px; | |
| border-bottom-left-radius: 4px; | |
| width: fit-content; | |
| } | |
| .typing-indicator span { | |
| width: 7px; height: 7px; | |
| background: #94a3b8; | |
| border-radius: 50%; | |
| display: inline-block; | |
| animation: bounce 1.2s infinite; | |
| } | |
| .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } | |
| .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } | |
| @keyframes bounce { | |
| 0%, 80%, 100% { transform: translateY(0); } | |
| 40% { transform: translateY(-6px); } | |
| } | |
| /* Feedback buttons */ | |
| .feedback-row { | |
| display: flex; | |
| gap: 6px; | |
| margin-top: 2px; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| } | |
| .message.bot:hover .feedback-row { opacity: 1; } | |
| .feedback-btn { | |
| background: none; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| padding: 3px 8px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| color: #94a3b8; | |
| transition: all 0.15s; | |
| line-height: 1.4; | |
| } | |
| .feedback-btn:hover { background: #f1f5f9; color: #475569; } | |
| .feedback-btn.voted-up { background: #dcfce7; border-color: #86efac; color: #16a34a; } | |
| .feedback-btn.voted-down { background: #fee2e2; border-color: #fca5a5; color: #dc2626; } | |
| /* Suggestion chips */ | |
| .suggestions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-top: 4px; | |
| max-width: 340px; | |
| } | |
| .suggestion-chip { | |
| background: white; | |
| border: 1.5px solid #e2e8f0; | |
| border-radius: 20px; | |
| padding: 6px 14px; | |
| font-size: 12.5px; | |
| color: #475569; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| font-family: var(--font-family); | |
| } | |
| .suggestion-chip:hover { | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| background: #f5f3ff; | |
| } | |
| /* Lead card */ | |
| .lead-card { | |
| background: #f8fafc; | |
| border: 2px solid #e2e8f0; | |
| border-radius: 20px; | |
| padding: 20px; | |
| margin: 10px 0; | |
| animation: slideUp 0.3s ease-out; | |
| } | |
| @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } | |
| .lead-card p { font-size: 14px; font-weight: 600; color: #1e293b; margin-bottom: 14px; } | |
| .lead-form { display: flex; flex-direction: column; gap: 8px; } | |
| .lead-input { | |
| width: 100%; padding: 10px; border: 1.5px solid #e2e8f0; border-radius: 10px; | |
| font-size: 13px; font-family: var(--font-family); transition: border-color 0.2s; | |
| } | |
| .lead-input:focus { outline: none; border-color: var(--primary); } | |
| .lead-btn-group { display: flex; flex-direction: row; gap: 8px; flex-wrap: wrap; } | |
| .lead-btn { | |
| flex: 1; min-width: 100px; padding: 11px 6px; | |
| border-radius: 12px; border: none; | |
| font-weight: 700; font-size: 12px; | |
| cursor: pointer; transition: all 0.2s; | |
| display: flex; align-items: center; justify-content: center; gap: 6px; | |
| } | |
| .btn-yes { background: #6366f1; color: white; } | |
| .btn-no { background: #f1f5f9; color: #64748b; } | |
| .btn-submit { background: var(--gradient); color: white; } | |
| .btn-email { background: #6366f1; color: white; } | |
| .btn-whatsapp { background: #22c55e; color: white; } | |
| .btn-cancel { background: #f1f5f9; color: #64748b; } | |
| .lead-btn:hover { filter: brightness(1.1); transform: translateY(-1px); } | |
| /* Input area */ | |
| .input-area { | |
| padding: 20px; | |
| border-top: 1px solid #f1f5f9; | |
| display: flex; gap: 12px; align-items: center; | |
| background: white; | |
| } | |
| .input-area input { | |
| flex: 1; | |
| border: 1.5px solid #e2e8f0; | |
| border-radius: 16px; | |
| padding: 12px 20px; | |
| font-size: 14px; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| font-family: var(--font-family); | |
| } | |
| .input-area input:focus { border-color: var(--primary); } | |
| .send-btn { | |
| width: 48px; height: 48px; | |
| background: var(--gradient); | |
| border: none; border-radius: 16px; | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| color: white; transition: all 0.2s; | |
| } | |
| .send-btn:hover { transform: scale(1.05); } | |
| .send-btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .source-citations { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); display: flex; flex-wrap: wrap; gap: 4px; } | |
| .cite-badge { display: inline-block; font-size: 11px; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 10px; padding: 2px 8px; color: #6366f1; text-decoration: none; } | |
| .cite-badge:hover { background: #e0e7ff; } | |
| .handoff-card { background: #f0fdf4; border: 2px solid #86efac; border-radius: 20px; padding: 18px; margin: 10px 0; animation: slideUp 0.3s ease-out; } | |
| .handoff-card p { font-size: 14px; font-weight: 600; color: #166534; margin-bottom: 12px; } | |
| .handoff-input { width:100%; padding:9px; border:1.5px solid #e2e8f0; border-radius:10px; font-size:13px; margin-bottom:8px; box-sizing:border-box; } | |
| .btn-handoff { background:#22c55e; color:white; border:none; border-radius:12px; padding:10px 20px; cursor:pointer; font-weight:600; font-size:13px; } | |
| .btn-handoff-cancel { background:#f1f5f9; color:#475569; border:1px solid #e2e8f0; border-radius:12px; padding:10px 16px; cursor:pointer; font-size:13px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="chat-container"> | |
| <div class="chat-header"> | |
| <div class="avatar" id="header-avatar">π€</div> | |
| <div class="info"> | |
| <h3 id="header-title">AI Support</h3> | |
| <p><span class="online-dot"></span> <span id="header-subtitle">Ready to assist</span></p> | |
| </div> | |
| <button class="new-chat-btn" onclick="newChat()">New Chat</button> | |
| </div> | |
| <div class="messages" id="messages"> | |
| <div class="message bot"> | |
| <div class="bubble" id="welcome-msg">Hi! How can I help you today?</div> | |
| </div> | |
| </div> | |
| <div class="input-area"> | |
| <input type="text" id="user-input" placeholder="Message..." autocomplete="off" /> | |
| <button class="send-btn" id="send-btn" onclick="sendMessage()"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="22" y1="2" x2="11" y2="13"></line> | |
| <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| // XSS protection: strip raw HTML blocks from LLM output β keep markdown formatting only | |
| (function() { | |
| const renderer = new marked.Renderer(); | |
| renderer.html = () => ''; // drop any raw <script>, <img onerror=...> etc from LLM | |
| marked.use({ renderer, breaks: true, gfm: true }); | |
| })(); | |
| const API_URL = (window.location.origin.includes('localhost') || window.location.origin.includes('127.0.0.1')) | |
| ? 'http://127.0.0.1:8000' : window.location.origin; | |
| const messagesEl = document.getElementById("messages"); | |
| const inputEl = document.getElementById("user-input"); | |
| const sendBtn = document.getElementById("send-btn"); | |
| const HANDOFF_RE = /\b(talk to|speak to|connect me with|live agent|real person|human support|call me|speak with someone|agent please|need help from)\b/i; | |
| // Session β reset if active DB has changed | |
| let sessionId = localStorage.getItem("chat_session_id"); | |
| if (!sessionId) { | |
| sessionId = crypto.randomUUID(); | |
| localStorage.setItem("chat_session_id", sessionId); | |
| } | |
| // Restore history from localStorage so conversation survives page reloads | |
| let HISTORY_KEY = "chat_history_" + sessionId; | |
| // Clear chat if DB switched since last visit | |
| fetch(`${API_URL}/health`).then(r => r.json()).then(h => { | |
| const currentDb = h.db || ""; | |
| const lastDb = localStorage.getItem("last_active_db") || ""; | |
| if (currentDb && lastDb && currentDb !== lastDb) { | |
| // DB changed β wipe old session | |
| localStorage.removeItem(HISTORY_KEY); | |
| sessionId = crypto.randomUUID(); | |
| localStorage.setItem("chat_session_id", sessionId); | |
| HISTORY_KEY = "chat_history_" + sessionId; | |
| history.length = 0; | |
| document.getElementById("messages").innerHTML = ""; | |
| } | |
| if (currentDb) localStorage.setItem("last_active_db", currentDb); | |
| }).catch(() => {}); | |
| const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); | |
| let widgetKey = ""; | |
| let contactEmail = "support@agentfactory.com"; | |
| let contactWhatsApp = ""; | |
| // Auto-clear chat when active bot changes (e.g. admin switches DB) | |
| let _activeBotName = ""; | |
| function _checkBotChanged() { | |
| fetch(`${API_URL}/config`).then(r => r.json()).then(cfg => { | |
| const name = cfg.bot_name || ""; | |
| if (_activeBotName && name && name !== _activeBotName) { | |
| localStorage.setItem("chat_session_id", crypto.randomUUID()); | |
| window.location.reload(); | |
| } | |
| _activeBotName = name || _activeBotName; | |
| }).catch(() => {}); | |
| } | |
| document.addEventListener("visibilitychange", () => { if (!document.hidden) _checkBotChanged(); }); | |
| window.addEventListener("focus", _checkBotChanged); | |
| // No idle polling: on scale-to-zero hosting every request keeps the | |
| // container awake (= billed). Bot-change detection happens on tab | |
| // focus/visibility and before each send instead. | |
| // Load branding config | |
| fetch(`${API_URL}/config`).then(r => r.json()).then(cfg => { | |
| widgetKey = cfg.widget_key; | |
| _activeBotName = cfg.bot_name || ""; | |
| contactEmail = cfg.contact_email; | |
| contactWhatsApp = cfg.whatsapp_number; | |
| const b = cfg.branding || {}; | |
| const p = b.primary_color || cfg.primary_color || "#6366f1"; | |
| const s = b.secondary_color || cfg.secondary_color || "#4f46e5"; | |
| document.documentElement.style.setProperty("--primary", p); | |
| document.documentElement.style.setProperty("--secondary", s); | |
| document.documentElement.style.setProperty("--gradient", `linear-gradient(135deg, ${p}, ${s})`); | |
| if (b.bot_bubble_color || cfg.bot_bubble_color) document.documentElement.style.setProperty("--bubble-bot", b.bot_bubble_color || cfg.bot_bubble_color); | |
| if (b.user_bubble_color || cfg.user_bubble_color) document.documentElement.style.setProperty("--bubble-user", b.user_bubble_color || cfg.user_bubble_color); | |
| document.getElementById("header-title").textContent = cfg.header_title || b.header_title || "Support"; | |
| document.getElementById("header-subtitle").textContent = cfg.header_subtitle || b.header_subtitle || "Ready to assist"; | |
| document.getElementById("welcome-msg").textContent = cfg.welcome_message || b.welcome_message || "Hi! How can I help you today?"; | |
| if (cfg.logo_emoji || b.logo_emoji) document.getElementById("header-avatar").textContent = cfg.logo_emoji || b.logo_emoji; | |
| }).catch(() => {}); | |
| // Restore previous messages from localStorage | |
| if (history.length > 0) { | |
| messagesEl.innerHTML = ""; | |
| for (let i = 0; i < history.length; i++) { | |
| const m = history[i]; | |
| const div = document.createElement("div"); | |
| div.className = "message " + (m.role === "user" ? "user" : "bot"); | |
| const bubble = document.createElement("div"); | |
| bubble.className = "bubble"; | |
| bubble.textContent = m.content; | |
| div.appendChild(bubble); | |
| messagesEl.appendChild(div); | |
| } | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| inputEl.addEventListener("keypress", e => { if (e.key === "Enter") sendMessage(); }); | |
| async function sendMessage() { | |
| const q = inputEl.value.trim(); | |
| if (!q) return; | |
| resetProactiveTimer(); | |
| // Human handoff detection | |
| if (HANDOFF_RE.test(q)) { | |
| addMessage(q, "user"); | |
| inputEl.value = ""; | |
| showHandoffCard(); | |
| return; | |
| } | |
| addMessage(q, "user"); | |
| inputEl.value = ""; | |
| sendBtn.disabled = true; | |
| // Show typing indicator | |
| const typingId = "typing-" + Date.now(); | |
| const typingDiv = document.createElement("div"); | |
| typingDiv.className = "message bot"; | |
| typingDiv.id = typingId; | |
| typingDiv.innerHTML = `<div class="typing-indicator"><span></span><span></span><span></span></div>`; | |
| messagesEl.appendChild(typingDiv); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| const botMsgId = "bot-" + Date.now(); | |
| let bubble = null; | |
| let fullAnswer = ""; | |
| let lastQuestion = q; | |
| try { | |
| const response = await fetch(`${API_URL}/chat`, { | |
| method: "POST", | |
| headers: Object.assign({"Content-Type": "application/json"}, widgetKey ? {"X-Widget-Key": widgetKey} : {}), | |
| body: JSON.stringify({ question: q, history, session_id: sessionId, | |
| visitor_id: sessionId, page_url: location.href, page_title: document.title }), | |
| }); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop(); // keep incomplete line | |
| for (const line of lines) { | |
| if (!line.startsWith("data: ")) continue; | |
| let data; | |
| try { data = JSON.parse(line.substring(6)); } catch { continue; } | |
| if (data.type === "reset") { | |
| // Server is replacing live-streamed text (a validator rewrote the answer, | |
| // or a failed LLM attempt is being retried) β clear and rebuild from next chunk. | |
| fullAnswer = ""; | |
| if (bubble) bubble.innerHTML = ""; | |
| continue; | |
| } | |
| if (data.type === "chunk") { | |
| // Remove typing indicator on first chunk | |
| if (!bubble) { | |
| document.getElementById(typingId)?.remove(); | |
| const msgDiv = document.createElement("div"); | |
| msgDiv.className = "message bot"; | |
| msgDiv.id = botMsgId; | |
| msgDiv.innerHTML = `<div class="bubble"></div>`; | |
| messagesEl.appendChild(msgDiv); | |
| bubble = msgDiv.querySelector(".bubble"); | |
| // Scroll to top of new bot message so user reads from the start | |
| msgDiv.scrollIntoView({ block: 'start', behavior: 'smooth' }); | |
| fullAnswer += data.content; | |
| bubble.innerHTML = DOMPurify.sanitize(marked.parse(renderCards(fullAnswer))); | |
| // Skip nearBottom on first chunk β scrollIntoView already positioned correctly | |
| continue; | |
| } | |
| fullAnswer += data.content; | |
| bubble.innerHTML = DOMPurify.sanitize(marked.parse(renderCards(fullAnswer))); | |
| // Only auto-scroll during streaming if user hasn't scrolled up to read | |
| const nearBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 80; | |
| if (nearBottom) messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } else if (data.type === "metadata") { | |
| if (data.capture_lead) showLeadBox(); | |
| if (data.sources && data.sources.length > 0) { | |
| const srcDiv = document.createElement("div"); | |
| srcDiv.className = "source-citations"; | |
| srcDiv.innerHTML = data.sources.map((s, i) => { | |
| try { | |
| const u = new URL(s); | |
| // Block non-http schemes (javascript:, data:, etc.) | |
| if (!['http:', 'https:'].includes(u.protocol)) return ''; | |
| const seg = u.pathname.split('/').filter(Boolean).pop() || 'source'; | |
| const safeUrl = u.href.replace(/"/g, '"'); | |
| return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer" class="cite-badge">[${i+1}] ${seg}</a>`; | |
| } catch { return ''; } | |
| }).filter(Boolean).join(''); | |
| if (bubble) bubble.appendChild(srcDiv); | |
| } | |
| if (data.options && data.options.length) renderSuggestions(data.options, botMsgId); | |
| } else if (data.type === "suggestions") { | |
| renderSuggestions(data.items, botMsgId); | |
| } | |
| } | |
| } | |
| // Append feedback row to bot message | |
| if (bubble) { | |
| renderFeedbackRow(botMsgId, q, fullAnswer); | |
| } | |
| resetProactiveTimer(); | |
| history.push({ role: "user", content: q }, { role: "assistant", content: fullAnswer }); | |
| // Keep last 20 messages (10 turns) to avoid unbounded growth | |
| if (history.length > 20) history.splice(0, history.length - 20); | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); | |
| } catch (e) { | |
| document.getElementById(typingId)?.remove(); | |
| addMessage("Error connecting to server.", "bot"); | |
| } | |
| sendBtn.disabled = false; | |
| inputEl.focus(); | |
| checkCsatTrigger(); | |
| } | |
| // --- Proactive idle trigger --- | |
| let proactiveSent = false; | |
| let proactiveTimer = null; | |
| function resetProactiveTimer() { | |
| clearTimeout(proactiveTimer); | |
| if (proactiveSent) return; | |
| proactiveTimer = setTimeout(() => { | |
| if (proactiveSent) return; | |
| proactiveSent = true; | |
| addMessage("Still there? Feel free to ask me anything β I'm here to help!", "bot"); | |
| }, 45000); | |
| } | |
| resetProactiveTimer(); | |
| // --- Human Handoff --- | |
| function showHandoffCard() { | |
| const cardId = "handoff-" + Date.now(); | |
| const div = document.createElement("div"); | |
| div.className = "message bot"; | |
| div.id = cardId; | |
| div.innerHTML = `<div class="handoff-card"> | |
| <p>I'll connect you with our team right away!</p> | |
| <input class="handoff-input" id="hf-name" placeholder="Your name (optional)" /> | |
| <input class="handoff-input" id="hf-contact" placeholder="Your email or WhatsApp (optional)" /> | |
| <div style="display:flex;gap:8px;flex-wrap:wrap;"> | |
| <button class="btn-handoff" onclick="submitHandoff('${cardId}')">Send my conversation to the team</button> | |
| <button class="btn-handoff-cancel" onclick="document.getElementById('${cardId}').remove()">Continue chatting</button> | |
| </div> | |
| </div>`; | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| async function submitHandoff(cardId) { | |
| const name = document.getElementById("hf-name")?.value.trim() || "Website Visitor"; | |
| const contact = document.getElementById("hf-contact")?.value.trim() || ""; | |
| const card = document.getElementById(cardId); | |
| const btn = card?.querySelector(".btn-handoff"); | |
| if (btn) btn.textContent = "Sending..."; | |
| try { | |
| const r = await fetch(`${API_URL}/handoff`, { | |
| method: "POST", | |
| headers: Object.assign({"Content-Type":"application/json"}, widgetKey ? {"X-Widget-Key":widgetKey} : {}), | |
| body: JSON.stringify({session_id: sessionId, conversation: history, name, contact_info: contact}) | |
| }); | |
| const data = await r.json(); | |
| const hc = card?.querySelector(".handoff-card"); | |
| if (hc) hc.innerHTML = `<p style="color:#166534">${data.message || "Team notified!"}</p>`; | |
| } catch { | |
| const hc = card?.querySelector(".handoff-card"); | |
| if (hc) hc.innerHTML = `<p style="color:#dc2626">Could not send. Please contact us directly.</p>`; | |
| } | |
| } | |
| function addMessage(html, role, id) { | |
| const div = document.createElement("div"); | |
| div.className = `message ${role}`; | |
| if (id) div.id = id; | |
| div.innerHTML = `<div class="bubble">${html}</div>`; | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| function renderFeedbackRow(msgId, question, answer) { | |
| const msgDiv = document.getElementById(msgId); | |
| if (!msgDiv) return; | |
| const row = document.createElement("div"); | |
| row.className = "feedback-row"; | |
| row.id = "fb-" + msgId; | |
| row.innerHTML = ` | |
| <button class="feedback-btn" title="Helpful" onclick="submitFeedback(1, '${msgId}', this)">π</button> | |
| <button class="feedback-btn" title="Not helpful" onclick="submitFeedback(-1, '${msgId}', this)">π</button> | |
| `; | |
| // Store question+answer in dataset for the feedback call | |
| row.dataset.question = question; | |
| row.dataset.answer = answer.substring(0, 400); | |
| msgDiv.appendChild(row); | |
| } | |
| async function submitFeedback(rating, msgId, btn) { | |
| const row = document.getElementById("fb-" + msgId); | |
| const question = row?.dataset.question || ""; | |
| const answer = row?.dataset.answer || ""; | |
| try { | |
| await fetch(`${API_URL}/feedback`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ session_id: sessionId, rating, question, answer }), | |
| }); | |
| // Visual feedback | |
| btn.classList.add(rating === 1 ? "voted-up" : "voted-down"); | |
| btn.disabled = true; | |
| const sibling = btn.parentElement.querySelector(`.feedback-btn:not(.voted-up):not(.voted-down)`); | |
| if (sibling) sibling.disabled = true; | |
| setTimeout(() => { if (row) row.style.display = "none"; }, 1500); | |
| } catch (e) { /* silent */ } | |
| } | |
| function renderSuggestions(items, afterMsgId) { | |
| if (!items || !items.length) return; | |
| const msgDiv = document.getElementById(afterMsgId); | |
| if (!msgDiv) return; | |
| const container = document.createElement("div"); | |
| container.className = "suggestions"; | |
| items.forEach(text => { | |
| const chip = document.createElement("button"); | |
| chip.className = "suggestion-chip"; | |
| chip.textContent = text; | |
| chip.onclick = () => { | |
| inputEl.value = text; | |
| container.remove(); | |
| sendMessage(); | |
| }; | |
| container.appendChild(chip); | |
| }); | |
| msgDiv.appendChild(container); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| function showLeadOptions(cardId) { | |
| document.getElementById(cardId).innerHTML = ` | |
| <p>Would you like to connect with our team?</p> | |
| <div class="lead-btn-group"> | |
| <button class="lead-btn btn-yes" onclick="showLeadEmailForm('${cardId}')">β Yes, connect me</button> | |
| <button class="lead-btn btn-no" onclick="dismissLeadBox('${cardId}')">β No thanks</button> | |
| </div> | |
| `; | |
| } | |
| function showLeadEmailForm(cardId) { | |
| document.getElementById(cardId).innerHTML = ` | |
| <div class="lead-form"> | |
| <p>Drop your details and we'll get back to you!</p> | |
| <input type="text" id="lead-name-${cardId}" placeholder="Your Name" class="lead-input"> | |
| <input type="email" id="lead-email-${cardId}" placeholder="Email Address" class="lead-input"> | |
| <input type="tel" id="lead-phone-${cardId}" placeholder="Phone / WhatsApp Number" class="lead-input"> | |
| <textarea id="lead-msg-${cardId}" placeholder="How can we help?" class="lead-input" rows="2"></textarea> | |
| <div class="lead-btn-group"> | |
| <button class="lead-btn btn-submit" onclick="submitLeadForm('${cardId}')">Send Details</button> | |
| <button class="lead-btn btn-cancel" onclick="showLeadOptions('${cardId}')">β Back</button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| async function submitLeadForm(cardId) { | |
| const name = document.getElementById(`lead-name-${cardId}`).value; | |
| const email = document.getElementById(`lead-email-${cardId}`).value; | |
| const phone = document.getElementById(`lead-phone-${cardId}`)?.value || ""; | |
| const msg = document.getElementById(`lead-msg-${cardId}`).value; | |
| if(!name || !email) { | |
| alert("Please provide at least your name and email."); | |
| return; | |
| } | |
| const btn = document.querySelector(`#${cardId} .btn-submit`); | |
| btn.disabled = true; | |
| btn.innerText = "Sending..."; | |
| try { | |
| await fetch(`${API_URL}/submit-lead`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ name, email, whatsapp: phone, message: msg, session_id: sessionId }) | |
| }); | |
| document.getElementById(cardId).innerHTML = ` | |
| <div style="text-align:center;color:#16a34a;padding:10px;"> | |
| <p>β <b>Sent!</b></p> | |
| <p style="font-size:12px;">We will contact you shortly.</p> | |
| </div> | |
| `; | |
| setTimeout(() => document.getElementById(cardId)?.remove(), 5000); | |
| } catch(e) { | |
| btn.disabled = false; | |
| btn.innerText = "Retry"; | |
| alert("Error sending details. Please try again."); | |
| } | |
| } | |
| function leadSessionKey(type) { | |
| return `lead_${type}_${sessionId}`; | |
| } | |
| function dismissLeadBox(cardId) { | |
| sessionStorage.setItem(leadSessionKey("dismissed"), "1"); | |
| document.getElementById(cardId)?.remove(); | |
| } | |
| function showLeadBox() { | |
| if ( | |
| sessionStorage.getItem(leadSessionKey("shown")) === "1" || | |
| sessionStorage.getItem(leadSessionKey("dismissed")) === "1" || | |
| document.querySelector(".lead-card") | |
| ) return; | |
| sessionStorage.setItem(leadSessionKey("shown"), "1"); | |
| const cardId = "lead-" + Date.now(); | |
| const div = document.createElement("div"); | |
| div.className = "lead-card"; | |
| div.id = cardId; | |
| div.innerHTML = ` | |
| <p>Would you like to connect with our team?</p> | |
| <div class="lead-btn-group"> | |
| <button class="lead-btn btn-yes" onclick="showLeadEmailForm('${cardId}')">β Yes, connect me</button> | |
| <button class="lead-btn btn-no" onclick="dismissLeadBox('${cardId}')">β No thanks</button> | |
| </div> | |
| `; | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| } | |
| function newChat() { | |
| localStorage.removeItem(HISTORY_KEY); | |
| history.length = 0; | |
| sessionId = crypto.randomUUID(); | |
| localStorage.setItem("chat_session_id", sessionId); | |
| HISTORY_KEY = "chat_history_" + sessionId; | |
| messagesEl.innerHTML = '<div class="message bot"><div class="bubble">Hi! How can I help you today?</div></div>'; | |
| clearTimeout(csatTimer); | |
| proactiveSent = false; | |
| resetProactiveTimer(); | |
| } | |
| // ββ Rich card renderer βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderCards(text) { | |
| return text.replace(/\[CARD\](.*?)\[\/CARD\]/gs, (_, inner) => { | |
| const [title, desc, url] = inner.split('|').map(s => s.trim()); | |
| const link = url ? `<a href="${url}" target="_blank" style="font-size:12px;color:#6366f1;">View β</a>` : ''; | |
| return `<div style="border:1px solid #e2e8f0;border-radius:12px;padding:10px 14px;margin:6px 0;background:#f8fafc;"> | |
| <b>${title || ''}</b>${desc ? `<br><span style="font-size:13px;color:#64748b;">${desc}</span>` : ''}${link ? `<br>${link}` : ''} | |
| </div>`; | |
| }); | |
| } | |
| // ββ CSAT survey (fires after 5th question, once per session) βββββββββββββ | |
| let csatShown = false; | |
| let csatQuestionCount = 0; | |
| function checkCsatTrigger() { | |
| if (csatShown) return; | |
| csatQuestionCount++; | |
| if (csatQuestionCount === 5) showCsat(); | |
| } | |
| function showCsat() { | |
| if (csatShown) return; | |
| csatShown = true; | |
| const div = document.createElement("div"); | |
| div.className = "message bot"; | |
| div.style.alignSelf = "center"; | |
| div.innerHTML = ` | |
| <div class="bubble" style="background:#fafafa;border:1px solid #e2e8f0;text-align:center;padding:16px 20px;"> | |
| <p style="margin:0 0 12px;font-weight:600;font-size:14px;color:#1e293b;">How would you rate your experience?</p> | |
| <div id="csat-stars" style="display:flex;justify-content:center;gap:4px;"> | |
| ${[1,2,3,4,5].map(n => ` | |
| <span class="csat-star" | |
| data-val="${n}" | |
| onmouseover="highlightStars(${n})" | |
| onmouseout="highlightStars(0)" | |
| onclick="submitCsat(${n},this)" | |
| style="font-size:28px;cursor:pointer;color:#cbd5e1;line-height:1;user-select:none;">★</span> | |
| `).join('')} | |
| </div> | |
| <p style="margin:8px 0 0;font-size:12px;color:#94a3b8;">Press 1β5 to rate</p> | |
| </div>`; | |
| messagesEl.appendChild(div); | |
| messagesEl.scrollTop = messagesEl.scrollHeight; | |
| // Keyboard shortcut: 1-5 | |
| const keyHandler = (e) => { | |
| if (['1','2','3','4','5'].includes(e.key)) { | |
| const stars = document.querySelectorAll('.csat-star'); | |
| if (stars.length) { submitCsat(parseInt(e.key), stars[parseInt(e.key)-1]); } | |
| document.removeEventListener('keydown', keyHandler); | |
| } | |
| }; | |
| document.addEventListener('keydown', keyHandler); | |
| } | |
| function highlightStars(n) { | |
| document.querySelectorAll('.csat-star').forEach(s => { | |
| s.style.color = parseInt(s.dataset.val) <= n ? '#f59e0b' : '#cbd5e1'; | |
| }); | |
| } | |
| async function submitCsat(rating, btn) { | |
| const bubble = btn.closest ? btn.closest('.bubble') : btn.parentElement.closest('.bubble'); | |
| bubble.innerHTML = ` | |
| <div style="text-align:center;padding:4px 0;"> | |
| <div style="display:flex;justify-content:center;gap:2px;margin-bottom:8px;"> | |
| ${[1,2,3,4,5].map(n => `<span style="font-size:24px;color:${n<=rating?'#f59e0b':'#cbd5e1'};">★</span>`).join('')} | |
| </div> | |
| <p style="margin:0;font-weight:600;font-size:14px;color:#1e293b;">Thank you for your feedback.</p> | |
| </div>`; | |
| try { | |
| await fetch(`${API_URL}/csat`, { | |
| method: 'POST', headers: {'Content-Type':'application/json'}, | |
| body: JSON.stringify({ rating, session_id: sessionId }) | |
| }); | |
| } catch {} | |
| } | |
| </script> | |
| </body> | |
| </html> | |