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

'+(rows||'')+'
TableSizeRows (est.)
'; + + '

Largest tables

'+(rows||'')+'
TableSizeRows (est.)
' + + '

Slow queries

slowest SQL by mean time · pg_stat_statements
loading…
'; + 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='' + + d.queries.map(q=>`` + +``).join('') + + '
QueryCallsMean msMax msTotal ms
${escapeH(q.query)}${Number(q.calls).toLocaleString()}${q.mean_ms}${q.max_ms}${Number(q.total_ms).toLocaleString()}
'; +} +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)=>`
${label}
`; + $('#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

+
+
+
${list||'
no pages yet
'}
+
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 ? '' + + rs.map(r=>``).join('') + + '
From→ ToCodeHits
${escapeH(r.from_path)}${escapeH(r.to_path)}${r.code}${r.hits}
' : '
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||'…')}
+
Body (HTML)
+ ${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)

connecting to Discogs…
'; + 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')}
+
+

Recent orders

loading…
+
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 ? '' + + d.orders.map(o=>``).join('')+'
OrderStatusBuyerItemsTotal
${escapeH(o.id)}${escapeH(o.status||'')}${escapeH(o.buyer||'')}${o.items}${money(o.total)}
' + : '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'); }