feat(intake): condition dropdown auto-fills suggested price

Each Find & Stage result card's condition dropdown now fetches a price suggestion and fills the
price input (unless the operator typed one). New GET /admin/intake/suggest?release_id=&condition=
maps the Discogs grade to a band of DealGod's composed value (M/NM→high, VG+→typ, VG→mid, G+→low).
Sourced from /api/value (AU listings) now; per-condition discogs_full.release_market suggestions
slot into the one dealgod.value() call when the fleet scrape pipeline is live — UI shape unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-30 18:40:06 +10:00
parent ca84b7f82f
commit c44e6643db
2 changed files with 355 additions and 19 deletions

View File

@ -15,6 +15,7 @@ from pydantic import BaseModel
from sqlalchemy import text
from . import vault
from . import dealgod
from .auth import require_token
from .db import get_db
@ -141,22 +142,24 @@ async def _grow_mirror(db, d, artist, title, thumb, weight, year):
# --- staging core ----------------------------------------------------------
async def _stage(db, store_id, rid, sku, condition, sleeve, price, notes, kind="vinyl",
identifier=None):
identifier=None, canon_id=None, title=None):
# records enrich from the Discogs mirror via rid; non-record goods (books/tools/…) carry a DealGod
# canon_id + an explicit title from /api/identify — that's the StoreGod generalisation.
meta = await _enrich(db, rid) if rid else None
sku = sku or _new_sku()
title = title or (meta or {}).get("title")
await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title, price,
INSERT INTO inventory (sku, store_id, kind, release_id, canon_id, identifier, title, price,
condition, sleeve_cond, weight_g, notes, staged, status)
VALUES (:sku,:sid,:kind,:rid,:ident,:title,:price,:cond,:sleeve,:wt,:notes,true,'staged')
VALUES (:sku,:sid,:kind,:rid,:canon,:ident,:title,:price,:cond,:sleeve,:wt,:notes,true,'staged')
ON CONFLICT (sku) DO UPDATE SET
release_id=EXCLUDED.release_id, title=EXCLUDED.title, price=EXCLUDED.price,
release_id=EXCLUDED.release_id, canon_id=EXCLUDED.canon_id, title=EXCLUDED.title, price=EXCLUDED.price,
condition=EXCLUDED.condition, sleeve_cond=EXCLUDED.sleeve_cond,
notes=EXCLUDED.notes, updated_at=now()"""),
{"sku": sku, "sid": store_id, "kind": kind, "rid": rid, "ident": identifier,
"title": (meta or {}).get("title"), "price": price, "cond": condition,
{"sku": sku, "sid": store_id, "kind": kind, "rid": rid, "canon": canon_id, "ident": identifier,
"title": title, "price": price, "cond": condition,
"sleeve": sleeve, "wt": (meta or {}).get("weight"), "notes": notes})
return {"sku": sku, "title": (meta or {}).get("title"),
"artist": (meta or {}).get("artist"), "enriched": bool(meta)}
return {"sku": sku, "title": title, "artist": (meta or {}).get("artist"), "enriched": bool(meta)}
class StageIn(BaseModel):
@ -178,6 +181,45 @@ async def stage(body: StageIn, ident=Depends(require_token), db=Depends(get_db))
return {"ok": True, "staged": True, **res}
# --- price suggestion (intake condition-dropdown auto-fill) -----------------
# Discogs grade → which band of the composed (low,typ,high) to suggest. Once the fleet's release-page
# scrape lands (discogs_full.release_market: low/med/high + per-condition price_suggestions,
# MISSION.md), prefer those — the swap is the one dealgod.value() call below; the {suggested,low,typ,
# high} shape the intake UI reads never changes.
_COND_BAND = {"M": "hi", "M-": "hi", "NM": "hi", "VG+": "typ",
"VG": "mid", "G+": "lo", "G": "lo", "F": "lo", "P": "lo"}
def _band(cond, low, typ, high):
c = (cond or "").upper().replace(" ", "")
key = next((k for k in ("M-", "NM", "VG+", "VG", "G+", "G", "F", "P", "M") if c.startswith(k)), "VG+")
lo = low if low is not None else typ
hi = high if high is not None else typ
mid = typ if typ is not None else (low if low is not None else high)
b = _COND_BAND.get(key, "typ")
if b == "hi":
return hi
if b == "lo":
return lo
if b == "mid" and lo is not None and mid is not None:
return round((lo + mid) / 2, 2)
return mid
@router.get("/suggest")
async def suggest(release_id: int = Query(...), condition: str = Query("VG+"),
ident=Depends(require_token), db=Depends(get_db)):
"""Suggested price for a release at a condition — powers the intake condition-dropdown auto-fill.
Sources DealGod's composed value (AU listings → low/typ/high); per-condition Discogs release_market
suggestions slot in here once the fleet scrape pipeline is live. Either source same shape."""
val = await dealgod.value(db, release_id=release_id, condition=condition)
if not val or val.get("typ") is None:
return {"ok": False}
low, typ, high = val.get("low"), val.get("typ"), val.get("high")
return {"ok": True, "suggested": _band(condition, low, typ, high),
"low": low, "typ": typ, "high": high, "source": val.get("source") or []}
# --- 1. internal lookup ----------------------------------------------------
@router.get("/search")

View File

@ -55,7 +55,7 @@
</head>
<body>
<div id="gate"><div class="box">
<h1>Record<b>God</b></h1>
<h1 class="brand">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>
@ -68,18 +68,24 @@
<a data-v="inventory">📦 Inventory</a>
<a data-v="orders">🛒 Orders</a>
<a data-v="customers">👤 Customers</a>
<a data-v="wantlist">📝 Wantlist</a>
<a data-v="wantlist" data-module="records">📝 Wantlist</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a>
<a data-v="newstock">📦 New stock</a>
<a data-v="buylist">🎯 Buy list</a>
<a data-v="heal">🩹 Heal</a>
<a data-v="identify" data-generic style="display:none">🔎 Identify</a>
<a data-v="melt" data-module="jewellery" style="display:none">💰 Melt</a>
<a data-v="priceguide" data-module="tools" style="display:none">🔧 Price guide</a>
<a data-v="intake" data-module="records">📥 Intake</a>
<a data-v="newstock" data-module="records">📦 New stock</a>
<a data-v="buylist" data-module="records">🎯 Buy list</a>
<a data-v="discogs" data-module="records">💿 Discogs</a>
<a data-v="heal" data-module="records">🩹 Heal</a>
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
<div class="sec" data-admin>System</div>
<a data-v="staff" data-admin>🧑‍💼 Staff</a>
<a data-v="connections" data-admin>🔌 Connections</a>
<a data-v="settings" data-admin>⚙️ Settings</a>
<a data-v="pages" data-admin>📄 Pages</a>
<a data-v="system" data-admin>🖥️ System</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a onclick="changeMyPassword()" style="cursor:pointer">🔑 Change password</a>
@ -104,6 +110,7 @@ async function signin(){
ROLE = me.role || 'staff';
localStorage.setItem('rg_token', TOKEN);
window.ME = me;
applyStore(me); // brand + show/hide specialist nav per the unlocked modules
// staff don't see admin-only nav (API keys / staff admin); the backend enforces it too
document.querySelectorAll('[data-admin]').forEach(el => el.style.display = ROLE==='admin' ? '' : 'none');
$('#gate').style.display='none'; $('#app').style.display='grid';
@ -114,7 +121,7 @@ document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.d
function go(v){
if(ROLE!=='admin' && (v==='connections'||v==='staff'||v==='system')) v='dashboard'; // staff can't reach admin views
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, newstock:vNewstock, buylist:vBuylist, heal:vHeal, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff, system:vSystem}[v])();
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, newstock:vNewstock, buylist:vBuylist, heal:vHeal, storemap:vStoreMap, identify:vIdentify, melt:vMelt, priceguide:vPriceguide, discogs:vDiscogs, crates:vCrates, reports:vReports, connections:vConn, settings:vSettings, pages:vPages, staff:vStaff, system:vSystem}[v])();
}
// ---------- Dashboard (fast tiles first, charts lazy) ----------
@ -226,6 +233,8 @@ function invEdit(sku){
<div class="bar" style="margin:-2px 0 8px"><span class=muted style="width:90px">Margin</span><span id="eMargin" class="muted"></span></div>
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">New / used</span><select id="eCondType"><option value="used"${r.condition_type!=='new'?' selected':''}>Used</option><option value="new"${r.condition_type==='new'?' selected':''}>New / sealed</option></select></div>
${fld('Target','eTarget',r.target,'type=number step=0.01')}
<div id="dgVal" style="margin:2px 0 8px;font-size:13px"><button class="ghost" type="button" onclick="dgValue('${sku}')">💎 DealGod value</button></div>
${r.release_id?`<div class="bar" style="margin:0 0 8px;font-size:13px"><span class=muted style="width:90px">Discogs</span><button class="ghost" type="button" onclick="dgListItem('${sku}')">💿 List</button><button class="ghost" type="button" onclick="dgDelistItem('${sku}')">Delist</button><span id="dgItemMsg" class="muted" style="margin-left:8px"></span></div>`:''}
${fld('Condition','eCond',r.condition)}
${fld('Sleeve','eSleeve',r.sleeve_cond)}
${fld('Crate id','eCrate',r.crate_id,'type=number')}
@ -246,6 +255,27 @@ function invMargin(){
const d=p-c, pct=p>0?Math.round(d/p*100):0;
m.innerHTML=`<b style="color:${d>=0?'var(--ok)':'var(--warn)'}">${money(d)}</b> (${pct}%)`;
}
async function dgValue(sku){
const el=$('#dgVal'); if(!el) return;
el.innerHTML='<span class=muted>checking DealGod…</span>';
try{
const d=await api('/value/'+encodeURIComponent(sku));
if(!d.ok){ el.innerHTML='<span class=muted>'+escapeH(d.reason||'unavailable')+'</span>'; return; }
const v=d.value;
if(!v){ el.innerHTML='<span class=muted>'+escapeH(d.note||'no value')+'</span>'; return; }
const sup=v.supply?` · ${v.supply.stores||0} stores/${v.supply.sellers||0} sellers`:'';
const fresh=v.as_of?` · ${v.as_of}${v.n?` (n=${v.n})`:''}`:'';
const ready=v.value_ready===false?' <span style="color:var(--warn)">· identify-only</span>':'';
let html=`💎 <b>${money(v.typ)}</b> <span class=muted>(${money(v.low)}${money(v.high)} ${escapeH(v.currency||'')}${fresh}${sup})</span>${ready} `
+`<button class="ghost" type="button" onclick="document.getElementById('eTarget').value=${Number(v.typ)||0}">→ Target</button>`;
const lo=d.lore;
if(lo && (lo.tier || (lo.faults||[]).length)){
html+=`<div class=muted style="margin-top:4px">${lo.tier?`tier <b>${escapeH(lo.tier)}</b>`:''}`
+`${(lo.faults||[]).length?` · watch: ${escapeH((lo.faults||[]).slice(0,2).join('; '))}`:''}</div>`;
}
el.innerHTML=html;
}catch(e){ el.innerHTML='<span class=muted>lookup failed</span>'; }
}
async function invSave(sku){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { title:v('eTitle'), price:numOrNull('ePrice'), cost_price:numOrNull('eCost'),
@ -536,7 +566,154 @@ async function vSystem(){
+ `<tr><td>Last sale</td><td class="price">${ago(f.last_sale)}</td><td class="muted">${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ `<tr><td>Inventory last updated</td><td class="price">${ago(f.inventory_updated)}</td><td class="muted">${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ '</tbody></table></div>'
+ '<div class="card"><h3>Largest tables</h3><table><thead><tr><th>Table</th><th>Size</th><th>Rows (est.)</th></tr></thead><tbody>'+(rows||'<tr><td colspan=3 class=muted></td></tr>')+'</tbody></table></div>';
+ '<div class="card"><h3>Largest tables</h3><table><thead><tr><th>Table</th><th>Size</th><th>Rows (est.)</th></tr></thead><tbody>'+(rows||'<tr><td colspan=3 class=muted></td></tr>')+'</tbody></table></div>'
+ '<div class="card"><h3>Slow queries <button class="ghost" style="float:right;font-size:12px" onclick="sqReset()">↺ reset stats</button></h3><div class="muted" style="font-size:12px;margin-bottom:6px">slowest SQL by mean time · pg_stat_statements</div><div id="sqOut"><div class="muted">loading…</div></div></div>';
loadSlowQueries();
}
async function loadSlowQueries(){
const el=$('#sqOut'); if(!el) return;
const d=await api('/slow-queries');
if(!d.ok){ el.innerHTML='<div class="muted">'+escapeH(d.reason||'unavailable')+'</div>'; return; }
if(!(d.queries||[]).length){ el.innerHTML='<div class="muted">no stats yet — they accumulate as the app runs</div>'; return; }
el.innerHTML='<table><thead><tr><th>Query</th><th>Calls</th><th>Mean ms</th><th>Max ms</th><th>Total ms</th></tr></thead><tbody>'
+ d.queries.map(q=>`<tr><td style="font-family:ui-monospace,monospace;font-size:11px;max-width:430px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeH(q.query)}">${escapeH(q.query)}</td>`
+`<td>${Number(q.calls).toLocaleString()}</td><td class="price"><b style="color:${q.mean_ms>50?'var(--warn)':'inherit'}">${q.mean_ms}</b></td><td>${q.max_ms}</td><td>${Number(q.total_ms).toLocaleString()}</td></tr>`).join('')
+ '</tbody></table>';
}
async function sqReset(){
await fetch('/admin/slow-queries/reset',{method:'POST',headers:hdr()});
loadSlowQueries();
}
// ---------- Store settings (WordPress General/Reading replacement) ----------
const SETTING_KEYS=['store_name','store_tagline','store_logo','store_url','contact_email','contact_phone','store_address','abn','timezone','currency_symbol','tax_rate','tax_type','homepage','seo_title','seo_description','social_instagram','social_facebook','policy_returns','policy_shipping','policy_privacy','receipt_footer','meta_pixel_id','umami_website_id','umami_src','ga4_id'];
async function vSettings(){
const s=await (await fetch('/sales/settings',{headers:hdr()})).json();
const f=(label,key,attr)=>`<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">${label}</span><input id="set_${key}" ${attr||''} value="${(s[key]==null?'':String(s[key])).replace(/"/g,'&quot;')}" style="flex:1"></div>`;
const ta=(label,key)=>`<div style="margin-bottom:8px"><div class=muted style="margin-bottom:3px">${label}</div><textarea id="set_${key}" style="width:100%;min-height:56px;padding:6px;border:1px solid var(--line);border-radius:6px;font:inherit">${escapeH(s[key]||'')}</textarea></div>`;
$('#content').innerHTML=`<h2>Store settings <span class="muted" style="font-size:13px">· the WordPress General/Reading replacement</span></h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start">
<div class="card" style="flex:1;min-width:300px"><h3>Identity &amp; contact</h3>
${f('Store name','store_name')}${f('Tagline','store_tagline')}${f('Logo URL','store_logo')}${f('Store URL','store_url')}
${f('Email','contact_email','type=email')}${f('Phone','contact_phone')}${f('Address','store_address')}${f('ABN','abn')}${f('Timezone','timezone')}
${f('Instagram','social_instagram')}${f('Facebook','social_facebook')}</div>
<div class="card" style="flex:1;min-width:300px"><h3>Tax &amp; currency</h3>
${f('Currency symbol','currency_symbol')}${f('GST rate %','tax_rate','type=number step=0.1')}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">Tax type</span><select id="set_tax_type"><option value="inclusive">Inclusive (AU)</option><option value="exclusive">Exclusive</option></select></div>
<h3 style="margin-top:14px">Homepage &amp; SEO</h3>
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">Homepage</span><select id="set_homepage"><option value="storefront">Storefront</option><option value="landing">Custom landing</option><option value="3d">3D store</option></select></div>
${f('SEO title','seo_title')}${ta('SEO description','seo_description')}
<h3 style="margin-top:14px">Analytics &amp; tracking</h3>
${f('Meta Pixel ID','meta_pixel_id')}${f('Umami website ID','umami_website_id')}${f('Umami src','umami_src','placeholder="https://cloud.umami.is/script.js"')}${f('GA4 ID','ga4_id','placeholder="G-XXXX"')}</div>
<div class="card" style="flex:1;min-width:300px"><h3>Policies</h3>
${ta('Returns','policy_returns')}${ta('Shipping','policy_shipping')}${ta('Privacy','policy_privacy')}${f('Receipt footer','receipt_footer')}</div>
</div>
<div class="bar" style="margin-top:12px"><button onclick="saveSettings()">Save settings</button><span id="setMsg" class="muted" style="margin-left:10px"></span></div>`;
if($('#set_tax_type')) $('#set_tax_type').value=s.tax_type||'exclusive';
if($('#set_homepage')) $('#set_homepage').value=s.homepage||'storefront';
}
async function saveSettings(){
const body={}; SETTING_KEYS.forEach(k=>{ const el=$('#set_'+k); if(el) body[k]=el.value; });
const r=await fetch('/sales/settings',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
$('#setMsg').textContent = r.ok?'✓ saved':'save failed'; setTimeout(()=>{const m=$('#setMsg');if(m)m.textContent='';},2000);
}
// ---------- Pages CMS (WordPress Pages replacement) ----------
async function vPages(){
const d=await api('/pages');
const list=(d.pages||[]).map(p=>`<div class="ri" style="cursor:pointer" onclick="pageEdit('${escapeH(p.slug)}')"><div style="flex:1"><b>${escapeH(p.title||p.slug)}</b> <span class=muted>/${escapeH(p.slug)}</span>${p.published?'':' <span style="color:var(--warn)">draft</span>'}</div><span class=muted style="font-size:12px">${p.body_len} ch</span></div>`).join('');
$('#content').innerHTML=`<h2>Pages <span class="muted" style="font-size:13px">· About / Shipping / Returns / Privacy — the WordPress Pages replacement</span></h2>
<div class="bar"><button onclick="pageEdit('')">+ New page</button></div>
<div style="display:flex;gap:18px;align-items:flex-start;margin-top:10px">
<div style="width:300px;flex-shrink:0"><div class="card">${list||'<div class=muted>no pages yet</div>'}</div></div>
<div id="pageEd" style="flex:1;min-width:0"><div class="card soon">pick a page or create one</div></div></div>
<div class="card" style="margin-top:16px"><h3>🔀 URL redirects <span class="muted" style="font-size:12px;font-weight:400">· 301s so old WowPlatter links don't break at cutover</span></h3>
<div class=muted style="font-size:12px;margin-bottom:4px">paste one per line — <code>/old/path , /new/path</code></div>
<textarea id="rdIn" placeholder="/product/some-old-record , /release/12345&#10;/shop-page , /shop" style="width:100%;min-height:80px;padding:8px;border:1px solid var(--line);border-radius:6px;font:12px ui-monospace,monospace"></textarea>
<div class="bar" style="margin-top:6px"><button onclick="importRedirects()">Import redirects</button><span id="rdMsg" class="muted" style="margin-left:10px"></span></div>
<div id="rdList" style="margin-top:10px"></div></div>`;
loadRedirects();
}
async function loadRedirects(){
const el=$('#rdList'); if(!el) return;
const d=await api('/redirects'); const rs=d.redirects||[];
el.innerHTML = rs.length ? '<table><thead><tr><th>From</th><th>→ To</th><th>Code</th><th>Hits</th><th></th></tr></thead><tbody>'
+ rs.map(r=>`<tr><td style="font-family:ui-monospace,monospace;font-size:12px">${escapeH(r.from_path)}</td><td style="font-family:ui-monospace,monospace;font-size:12px">${escapeH(r.to_path)}</td><td>${r.code}</td><td>${r.hits}</td><td><span style="cursor:pointer;color:#e66" onclick="delRedirect('${escapeH(r.from_path)}')"></span></td></tr>`).join('')
+ '</tbody></table>' : '<div class=muted style="font-size:12px">no redirects yet</div>';
}
async function importRedirects(){
const lines=$('#rdIn').value.split('\n').map(l=>l.trim()).filter(Boolean);
const redirects=[]; lines.forEach(l=>{ const m=l.split(/\s*[,→]\s*|\s+->\s+/); if(m.length>=2 && m[0] && m[1]) redirects.push({from_path:m[0],to_path:m[1],code:301}); });
if(!redirects.length){ $('#rdMsg').textContent='nothing to import'; return; }
const r=await fetch('/admin/redirects',{method:'POST',headers:hdr(),body:JSON.stringify({redirects})}).then(x=>x.json());
$('#rdMsg').textContent='✓ '+(r.saved||0)+' saved'; $('#rdIn').value=''; loadRedirects();
}
async function delRedirect(fp){
await fetch('/admin/redirects?from_path='+encodeURIComponent(fp),{method:'DELETE',headers:hdr()});
loadRedirects();
}
function pgFld(l,id,v){ return `<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">${l}</span><input id="${id}" value="${(v==null?'':String(v)).replace(/"/g,'&quot;')}" style="flex:1"></div>`; }
async function pageEdit(slug){
let p={slug:'',title:'',body:'',published:true,seo_title:'',seo_description:''};
if(slug){ const d=await api('/pages/'+encodeURIComponent(slug)); if(d.page) p=d.page; }
$('#pageEd').innerHTML=`<div class="card">
${pgFld('Title','pgTitle',p.title)}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">Slug</span><input id="pgSlug" value="${escapeH(p.slug)}" ${slug?'readonly':''} placeholder="about" style="flex:1"><span class=muted style="font-size:11px;margin-left:6px">/shop/page/<b id="pgSlugPrev">${escapeH(p.slug||'…')}</b></span></div>
<div style="margin-bottom:6px"><div class=muted style="margin-bottom:3px">Body (HTML)</div><textarea id="pgBody" style="width:100%;min-height:200px;padding:8px;border:1px solid var(--line);border-radius:6px;font:13px ui-monospace,monospace">${escapeH(p.body)}</textarea></div>
${pgFld('SEO title','pgSeoT',p.seo_title)}${pgFld('SEO desc','pgSeoD',p.seo_description)}
<div class="bar" style="margin-top:6px"><label class=muted style="display:flex;align-items:center;gap:5px"><input type=checkbox id="pgPub" ${p.published?'checked':''}> published</label>
<span style="flex:1"></span><button onclick="pageSave('${slug}')">Save</button>
${p.slug?`<a class="ghost" href="/shop/page/${escapeH(p.slug)}" target="_blank" style="padding:8px 12px">View ↗</a>`:''}
${slug?`<button class="ghost" style="color:#e66" onclick="pageDelete('${escapeH(slug)}')">Delete</button>`:''}</div>
<div id="pgMsg" class="muted" style="margin-top:6px"></div></div>`;
const sl=$('#pgSlug'); if(sl && !slug) sl.addEventListener('input',()=>{$('#pgSlugPrev').textContent=sl.value.toLowerCase().replace(/[^a-z0-9-]+/g,'-')||'…';});
}
async function pageSave(slug){
const body={slug: slug || $('#pgSlug').value, title:$('#pgTitle').value, body:$('#pgBody').value, published:$('#pgPub').checked, seo_title:$('#pgSeoT').value||null, seo_description:$('#pgSeoD').value||null};
if(!body.slug && !body.title){ $('#pgMsg').textContent='need a title or slug'; return; }
const r=await fetch('/admin/pages',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json());
if(r.ok) vPages(); else $('#pgMsg').textContent='save failed';
}
async function pageDelete(slug){
if(!confirm('Delete page /'+slug+'?')) return;
await fetch('/admin/pages/'+encodeURIComponent(slug),{method:'DELETE',headers:hdr()});
vPages();
}
// ---------- Discogs marketplace cockpit ----------
async function vDiscogs(){
$('#content').innerHTML='<h2>Discogs marketplace <span class="muted" style="font-size:13px">· your listings + orders, synced cross-channel (sell anywhere → off everywhere)</span></h2><div id="dgBody"><div class="muted">connecting to Discogs…</div></div>';
const s=await api('/discogs');
if(!s.ok){ $('#dgBody').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(s.reason||'Discogs not connected')+' — add your token in Connections</div>'; return; }
$('#dgBody').innerHTML=`<div class="kpis">${kpi(escapeH(s.seller),'seller')}${kpi(s.listings==null?'—':s.listings,'listings for sale')}${kpi(s.orders==null?'—':s.orders,'orders')}</div>
<div class="bar" style="margin:10px 0"><button onclick="dgSync()">↺ Sync orders</button><span id="dgSyncMsg" class="muted" style="margin-left:10px"></span></div>
<div class="card"><h3>Recent orders</h3><div id="dgOrders" class="muted">loading…</div></div>
<div class="card soon" style="margin-top:12px">To list a record: open it in <b>Inventory</b><b>💿 List</b>. Selling it anywhere — POS, web checkout, or Discogs — auto-delists it everywhere (no overselling).</div>`;
loadDgOrders();
}
async function loadDgOrders(){
const el=$('#dgOrders'); if(!el) return;
const d=await api('/discogs/orders');
if(!d.ok){ el.innerHTML='<span class=muted>'+escapeH(d.reason||'')+'</span>'; return; }
el.innerHTML = (d.orders||[]).length ? '<table><thead><tr><th>Order</th><th>Status</th><th>Buyer</th><th>Items</th><th>Total</th></tr></thead><tbody>'
+ d.orders.map(o=>`<tr><td>${escapeH(o.id)}</td><td>${escapeH(o.status||'')}</td><td>${escapeH(o.buyer||'')}</td><td>${o.items}</td><td class="price">${money(o.total)}</td></tr>`).join('')+'</tbody></table>'
: '<span class=muted>no orders yet</span>';
}
async function dgSync(){
$('#dgSyncMsg').textContent='syncing…';
const r=await fetch('/admin/discogs/sync',{method:'POST',headers:hdr()}).then(x=>x.json());
$('#dgSyncMsg').textContent = r.ok ? `✓ imported ${r.count||0} order(s)` : ('failed: '+escapeH(r.reason||''));
if(r.ok) setTimeout(vDiscogs,800);
}
async function dgListItem(sku){
const m=$('#dgItemMsg'); if(m) m.textContent='listing on Discogs…';
const r=await fetch('/admin/discogs/list/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({status:'For Sale'})}).then(x=>x.json());
if(m) m.innerHTML = r.ok ? '✓ listed #'+r.listing_id : '<span style="color:var(--warn)">'+escapeH(r.error||'failed')+'</span>';
}
async function dgDelistItem(sku){
const m=$('#dgItemMsg'); if(m) m.textContent='delisting…';
const r=await fetch('/admin/discogs/list/'+encodeURIComponent(sku),{method:'DELETE',headers:hdr()}).then(x=>x.json());
if(m) m.textContent = r.ok ? '✓ delisted' : 'failed';
}
// ---------- Connections ----------
@ -568,7 +745,7 @@ async function testConn(svc){
// ---------- Intake ----------
const CONDS = ['M','NM','VG+','VG','G+','G','F','P'];
const condSel = (sel,cls) => `<select class="${cls||''}">`+CONDS.map(c=>`<option${c===(sel||'VG+')?' selected':''}>${c}</option>`).join('')+'</select>';
const condSel = (sel,cls,attrs) => `<select class="${cls||''}" ${attrs||''}>`+CONDS.map(c=>`<option${c===(sel||'VG+')?' selected':''}>${c}</option>`).join('')+'</select>';
let inkTab = 'find';
function vIntake(){
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
@ -624,12 +801,25 @@ function inkCard(it,ref){
<div class="muted" style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.artist||'')}</div>
<div class="muted" style="font-size:11px">${[it.year,it.country,it.format].filter(Boolean).map(escapeH).join(' · ')}${it.in_stock?' · <span style="color:var(--ok)">in stock</span>':''}</div>
<div style="display:flex;gap:6px;margin-top:8px;align-items:center">
${condSel(it.media,'ik_c')}
<input class="ik_p" type="number" step="0.01" placeholder="$" style="width:70px">
${condSel(it.media,'ik_c',`onchange="inkSuggest(this,${it.release_id})"`)}
<input class="ik_p" type="number" step="0.01" placeholder="$" style="width:70px" oninput="this.dataset.t=1" onfocus="if(!this.value)inkSuggest(this.closest('.card').querySelector('.ik_c'),${it.release_id})">
<button onclick="inkStage(${ref?`window.${ref}.release_id`:it.release_id},this)" style="padding:5px 12px">Stage</button>
</div>
<div class="ik_sugg muted" style="font-size:11px;margin-top:5px"></div>
</div></div>`;
}
// Condition dropdown → auto-fill the suggested price (DealGod value → release_market when live).
async function inkSuggest(sel, rid){
const card = sel.closest('.card'); if(!card || !rid) return;
const p = card.querySelector('.ik_p'), hint = card.querySelector('.ik_sugg');
if(hint) hint.textContent = '…';
try{
const d = await (await fetch(`/admin/intake/suggest?release_id=${rid}&condition=${encodeURIComponent(sel.value)}`,{headers:hdr()})).json();
if(!d.ok){ if(hint) hint.textContent = 'no market price yet'; return; }
if(p && (!p.value || p.dataset.t!=='1')) p.value = d.suggested; // fill unless operator typed
if(hint) hint.innerHTML = `💡 suggested <b>$${d.suggested}</b> · ${sel.value} · range $${d.low??'?'}$${d.high??'?'} <span style="opacity:.55">(${(d.source||[]).includes('lore')?'lore':'market'})</span>`;
}catch(e){ if(hint) hint.textContent = ''; }
}
async function inkStage(rid, btn){
const row=btn.parentElement, cond=row.querySelector('.ik_c').value, price=parseFloat(row.querySelector('.ik_p').value)||null;
btn.disabled=true; btn.textContent='…';
@ -1000,6 +1190,109 @@ async function mapFind(){
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==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// ---------- skin / vertical pack ----------
function brandHtml(brand){ brand=brand||'StoreGod'; return /God$/.test(brand) ? `${escapeH(brand.slice(0,-3))}<b>God</b>` : escapeH(brand); }
function applyStore(s){ // StoreGod base + unlocked modules drive brand + which specialist nav shows
s = s || {brand:'StoreGod', modules:[]};
document.querySelectorAll('.brand').forEach(el=>el.innerHTML=brandHtml(s.brand||'StoreGod'));
document.title = (s.brand||'StoreGod')+' · admin';
const mods = s.modules || [];
document.querySelectorAll('[data-module]').forEach(el=>el.style.display = mods.includes(el.dataset.module) ? '' : 'none');
document.querySelectorAll('[data-generic]').forEach(el=>el.style.display = ''); // Identify = StoreGod base, always on
}
let idCands=[];
function vIdentify(){
const scope=(window.ME||{}).scope||[];
$('#content').innerHTML = `<h2>Identify &amp; add <span class="muted" style="font-size:13px">· ${escapeH(scope.join(', ')||'any')} · via DealGod</span></h2>
<div class="bar"><input id="idQ" placeholder="barcode / ISBN / type a title…" style="flex:1"><button onclick="idRun()">🔎 Identify</button></div>
<div id="idOut" style="margin-top:10px"><div class="card soon">scan or type — DealGod identifies it, you stage it</div></div>`;
$('#idQ').addEventListener('keydown',e=>{ if(e.key==='Enter') idRun(); });
$('#idQ').focus();
}
async function idRun(){
const q=$('#idQ').value.trim(); if(!q) return;
const out=$('#idOut'); out.innerHTML='<div class=muted>asking DealGod…</div>';
const digits=q.replace(/\D/g,'');
const clue = /^[0-9\s-]{8,}$/.test(q) ? (digits.length>=12?{barcode:digits}:{isbn:digits}) : {text:q};
let r; try{ r=await fetch('/admin/identify',{method:'POST',headers:hdr(),body:JSON.stringify({clues:clue})}).then(x=>x.json()); }
catch(e){ out.innerHTML='<div class="card muted">lookup failed</div>'; return; }
idCands=r.candidates||[];
if(!idCands.length){ out.innerHTML='<div class="card muted">No match — '+escapeH(r.reason||'the brain may not cover this category yet')+'.</div>'; return; }
out.innerHTML = idCands.slice(0,6).map((c,i)=>`<div class="card" style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div><b>${escapeH(c.display_name||('#'+c.ref_id))}</b> <span class=muted>· ${escapeH(c.category||'')} · ${Math.round((c.confidence||0)*100)}%</span>
${(c.id_keys||[]).length?`<div class="muted" style="font-size:12px">look for: ${escapeH((c.id_keys||[]).slice(0,3).join(', '))}</div>`:''}</div>
<button onclick="idAdd(${i})">+ Add</button></div>`).join('');
}
async function idAdd(i){
const c=idCands[i]; if(!c) return;
const KMAP={books:'book',comics:'comic',games:'game',records:'vinyl'};
const mods=(window.ME||{}).modules||[];
const body={ ref_type:c.ref_type, ref_id:c.ref_id, title:c.display_name, kind:(mods.length===1?(KMAP[mods[0]]||'other'):'other') };
const r=await fetch('/admin/intake/identify',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json()).catch(()=>({}));
$('#idOut').innerHTML = r.ok ? '<div class="card" style="color:var(--ok)">✓ staged <b>'+escapeH(r.title||r.sku)+'</b> — find it in Inventory (staged).</div>' : '<div class="card" style="color:var(--warn)">add failed</div>';
}
// ---------- Price guide (tools module — value + variants + faults + availability) ----------
function vPriceguide(){
$('#content').innerHTML = `<h2>Price guide <span class="muted" style="font-size:13px">· what it's worth + what to watch for · via DealGod</span></h2>
<div class="bar"><input id="pgQ" placeholder="model / barcode — e.g. Makita DHP484…" style="flex:1"><button onclick="pgRun()">🔧 Look up</button></div>
<div id="pgOut" style="margin-top:10px"><div class="card soon">look up any item — value, variants, common faults, where else it's stocked</div></div>`;
$('#pgQ').addEventListener('keydown',e=>{ if(e.key==='Enter') pgRun(); });
$('#pgQ').focus();
}
async function pgRun(){
const q=$('#pgQ').value.trim(); if(!q) return;
const out=$('#pgOut'); out.innerHTML='<div class=muted>asking DealGod…</div>';
const d=await fetch('/admin/priceguide',{method:'POST',headers:hdr(),body:JSON.stringify({query:q})}).then(x=>x.json()).catch(()=>({}));
if(!d.ok || !d.candidate){ out.innerHTML='<div class="card muted">No match'+(d.reason?' — '+escapeH(String(d.reason)):'')+'.</div>'; return; }
const c=d.candidate, v=d.value||{}, lo=d.lore||{}, sup=d.supply;
const idk=(c.id_keys&&c.id_keys.length?c.id_keys:lo.id_keys)||[];
let h=`<div class="card"><div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px">
<h3 style="margin:0">${escapeH(c.display_name||('#'+c.ref_id))}</h3>
<div style="text-align:right">${v.typ!=null?`<div style="font-size:22px"><b>${money(v.typ)}</b></div><div class=muted style="font-size:12px">${money(v.low)}${money(v.high)} ${escapeH(v.currency||'')}${v.as_of?` · ${escapeH(v.as_of)}`:''}${v.n?` · n=${v.n}`:''}</div>`:'<span class=muted>no value yet</span>'}</div></div>
<div class=muted style="font-size:12px;margin:2px 0 8px">${escapeH(c.category||'')}${lo.tier?` · tier <b>${escapeH(lo.tier)}</b>`:''}${v.source?` · ${escapeH((v.source||[]).join('+'))}`:''}${sup?` · ${sup.store_count||0} stores / ${sup.discogs_seller_count||sup.sellers||0} sellers`:''}</div>`;
if(lo.description) h+=`<div style="font-size:13px;margin-bottom:8px">${escapeH(lo.description)}</div>`;
if((lo.variants||[]).length) h+=`<div style="margin-bottom:6px"><b style="font-size:13px">Variants</b><div class=muted style="font-size:12px">${lo.variants.map(x=>escapeH((x.sku?x.sku+' — ':'')+(x.note||''))).join(' · ')}</div></div>`;
if((lo.faults||[]).length) h+=`<div style="margin-bottom:6px"><b style="font-size:13px;color:var(--warn)">Common faults</b><div class=muted style="font-size:12px">${escapeH(lo.faults.join('; '))}</div></div>`;
if(idk.length) h+=`<div><b style="font-size:13px">Spot it</b><div class=muted style="font-size:12px">${escapeH(idk.join(' · '))}</div></div>`;
h+='</div>';
if((d.others||[]).length) h+=`<div class=muted style="font-size:12px;margin-top:6px">also: ${d.others.map(o=>escapeH(o.display_name||('#'+o.ref_id))).join(' · ')}</div>`;
out.innerHTML=h;
}
// ---------- Melt calculator (jewellery module) ----------
let meltT;
function vMelt(){
$('#content').innerHTML = `<h2>Melt calculator <span class="muted" style="font-size:13px">· live AU spot · the floor you never sell below</span></h2>
<div class="card" style="max-width:480px">
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Metal</span>
<select id="mMetal" onchange="meltCalc()"><option value="gold">Gold</option><option value="silver">Silver</option><option value="platinum">Platinum</option></select></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Purity</span>
<input id="mKarat" type="number" step="0.5" value="18" oninput="meltCalc()" style="width:110px">
<span class="muted" style="margin-left:8px;font-size:12px">karat for gold (9/14/18/22/24) · fineness for silver/plat (925, 950)</span></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Weight (g)</span><input id="mGrams" type="number" step="0.01" value="0" oninput="meltCalc()" style="width:110px"></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Asking $</span><input id="mAsk" type="number" step="0.01" placeholder="optional — is it below melt?" oninput="meltCalc()" style="width:200px"></div>
<div id="meltOut" style="margin-top:6px"></div>
</div>`;
meltCalc();
}
function meltCalc(){
clearTimeout(meltT); meltT=setTimeout(async()=>{
const m=$('#mMetal').value, k=parseFloat($('#mKarat').value)||0, g=parseFloat($('#mGrams').value)||0;
const d=await api(`/melt?metal=${m}&karat=${k}&grams=${g}`);
const o=$('#meltOut'); if(!o) return;
if(!d.ok){ o.innerHTML='<span class=muted>'+escapeH(d.reason||'')+'</span>'; return; }
let h=`<div style="font-size:24px"><b>${money(d.melt)}</b> <span class=muted style="font-size:13px">melt floor</span></div>
<div class=muted style="font-size:12px">${money(d.per_gram)}/g · ${escapeH(m)} ${k} (${Math.round(d.purity*100)}%) · spot ${escapeH((d.captured_at||'').slice(0,10))}</div>`;
const ask=parseFloat($('#mAsk').value);
if(!isNaN(ask)){ const diff=ask-d.melt;
h += diff<0
? `<div style="margin-top:8px"><b style="color:var(--warn)">⚠ ${money(ask)} is ${money(-diff)} BELOW melt</b> — scrap/buy signal</div>`
: `<div style="margin-top:8px"><b style="color:var(--ok)">${money(ask)} is ${money(diff)} above melt</b></div>`; }
o.innerHTML=h;
},150);
}
function signOut(){ localStorage.removeItem('rg_token'); localStorage.removeItem('rg_name'); localStorage.removeItem('rg_role'); location.replace('/login'); }
async function changeMyPassword(){
const np = prompt('New password (min 6 characters):'); if(np===null) return;
@ -1008,6 +1301,7 @@ async function changeMyPassword(){
const r = await fetch('/auth/change-password',{method:'POST',headers:hdr(),body:JSON.stringify({current_password:cur,new_password:np})});
alert(r.ok ? 'Password updated.' : 'Failed: ' + ((await r.json().catch(()=>({}))).detail || r.status));
}
fetch('/pack').then(r=>r.json()).then(applyStore).catch(()=>{}); // brand the login/gate before auth
if(TOKEN){ signin(); } else { location.replace('/login'); }
</script>
</body>