← back to Estimate Instant
public/admin.html
148 lines
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>estimate-instant — captured quotes (admin)</title>
<style>
:root{--ink:#1a1a1a;--line:#e6e6e6;--accent:#2a3a4a;--gold:#a98b4a;--bg:#f7f5f2}
*{box-sizing:border-box}
body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:var(--bg)}
header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:14px 20px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
h1{font-size:17px;margin:0;white-space:nowrap}
.count{font-size:12px;color:#999;white-space:nowrap}
.spacer{flex:1}
a{color:#888;font-size:13px}
/* Sort + Density — house rule: every admin grid gets these controls */
.controls{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 20px;background:#fff;border-bottom:1px solid var(--line)}
.controls label{font-size:12px;color:#888;display:flex;align-items:center;gap:6px;white-space:nowrap}
select{font:inherit;font-size:13px;padding:5px 8px;border:1px solid #d8d4cd;border-radius:7px;background:#fff;cursor:pointer}
input[type=range]{width:100px;accent-color:var(--accent);cursor:pointer}
.density-val{font-size:12px;color:#888;min-width:26px}
/* Grid */
.grid{display:grid;gap:14px;padding:18px 20px}
/* Card — admin card MUST show created date+time (house rule) */
.card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:4px}
.email{font-weight:700;font-size:15px;word-break:break-all}
/* 🕓 created date+time chip — house rule, ALWAYS visible on the card */
.when{display:inline-flex;align-items:center;gap:4px;margin-top:4px;font-size:12px;color:#777;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:2px 7px;width:fit-content}
.q{margin-top:8px;font-size:13px;color:#555;border-top:1px solid var(--line);padding-top:8px}
.q b{color:var(--ink)}
.q .roll{color:#888;font-size:12px;margin-top:2px}
.price-tag{display:inline-block;background:var(--accent);color:#fff;border-radius:5px;padding:1px 7px;font-size:13px;font-weight:700;margin-left:4px}
.empty{padding:50px;text-align:center;color:#999;grid-column:1/-1}
.no-leads{text-align:center;padding:50px 20px;color:#999}
</style></head><body>
<header>
<h1>Captured quotes</h1>
<span class="count" id="count"></span>
<span class="spacer"></span>
<a href="/">← calculator</a>
</header>
<!-- Sort + Density — house rule: every admin grid gets these controls, persisted to localStorage -->
<div class="controls">
<label>Sort
<select id="sortSel">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="email">Email A→Z</option>
<option value="price-desc">Price ↓</option>
<option value="price-asc">Price ↑</option>
</select>
</label>
<label>Density
<input type="range" id="density" min="200" max="480" step="20" value="280">
<span class="density-val" id="dval">280</span>
</label>
</div>
<div class="grid" id="grid"></div>
<script>
const $ = id => document.getElementById(id);
let allLeads = [];
// House rule: format date as "🕓 Jul 15, 2026, 3:17 AM" with full ISO in title=
function fmt(iso) {
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
});
} catch { return iso; }
}
function sortLeads(leads, mode) {
const a = leads.slice();
switch (mode) {
case 'oldest': return a.sort((x,y) => x.created_at.localeCompare(y.created_at));
case 'email': return a.sort((x,y) => x.email.localeCompare(y.email));
case 'price-desc':return a.sort((x,y) => (y.quote?.price||0) - (x.quote?.price||0));
case 'price-asc': return a.sort((x,y) => (x.quote?.price||0) - (y.quote?.price||0));
default: return a.sort((x,y) => y.created_at.localeCompare(x.created_at)); // newest
}
}
function render() {
const sort = $('sortSel').value;
const density = parseInt($('density').value, 10);
const leads = sortLeads(allLeads, sort);
$('grid').style.gridTemplateColumns = `repeat(auto-fill,minmax(${density}px,1fr))`;
$('count').textContent = leads.length + ' lead' + (leads.length === 1 ? '' : 's');
if (!leads.length) {
$('grid').innerHTML = '<div class="no-leads">No quotes captured yet.<br><a href="/">← Go calculate one</a></div>';
return;
}
$('grid').innerHTML = leads.map(l => {
const q = l.quote;
const quoteHtml = q && q.ok !== false
? `<div class="q">
<div>${q.pattern || '—'} · <b>${q.rollsNeeded || '?'} rolls</b><span class="price-tag">$${(q.price||0).toLocaleString()}</span></div>
<div class="roll">${q.sku || ''} · $${q.pricePerRoll || '?'}/roll · ${q.totalStrips || '?'} strips · ${q.wastePct || 0}% waste</div>
</div>`
: '';
// HARD RULE: 🕓 created date+time ALWAYS visible on card; ISO timestamp in title=
return `<div class="card">
<div class="email">${escHtml(l.email)}</div>
<span class="when" title="${escHtml(l.created_at)}">🕓 ${fmt(l.created_at)}</span>
${quoteHtml}
</div>`;
}).join('');
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// Persist sort + density to localStorage
function persist() {
try { localStorage.setItem('ei-admin-sort', $('sortSel').value); } catch {}
try { localStorage.setItem('ei-admin-density', $('density').value); } catch {}
}
function restore() {
try { const v = localStorage.getItem('ei-admin-sort'); if (v) $('sortSel').value = v; } catch {}
try { const v = localStorage.getItem('ei-admin-density'); if (v) { $('density').value = v; $('dval').textContent = v; } } catch {}
}
$('sortSel').addEventListener('change', () => { persist(); render(); });
$('density').addEventListener('input', () => {
$('dval').textContent = $('density').value;
persist(); render();
});
restore();
fetch('/api/leads')
.then(r => r.json())
.then(({ leads }) => {
allLeads = leads || [];
render();
})
.catch(() => {
$('grid').innerHTML = '<div class="no-leads">Failed to load leads.</div>';
});
</script>
</body></html>