MOHAN 4be12dc9d4 redesign admin panel with full light theme (Stripe/Linear aesthetic)
- Login: vibrant blue gradient left panel + pure white right form panel
- Dashboard: white topbar with blue accent, white cards, light gray page bg
- Status tags: light-coloured pill badges (green/red/blue) with dot indicators
- Remove all dark glassmorphism and near-black surfaces
- Sync count badge across topbar and table header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:34:55 +05:30

648 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 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 });
});
// ── 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;}
.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;}
</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-right">
<span class="topbar-badge" id="shops-count">0 shops</span>
<button class="logout-btn" onclick="doLogout()">Sign Out</button>
</div>
</div>
<div class="content">
<div class="page-header">
<h2>Whitelisted Shops</h2>
<p>Shops listed here bypass subscription checks and get full app access.</p>
</div>
<!-- Add shop form -->
<div class="add-card">
<div class="add-card-header">
<div class="add-card-icon"></div>
<div>
<h3>Grant Free Access</h3>
<div class="sub">Add a shop domain and optional expiry date.</div>
</div>
</div>
<div class="form-row">
<div class="field">
<label>Shop Domain</label>
<input type="text" id="inp-shop" placeholder="yourstore.myshopify.com"/>
</div>
<div class="field">
<label>Expiry Date <span style="color:#b0b8c4;font-weight:400">(blank = permanent)</span></label>
<input type="date" id="inp-expires"/>
</div>
<div class="field">
<label>Note <span style="color:#b0b8c4;font-weight:400">(optional)</span></label>
<input type="text" id="inp-note" placeholder="e.g. Partner shop"/>
</div>
<button class="btn-add" onclick="doAddShop()">Add Shop</button>
</div>
<div class="success-msg" id="add-success">✅ Shop added successfully!</div>
<div class="err-msg-dash" id="add-err"></div>
</div>
<!-- Shops table -->
<div class="shops-card">
<div class="shops-card-header">
<h3>Active Whitelist</h3>
<span class="count-badge" id="shops-count-table">0 shops</span>
</div>
<div id="shops-table-wrap">
<div class="loading-row"><div class="spinner-dark"></div></div>
</div>
</div>
</div>
</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';
loadShops();
}
// ── LOAD SHOPS ──────────────────────────────────────────────────────────────
async function loadShops() {
const wrap = document.getElementById('shops-table-wrap');
const r = await api('GET', '/api/shops');
if (!r.ok) { wrap.innerHTML = '<div class="empty-state"><div>Failed to load shops</div></div>'; return; }
const shops = r.data.shops || [];
const countLabel = shops.length + ' shop' + (shops.length !== 1 ? 's' : '');
document.getElementById('shops-count').textContent = countLabel;
document.getElementById('shops-count-table').textContent = countLabel;
if (shops.length === 0) {
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><p>No shops added yet.</p><small>Use the form above to grant free access to a shop.</small></div>';
return;
}
const rows = shops.map(s => {
const statusTag = s.expired
? '<span class="tag tag-exp"><span class="tag-dot"></span>Expired</span>'
: s.permanent
? '<span class="tag tag-perm"><span class="tag-dot"></span>Permanent</span>'
: '<span class="tag tag-ok"><span class="tag-dot"></span>Active</span>';
const expiryDisplay = s.expiresAt
? '<span class="date-cell">' + new Date(s.expiresAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
: '<span class="date-cell" style="color:#b0b8c4">Never</span>';
const grantedDisplay = s.grantedAt
? '<span class="date-cell">' + new Date(s.grantedAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
: '—';
return \`<tr>
<td><div class="shop-name">\${escHtml(s.shop)}</div></td>
<td>\${statusTag}</td>
<td>\${grantedDisplay}</td>
<td>\${expiryDisplay}</td>
<td><span class="note-text">\${escHtml(s.note || '—')}</span></td>
<td><button class="btn-del" onclick="doRemoveShop('\${escHtml(s.shop)}')">Remove</button></td>
</tr>\`;
}).join('');
wrap.innerHTML = \`<table>
<thead><tr>
<th>Shop Domain</th><th>Status</th><th>Granted</th><th>Expires</th><th>Note</th><th></th>
</tr></thead>
<tbody>\${rows}</tbody>
</table>\`;
}
// ── ADD SHOP ─────────────────────────────────────────────────────────────────
async function doAddShop() {
const shop = document.getElementById('inp-shop').value.trim();
const expires = document.getElementById('inp-expires').value || null;
const note = document.getElementById('inp-note').value.trim();
const ok = document.getElementById('add-success');
const err = document.getElementById('add-err');
ok.style.display = 'none';
err.style.display = 'none';
if (!shop) { err.textContent = '⚠️ Shop domain is required.'; err.style.display = 'block'; return; }
const expiresAt = expires ? new Date(expires + 'T23:59:59.000Z').toISOString() : null;
const r = await api('POST', '/api/shops', { shop, expiresAt, note });
if (r.ok) {
ok.style.display = 'block';
document.getElementById('inp-shop').value = '';
document.getElementById('inp-expires').value = '';
document.getElementById('inp-note').value = '';
loadShops();
} else {
err.textContent = r.data.error || 'Failed to add shop';
err.style.display = 'block';
}
}
// ── REMOVE SHOP ───────────────────────────────────────────────────────────────
async function doRemoveShop(shop) {
if (!confirm('Remove free access for ' + shop + '?')) return;
const r = await api('DELETE', '/api/shops/' + encodeURIComponent(shop));
if (r.ok) loadShops();
else alert('Failed to remove shop');
}
// ── UTIL ──────────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>`;
}
module.exports = router;