From aef977eb9921ac3fb5dd9daac6085c3d66c489c2 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Fri, 12 Jun 2026 20:45:18 +0530 Subject: [PATCH] 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: Co-Authored-By: Claude Sonnet 4.6 --- chatStore.js | 69 +++++++++++ routes/adminPanel.js | 265 ++++++++++++++++++++++++++++++++++++++++++- server.js | 92 +++++++++++++++ 3 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 chatStore.js diff --git a/chatStore.js b/chatStore.js new file mode 100644 index 0000000..133e39b --- /dev/null +++ b/chatStore.js @@ -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 }; diff --git a/routes/adminPanel.js b/routes/adminPanel.js index 64c848b..8b88bd8 100644 --- a/routes/adminPanel.js +++ b/routes/adminPanel.js @@ -3,6 +3,7 @@ const express = require('express'); const crypto = require('crypto'); const { isShopAllowed, addShop, removeShop, listShops } = require('../freeAccessStore'); const { listTokens } = require('../tokenStore'); +const { addMessage, readChat, markRead, listChats } = require('../chatStore'); const router = express.Router(); @@ -99,6 +100,25 @@ router.get('/api/users', requireAuth, (_req, res) => { 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) ──────────────────── router.get('/', (_req, res) => { 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;} .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;} + + /* ═══ 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;} @@ -462,12 +535,19 @@ function adminHtml() {
Free Access Manager
+
+ + +
0 users
+
+
+ + +
+
+ + +
+
+

Messages

+ +
+
+
+
+
+ + +
+
+
💬
+

Select a conversation

+ Choose a shop from the left to view messages +
+
+
+
+
+
+
Customer Support
+
+
+
+
+ + +
+
+
+ +
+
+ +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='
'; + document.body.appendChild(fab); + var win=document.createElement('div');win.id='d4a-win'; + win.innerHTML='
💬
Chat with us
We typically reply within a few hours
Send us a message and we\'ll get back to you!
'; + 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 '
'+esc(m.text)+'
'+t+'
'; + }).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,'&').replace(//g,'>');} + loadMsgs(true); +})();`); +}); + // Health check app.get('/health', (req, res) => { res.json({ ok: true, uptime: process.uptime(), timestamp: new Date().toISOString() });