← back to Reno Visualizer
public/admin.html
240 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reno Visualizer — Admin</title>
<style>
:root {
--brand: #2a4a3e;
--accent: #c8a86a;
--border: #e4e0d8;
--surface: #fafaf8;
--card-bg: #fff;
--muted: #7a7870;
--cols: 2;
}
*,*::before,*::after { box-sizing: border-box; margin:0; padding:0; }
body { font: 14px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif; color: #1a1a18; background: var(--surface); }
.nav {
display: flex; align-items: center; gap: 16px; padding: 12px 24px;
background: var(--brand); color: #fff; position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.nav h1 { font-size: 16px; font-weight: 700; }
.nav .badge { font-size: 11px; background: rgba(255,255,255,0.15); border-radius: 12px; padding: 2px 10px; }
.nav-spacer { flex: 1; }
.nav a { color: rgba(255,255,255,0.75); font-size: 12px; text-decoration: none; }
.nav a:hover { color: #fff; }
.toolbar {
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
padding: 14px 24px; background: #fff; border-bottom: 1px solid var(--border);
position: sticky; top: 52px; z-index: 10;
}
.toolbar label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.toolbar select { font: 13px inherit; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
.toolbar input[type=range] { width: 80px; }
.toolbar .spacer { flex: 1; }
.count-badge { font-size: 11px; color: var(--muted); background: var(--surface); border: 1px solid var(--border);
padding: 3px 10px; border-radius: 12px; }
.grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 16px;
padding: 24px;
}
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-header {
display: flex; gap: 12px; align-items: flex-start; padding: 14px 16px 10px;
border-bottom: 1px solid var(--border); background: var(--surface);
}
.card-pattern-thumb {
width: 56px; height: 56px; object-fit: cover; border-radius: 6px;
flex-shrink: 0; background: #e8e4dc; border: 1px solid var(--border);
}
.card-meta { flex: 1; min-width: 0; }
.card-meta h3 { font-size: 14px; font-weight: 700; color: var(--brand); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta .sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
.card-meta .sku { font-size: 11px; font-family: monospace; background: #f0ede8; border-radius: 4px; padding: 1px 5px; }
.card-body { padding: 12px 16px; }
.card-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.card-row:last-child { margin-bottom: 0; }
.card-key { font-size: 11px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.card-val { font-size: 12px; color: #1a1a18; text-align: right; max-width: 60%; word-break: break-word; }
.card-val.email { color: var(--brand); font-weight: 600; }
.card-val.interest { background: #f0f5f0; color: var(--brand); border-radius: 10px; padding: 2px 8px; font-size: 11px; }
/* HARD RULE: created date+time always visible on every admin card */
.card-when {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--muted); margin-top: 10px;
padding-top: 8px; border-top: 1px solid var(--border);
}
.card-when .clock { opacity: 0.7; }
.card-id { font-size: 10px; font-family: monospace; color: #b0b0a8; margin-left: auto; }
.empty-state { padding: 60px 24px; text-align: center; color: var(--muted); font-size: 15px; }
.empty-state .big { font-size: 40px; display: block; margin-bottom: 12px; }
.loading { text-align: center; padding: 40px; color: var(--muted); }
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #e0e0d8;
border-top-color: var(--brand); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<nav class="nav">
<h1>Reno Visualizer Admin</h1>
<span class="badge" id="nav-count">0 leads</span>
<span class="nav-spacer"></span>
<a href="/">← Back to Visualizer</a>
</nav>
<div class="toolbar">
<label>Sort
<select id="sort-sel" onchange="loadLeads()">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<label>Density <input type="range" id="density-slider" min="1" max="4" value="2" oninput="onDensityChange()"></label>
<span class="spacer"></span>
<span class="count-badge" id="count-badge">Loading…</span>
</div>
<div id="grid-wrap">
<div class="loading"><span class="spinner"></span></div>
</div>
<script>
const $ = s => document.querySelector(s);
function esc(s) { return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
/* HARD RULE — created date+time on every card, both human readable + ISO in title= */
function fmtDate(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
});
} catch { return iso; }
}
function interestLabel(v) {
return { order: 'Order rolls', sample: 'Request sample', consult: 'Design consult', quote: 'Get a quote' }[v] || v || '—';
}
function renderCard(lead) {
return `<div class="card">
<div class="card-header">
${lead.image_url
? `<img class="card-pattern-thumb" src="${esc(lead.image_url)}" alt="${esc(lead.pattern_title||'')}" onerror="this.style.background='#e8e4dc'">`
: `<div class="card-pattern-thumb" style="background:${esc(lead.pattern_color||'#e8e4dc')};display:flex;align-items:center;justify-content:center;font-size:22px;">🖼</div>`
}
<div class="card-meta">
<h3>${esc(lead.email)}</h3>
${lead.pattern_title ? `<div class="sub">${esc(lead.pattern_title)}</div>` : ''}
${lead.pattern_sku ? `<div class="sub"><span class="sku">${esc(lead.pattern_sku)}</span></div>` : ''}
</div>
</div>
<div class="card-body">
<div class="card-row">
<span class="card-key">Room</span>
<span class="card-val">${esc(lead.room_label||'—')}</span>
</div>
<div class="card-row">
<span class="card-key">Vendor</span>
<span class="card-val">${esc(lead.pattern_vendor||'—')}</span>
</div>
<div class="card-row">
<span class="card-key">Interest</span>
<span class="card-val interest">${esc(interestLabel(lead.interest))}</span>
</div>
${lead.message ? `<div class="card-row" style="align-items:flex-start;">
<span class="card-key">Notes</span>
<span class="card-val" style="font-style:italic;color:#5a5a50;">${esc(lead.message)}</span>
</div>` : ''}
${lead.pattern_shopify_url ? `<div class="card-row">
<span class="card-key">Product</span>
<span class="card-val"><a href="${esc(lead.pattern_shopify_url)}" target="_blank" rel="noopener noreferrer" style="color:var(--brand);font-size:11px;">View on DW →</a></span>
</div>` : ''}
<!-- HARD RULE: created date+time, full ISO in title= -->
<div class="card-when">
<span class="clock">🕓</span>
<span title="${esc(lead.created_at||'')}">${fmtDate(lead.created_at)}</span>
<span class="card-id">${esc(lead.id||'')}</span>
</div>
</div>
</div>`;
}
async function loadLeads() {
const sort = $('#sort-sel').value;
try {
const r = await fetch('/api/leads?sort=' + encodeURIComponent(sort));
if (r.status === 401) {
document.body.innerHTML = '<div style="text-align:center;padding:80px;font-size:16px;color:#666;">Access denied — admin auth required.</div>';
return;
}
const d = await r.json();
const leads = d.leads || [];
$('#nav-count').textContent = leads.length + ' lead' + (leads.length !== 1 ? 's' : '');
$('#count-badge').textContent = leads.length + ' captured looks';
if (!leads.length) {
$('#grid-wrap').innerHTML = `<div class="empty-state">
<span class="big">📋</span>
No leads yet — they'll appear here after someone shops a look.
</div>`;
return;
}
// Enrich with pattern image from cached products list
const products = window._products || [];
const bySkuImg = {};
const bySkuColor = {};
products.forEach(p => { bySkuImg[p.sku] = p.image_url || p.image || ''; bySkuColor[p.sku] = p.hex || ''; });
const cards = leads.map(lead => {
lead.image_url = lead.pattern_sku ? (bySkuImg[lead.pattern_sku] || '') : '';
lead.pattern_color = lead.pattern_sku ? (bySkuColor[lead.pattern_sku] || '#e8e4dc') : '#e8e4dc';
return renderCard(lead);
}).join('');
$('#grid-wrap').innerHTML = `<div class="grid" id="leads-grid">${cards}</div>`;
} catch(e) {
$('#grid-wrap').innerHTML = '<div class="empty-state">Error loading leads: ' + esc(String(e)) + '</div>';
}
}
function onDensityChange() {
const v = $('#density-slider').value;
localStorage.setItem('admin-cols', v);
document.documentElement.style.setProperty('--cols', v);
}
// Restore density
(function() {
const v = localStorage.getItem('admin-cols') || '2';
$('#density-slider').value = v;
document.documentElement.style.setProperty('--cols', v);
})();
// Load products for enriching leads
fetch('/api/products').then(r=>r.json()).then(d => {
window._products = d.products || [];
loadLeads();
}).catch(() => loadLeads());
</script>
</body>
</html>