- 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>
952 lines
45 KiB
JavaScript
952 lines
45 KiB
JavaScript
// routes/adminPanel.js — password-protected admin panel for free-access shop management
|
|
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();
|
|
|
|
const ADMIN_USER = 'd4a-admin';
|
|
const ADMIN_PASS = 'Data4autos@2026.';
|
|
const COOKIE_NAME = 'd4a_admin_tok';
|
|
const COOKIE_MAX_AGE = 8 * 60 * 60 * 1000; // 8 hours
|
|
|
|
// In-memory valid tokens (cleared on server restart)
|
|
const validTokens = new Set();
|
|
|
|
// ── auth middleware ─────────────────────────────────────────────────────────
|
|
function requireAuth(req, res, next) {
|
|
const tok = req.cookies?.[COOKIE_NAME] || req.headers['x-admin-token'];
|
|
if (tok && validTokens.has(tok)) return next();
|
|
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorised' });
|
|
res.redirect('/d4a-admin');
|
|
}
|
|
|
|
// ── cookie parser (lightweight, no dependency) ──────────────────────────────
|
|
router.use((req, _res, next) => {
|
|
const raw = req.headers.cookie || '';
|
|
req.cookies = Object.fromEntries(
|
|
raw.split(';').map(c => c.trim().split('=').map(decodeURIComponent))
|
|
.filter(([k]) => k).map(([k, ...v]) => [k, v.join('=')])
|
|
);
|
|
next();
|
|
});
|
|
|
|
router.use(express.urlencoded({ extended: false }));
|
|
router.use(express.json());
|
|
|
|
// ── login ────────────────────────────────────────────────────────────────────
|
|
router.post('/login', (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (username === ADMIN_USER && password === ADMIN_PASS) {
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
validTokens.add(token);
|
|
setTimeout(() => validTokens.delete(token), COOKIE_MAX_AGE);
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${COOKIE_MAX_AGE / 1000}; SameSite=Lax`);
|
|
return res.json({ ok: true });
|
|
}
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
});
|
|
|
|
// ── logout ───────────────────────────────────────────────────────────────────
|
|
router.post('/logout', (req, res) => {
|
|
const tok = req.cookies?.[COOKIE_NAME];
|
|
if (tok) validTokens.delete(tok);
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ── check-auth ───────────────────────────────────────────────────────────────
|
|
router.get('/api/check-auth', (req, res) => {
|
|
const tok = req.cookies?.[COOKIE_NAME];
|
|
if (tok && validTokens.has(tok)) return res.json({ ok: true });
|
|
res.status(401).json({ ok: false });
|
|
});
|
|
|
|
// ── shops API (protected) ────────────────────────────────────────────────────
|
|
router.get('/api/shops', requireAuth, (_req, res) => {
|
|
res.json({ shops: listShops() });
|
|
});
|
|
|
|
router.post('/api/shops', requireAuth, (req, res) => {
|
|
const { shop, expiresAt, note } = req.body;
|
|
if (!shop) return res.status(400).json({ error: 'shop is required' });
|
|
const entry = addShop(shop.trim(), expiresAt || null, note || '');
|
|
res.json({ ok: true, shop: shop.trim(), entry });
|
|
});
|
|
|
|
router.delete('/api/shops/:shop', requireAuth, (req, res) => {
|
|
removeShop(decodeURIComponent(req.params.shop));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ── all installed users (from tokens.json) merged with free-access status ────
|
|
router.get('/api/users', requireAuth, (_req, res) => {
|
|
const tokens = listTokens();
|
|
const freeList = listShops();
|
|
const freeMap = Object.fromEntries(freeList.map(s => [s.shop, s]));
|
|
const users = Object.entries(tokens).map(([shop, t]) => {
|
|
const fa = freeMap[shop] || null;
|
|
return {
|
|
shop,
|
|
savedAt: t.savedAt || null,
|
|
hasToken: !!t.accessToken,
|
|
freeAccess: !!fa && !fa.expired,
|
|
freeEntry: fa || null,
|
|
};
|
|
});
|
|
users.sort((a, b) => (b.savedAt || '').localeCompare(a.savedAt || ''));
|
|
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');
|
|
res.send(adminHtml());
|
|
});
|
|
|
|
// ── HTML ─────────────────────────────────────────────────────────────────────
|
|
function adminHtml() {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>Data4Autos — Admin Portal</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Inter',system-ui,sans-serif;background:#f1f5f9;min-height:100vh;color:#0f172a;overflow-x:hidden}
|
|
|
|
/* ═══════════════════════════ LOGIN ═══════════════════════════ */
|
|
#login-screen{display:flex;align-items:stretch;min-height:100vh;}
|
|
|
|
/* Left — vibrant blue panel */
|
|
.login-left{
|
|
flex:1;display:flex;flex-direction:column;justify-content:center;
|
|
padding:64px 56px;
|
|
background:linear-gradient(145deg,#1e40af 0%,#2563eb 45%,#4f46e5 100%);
|
|
position:relative;overflow:hidden;
|
|
}
|
|
@media(max-width:860px){.login-left{display:none}}
|
|
.login-left::before{
|
|
content:'';position:absolute;width:500px;height:500px;border-radius:50%;
|
|
background:rgba(255,255,255,0.06);top:-160px;right:-120px;
|
|
}
|
|
.login-left::after{
|
|
content:'';position:absolute;width:320px;height:320px;border-radius:50%;
|
|
background:rgba(255,255,255,0.05);bottom:-80px;left:-60px;
|
|
}
|
|
.ll-logo{
|
|
display:flex;align-items:center;gap:14px;margin-bottom:52px;position:relative;z-index:1;
|
|
}
|
|
.ll-icon{
|
|
width:52px;height:52px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.25);
|
|
border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:24px;
|
|
}
|
|
.ll-brand{font-size:20px;font-weight:800;color:#fff;letter-spacing:-0.3px;}
|
|
.ll-brand span{display:block;font-size:12px;font-weight:500;color:rgba(255,255,255,0.6);letter-spacing:0;margin-top:1px;}
|
|
.ll-heading{font-size:34px;font-weight:900;color:#fff;line-height:1.2;letter-spacing:-1px;margin-bottom:16px;position:relative;z-index:1;}
|
|
.ll-heading em{font-style:normal;color:#bfdbfe;}
|
|
.ll-desc{font-size:15px;color:rgba(255,255,255,0.72);line-height:1.7;max-width:340px;margin-bottom:48px;position:relative;z-index:1;}
|
|
.ll-features{display:flex;flex-direction:column;gap:12px;position:relative;z-index:1;}
|
|
.ll-feat{
|
|
display:flex;align-items:center;gap:12px;
|
|
background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);
|
|
border-radius:10px;padding:12px 16px;
|
|
}
|
|
.ll-feat-check{
|
|
width:22px;height:22px;border-radius:6px;background:rgba(255,255,255,0.15);
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:12px;color:#fff;flex-shrink:0;font-weight:700;
|
|
}
|
|
.ll-feat span{font-size:13px;color:rgba(255,255,255,0.85);font-weight:500;}
|
|
|
|
/* Right — white form panel */
|
|
.login-right{
|
|
width:500px;flex-shrink:0;background:#fff;
|
|
display:flex;align-items:center;justify-content:center;
|
|
padding:48px 40px;
|
|
}
|
|
@media(max-width:860px){.login-right{width:100%;}}
|
|
.login-card{width:100%;max-width:380px;}
|
|
@keyframes cardIn{from{opacity:0;transform:translateY(16px);}to{opacity:1;transform:none;}}
|
|
.login-card{animation:cardIn 0.4s ease both;}
|
|
|
|
.lc-top{margin-bottom:36px;}
|
|
.lc-eyebrow{
|
|
display:inline-flex;align-items:center;gap:6px;
|
|
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
|
padding:4px 12px;font-size:11px;font-weight:700;color:#1d4ed8;
|
|
letter-spacing:0.06em;text-transform:uppercase;margin-bottom:18px;
|
|
}
|
|
.lc-eyebrow .status-dot{width:6px;height:6px;border-radius:50%;background:#16a34a;}
|
|
.lc-title{font-size:26px;font-weight:900;color:#0f172a;letter-spacing:-0.8px;margin-bottom:6px;}
|
|
.lc-sub{font-size:14px;color:#64748b;line-height:1.5;}
|
|
|
|
/* fields */
|
|
.lc-field{margin-bottom:20px;}
|
|
.lc-field label{
|
|
display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:7px;
|
|
}
|
|
.lc-inp-wrap{position:relative;}
|
|
.lc-inp-icon{
|
|
position:absolute;left:13px;top:50%;transform:translateY(-50%);
|
|
width:18px;height:18px;display:flex;align-items:center;justify-content:center;
|
|
color:#9ca3af;font-size:14px;pointer-events:none;
|
|
}
|
|
.lc-field input{
|
|
width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:10px;
|
|
padding:12px 40px 12px 40px;color:#0f172a;font-size:14px;font-family:inherit;
|
|
outline:none;transition:border-color 0.15s,box-shadow 0.15s,background 0.15s;
|
|
}
|
|
.lc-field input::placeholder{color:#b0b8c4;}
|
|
.lc-field input:focus{
|
|
border-color:#2563eb;background:#fff;
|
|
box-shadow:0 0 0 3px rgba(37,99,235,0.1);
|
|
}
|
|
.lc-eye{
|
|
position:absolute;right:12px;top:50%;transform:translateY(-50%);
|
|
background:none;border:none;cursor:pointer;color:#9ca3af;font-size:14px;
|
|
padding:4px;transition:color 0.15s;line-height:1;
|
|
}
|
|
.lc-eye:hover{color:#374151;}
|
|
|
|
/* divider */
|
|
.lc-divider{height:1px;background:#f1f5f9;margin:4px 0 20px;}
|
|
|
|
/* sign in button */
|
|
.btn-login{
|
|
width:100%;padding:13px 20px;border:none;border-radius:10px;
|
|
background:#2563eb;color:#fff;font-size:15px;font-weight:700;font-family:inherit;
|
|
cursor:pointer;position:relative;overflow:hidden;
|
|
transition:background 0.15s,transform 0.1s,box-shadow 0.15s;
|
|
box-shadow:0 1px 3px rgba(37,99,235,0.3),0 4px 12px rgba(37,99,235,0.2);
|
|
}
|
|
.btn-login:hover{background:#1d4ed8;transform:translateY(-1px);box-shadow:0 4px 16px rgba(37,99,235,0.35);}
|
|
.btn-login:active{transform:none;background:#1e40af;}
|
|
.btn-login:disabled{background:#93c5fd;cursor:not-allowed;transform:none;box-shadow:none;}
|
|
|
|
/* error */
|
|
.err-msg{
|
|
background:#fef2f2;border:1px solid #fecaca;border-radius:8px;
|
|
padding:10px 14px;font-size:13px;color:#dc2626;margin-top:14px;display:none;
|
|
}
|
|
@keyframes shake{0%,100%{transform:none;}20%,60%{transform:translateX(-5px);}40%,80%{transform:translateX(5px);}}
|
|
.err-msg.shake{animation:shake 0.3s ease;}
|
|
|
|
/* footer */
|
|
.lc-footer{
|
|
display:flex;align-items:center;justify-content:center;gap:6px;
|
|
margin-top:24px;font-size:11px;color:#94a3b8;
|
|
}
|
|
.lc-footer svg{opacity:0.5;}
|
|
|
|
/* spinner */
|
|
.spinner{display:inline-block;width:15px;height:15px;border:2px solid rgba(255,255,255,0.35);border-top-color:#fff;border-radius:50%;animation:spin 0.65s linear infinite;vertical-align:middle;margin-right:6px;}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
|
/* ═══════════════════════════ DASHBOARD ═══════════════════════════ */
|
|
#dashboard{display:none}
|
|
|
|
/* topbar */
|
|
.topbar{
|
|
background:#fff;border-bottom:1px solid #e2e8f0;
|
|
padding:0 32px;height:60px;
|
|
display:flex;align-items:center;justify-content:space-between;
|
|
position:sticky;top:0;z-index:100;
|
|
box-shadow:0 1px 3px rgba(0,0,0,0.06);
|
|
}
|
|
.topbar-left{display:flex;align-items:center;gap:12px;}
|
|
.topbar-logo{
|
|
width:36px;height:36px;background:linear-gradient(135deg,#2563eb,#4f46e5);
|
|
border-radius:10px;display:flex;align-items:center;justify-content:center;
|
|
font-size:18px;
|
|
}
|
|
.topbar-title{font-size:16px;font-weight:800;color:#0f172a;letter-spacing:-0.3px;}
|
|
.topbar-sub{font-size:12px;color:#94a3b8;margin-top:1px;}
|
|
.topbar-right{display:flex;align-items:center;gap:12px;}
|
|
.topbar-badge{
|
|
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
|
padding:3px 10px;font-size:11px;font-weight:700;color:#2563eb;
|
|
}
|
|
.logout-btn{
|
|
background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;
|
|
color:#374151;padding:7px 14px;font-size:13px;font-weight:600;
|
|
cursor:pointer;font-family:inherit;transition:background 0.12s,border-color 0.12s;
|
|
}
|
|
.logout-btn:hover{background:#f1f5f9;border-color:#cbd5e1;}
|
|
|
|
/* content */
|
|
.content{max-width:1000px;margin:0 auto;padding:32px 24px;}
|
|
|
|
/* page header inside dashboard */
|
|
.page-header{margin-bottom:24px;}
|
|
.page-header h2{font-size:20px;font-weight:800;color:#0f172a;letter-spacing:-0.4px;}
|
|
.page-header p{font-size:13px;color:#64748b;margin-top:4px;}
|
|
|
|
/* add card */
|
|
.add-card{
|
|
background:#fff;border:1px solid #e2e8f0;border-radius:14px;
|
|
padding:24px 28px;margin-bottom:24px;
|
|
box-shadow:0 1px 3px rgba(0,0,0,0.05);
|
|
}
|
|
.add-card-header{display:flex;align-items:center;gap:10px;margin-bottom:20px;}
|
|
.add-card-icon{
|
|
width:36px;height:36px;background:#eff6ff;border-radius:9px;
|
|
display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0;
|
|
}
|
|
.add-card h3{font-size:15px;font-weight:700;color:#0f172a;}
|
|
.add-card .sub{font-size:12px;color:#94a3b8;margin-top:1px;}
|
|
|
|
.form-row{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:14px;align-items:end;}
|
|
@media(max-width:720px){.form-row{grid-template-columns:1fr}}
|
|
.field{margin-bottom:0;}
|
|
.field label{display:block;font-size:12px;font-weight:600;color:#374151;margin-bottom:6px;}
|
|
.field input{
|
|
width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;
|
|
padding:10px 13px;color:#0f172a;font-size:13px;font-family:inherit;
|
|
outline:none;transition:border-color 0.15s,box-shadow 0.15s,background 0.15s;
|
|
}
|
|
.field input:focus{border-color:#2563eb;background:#fff;box-shadow:0 0 0 3px rgba(37,99,235,0.1);}
|
|
.field input::placeholder{color:#b0b8c4;}
|
|
.btn-add{
|
|
background:#2563eb;border:none;border-radius:9px;color:#fff;
|
|
padding:10px 20px;font-size:13px;font-weight:700;cursor:pointer;
|
|
white-space:nowrap;font-family:inherit;
|
|
box-shadow:0 1px 3px rgba(37,99,235,0.25);transition:background 0.12s,transform 0.1s;
|
|
}
|
|
.btn-add:hover{background:#1d4ed8;transform:translateY(-1px);}
|
|
.btn-add:active{transform:none;}
|
|
|
|
.success-msg{
|
|
background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;
|
|
padding:10px 14px;font-size:13px;color:#15803d;font-weight:600;
|
|
margin-top:14px;display:none;
|
|
}
|
|
.err-msg-dash{
|
|
background:#fef2f2;border:1px solid #fecaca;border-radius:8px;
|
|
padding:10px 14px;font-size:13px;color:#dc2626;
|
|
margin-top:14px;display:none;
|
|
}
|
|
|
|
/* shops card */
|
|
.shops-card{
|
|
background:#fff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;
|
|
box-shadow:0 1px 3px rgba(0,0,0,0.05);
|
|
}
|
|
.shops-card-header{
|
|
padding:16px 24px;border-bottom:1px solid #f1f5f9;
|
|
display:flex;align-items:center;justify-content:space-between;
|
|
background:#fafafa;
|
|
}
|
|
.shops-card-header h3{font-size:14px;font-weight:700;color:#0f172a;}
|
|
.count-badge{
|
|
background:#eff6ff;border:1px solid #bfdbfe;border-radius:20px;
|
|
padding:3px 10px;font-size:11px;font-weight:700;color:#2563eb;
|
|
}
|
|
|
|
table{width:100%;border-collapse:collapse;}
|
|
th{
|
|
background:#fafafa;padding:10px 20px;
|
|
font-size:11px;font-weight:700;color:#6b7280;
|
|
text-transform:uppercase;letter-spacing:0.06em;text-align:left;
|
|
border-bottom:1px solid #f1f5f9;
|
|
}
|
|
td{padding:14px 20px;font-size:13px;border-top:1px solid #f8fafc;vertical-align:middle;color:#374151;}
|
|
tr:hover td{background:#fafcff;}
|
|
.shop-name{font-weight:700;color:#0f172a;font-size:14px;}
|
|
.shop-domain{font-size:11px;color:#94a3b8;margin-top:2px;font-family:monospace;}
|
|
.note-text{color:#94a3b8;font-size:12px;font-style:italic;}
|
|
.tag{display:inline-flex;align-items:center;gap:5px;border-radius:20px;padding:3px 10px;font-size:11px;font-weight:700;}
|
|
.tag-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;}
|
|
.tag-ok{background:#f0fdf4;color:#15803d;border:1px solid #bbf7d0;}
|
|
.tag-ok .tag-dot{background:#16a34a;}
|
|
.tag-exp{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
|
|
.tag-exp .tag-dot{background:#dc2626;}
|
|
.tag-perm{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe;}
|
|
.tag-perm .tag-dot{background:#2563eb;}
|
|
.date-cell{font-size:12px;color:#6b7280;}
|
|
.btn-del{
|
|
background:#fef2f2;border:1px solid #fecaca;border-radius:6px;
|
|
color:#dc2626;padding:5px 12px;font-size:12px;font-weight:600;
|
|
cursor:pointer;font-family:inherit;transition:background 0.12s;
|
|
}
|
|
.btn-del:hover{background:#fee2e2;}
|
|
.btn-grant{
|
|
background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;
|
|
color:#2563eb;padding:5px 12px;font-size:12px;font-weight:600;
|
|
cursor:pointer;font-family:inherit;transition:background 0.12s;
|
|
}
|
|
.btn-grant:hover{background:#dbeafe;}
|
|
.note-cell{max-width:180px;}
|
|
.empty-state{padding:56px 24px;text-align:center;}
|
|
.empty-state .icon{font-size:38px;margin-bottom:12px;}
|
|
.empty-state p{font-size:14px;color:#94a3b8;font-weight:500;}
|
|
.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;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ═══════════════════════════ LOGIN ═══════════════════════════ -->
|
|
<div id="login-screen">
|
|
|
|
<!-- Left branding panel -->
|
|
<div class="login-left">
|
|
<div class="ll-logo">
|
|
<div class="ll-icon">🛡️</div>
|
|
<div class="ll-brand">Data4Autos <span>Admin Portal</span></div>
|
|
</div>
|
|
<div class="ll-heading">Manage <em>free access</em><br>with confidence</div>
|
|
<div class="ll-desc">Grant, revoke, and schedule free-access windows for any Shopify store — no code changes, no redeployment needed.</div>
|
|
<div class="ll-features">
|
|
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Grant free access to any Shopify store</span></div>
|
|
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Set custom expiry dates per shop</span></div>
|
|
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Changes take effect instantly</span></div>
|
|
<div class="ll-feat"><div class="ll-feat-check">✓</div><span>Persisted to disk — survives restarts</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right login panel -->
|
|
<div class="login-right">
|
|
<div class="login-card">
|
|
<div class="lc-top">
|
|
<div class="lc-eyebrow"><div class="status-dot"></div>Secure Portal</div>
|
|
<div class="lc-title">Welcome back</div>
|
|
<div class="lc-sub">Sign in to manage shop access permissions.</div>
|
|
</div>
|
|
|
|
<div class="lc-divider"></div>
|
|
|
|
<div class="lc-field">
|
|
<label for="inp-user">Username</label>
|
|
<div class="lc-inp-wrap">
|
|
<span class="lc-inp-icon">👤</span>
|
|
<input type="text" id="inp-user" placeholder="d4a-admin" autocomplete="username"/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lc-field">
|
|
<label for="inp-pass">Password</label>
|
|
<div class="lc-inp-wrap">
|
|
<span class="lc-inp-icon">🔒</span>
|
|
<input type="password" id="inp-pass" placeholder="••••••••••••" autocomplete="current-password"/>
|
|
<button class="lc-eye" id="eye-btn" onclick="togglePass()" type="button" tabindex="-1">👁</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn-login" id="login-btn" onclick="doLogin()">
|
|
Sign In to Admin Portal
|
|
</button>
|
|
|
|
<div class="err-msg" id="login-err"></div>
|
|
|
|
<div class="lc-footer">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
Session secured · Expires in 8 hours
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════════════════════ DASHBOARD ═══════════════════════════ -->
|
|
<div id="dashboard">
|
|
|
|
<div class="topbar">
|
|
<div class="topbar-left">
|
|
<div class="topbar-logo">🛡️</div>
|
|
<div>
|
|
<div class="topbar-title">Data4Autos Admin</div>
|
|
<div class="topbar-sub">Free Access Manager</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">
|
|
<span class="topbar-badge" id="shops-count">0 users</span>
|
|
<button class="logout-btn" onclick="doLogout()">Sign Out</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="section-users">
|
|
<div class="content">
|
|
<div class="page-header">
|
|
<h2>App Users</h2>
|
|
<p>All shops that have installed Data4Autos. Toggle free access directly from this list.</p>
|
|
</div>
|
|
|
|
<!-- Grant expiry modal -->
|
|
<div id="expiry-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,0.4);z-index:999;align-items:center;justify-content:center;">
|
|
<div style="background:#fff;border-radius:16px;padding:32px 28px;width:100%;max-width:400px;box-shadow:0 20px 60px rgba(0,0,0,0.15);margin:0 16px;">
|
|
<div style="font-size:16px;font-weight:800;color:#0f172a;margin-bottom:6px;">Grant Free Access</div>
|
|
<div id="modal-shop-name" style="font-size:13px;color:#64748b;margin-bottom:20px;font-family:monospace;"></div>
|
|
<div class="field" style="margin-bottom:14px;">
|
|
<label>Expiry Date <span style="color:#b0b8c4;font-weight:400">(blank = permanent)</span></label>
|
|
<input type="date" id="modal-expires" style="width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;padding:10px 13px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;"/>
|
|
</div>
|
|
<div class="field" style="margin-bottom:20px;">
|
|
<label>Note <span style="color:#b0b8c4;font-weight:400">(optional)</span></label>
|
|
<input type="text" id="modal-note" placeholder="e.g. Partner shop" style="width:100%;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:9px;padding:10px 13px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;"/>
|
|
</div>
|
|
<div style="display:flex;gap:10px;">
|
|
<button onclick="confirmGrant()" style="flex:1;background:#2563eb;border:none;border-radius:9px;color:#fff;padding:11px;font-size:14px;font-weight:700;cursor:pointer;font-family:inherit;">Grant Access</button>
|
|
<button onclick="closeModal()" style="flex:1;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:9px;color:#374151;padding:11px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users table -->
|
|
<div class="shops-card">
|
|
<div class="shops-card-header">
|
|
<h3>Installed Shops</h3>
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<input id="search-inp" placeholder="Search shops…" oninput="filterUsers()" style="background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:8px;padding:6px 12px;font-size:13px;font-family:inherit;outline:none;color:#0f172a;width:200px;"/>
|
|
<span class="count-badge" id="shops-count-table">0 shops</span>
|
|
</div>
|
|
</div>
|
|
<div id="shops-table-wrap">
|
|
<div class="loading-row"><div class="spinner-dark"></div></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>
|
|
|
|
<script>
|
|
const BASE = '/d4a-admin';
|
|
|
|
async function api(method, path, body) {
|
|
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
|
|
if (body) opts.body = JSON.stringify(body);
|
|
const r = await fetch(BASE + path, opts);
|
|
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
|
|
}
|
|
|
|
// ── AUTH CHECK ──────────────────────────────────────────────────────────────
|
|
async function checkAuth() {
|
|
const r = await api('GET', '/api/check-auth');
|
|
if (r.ok) showDashboard();
|
|
}
|
|
checkAuth();
|
|
|
|
// ── PASSWORD TOGGLE ──────────────────────────────────────────────────────────
|
|
function togglePass() {
|
|
const inp = document.getElementById('inp-pass');
|
|
const btn = document.getElementById('eye-btn');
|
|
if (inp.type === 'password') { inp.type = 'text'; btn.textContent = '🙈'; }
|
|
else { inp.type = 'password'; btn.textContent = '👁'; }
|
|
}
|
|
|
|
// ── LOGIN ───────────────────────────────────────────────────────────────────
|
|
async function doLogin() {
|
|
const btn = document.getElementById('login-btn');
|
|
const err = document.getElementById('login-err');
|
|
const user = document.getElementById('inp-user').value.trim();
|
|
const pass = document.getElementById('inp-pass').value;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span> Signing in…';
|
|
err.style.display = 'none';
|
|
|
|
const r = await api('POST', '/login', { username: user, password: pass });
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sign In to Admin Portal';
|
|
if (r.ok) {
|
|
showDashboard();
|
|
} else {
|
|
err.textContent = '⚠️ ' + (r.data.error || 'Invalid credentials');
|
|
err.style.display = 'block';
|
|
}
|
|
}
|
|
document.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
|
|
|
// ── LOGOUT ──────────────────────────────────────────────────────────────────
|
|
async function doLogout() {
|
|
await api('POST', '/logout');
|
|
document.getElementById('dashboard').style.display = 'none';
|
|
document.getElementById('login-screen').style.display = 'flex';
|
|
document.getElementById('inp-user').value = '';
|
|
document.getElementById('inp-pass').value = '';
|
|
}
|
|
|
|
// ── SHOW DASHBOARD ──────────────────────────────────────────────────────────
|
|
function showDashboard() {
|
|
document.getElementById('login-screen').style.display = 'none';
|
|
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();
|
|
if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; }
|
|
} else {
|
|
loadChatList();
|
|
if (!chatPollInterval) chatPollInterval = setInterval(() => {
|
|
loadChatListSilent();
|
|
if (selectedChatShop) loadMessages(selectedChatShop, false);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// ── ALL USERS DATA ───────────────────────────────────────────────────────────
|
|
let allUsers = [];
|
|
|
|
async function loadUsers() {
|
|
const wrap = document.getElementById('shops-table-wrap');
|
|
wrap.innerHTML = '<div class="loading-row"><div class="spinner-dark"></div></div>';
|
|
const r = await api('GET', '/api/users');
|
|
if (!r.ok) { wrap.innerHTML = '<div class="empty-state"><p>Failed to load users.</p></div>'; return; }
|
|
allUsers = r.data.users || [];
|
|
document.getElementById('shops-count').textContent = allUsers.length + ' user' + (allUsers.length !== 1 ? 's' : '');
|
|
renderUsers(allUsers);
|
|
}
|
|
|
|
function filterUsers() {
|
|
const q = document.getElementById('search-inp').value.toLowerCase();
|
|
renderUsers(q ? allUsers.filter(u => u.shop.toLowerCase().includes(q)) : allUsers);
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
const wrap = document.getElementById('shops-table-wrap');
|
|
document.getElementById('shops-count-table').textContent = users.length + ' shop' + (users.length !== 1 ? 's' : '');
|
|
|
|
if (users.length === 0) {
|
|
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><p>No shops found.</p></div>';
|
|
return;
|
|
}
|
|
|
|
const rows = users.map(u => {
|
|
const lastSeen = u.savedAt
|
|
? new Date(u.savedAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})
|
|
: '—';
|
|
|
|
let statusTag, actionBtn;
|
|
if (u.freeAccess) {
|
|
const fa = u.freeEntry;
|
|
const expLabel = fa?.expiresAt
|
|
? 'Until ' + new Date(fa.expiresAt).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'})
|
|
: 'Permanent';
|
|
statusTag = \`<span class="tag tag-ok"><span class="tag-dot"></span>Free Access · \${expLabel}</span>\`;
|
|
actionBtn = \`<button class="btn-del" onclick="doRevoke('\${escHtml(u.shop)}')">Revoke</button>\`;
|
|
} else {
|
|
statusTag = '<span class="tag" style="background:#f8fafc;color:#94a3b8;border:1px solid #e2e8f0;"><span class="tag-dot" style="background:#cbd5e1;"></span>Subscribed</span>';
|
|
actionBtn = \`<button class="btn-grant" onclick="openModal('\${escHtml(u.shop)}')">Grant Free</button>\`;
|
|
}
|
|
|
|
return \`<tr data-shop="\${escHtml(u.shop)}">
|
|
<td>
|
|
<div class="shop-name">\${escHtml(u.shop)}</div>
|
|
<div class="shop-domain">Last auth: \${lastSeen}</div>
|
|
</td>
|
|
<td>\${statusTag}</td>
|
|
<td class="note-cell"><span class="note-text">\${escHtml(u.freeEntry?.note || '—')}</span></td>
|
|
<td style="text-align:right">\${actionBtn}</td>
|
|
</tr>\`;
|
|
}).join('');
|
|
|
|
wrap.innerHTML = \`<table>
|
|
<thead><tr>
|
|
<th>Shop Domain</th>
|
|
<th>Access Status</th>
|
|
<th>Note</th>
|
|
<th></th>
|
|
</tr></thead>
|
|
<tbody>\${rows}</tbody>
|
|
</table>\`;
|
|
}
|
|
|
|
// ── MODAL ────────────────────────────────────────────────────────────────────
|
|
let _modalShop = null;
|
|
|
|
function openModal(shop) {
|
|
_modalShop = shop;
|
|
document.getElementById('modal-shop-name').textContent = shop;
|
|
document.getElementById('modal-expires').value = '';
|
|
document.getElementById('modal-note').value = '';
|
|
const m = document.getElementById('expiry-modal');
|
|
m.style.display = 'flex';
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('expiry-modal').style.display = 'none';
|
|
_modalShop = null;
|
|
}
|
|
|
|
async function confirmGrant() {
|
|
if (!_modalShop) return;
|
|
const expires = document.getElementById('modal-expires').value || null;
|
|
const note = document.getElementById('modal-note').value.trim();
|
|
const expiresAt = expires ? new Date(expires + 'T23:59:59.000Z').toISOString() : null;
|
|
const r = await api('POST', '/api/shops', { shop: _modalShop, expiresAt, note });
|
|
if (r.ok) { closeModal(); loadUsers(); }
|
|
else alert('Failed to grant access: ' + (r.data.error || 'Unknown error'));
|
|
}
|
|
|
|
// ── REVOKE ───────────────────────────────────────────────────────────────────
|
|
async function doRevoke(shop) {
|
|
if (!confirm('Revoke free access for ' + shop + '? They will need an active subscription.')) return;
|
|
const r = await api('DELETE', '/api/shops/' + encodeURIComponent(shop));
|
|
if (r.ok) loadUsers();
|
|
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 ──────────────────────────────────────────────────────────────────────
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
module.exports = router;
|