feat: customer chat system with admin panel inbox and embeddable widget

- chatStore.js: per-shop JSON storage in data/chats/, message history,
  unread tracking, markRead on admin open
- adminPanel.js: new Chat tab (WhatsApp-style left sidebar + right panel),
  3s polling, admin reply, unread badge on tab
- server.js: public POST /chat/:shop and GET /chat/:shop for widget,
  GET /chat/widget.js serves embeddable script
- Widget: floating chat button, popup window, customer sends messages,
  polls for admin replies, visitor ID persisted in localStorage
  Usage: <script src="https://backend.data4autos.com/chat/widget.js?shop=SHOP"></script>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-06-12 20:45:18 +05:30
parent 0cb6e260f7
commit aef977eb99
3 changed files with 425 additions and 1 deletions

69
chatStore.js Normal file
View File

@ -0,0 +1,69 @@
// chatStore.js — per-shop customer chat message storage
const fs = require('fs');
const path = require('path');
const { v4: uuid } = require('uuid');
const chatsDir = path.resolve(__dirname, 'data', 'chats');
if (!fs.existsSync(chatsDir)) fs.mkdirSync(chatsDir, { recursive: true });
function safeKey(shop) {
return shop.toLowerCase().trim().replace(/[^a-z0-9.-]/g, '_');
}
function chatFile(shop) {
return path.join(chatsDir, safeKey(shop) + '.json');
}
function readChat(shop) {
const f = chatFile(shop);
if (!fs.existsSync(f)) return { shop, messages: [] };
try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
catch { return { shop, messages: [] }; }
}
function saveChat(shop, data) {
fs.writeFileSync(chatFile(shop), JSON.stringify(data, null, 2), 'utf8');
}
function addMessage(shop, from, text, visitorId = null) {
const chat = readChat(shop);
const msg = {
id: uuid(),
from, // 'customer' | 'admin'
text: String(text).trim().slice(0, 2000),
timestamp: new Date().toISOString(),
read: from === 'admin',
visitorId: visitorId || null,
};
chat.messages.push(msg);
saveChat(shop, chat);
return msg;
}
function markRead(shop) {
const chat = readChat(shop);
let changed = false;
chat.messages.forEach(m => {
if (m.from === 'customer' && !m.read) { m.read = true; changed = true; }
});
if (changed) saveChat(shop, chat);
}
function listChats() {
if (!fs.existsSync(chatsDir)) return [];
return fs.readdirSync(chatsDir)
.filter(f => f.endsWith('.json'))
.map(f => {
try {
const data = JSON.parse(fs.readFileSync(path.join(chatsDir, f), 'utf8'));
const msgs = data.messages || [];
const last = msgs[msgs.length - 1] || null;
const unread = msgs.filter(m => m.from === 'customer' && !m.read).length;
return { shop: data.shop, lastMessage: last, unread, messageCount: msgs.length };
} catch { return null; }
})
.filter(Boolean)
.sort((a, b) => (b.lastMessage?.timestamp || '').localeCompare(a.lastMessage?.timestamp || ''));
}
module.exports = { addMessage, readChat, markRead, listChats };

View File

