306 lines
20 KiB
HTML
306 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>RecordGod — admin</title>
|
||
<script defer src="/nav.js?v=3"></script>
|
||
<style>
|
||
:root{--pink:#ff5db1;--ink:#0b0b0d;--panel:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
|
||
a{color:var(--pink);text-decoration:none}
|
||
/* gate */
|
||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:10}
|
||
#gate .box{width:340px;text-align:center}
|
||
#gate h1{color:var(--pink);font-weight:600}
|
||
input,select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#0e0e11;color:#eee;font:14px system-ui}
|
||
button{padding:9px 13px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer}
|
||
button.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line)}
|
||
/* layout */
|
||
#app{display:none;grid-template-columns:212px 1fr;height:calc(100vh - 44px)}
|
||
nav{background:#0e0e11;border-right:1px solid var(--line);padding:14px 10px;overflow:auto}
|
||
nav .brand{font-weight:600;font-size:18px;padding:6px 10px 14px}
|
||
nav .brand b{color:var(--pink)}
|
||
nav a{display:block;color:#cfcfd6;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
|
||
nav a:hover{background:#17171c}
|
||
nav a.on{background:#221a20;color:var(--pink)}
|
||
nav .sec{color:#55555f;font-size:11px;text-transform:uppercase;letter-spacing:.1em;padding:14px 12px 4px}
|
||
main{overflow:auto;padding:22px 26px}
|
||
h2{font-weight:600;font-size:20px;margin:0 0 16px}
|
||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:24px}
|
||
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
|
||
.kpi .n{font-size:28px;font-weight:600}
|
||
.kpi .l{color:var(--mut);font-size:13px;margin-top:2px}
|
||
.kpi .n.pink{color:var(--pink)}
|
||
.bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||
.bar input{flex:1;min-width:200px}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th{text-align:left;color:var(--mut);font-weight:500;padding:8px 10px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--ink)}
|
||
td{padding:8px 10px;border-bottom:1px solid #1b1b20}
|
||
tr:hover td{background:#141418}
|
||
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#222;vertical-align:middle}
|
||
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#1d1d22;color:#bbb}
|
||
.pill.in{background:#15291f;color:var(--ok)} .pill.out{background:#2a1a1a;color:#e66}
|
||
.price{font-weight:500}
|
||
.muted{color:var(--mut)} .soon{color:#55555f}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
|
||
.card h3{margin:0 0 12px;font-weight:500;font-size:15px}
|
||
label{display:block;color:var(--mut);font-size:13px;margin:10px 0 4px}
|
||
.field{display:flex;gap:8px;align-items:center} .field input{flex:1}
|
||
.conn{display:flex;gap:10px;align-items:center;margin:9px 0}
|
||
.pg{display:flex;gap:8px;align-items:center;margin-top:14px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="gate"><div class="box">
|
||
<h1>Record<b>God</b></h1>
|
||
<p class="muted">admin sign-in</p>
|
||
<div class="field"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button onclick="signin()">Enter</button></div>
|
||
<div id="gerr" class="soon" style="margin-top:10px"></div>
|
||
</div></div>
|
||
|
||
<div id="app">
|
||
<nav>
|
||
<div class="brand">Record<b>God</b></div>
|
||
<a data-v="dashboard" class="on">📊 Dashboard</a>
|
||
<a data-v="inventory">📦 Inventory</a>
|
||
<a data-v="orders">🛒 Orders</a>
|
||
<div class="sec">Catalog</div>
|
||
<a data-v="intake">📥 Intake</a>
|
||
<a data-v="storemap">🗺️ Store map</a>
|
||
<a data-v="crates">🗄️ Crates</a>
|
||
<a data-v="reports">📈 Reports</a>
|
||
<div class="sec">System</div>
|
||
<a data-v="connections">🔌 Connections</a>
|
||
<a href="/store/">🎧 Virtual store ↗</a>
|
||
<a href="/">← Home</a>
|
||
</nav>
|
||
<main id="content"></main>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
let TOKEN = localStorage.getItem('rg_token') || '';
|
||
const hdr = () => ({ 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' });
|
||
const money = n => n==null ? '—' : '$' + Number(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||
|
||
async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() }); if(!r.ok) throw new Error(r.status); return r.json(); }
|
||
|
||
async function signin(){
|
||
TOKEN = $('#tok').value.trim();
|
||
try { await api('/stats'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; }
|
||
localStorage.setItem('rg_token', TOKEN);
|
||
$('#gate').style.display='none'; $('#app').style.display='grid';
|
||
go('dashboard');
|
||
}
|
||
|
||
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
|
||
function go(v){
|
||
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
|
||
({dashboard:vDash, inventory:vInv, orders:vOrders, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
|
||
}
|
||
|
||
// ---------- Dashboard ----------
|
||
async function vDash(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Dashboard</h2><div class="muted">loading…</div>';
|
||
const s = await api('/stats');
|
||
m.innerHTML = '<h2>Dashboard</h2><div class="kpis">'
|
||
+ kpi(s.items.toLocaleString(),'items in catalog', true)
|
||
+ kpi(s.in_stock.toLocaleString(),'in stock')
|
||
+ kpi(money(s.stock_value),'stock value', true)
|
||
+ kpi(s.vinyl.toLocaleString(),'vinyl')
|
||
+ kpi(s.other_goods.toLocaleString(),'other goods')
|
||
+ kpi(s.crates.toLocaleString(),'crates')
|
||
+ kpi(s.sales.toLocaleString(),'sales')
|
||
+ kpi(money(s.sales_total),'sales total')
|
||
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged (un-published)') : '')
|
||
+ '</div>'
|
||
+ '<div class="card"><h3>Quick actions</h3><div class="bar">'
|
||
+ '<button class="ghost" onclick="go(\'inventory\')">Browse inventory</button>'
|
||
+ '<button class="ghost" onclick="go(\'orders\')">Live orders</button>'
|
||
+ '<button class="ghost" onclick="window.open(\'/store/\')">Open the 3D store</button>'
|
||
+ '</div></div>';
|
||
}
|
||
const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
|
||
|
||
// ---------- Inventory ----------
|
||
let invState = { q:'', kind:'', crate_id:null, page:1 };
|
||
async function vInv(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Inventory</h2>'
|
||
+ '<div class="bar"><input id="iq" placeholder="search title / artist / sku…" value="'+invState.q+'">'
|
||
+ '<select id="ikind"><option value="">all kinds</option><option>vinyl</option><option>book</option><option>magazine</option><option>cd</option></select>'
|
||
+ '<button onclick="invSearch()">Search</button></div>'
|
||
+ '<div id="invrows" class="muted">loading…</div>';
|
||
$('#iq').addEventListener('keydown', e => { if(e.key==='Enter') invSearch(); });
|
||
$('#ikind').value = invState.kind;
|
||
loadInv();
|
||
}
|
||
function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; loadInv(); }
|
||
async function loadInv(){
|
||
const cr = invState.crate_id ? `&crate_id=${invState.crate_id}` : '';
|
||
const d = await api(`/inventory?q=${encodeURIComponent(invState.q)}&kind=${invState.kind}&page=${invState.page}${cr}`);
|
||
const rows = d.items.map(r => `<tr>
|
||
<td>${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}</td>
|
||
<td>${r.title?escapeH(r.title):'<span class=muted>'+r.sku+'</span>'}<div class="muted">${r.artist?escapeH(r.artist):''}</div></td>
|
||
<td><span class="pill">${r.kind}</span></td>
|
||
<td>${r.condition||'—'}</td>
|
||
<td class="price">${money(r.price)}</td>
|
||
<td>${r.target?'<span class=muted>'+money(r.target)+'</span>':'—'}</td>
|
||
<td>${r.crate?escapeH(r.crate):(r.crate_id||'—')}</td>
|
||
<td>${r.in_stock?'<span class="pill in">in</span>':'<span class="pill out">sold</span>'}</td>
|
||
</tr>`).join('');
|
||
const pages = Math.max(1, Math.ceil(d.total/d.per));
|
||
$('#invrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${d.total.toLocaleString()} items</div>`
|
||
+ `<table><thead><tr><th></th><th>Title / artist</th><th>Kind</th><th>Cond</th><th>Price</th><th>Target</th><th>Crate</th><th>Stock</th></tr></thead><tbody>${rows||'<tr><td colspan=8 class=muted>nothing found</td></tr>'}</tbody></table>`
|
||
+ `<div class="pg"><button class="ghost" onclick="invPage(-1)" ${d.page<=1?'disabled':''}>‹ prev</button><span class="muted">page ${d.page} / ${pages}</span><button class="ghost" onclick="invPage(1)" ${d.page>=pages?'disabled':''}>next ›</button></div>`;
|
||
}
|
||
function invPage(n){ invState.page += n; loadInv(); }
|
||
|
||
// ---------- Orders ----------
|
||
async function vOrders(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2><div class="muted">loading…</div>';
|
||
const d = await api('/orders');
|
||
if(!d.ok){ m.innerHTML += `<div class="card warn">${d.reason} — <a onclick="go('connections')">go to Connections</a></div>`; return; }
|
||
const rows = d.orders.map(o => `<tr>
|
||
<td>#${o.number}</td><td>${o.date}</td><td>${escapeH(o.customer||'—')}</td>
|
||
<td>${o.items} item${o.items==1?'':'s'}</td><td class="price">${o.currency||''} ${o.total}</td>
|
||
<td><span class="pill">${o.status}</span></td></tr>`).join('');
|
||
m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2>'
|
||
+ `<table><thead><tr><th>Order</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no orders</td></tr>'}</tbody></table>`;
|
||
}
|
||
|
||
// ---------- Connections ----------
|
||
async function vConn(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Connections</h2><div class="card"><h3>Credentials <span class="soon">— encrypted at rest, never shown back</span></h3><div id="secrets" class="muted">loading…</div></div>'
|
||
+ '<div class="card"><h3>Test connections</h3>'
|
||
+ ['discogs','woo','dealgod'].map(s=>`<div class="conn"><button class="ghost" onclick="testConn('${s}')">Test ${s}</button><span id="c-${s}" class="soon"></span></div>`).join('')
|
||
+ '</div>';
|
||
const d = await fetch('/settings/secrets', { headers: hdr() }).then(r=>r.json());
|
||
$('#secrets').innerHTML = d.fields.map(f => {
|
||
const st = f.set ? `<span style="color:var(--ok);font-size:12px">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
|
||
return `<label>${f.label} ${st}</label><div class="field"><input type="password" placeholder="${f.set?'•••••• (replace to update)':'paste value'}" data-name="${f.name}"><button class="ghost" data-save="${f.name}">Save</button></div>`;
|
||
}).join('');
|
||
document.querySelectorAll('[data-save]').forEach(b => b.onclick = () => saveSecret(b.dataset.save, b));
|
||
}
|
||
async function saveSecret(name, btn){
|
||
const i = document.querySelector(`input[data-name="${name}"]`); if(!i.value.trim()) return;
|
||
btn.textContent='…';
|
||
const r = await fetch('/settings/secrets', { method:'POST', headers:hdr(), body: JSON.stringify({name, value:i.value.trim()}) });
|
||
btn.textContent = r.ok ? 'Saved' : 'Error'; if(r.ok){ i.value=''; i.placeholder='•••••• (replace to update)'; } setTimeout(()=>btn.textContent='Save',1500);
|
||
}
|
||
async function testConn(svc){
|
||
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='var(--mut)';
|
||
let r,d; try { r = await fetch('/settings/test/'+svc,{method:'POST',headers:hdr()}); d = await r.json(); } catch(e){ el.textContent='✕ network'; return; }
|
||
if(d.connected){ el.textContent='✓ connected'+(d.as?' as '+d.as:''); el.style.color='var(--ok)'; }
|
||
else { el.textContent='✕ '+(d.detail||r.status); el.style.color='var(--warn)'; }
|
||
}
|
||
|
||
// ---------- Intake ----------
|
||
function vIntake(){
|
||
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
|
||
+ '<div class="card" style="max-width:460px">'
|
||
+ '<label>Discogs release id</label><input id="in_rid" placeholder="e.g. 8949699" style="width:100%">'
|
||
+ '<label>Condition</label><select id="in_cond" style="width:100%"><option>M</option><option>NM</option><option selected>VG+</option><option>VG</option><option>G+</option><option>G</option></select>'
|
||
+ '<label>Price (AUD)</label><input id="in_price" type="number" step="0.01" placeholder="0.00" style="width:100%">'
|
||
+ '<div style="margin-top:14px"><button onclick="doIntake()">Stage item</button></div>'
|
||
+ '<div id="in_res" class="muted" style="margin-top:14px"></div></div>';
|
||
}
|
||
async function doIntake(){
|
||
const rid = parseInt($('#in_rid').value, 10), res = $('#in_res');
|
||
if(!rid){ res.textContent = 'enter a Discogs release id'; return; }
|
||
res.textContent = 'enriching + staging…';
|
||
const r = await fetch('/wowplatter/v1/inventory/intake', { method:'POST', headers:hdr(),
|
||
body: JSON.stringify({ release_id: rid, condition: $('#in_cond').value, price: parseFloat($('#in_price').value)||null }) });
|
||
const d = await r.json();
|
||
if(d.ok){ res.innerHTML = `<span style="color:var(--ok)">✓ staged ${d.sku}</span> — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : '<span style="color:var(--warn)">not in catalog cache (un-enriched)</span>'); $('#in_rid').value=''; $('#in_price').value=''; }
|
||
else { res.innerHTML = `<span style="color:var(--warn)">${d.reason||'failed'}</span>`; }
|
||
}
|
||
|
||
// ---------- Crates ----------
|
||
async function vCrates(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';
|
||
const d = await api('/crates');
|
||
const cards = d.crates.map(c => `<div class="kpi" style="cursor:pointer" onclick="crateOpen(${c.id})">
|
||
<div class="n pink" style="font-size:17px">${escapeH(c.label_text||c.name||('#'+c.id))}</div>
|
||
<div class="l">${c.items||0} items · ${money(c.value)}</div>
|
||
<div class="soon">${escapeH(c.purpose||'stock')}</div></div>`).join('');
|
||
m.innerHTML = `<h2>Crates <span class="muted" style="font-size:13px">· ${d.crates.length} bins · click to see contents</span></h2><div class="kpis">${cards}</div>`;
|
||
}
|
||
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
|
||
|
||
// ---------- Reports ----------
|
||
async function vReports(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Reports</h2><div class="muted">loading…</div>';
|
||
const d = await api('/reports'), s = d.summary;
|
||
const months = d.by_month.map(x => `<tr><td>${x.month}</td><td>${x.orders}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
|
||
const top = d.top.map(x => `<tr><td>${escapeH(x.item_name||'—')}</td><td>${x.qty}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
|
||
m.innerHTML = '<h2>Reports</h2><div class="kpis">'
|
||
+ kpi(s.orders.toLocaleString(),'total sales',true) + kpi(money(s.revenue),'revenue',true) + kpi(money(s.avg_order),'avg order')
|
||
+ '</div><div class="card"><h3>Revenue by month</h3><table><thead><tr><th>Month</th><th>Sales</th><th>Revenue</th></tr></thead><tbody>'
|
||
+ (months||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>'
|
||
+ '<div class="card"><h3>Top items</h3><table><thead><tr><th>Item</th><th>Qty</th><th>Revenue</th></tr></thead><tbody>'
|
||
+ (top||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>';
|
||
}
|
||
|
||
// ---------- Store map ----------
|
||
let mapData = null;
|
||
async function vStoreMap(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Store map <span class="muted" style="font-size:13px">· click a bin for contents · brighter = fuller</span></h2>'
|
||
+ '<div class="bar"><input id="mq" placeholder="find a record → locate its bin…"><button onclick="mapFind()">Locate</button></div>'
|
||
+ '<div style="display:flex;gap:18px;align-items:flex-start">'
|
||
+ '<div id="mapwrap" style="flex:1;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;overflow:auto">loading…</div>'
|
||
+ '<div id="cratepane" style="width:300px;flex-shrink:0"><div class="card soon">click a bin on the map</div></div></div>';
|
||
$('#mq').addEventListener('keydown', e => { if(e.key==='Enter') mapFind(); });
|
||
mapData = await api('/storemap');
|
||
renderMap();
|
||
}
|
||
function renderMap(highlight){
|
||
const { crates, racks, crate_types:ct } = mapData;
|
||
const pts = crates.map(c=>[c.x,c.z]).concat(racks.map(r=>[r.x,r.z]));
|
||
if(!pts.length){ $('#mapwrap').innerHTML = '<div class="muted">no crate positions in this space</div>'; return; }
|
||
const minX=Math.min(...pts.map(p=>p[0]))-1, maxX=Math.max(...pts.map(p=>p[0]))+1;
|
||
const minZ=Math.min(...pts.map(p=>p[1]))-1, maxZ=Math.max(...pts.map(p=>p[1]))+1;
|
||
const W=maxX-minX, D=maxZ-minZ, scale=Math.min(820/W, 560/D);
|
||
const sx=x=>((x-minX)*scale).toFixed(1), sy=z=>((z-minZ)*scale).toFixed(1);
|
||
const maxItems=Math.max(1, ...crates.map(c=>c.items||0));
|
||
let svg = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#0c0c0e;border-radius:8px">`;
|
||
racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale;
|
||
svg += `<g transform="translate(${sx(r.x)},${sy(r.z)}) rotate(${r.rot||0})"><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="2" fill="#16161b" stroke="#2a2a30" stroke-width="0.5"/></g>`; });
|
||
crates.forEach(c => { const t=ct[c.crate_type_id]||{w:0.35,d:0.5}; const w=Math.max(7,t.w*scale), d=Math.max(7,t.d*scale);
|
||
const fill = c.items ? `rgba(255,93,177,${(0.22+0.65*(c.items/maxItems)).toFixed(2)})` : '#22222a';
|
||
const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#0a0a0c" stroke-width="0.5"';
|
||
svg += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
|
||
$('#mapwrap').innerHTML = svg + '</svg>';
|
||
}
|
||
async function openCrate(id){
|
||
renderMap(id);
|
||
const pane = $('#cratepane'); pane.innerHTML = '<div class="card muted">loading…</div>';
|
||
const d = await api(`/inventory?crate_id=${id}&page=1`);
|
||
const c = mapData.crates.find(x => x.id===id) || {};
|
||
const rows = d.items.map(r => `<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #1b1b20">
|
||
${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}
|
||
<div style="min-width:0"><div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||r.sku)}</div>
|
||
<div class="muted" style="font-size:12px">${escapeH(r.artist||'')} · ${money(r.price)}</div></div></div>`).join('');
|
||
pane.innerHTML = `<div class="card"><h3>${escapeH(c.label_text||c.name||('Crate #'+id))} <span class="muted" style="font-weight:400">· ${d.total} items</span></h3>${rows||'<div class=muted>empty</div>'}</div>`;
|
||
}
|
||
async function mapFind(){
|
||
const q = $('#mq').value.trim(); if(!q) return;
|
||
const d = await api(`/inventory?q=${encodeURIComponent(q)}&page=1`);
|
||
const hit = d.items.find(i => i.crate_id);
|
||
if(!hit){ $('#cratepane').innerHTML = `<div class="card" style="color:var(--warn)">no in-store match for “${escapeH(q)}”</div>`; return; }
|
||
openCrate(hit.crate_id);
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
function vSoon(title, body){ return () => { $('#content').innerHTML = `<h2>${title}</h2><div class="card soon">${body}<br><br>Coming next.</div>`; }; }
|
||
function escapeH(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
|
||
</script>
|
||
</body>
|
||
</html>
|