diff --git a/app/intake_routes.py b/app/intake_routes.py
index 31bcf89..0325ba3 100644
--- a/app/intake_routes.py
+++ b/app/intake_routes.py
@@ -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")
diff --git a/site/admin.html b/site/admin.html
index 4f63014..fd24fdc 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -55,7 +55,7 @@
-
RecordGod
+
RecordGod
admin sign-in
@@ -68,18 +68,24 @@
📦 Inventory
🛒 Orders
👤 Customers
-
📝 Wantlist
+
📝 Wantlist
Catalog
-
📥 Intake
-
📦 New stock
-
🎯 Buy list
-
🩹 Heal
+
🔎 Identify
+
💰 Melt
+
🔧 Price guide
+
📥 Intake
+
📦 New stock
+
🎯 Buy list
+
💿 Discogs
+
🩹 Heal
🗺️ Store map
🗄️ Crates
📈 Reports
System
🧑💼 Staff
🔌 Connections
+
⚙️ Settings
+
📄 Pages
🖥️ System
🎧 Virtual store ↗
🔑 Change password
@@ -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){
Margin—
New / used
${fld('Target','eTarget',r.target,'type=number step=0.01')}
+
+ ${r.release_id?`
Discogs
`:''}
${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=`
${money(d)} (${pct}%)`;
}
+async function dgValue(sku){
+ const el=$('#dgVal'); if(!el) return;
+ el.innerHTML='
checking DealGod…';
+ try{
+ const d=await api('/value/'+encodeURIComponent(sku));
+ if(!d.ok){ el.innerHTML='
'+escapeH(d.reason||'unavailable')+''; return; }
+ const v=d.value;
+ if(!v){ el.innerHTML='
'+escapeH(d.note||'no value')+''; 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?'
· identify-only':'';
+ let html=`💎
${money(v.typ)} (${money(v.low)}–${money(v.high)} ${escapeH(v.currency||'')}${fresh}${sup})${ready} `
+ +`
`;
+ const lo=d.lore;
+ if(lo && (lo.tier || (lo.faults||[]).length)){
+ html+=`
${lo.tier?`tier ${escapeH(lo.tier)}`:''}`
+ +`${(lo.faults||[]).length?` · watch: ${escapeH((lo.faults||[]).slice(0,2).join('; '))}`:''}
`;
+ }
+ el.innerHTML=html;
+ }catch(e){ el.innerHTML='
lookup failed'; }
+}
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(){
+ `
| Last sale | ${ago(f.last_sale)} | ${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'—'} |
`
+ `
| Inventory last updated | ${ago(f.inventory_updated)} | ${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'—'} |
`
+ '
'
- + '
Largest tables
| Table | Size | Rows (est.) |
'+(rows||'| — |
')+'
';
+ + '
Largest tables
| Table | Size | Rows (est.) |
'+(rows||'| — |
')+'
'
+ + '
Slow queries
slowest SQL by mean time · pg_stat_statements
';
+ loadSlowQueries();
+}
+async function loadSlowQueries(){
+ const el=$('#sqOut'); if(!el) return;
+ const d=await api('/slow-queries');
+ if(!d.ok){ el.innerHTML='
'+escapeH(d.reason||'unavailable')+'
'; return; }
+ if(!(d.queries||[]).length){ el.innerHTML='
no stats yet — they accumulate as the app runs
'; return; }
+ el.innerHTML='
| Query | Calls | Mean ms | Max ms | Total ms |
'
+ + d.queries.map(q=>`| ${escapeH(q.query)} | `
+ +`${Number(q.calls).toLocaleString()} | ${q.mean_ms} | ${q.max_ms} | ${Number(q.total_ms).toLocaleString()} |
`).join('')
+ + '
';
+}
+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)=>`
${label}
`;
+ const ta=(label,key)=>`
`;
+ $('#content').innerHTML=`
Store settings · the WordPress General/Reading replacement
+
+
Identity & contact
+ ${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')}
+
Tax & currency
+ ${f('Currency symbol','currency_symbol')}${f('GST rate %','tax_rate','type=number step=0.1')}
+
Tax type
+
Homepage & SEO
+
Homepage
+ ${f('SEO title','seo_title')}${ta('SEO description','seo_description')}
+
Analytics & tracking
+ ${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"')}
+
Policies
+ ${ta('Returns','policy_returns')}${ta('Shipping','policy_shipping')}${ta('Privacy','policy_privacy')}${f('Receipt footer','receipt_footer')}
+
+
`;
+ 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=>`
${escapeH(p.title||p.slug)} /${escapeH(p.slug)}${p.published?'':' draft'}
${p.body_len} ch`).join('');
+ $('#content').innerHTML=`
Pages · About / Shipping / Returns / Privacy — the WordPress Pages replacement
+
+
+
+
pick a page or create one
+
🔀 URL redirects · 301s so old WowPlatter links don't break at cutover
+
paste one per line — /old/path , /new/path
+
+
+
`;
+ loadRedirects();
+}
+async function loadRedirects(){
+ const el=$('#rdList'); if(!el) return;
+ const d=await api('/redirects'); const rs=d.redirects||[];
+ el.innerHTML = rs.length ? '
| From | → To | Code | Hits | |
'
+ + rs.map(r=>`| ${escapeH(r.from_path)} | ${escapeH(r.to_path)} | ${r.code} | ${r.hits} | ✕ |
`).join('')
+ + '
' : '
no redirects yet
';
+}
+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 `
${l}
`; }
+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=`
+ ${pgFld('Title','pgTitle',p.title)}
+
Slug/shop/page/${escapeH(p.slug||'…')}
+
+ ${pgFld('SEO title','pgSeoT',p.seo_title)}${pgFld('SEO desc','pgSeoD',p.seo_description)}
+
+
+ ${p.slug?`
View ↗`:''}
+ ${slug?`
`:''}
+
`;
+ 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='
Discogs marketplace · your listings + orders, synced cross-channel (sell anywhere → off everywhere)
';
+ const s=await api('/discogs');
+ if(!s.ok){ $('#dgBody').innerHTML='
'+escapeH(s.reason||'Discogs not connected')+' — add your token in Connections
'; return; }
+ $('#dgBody').innerHTML=`
${kpi(escapeH(s.seller),'seller')}${kpi(s.listings==null?'—':s.listings,'listings for sale')}${kpi(s.orders==null?'—':s.orders,'orders')}
+
+
+
To list a record: open it in Inventory → 💿 List. Selling it anywhere — POS, web checkout, or Discogs — auto-delists it everywhere (no overselling).
`;
+ loadDgOrders();
+}
+async function loadDgOrders(){
+ const el=$('#dgOrders'); if(!el) return;
+ const d=await api('/discogs/orders');
+ if(!d.ok){ el.innerHTML='
'+escapeH(d.reason||'')+''; return; }
+ el.innerHTML = (d.orders||[]).length ? '
| Order | Status | Buyer | Items | Total |
'
+ + d.orders.map(o=>`| ${escapeH(o.id)} | ${escapeH(o.status||'')} | ${escapeH(o.buyer||'')} | ${o.items} | ${money(o.total)} |
`).join('')+'
'
+ : '
no orders yet';
+}
+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 : '
'+escapeH(r.error||'failed')+'';
+}
+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) => `
';
+const condSel = (sel,cls,attrs) => `
';
let inkTab = 'find';
function vIntake(){
$('#content').innerHTML = '
Intake · stage new stock
'
@@ -624,12 +801,25 @@ function inkCard(it,ref){
${escapeH(it.artist||'')}
${[it.year,it.country,it.format].filter(Boolean).map(escapeH).join(' · ')}${it.in_stock?' · in stock':''}
- ${condSel(it.media,'ik_c')}
-
+ ${condSel(it.media,'ik_c',`onchange="inkSuggest(this,${it.release_id})"`)}
+
+
`;
}
+// 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 $${d.suggested} · ${sel.value} · range $${d.low??'?'}–$${d.high??'?'} (${(d.source||[]).includes('lore')?'lore':'market'})`;
+ }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 = `${title}
${body}
Coming next.
`; }; }
function escapeH(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+// ---------- skin / vertical pack ----------
+function brandHtml(brand){ brand=brand||'StoreGod'; return /God$/.test(brand) ? `${escapeH(brand.slice(0,-3))}God` : 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 = `Identify & add · ${escapeH(scope.join(', ')||'any')} · via DealGod
+
+ scan or type — DealGod identifies it, you stage it
`;
+ $('#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='asking DealGod…
';
+ 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='lookup failed
'; return; }
+ idCands=r.candidates||[];
+ if(!idCands.length){ out.innerHTML='No match — '+escapeH(r.reason||'the brain may not cover this category yet')+'.
'; return; }
+ out.innerHTML = idCands.slice(0,6).map((c,i)=>`
+
${escapeH(c.display_name||('#'+c.ref_id))} · ${escapeH(c.category||'')} · ${Math.round((c.confidence||0)*100)}%
+ ${(c.id_keys||[]).length?`
look for: ${escapeH((c.id_keys||[]).slice(0,3).join(', '))}
`:''}
+
`).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 ? '✓ staged '+escapeH(r.title||r.sku)+' — find it in Inventory (staged).
' : 'add failed
';
+}
+
+// ---------- Price guide (tools module — value + variants + faults + availability) ----------
+function vPriceguide(){
+ $('#content').innerHTML = `Price guide · what it's worth + what to watch for · via DealGod
+
+ look up any item — value, variants, common faults, where else it's stocked
`;
+ $('#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='asking DealGod…
';
+ 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='No match'+(d.reason?' — '+escapeH(String(d.reason)):'')+'.
'; 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=`
+
${escapeH(c.display_name||('#'+c.ref_id))}
+
${v.typ!=null?`
${money(v.typ)}
${money(v.low)}–${money(v.high)} ${escapeH(v.currency||'')}${v.as_of?` · ${escapeH(v.as_of)}`:''}${v.n?` · n=${v.n}`:''}
`:'
no value yet'}
+
${escapeH(c.category||'')}${lo.tier?` · tier ${escapeH(lo.tier)}`:''}${v.source?` · ${escapeH((v.source||[]).join('+'))}`:''}${sup?` · ${sup.store_count||0} stores / ${sup.discogs_seller_count||sup.sellers||0} sellers`:''}
`;
+ if(lo.description) h+=`
${escapeH(lo.description)}
`;
+ if((lo.variants||[]).length) h+=`
Variants${lo.variants.map(x=>escapeH((x.sku?x.sku+' — ':'')+(x.note||''))).join(' · ')}
`;
+ if((lo.faults||[]).length) h+=`
Common faults${escapeH(lo.faults.join('; '))}
`;
+ if(idk.length) h+=`
Spot it${escapeH(idk.join(' · '))}
`;
+ h+='
';
+ if((d.others||[]).length) h+=`also: ${d.others.map(o=>escapeH(o.display_name||('#'+o.ref_id))).join(' · ')}
`;
+ out.innerHTML=h;
+}
+
+// ---------- Melt calculator (jewellery module) ----------
+let meltT;
+function vMelt(){
+ $('#content').innerHTML = `Melt calculator · live AU spot · the floor you never sell below
+
+
Metal
+
+
Purity
+
+ karat for gold (9/14/18/22/24) · fineness for silver/plat (925, 950)
+
Weight (g)
+
Asking $
+
+
`;
+ 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=''+escapeH(d.reason||'')+''; return; }
+ let h=`${money(d.melt)} melt floor
+ ${money(d.per_gram)}/g · ${escapeH(m)} ${k} (${Math.round(d.purity*100)}%) · spot ${escapeH((d.captured_at||'').slice(0,10))}
`;
+ const ask=parseFloat($('#mAsk').value);
+ if(!isNaN(ask)){ const diff=ask-d.melt;
+ h += diff<0
+ ? `⚠ ${money(ask)} is ${money(-diff)} BELOW melt — scrap/buy signal
`
+ : `${money(ask)} is ${money(diff)} above melt
`; }
+ 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'); }