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:
parent
ca84b7f82f
commit
c44e6643db
@ -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")
|
||||
|
||||
316
site/admin.html
316
site/admin.html
@ -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,'"')}" 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 & 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 & 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 & 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 & 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 /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,'"')}" 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 & 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user