@ -3,6 +3,7 @@ const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore'); const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore');
const { listTokens } = require('../tokenStore'); const { listTokens } = require('../tokenStore');
const { addMessage, readChat, markRead, listChats } = require('../chatStore');
const router = express.Router(); const router = express.Router();
@ -99,6 +100,25 @@ router.get('/api/users', requireAuth, (_req, res) => {
res.json({ users }); res.json({ users });
}); });
// ── chat API (admin-protected) ───────────────────────────────────────────────
router.get('/api/chats', requireAuth, (_req, res) => {
res.json({ chats: listChats() });
});
router.get('/api/chats/:shop', requireAuth, (req, res) => {
const shop = decodeURIComponent(req.params.shop);
markRead(shop);
res.json(readChat(shop));
});
router.post('/api/chats/:shop/reply', requireAuth, (req, res) => {
const shop = decodeURIComponent(req.params.shop);
const { text } = req.body;
if (!text) return res.status(400).json({ error: 'text required' });
const msg = addMessage(shop, 'admin', text);
res.json({ ok: true, message: msg });
});
// ── serve admin HTML (always — login gate is client-side) ──────────────────── // ── serve admin HTML (always — login gate is client-side) ────────────────────
router.get('/', (_req, res) => { router.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
@ -386,6 +406,59 @@ function adminHtml() {
.empty-state small{font-size:12px;color:#b0b8c4;display:block;margin-top:4px;} .empty-state small{font-size:12px;color:#b0b8c4;display:block;margin-top:4px;}
.loading-row{padding:32px;text-align:center;} .loading-row{padding:32px;text-align:center;}
.spinner-dark{display:inline-block;width:20px;height:20px;border:2px solid #e2e8f0;border-top-color:#2563eb;border-radius:50%;animation:spin 0.7s linear infinite;} .spinner-dark{display:inline-block;width:20px;height:20px;border:2px solid #e2e8f0;border-top-color:#2563eb;border-radius:50%;animation:spin 0.7s linear infinite;}
/* ═══ NAV TABS ═══ */
.topbar-nav{display:flex;gap:3px;background:#f1f5f9;border-radius:10px;padding:4px;}
.nav-tab{background:none;border:none;border-radius:7px;padding:7px 18px;font-size:13px;font-weight:600;color:#64748b;cursor:pointer;font-family:inherit;transition:background 0.12s,color 0.12s;display:flex;align-items:center;gap:6px;}
.nav-tab.active{background:#fff;color:#0f172a;box-shadow:0 1px 3px rgba(0,0,0,0.08);}
.nav-tab:hover:not(.active){background:rgba(255,255,255,0.6);}
.nav-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;background:#ef4444;border-radius:8px;font-size:10px;font-weight:700;color:#fff;padding:0 3px;}
/* ═══ CHAT LAYOUT ═══ */
#section-chat{height:calc(100vh - 61px);overflow:hidden;display:none;}
.chat-wrap{display:flex;height:100%;}
.chat-sidebar{width:300px;flex-shrink:0;background:#fff;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;overflow:hidden;}
.chat-sidebar-head{padding:16px 18px;border-bottom:1px solid #f1f5f9;background:#fafafa;}
.chat-sidebar-head h3{font-size:15px;font-weight:800;color:#0f172a;margin-bottom:10px;}
.chat-search{width:100%;background:#f1f5f9;border:none;border-radius:8px;padding:8px 12px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;}
.chat-list{flex:1;overflow-y:auto;}
.chat-item{padding:13px 18px;cursor:pointer;display:flex;align-items:center;gap:12px;border-bottom:1px solid #f8fafc;transition:background 0.1s;}
.chat-item:hover{background:#f8fafc;}
.chat-item.active{background:#eff6ff;}
.chat-avatar{width:42px;height:42px;background:linear-gradient(135deg,#2563eb,#4f46e5);border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;color:#fff;flex-shrink:0;}
.chat-item-meta{flex:1;min-width:0;}
.chat-item-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;}
.chat-item-shop{font-size:13px;font-weight:700;color:#0f172a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:155px;}
.chat-item-time{font-size:11px;color:#94a3b8;flex-shrink:0;margin-left:6px;}
.chat-item-bot{display:flex;justify-content:space-between;align-items:center;}
.chat-item-preview{font-size:12px;color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:185px;}
.chat-unread{min-width:18px;height:18px;background:#2563eb;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;padding:0 4px;flex-shrink:0;margin-left:6px;}
.chat-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;}
.chat-panel-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#94a3b8;}
.chat-panel-empty .icon{font-size:52px;margin-bottom:16px;}
.chat-panel-empty p{font-size:15px;font-weight:600;color:#374151;margin-bottom:6px;}
.chat-panel-empty small{font-size:13px;}
.chat-active{flex:1;display:none;flex-direction:column;overflow:hidden;}
.chat-active.open{display:flex;}
.chat-hdr{padding:14px 24px;background:#fff;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:12px;flex-shrink:0;}
.chat-hdr-avatar{width:40px;height:40px;background:linear-gradient(135deg,#2563eb,#4f46e5);border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:800;color:#fff;flex-shrink:0;}
.chat-hdr-shop{font-size:14px;font-weight:700;color:#0f172a;}
.chat-hdr-sub{font-size:12px;color:#94a3b8;margin-top:1px;}
.chat-msgs{flex:1;overflow-y:auto;padding:20px 24px;display:flex;flex-direction:column;gap:12px;background:#f8fafc;}
.chat-bubble-wrap{display:flex;flex-direction:column;}
.chat-bubble-wrap.admin{align-items:flex-end;}
.chat-bubble-wrap.customer{align-items:flex-start;}
.chat-bubble{max-width:70%;padding:10px 14px;font-size:14px;line-height:1.55;box-shadow:0 1px 3px rgba(0,0,0,0.07);}
.chat-bubble.admin{background:#2563eb;color:#fff;border-radius:16px 16px 4px 16px;}
.chat-bubble.customer{background:#fff;color:#0f172a;border-radius:16px 16px 16px 4px;border:1px solid #f1f5f9;}
.chat-bubble-time{font-size:11px;color:#94a3b8;margin-top:4px;}
.chat-input-row{padding:14px 20px;background:#fff;border-top:1px solid #e2e8f0;display:flex;gap:10px;align-items:flex-end;flex-shrink:0;}
.chat-textarea{flex:1;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:10px;padding:10px 14px;font-size:14px;font-family:inherit;outline:none;color:#0f172a;resize:none;line-height:1.5;max-height:120px;}
.chat-textarea:focus{border-color:#2563eb;background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,0.08);}
.chat-send{background:#2563eb;border:none;border-radius:10px;color:#fff;padding:10px 22px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap;flex-shrink:0;transition:background 0.12s;}
.chat-send:hover{background:#1d4ed8;}
.chat-send:disabled{background:#93c5fd;cursor:not-allowed;}
</style> </style>
</head> </head>
<body> <body>
@ -462,12 +535,19 @@ function adminHtml() {
<div class="topbar-sub">Free Access Manager</div> <div class="topbar-sub">Free Access Manager</div>
</div> </div>
</div> </div>
<div class="topbar-nav">
<button class="nav-tab active" id="tab-users" onclick="showTab('users')">👥 Users</button>
<button class="nav-tab" id="tab-chat" onclick="showTab('chat')">
💬 Chat <span class="nav-badge" id="chat-unread-badge" style="display:none">0</span>
</button>
</div>
<div class="topbar-right"> <div class="topbar-right">
<span class="topbar-badge" id="shops-count">0 users</span> <span class="topbar-badge" id="shops-count">0 users</span>
<button class="logout-btn" onclick="doLogout()">Sign Out</button> <button class="logout-btn" onclick="doLogout()">Sign Out</button>
</div> </div>
</div> </div>
<div id="section-users">
<div class="content"> <div class="content">
<div class="page-header"> <div class="page-header">
<h2>App Users</h2> <h2>App Users</h2>
@ -509,6 +589,50 @@ function adminHtml() {
</div> </div>
</div> </div>
</div><!-- /section-users -->
<!-- CHAT SECTION -->
<div id="section-chat">
<div class="chat-wrap">
<!-- Left sidebar -->
<div class="chat-sidebar">
<div class="chat-sidebar-head">
<h3>Messages</h3>
<input class="chat-search" id="chat-search" placeholder="Search shops…" oninput="filterChats()"/>
</div>
<div class="chat-list" id="chat-list">
<div class="loading-row"><div class="spinner-dark"></div></div>
</div>
</div>
<!-- Right panel -->
<div class="chat-panel">
<div class="chat-panel-empty" id="chat-empty">
<div class="icon">💬</div>
<p>Select a conversation</p>
<small>Choose a shop from the left to view messages</small>
</div>
<div class="chat-active" id="chat-active">
<div class="chat-hdr">
<div class="chat-hdr-avatar" id="chat-hdr-avatar"></div>
<div>
<div class="chat-hdr-shop" id="chat-hdr-shop"></div>
<div class="chat-hdr-sub">Customer Support</div>
</div>
</div>
<div class="chat-msgs" id="chat-msgs"></div>
<div class="chat-input-row">
<textarea class="chat-textarea" id="chat-input" rows="2" placeholder="Type a reply… (Enter to send, Shift+Enter for new line)"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendReply();}"></textarea>
<button class="chat-send" id="chat-send-btn" onclick="sendReply()">Send</button>
</div>
</div>
</div>
</div>
</div><!-- /section-chat -->
</div> </div>
<script> <script>
@ -571,7 +695,27 @@ async function doLogout() {
function showDashboard() { function showDashboard() {
document.getElementById('login-screen').style.display = 'none'; document.getElementById('login-screen').style.display = 'none';
document.getElementById('dashboard').style.display = 'block'; document.getElementById('dashboard').style.display = 'block';
showTab('users');
}
// ── TAB SWITCHING ────────────────────────────────────────────────────────────
let chatPollInterval = null;
function showTab(tab) {
document.getElementById('section-users').style.display = tab === 'users' ? 'block' : 'none';
document.getElementById('section-chat').style.display = tab === 'chat' ? 'block' : 'none';
document.getElementById('tab-users').classList.toggle('active', tab === 'users');
document.getElementById('tab-chat').classList.toggle('active', tab === 'chat');
if (tab === 'users') {
loadUsers(); loadUsers();
if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; }
} else {
loadChatList();
if (!chatPollInterval) chatPollInterval = setInterval(() => {
loadChatListSilent();
if (selectedChatShop) loadMessages(selectedChatShop, false);
}, 3000);
}
} }
// ── ALL USERS DATA ─────────────────────────────────────────────────────────── // ── ALL USERS DATA ───────────────────────────────────────────────────────────
@ -676,6 +820,125 @@ async function doRevoke(shop) {
else alert('Failed to revoke access'); else alert('Failed to revoke access');
} }
// ── CHAT ─────────────────────────────────────────────────────────────────────
let allChats = [];
let selectedChatShop = null;
async function loadChatList() {
const el = document.getElementById('chat-list');
el.innerHTML = '<div class="loading-row"><div class="spinner-dark"></div></div>';
const r = await api('GET', '/api/chats');
if (!r.ok) { el.innerHTML = '<div style="padding:20px;text-align:center;color:#94a3b8;font-size:13px;">Failed to load</div>'; return; }
allChats = r.data.chats || [];
updateChatBadge();
renderChatList(allChats);
}
async function loadChatListSilent() {
const r = await api('GET', '/api/chats');
if (!r.ok) return;
allChats = r.data.chats || [];
updateChatBadge();
renderChatList(allChats);
}
function updateChatBadge() {
const total = allChats.reduce((s, c) => s + (c.unread || 0), 0);
const badge = document.getElementById('chat-unread-badge');
if (total > 0) { badge.textContent = total > 99 ? '99+' : total; badge.style.display = 'inline-flex'; }
else badge.style.display = 'none';
}
function filterChats() {
const q = document.getElementById('chat-search').value.toLowerCase();
renderChatList(q ? allChats.filter(c => c.shop.toLowerCase().includes(q)) : allChats);
}
function renderChatList(chats) {
const el = document.getElementById('chat-list');
if (chats.length === 0) {
el.innerHTML = '<div style="padding:32px 16px;text-align:center;color:#94a3b8;font-size:13px;">No conversations yet</div>';
return;
}
el.innerHTML = chats.map(c => {
const isActive = c.shop === selectedChatShop;
const preview = c.lastMessage ? escHtml(c.lastMessage.text.slice(0,42) + (c.lastMessage.text.length > 42 ? '…' : '')) : '<em>No messages yet</em>';
const t = c.lastMessage ? fmtTime(c.lastMessage.timestamp) : '';
const initials = c.shop.slice(0,2).toUpperCase();
const unreadBadge = c.unread > 0 ? \`<div class="chat-unread">\${c.unread > 99 ? '99+' : c.unread}</div>\` : '';
return \`<div class="chat-item\${isActive ? ' active' : ''}" onclick="selectChat('\${escHtml(c.shop)}')">
<div class="chat-avatar">\${initials}</div>
<div class="chat-item-meta">
<div class="chat-item-top">
<div class="chat-item-shop">\${escHtml(c.shop)}</div>
<div class="chat-item-time">\${t}</div>
</div>
<div class="chat-item-bot">
<div class="chat-item-preview">\${preview}</div>
\${unreadBadge}
</div>
</div>
</div>\`;
}).join('');
}
async function selectChat(shop) {
selectedChatShop = shop;
document.getElementById('chat-empty').style.display = 'none';
const active = document.getElementById('chat-active');
active.classList.add('open');
document.getElementById('chat-hdr-shop').textContent = shop;
document.getElementById('chat-hdr-avatar').textContent = shop.slice(0,2).toUpperCase();
renderChatList(allChats);
await loadMessages(shop, true);
}
async function loadMessages(shop, scrollToBottom) {
const r = await api('GET', '/api/chats/' + encodeURIComponent(shop));
if (!r.ok) return;
// clear unread in local data
const idx = allChats.findIndex(c => c.shop === shop);
if (idx !== -1) { allChats[idx].unread = 0; updateChatBadge(); }
const msgs = r.data.messages || [];
const el = document.getElementById('chat-msgs');
const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
if (msgs.length === 0) {
el.innerHTML = '<div style="text-align:center;color:#94a3b8;font-size:13px;margin-top:40px;">No messages yet.<br>Customer messages from the widget will appear here.</div>';
return;
}
el.innerHTML = msgs.map(m => {
const isAdmin = m.from === 'admin';
const time = new Date(m.timestamp).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'});
return \`<div class="chat-bubble-wrap \${isAdmin ? 'admin' : 'customer'}">
<div class="chat-bubble \${isAdmin ? 'admin' : 'customer'}">\${escHtml(m.text)}</div>
<div class="chat-bubble-time">\${isAdmin ? 'You · ' : ''}\${time}</div>
</div>\`;
}).join('');
if (scrollToBottom || wasAtBottom) el.scrollTop = el.scrollHeight;
}
async function sendReply() {
if (!selectedChatShop) return;
const inp = document.getElementById('chat-input');
const btn = document.getElementById('chat-send-btn');
const text = inp.value.trim();
if (!text) return;
btn.disabled = true;
const r = await api('POST', '/api/chats/' + encodeURIComponent(selectedChatShop) + '/reply', { text });
btn.disabled = false;
if (r.ok) { inp.value = ''; await loadMessages(selectedChatShop, true); loadChatListSilent(); }
else alert('Failed to send reply');
}
function fmtTime(ts) {
if (!ts) return '';
const d = new Date(ts), now = new Date(), diff = now - d;
if (diff < 60000) return 'now';
if (diff < 3600000) return Math.floor(diff/60000) + 'm';
if (diff < 86400000) return d.toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'});
return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});
}
// ── UTIL ────────────────────────────────────────────────────────────────────── // ── UTIL ──────────────────────────────────────────────────────────────────────
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

View File

@ -14,6 +14,7 @@ const privacyLawWebhooks = require('./routes/privacyLawWebhooks');
const { getToken, listTokens } = require('./tokenStore'); const { getToken, listTokens } = require('./tokenStore');
const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore'); const { listJobs, getJob, cancelJob, getLatestJobForShop } = require('./jobStore');
const { isShopAllowed } = require('./freeAccessStore'); const { isShopAllowed } = require('./freeAccessStore');
const { addMessage: addChatMessage, readChat } = require('./chatStore');
const app = express(); const app = express();
const PORT = process.env.PORT || 3002; const PORT = process.env.PORT || 3002;
@ -31,6 +32,97 @@ app.get('/free-access/:shop', (req, res) => {
res.json({ shop, allowed }); res.json({ shop, allowed });
}); });
// ── PUBLIC CHAT ENDPOINTS (for customer widget) ──────────────────────────────
app.post('/chat/:shop', (req, res) => {
const shop = decodeURIComponent(req.params.shop || '').toLowerCase().trim();
const { text, visitorId } = req.body;
if (!shop || !text) return res.status(400).json({ error: 'shop and text required' });
const msg = addChatMessage(shop, 'customer', String(text).slice(0, 1000), visitorId || null);
res.json({ ok: true, message: msg });
});
app.get('/chat/:shop', (req, res) => {
const shop = decodeURIComponent(req.params.shop || '').toLowerCase().trim();
if (!shop) return res.status(400).json({ error: 'shop required' });
const chat = readChat(shop);
res.json({ messages: chat.messages || [] });
});
// Embeddable widget script ─ <script src="https://backend.data4autos.com/chat/widget.js?shop=SHOP"></script>
app.get('/chat/widget.js', (req, res) => {
const shop = (req.query.shop || '').toLowerCase().trim();
const backendUrl = 'https://backend.data4autos.com';
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=300');
res.send(`(function(){
if(!window.__d4aChat) window.__d4aChat={};
var SHOP='${shop}', BASE='${backendUrl}';
var VID=localStorage.getItem('d4a_vid')||'v'+(Math.random().toString(36).slice(2));
localStorage.setItem('d4a_vid',VID);
var css=\`
#d4a-fab{position:fixed;bottom:24px;right:24px;width:56px;height:56px;background:linear-gradient(135deg,#2563eb,#4f46e5);border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 20px rgba(37,99,235,0.4);z-index:99999;transition:transform .15s;}
#d4a-fab:hover{transform:scale(1.08);}
#d4a-fab svg{width:26px;height:26px;fill:none;stroke:#fff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
#d4a-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;background:#ef4444;border-radius:9px;font-size:11px;font-weight:700;color:#fff;display:none;align-items:center;justify-content:center;padding:0 4px;border:2px solid #fff;}
#d4a-win{position:fixed;bottom:92px;right:24px;width:360px;max-height:520px;background:#fff;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,0.18);z-index:99998;display:none;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;}
#d4a-hdr{background:linear-gradient(135deg,#2563eb,#4f46e5);padding:16px 18px;display:flex;align-items:center;gap:10px;}
#d4a-hdr-icon{width:36px;height:36px;background:rgba(255,255,255,.2);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:18px;}
#d4a-hdr-title{font-size:14px;font-weight:700;color:#fff;}
#d4a-hdr-sub{font-size:12px;color:rgba(255,255,255,.75);margin-top:1px;}
#d4a-msgs{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px;background:#f8fafc;min-height:200px;max-height:340px;}
.d4a-bub{max-width:80%;padding:9px 13px;font-size:13px;line-height:1.5;border-radius:12px;}
.d4a-bub.admin{background:#fff;color:#0f172a;border:1px solid #e2e8f0;align-self:flex-start;border-radius:12px 12px 12px 3px;}
.d4a-bub.customer{background:#2563eb;color:#fff;align-self:flex-end;border-radius:12px 12px 3px 12px;}
.d4a-time{font-size:10px;color:#94a3b8;margin-top:3px;}
.d4a-time.r{text-align:right;}
#d4a-inp-row{padding:12px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff;}
#d4a-inp{flex:1;border:1.5px solid #e2e8f0;border-radius:8px;padding:9px 12px;font-size:13px;font-family:inherit;outline:none;resize:none;}
#d4a-inp:focus{border-color:#2563eb;}
#d4a-send{background:#2563eb;border:none;border-radius:8px;color:#fff;padding:9px 16px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;}
#d4a-send:hover{background:#1d4ed8;}
\`;
var el=document.createElement('style');el.textContent=css;document.head.appendChild(el);
var fab=document.createElement('div');fab.id='d4a-fab';
fab.innerHTML='<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><div id="d4a-badge"></div>';
document.body.appendChild(fab);
var win=document.createElement('div');win.id='d4a-win';
win.innerHTML='<div id="d4a-hdr"><div id="d4a-hdr-icon">💬</div><div><div id="d4a-hdr-title">Chat with us</div><div id="d4a-hdr-sub">We typically reply within a few hours</div></div></div><div id="d4a-msgs"><div style="text-align:center;color:#94a3b8;font-size:13px;padding-top:20px;">Send us a message and we\'ll get back to you!</div></div><div id="d4a-inp-row"><textarea id="d4a-inp" rows="1" placeholder="Type a message…" onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();window.__d4aChat.send();}"></textarea><button id="d4a-send" onclick="window.__d4aChat.send()">Send</button></div>';
document.body.appendChild(win);
var open=false,unread=0,lastTs='',polling=null;
fab.onclick=function(){
open=!open;win.style.display=open?'flex':'none';
if(open){unread=0;document.getElementById('d4a-badge').style.display='none';loadMsgs(true);}
else if(polling){clearInterval(polling);polling=null;}
if(open&&!polling) polling=setInterval(function(){loadMsgs(false);},3000);
};
function loadMsgs(scroll){
fetch(BASE+'/chat/'+encodeURIComponent(SHOP)+'?vid='+VID).then(r=>r.json()).then(function(d){
var msgs=d.messages||[];var el=document.getElementById('d4a-msgs');
if(!msgs.length) return;
var latest=msgs[msgs.length-1]?.timestamp||'';
var isNew=latest&&latest!==lastTs;lastTs=latest;
if(!isNew&&!scroll) return;
el.innerHTML=msgs.map(function(m){
var isAdmin=m.from==='admin';
var t=new Date(m.timestamp).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit'});
return '<div style="display:flex;flex-direction:column;align-items:'+(isAdmin?'flex-start':'flex-end')+'"><div class="d4a-bub '+(isAdmin?'admin':'customer')+'">'+esc(m.text)+'</div><div class="d4a-time'+(isAdmin?'':' r')+'">'+t+'</div></div>';
}).join('');
if(scroll||el.scrollHeight-el.scrollTop-el.clientHeight<80) el.scrollTop=el.scrollHeight;
if(!open&&isNew&&msgs[msgs.length-1]?.from==='admin'){
unread++;var b=document.getElementById('d4a-badge');b.textContent=unread>9?'9+':unread;b.style.display='flex';
}
}).catch(function(){});
}
window.__d4aChat.send=function(){
var inp=document.getElementById('d4a-inp');var text=inp.value.trim();if(!text) return;
inp.value='';
fetch(BASE+'/chat/'+encodeURIComponent(SHOP),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text,visitorId:VID})}).then(function(){loadMsgs(true);});
};
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
loadMsgs(true);
})();`);
});
// Health check // Health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ ok: true, uptime: process.uptime(), timestamp: new Date().toISOString() }); res.json({ ok: true, uptime: process.uptime(), timestamp: new Date().toISOString() });