MOHAN 0c52aeb1aa style: redesign admin login page — split layout, glassmorphism, animated blobs
- Full-screen split layout: left branding panel + right login card
- Animated background gradient blobs with CSS keyframes
- Dot-grid overlay for depth
- Glassmorphism login card (backdrop-filter blur, subtle borders)
- Brand icon with pulsing glow animation
- Feature list on left panel
- Input fields with icon prefix (user/key) and password show/hide toggle
- Shimmer effect on sign-in button on hover
- Error shake animation
- Secure badge with green dot indicator
- Blobs hidden after login so dashboard background is clean

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

600 lines
27 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:#020617;min-height:100vh;color:#f1f5f9;overflow-x:hidden}
/* ═══════════════════════════════════════════
LOGIN SCREEN
═══════════════════════════════════════════ */
#login-screen{
display:flex;align-items:stretch;min-height:100vh;
background:#020617;
}
/* animated background blobs */
.login-bg{
position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;
}
.blob{
position:absolute;border-radius:50%;filter:blur(80px);opacity:0.18;
animation:blobFloat 12s ease-in-out infinite;
}
.blob1{width:600px;height:600px;background:radial-gradient(circle,#3b82f6,#1e3a8a);top:-200px;left:-150px;animation-delay:0s;}
.blob2{width:500px;height:500px;background:radial-gradient(circle,#8b5cf6,#1e1b4b);bottom:-180px;right:-100px;animation-delay:-4s;}
.blob3{width:350px;height:350px;background:radial-gradient(circle,#06b6d4,#0e7490);top:40%;left:55%;animation-delay:-7s;}
@keyframes blobFloat{
0%,100%{transform:translateY(0) scale(1);}
33%{transform:translateY(-30px) scale(1.05);}
66%{transform:translateY(20px) scale(0.97);}
}
/* grid dot overlay */
.login-bg::after{
content:'';position:absolute;inset:0;
background-image:radial-gradient(rgba(255,255,255,0.04) 1px,transparent 1px);
background-size:32px 32px;
}
/* left branding panel */
.login-left{
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:60px 48px;position:relative;z-index:1;
}
@media(max-width:800px){.login-left{display:none}}
.brand-icon{
width:88px;height:88px;
background:linear-gradient(135deg,#1e40af,#3b82f6,#06b6d4);
border-radius:24px;display:flex;align-items:center;justify-content:center;
font-size:40px;margin-bottom:28px;
box-shadow:0 0 0 1px rgba(59,130,246,0.3),0 0 40px rgba(59,130,246,0.25);
animation:iconPulse 3s ease-in-out infinite;
}
@keyframes iconPulse{
0%,100%{box-shadow:0 0 0 1px rgba(59,130,246,0.3),0 0 40px rgba(59,130,246,0.25);}
50%{box-shadow:0 0 0 1px rgba(59,130,246,0.5),0 0 60px rgba(59,130,246,0.4);}
}
.brand-name{
font-size:32px;font-weight:900;letter-spacing:-1px;
background:linear-gradient(135deg,#f8fafc,#93c5fd);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;margin-bottom:10px;text-align:center;
}
.brand-tagline{
font-size:14px;color:#475569;text-align:center;max-width:300px;line-height:1.7;
}
.feature-list{
margin-top:44px;display:flex;flex-direction:column;gap:14px;width:100%;max-width:320px;
}
.feature-item{
display:flex;align-items:center;gap:12px;
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);
border-radius:10px;padding:12px 16px;
}
.feature-dot{
width:8px;height:8px;border-radius:50%;background:#3b82f6;flex-shrink:0;
box-shadow:0 0 8px #3b82f6;
}
.feature-item span{font-size:13px;color:#94a3b8;font-weight:500;}
/* right login panel */
.login-right{
width:480px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
padding:40px 32px;position:relative;z-index:1;
}
@media(max-width:800px){.login-right{width:100%;}}
.login-card{
width:100%;max-width:400px;
background:rgba(15,23,42,0.7);
backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);
border:1px solid rgba(255,255,255,0.08);
border-radius:24px;
padding:44px 40px;
box-shadow:0 32px 80px rgba(0,0,0,0.6),inset 0 1px 0 rgba(255,255,255,0.06);
animation:cardIn 0.5s cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes cardIn{from{opacity:0;transform:translateY(24px);}to{opacity:1;transform:none;}}
.card-header{text-align:center;margin-bottom:36px;}
.card-mini-logo{
display:inline-flex;align-items:center;gap:8px;
background:linear-gradient(135deg,rgba(30,64,175,0.6),rgba(37,99,235,0.6));
border:1px solid rgba(59,130,246,0.3);
border-radius:10px;padding:6px 14px;margin-bottom:20px;
}
.card-mini-logo span{font-size:12px;font-weight:800;color:#93c5fd;letter-spacing:0.08em;text-transform:uppercase;}
.card-title{font-size:22px;font-weight:800;color:#f8fafc;letter-spacing:-0.5px;margin-bottom:6px;}
.card-sub{font-size:13px;color:#475569;}
/* form fields */
.inp-wrap{position:relative;margin-bottom:18px;}
.inp-wrap label{
display:block;font-size:11px;font-weight:700;
color:#64748b;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:8px;
}
.inp-inner{position:relative;}
.inp-icon{
position:absolute;left:14px;top:50%;transform:translateY(-50%);
font-size:15px;color:#475569;pointer-events:none;line-height:1;
}
.inp-wrap input{
width:100%;
background:rgba(2,6,23,0.6);
border:1px solid rgba(255,255,255,0.08);
border-radius:10px;
padding:12px 44px 12px 42px;
color:#f1f5f9;font-size:14px;font-family:inherit;
outline:none;transition:border-color 0.2s,box-shadow 0.2s,background 0.2s;
}
.inp-wrap input::placeholder{color:#334155;}
.inp-wrap input:focus{
border-color:rgba(59,130,246,0.6);
background:rgba(2,6,23,0.8);
box-shadow:0 0 0 3px rgba(59,130,246,0.12);
}
.eye-btn{
position:absolute;right:13px;top:50%;transform:translateY(-50%);
background:none;border:none;color:#475569;cursor:pointer;
font-size:15px;padding:2px;line-height:1;transition:color 0.15s;
}
.eye-btn:hover{color:#94a3b8;}
/* submit button */
.btn-login{
width:100%;margin-top:6px;
padding:13px;border:none;border-radius:10px;
background:linear-gradient(135deg,#1d4ed8 0%,#2563eb 50%,#3b82f6 100%);
background-size:200% 200%;
color:#fff;font-size:15px;font-weight:700;font-family:inherit;
cursor:pointer;position:relative;overflow:hidden;
transition:transform 0.15s,box-shadow 0.2s,background-position 0.4s;
box-shadow:0 4px 20px rgba(37,99,235,0.35);
}
.btn-login::after{
content:'';position:absolute;inset:0;
background:linear-gradient(105deg,transparent 40%,rgba(255,255,255,0.18) 50%,transparent 60%);
transform:translateX(-100%);transition:transform 0.5s;
}
.btn-login:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(37,99,235,0.5);background-position:100% 0;}
.btn-login:hover::after{transform:translateX(100%);}
.btn-login:active{transform:translateY(0);}
.btn-login:disabled{opacity:0.5;cursor:not-allowed;transform:none;box-shadow:none;}
.btn-login:disabled::after{display:none;}
/* error */
.err-msg{
background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);
border-radius:8px;padding:10px 14px;
font-size:13px;color:#f87171;margin-top:14px;display:none;
animation:shake 0.35s ease;
}
@keyframes shake{
0%,100%{transform:none;}10%,50%,90%{transform:translateX(-4px);}30%,70%{transform:translateX(4px);}
}
/* secure badge */
.secure-badge{
display:flex;align-items:center;justify-content:center;gap:6px;
margin-top:24px;font-size:11px;color:#334155;
}
.secure-badge .dot{width:6px;height:6px;border-radius:50%;background:#16a34a;box-shadow:0 0 6px #16a34a;}
/* spinner */
.spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,0.25);border-top-color:#fff;border-radius:50%;animation:spin 0.7s linear infinite;vertical-align:middle;}
@keyframes spin{to{transform:rotate(360deg)}}
/* ═══════════════════════════════════════════
DASHBOARD
═══════════════════════════════════════════ */
#dashboard{display:none}
.topbar{background:linear-gradient(135deg,#1e3a5f 0%,#2563eb 100%);padding:16px 32px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
.topbar-title{font-size:18px;font-weight:900;color:#fff}
.topbar-sub{font-size:13px;color:rgba(255,255,255,0.65);margin-top:2px}
.logout-btn{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.25);border-radius:8px;color:#fff;padding:7px 16px;font-size:13px;font-weight:600;cursor:pointer}
.logout-btn:hover{background:rgba(255,255,255,0.2)}
.content{max-width:960px;margin:0 auto;padding:32px 24px}
.add-card{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px 28px;margin-bottom:28px}
.add-card h3{font-size:15px;font-weight:800;color:#f1f5f9;margin-bottom:18px}
.form-row{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end;flex-wrap:wrap}
@media(max-width:700px){.form-row{grid-template-columns:1fr}}
.form-row .field{margin-bottom:0}
.field{margin-bottom:16px}
.field label{display:block;font-size:12px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px}
.field input{width:100%;background:#0f172a;border:1px solid #334155;border-radius:8px;padding:11px 14px;color:#f1f5f9;font-size:14px;font-family:inherit;outline:none;transition:border-color 0.15s}
.field input:focus{border-color:#2563eb}
.btn-add{background:linear-gradient(135deg,#15803d,#16a34a);border:none;border-radius:8px;color:#fff;padding:11px 20px;font-size:14px;font-weight:700;cursor:pointer;white-space:nowrap;font-family:inherit}
.btn-add:hover{opacity:0.9}
.success-msg{background:rgba(22,163,74,0.1);border:1px solid rgba(22,163,74,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#4ade80;margin-top:12px;display:none}
.err-msg-dash{background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.3);border-radius:8px;padding:10px 14px;font-size:13px;color:#f87171;margin-top:12px;display:none}
.shops-card{background:#1e293b;border:1px solid #334155;border-radius:14px;overflow:hidden}
.shops-card-header{padding:18px 24px;border-bottom:1px solid #334155;display:flex;align-items:center;justify-content:space-between}
.shops-card-header h3{font-size:15px;font-weight:800;color:#f1f5f9}
.count-badge{background:#1e3a5f;border:1px solid #2563eb;border-radius:20px;padding:3px 12px;font-size:12px;font-weight:700;color:#60a5fa}
table{width:100%;border-collapse:collapse}
th{background:#0f172a;padding:11px 16px;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:0.06em;text-align:left}
td{padding:13px 16px;font-size:13px;border-top:1px solid #334155;vertical-align:middle}
tr:hover td{background:rgba(255,255,255,0.02)}
.shop-name{font-weight:700;color:#f1f5f9;font-size:14px}
.note-text{color:#64748b;font-style:italic}
.tag{display:inline-block;border-radius:20px;padding:3px 10px;font-size:11px;font-weight:700}
.tag-ok{background:rgba(22,163,74,0.15);color:#4ade80;border:1px solid rgba(22,163,74,0.3)}
.tag-exp{background:rgba(220,38,38,0.12);color:#f87171;border:1px solid rgba(220,38,38,0.25)}
.tag-perm{background:rgba(59,130,246,0.12);color:#60a5fa;border:1px solid rgba(59,130,246,0.25)}
.date-text{color:#94a3b8;font-size:12px}
.btn-del{background:rgba(220,38,38,0.1);border:1px solid rgba(220,38,38,0.25);border-radius:6px;color:#f87171;padding:5px 12px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.btn-del:hover{background:rgba(220,38,38,0.2)}
.empty-state{padding:48px;text-align:center;color:#475569}
.empty-state .icon{font-size:36px;margin-bottom:12px}
</style>
</head>
<body>
<!-- background blobs (login only) -->
<div class="login-bg" id="login-bg">
<div class="blob blob1"></div>
<div class="blob blob2"></div>
<div class="blob blob3"></div>
</div>
<!-- ══════════════════════════════════════
LOGIN SCREEN
═══════════════════════════════════════ -->
<div id="login-screen">
<!-- Left branding panel -->
<div class="login-left">
<div class="brand-icon">🛡️</div>
<div class="brand-name">Data4Autos</div>
<div class="brand-tagline">Secure admin portal for managing free-access shops on the Turn14 integration platform.</div>
<div class="feature-list">
<div class="feature-item"><div class="feature-dot"></div><span>Grant free access to any Shopify store</span></div>
<div class="feature-item"><div class="feature-dot"></div><span>Set custom expiry dates per shop</span></div>
<div class="feature-item"><div class="feature-dot"></div><span>Changes take effect instantly</span></div>
<div class="feature-item"><div class="feature-dot"></div><span>No redeployment required</span></div>
</div>
</div>
<!-- Right login panel -->
<div class="login-right">
<div class="login-card">
<div class="card-header">
<div class="card-mini-logo"><span>D4A Admin</span></div>
<div class="card-title">Welcome back</div>
<div class="card-sub">Sign in to your admin portal</div>
</div>
<div class="inp-wrap">
<label>Username</label>
<div class="inp-inner">
<span class="inp-icon">👤</span>
<input type="text" id="inp-user" placeholder="Enter your username" autocomplete="username"/>
</div>
</div>
<div class="inp-wrap">
<label>Password</label>
<div class="inp-inner">
<span class="inp-icon">🔑</span>
<input type="password" id="inp-pass" placeholder="Enter your password" autocomplete="current-password"/>
<button class="eye-btn" 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="secure-badge">
<div class="dot"></div>
<span>Secure connection · Session expires in 8 hours</span>
</div>
</div>
</div>
</div>
<!-- ── DASHBOARD ── -->
<div id="dashboard">
<div class="topbar">
<div>
<div class="topbar-title">🛡️ Data4Autos — Free Access Manager</div>
<div class="topbar-sub">Manage shops with free / bypassed subscription access</div>
</div>
<button class="logout-btn" onclick="doLogout()">Sign Out</button>
</div>
<div class="content">
<!-- Add shop form -->
<div class="add-card">
<h3> Grant Free Access to a Shop</h3>
<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:#475569;font-weight:400">(leave blank = permanent)</span></label>
<input type="date" id="inp-expires"/>
</div>
<div class="field">
<label>Note <span style="color:#475569;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>🏪 Whitelisted Shops</h3>
<span class="count-badge" id="shops-count">0 shops</span>
</div>
<div id="shops-table-wrap">
<div class="empty-state"><div class="spinner"></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('login-bg').style.display = 'block';
document.getElementById('inp-user').value = '';
document.getElementById('inp-pass').value = '';
}
// ── SHOW DASHBOARD ──────────────────────────────────────────────────────────
function showDashboard() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('login-bg').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 || [];
document.getElementById('shops-count').textContent = shops.length + ' shop' + (shops.length !== 1 ? 's' : '');
if (shops.length === 0) {
wrap.innerHTML = '<div class="empty-state"><div class="icon">🏪</div><div>No shops added yet.<br>Use the form above to grant free access.</div></div>';
return;
}
const rows = shops.map(s => {
const statusTag = s.expired
? '<span class="tag tag-exp">Expired</span>'
: s.permanent
? '<span class="tag tag-perm">Permanent</span>'
: '<span class="tag tag-ok">Active</span>';
const expiryDisplay = s.expiresAt
? '<span class="date-text">' + new Date(s.expiresAt).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}) + '</span>'
: '<span style="color:#475569;font-size:12px">Never</span>';
const grantedDisplay = s.grantedAt
? '<span class="date-text">' + 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;