feat(shop): P4 wantlist — public 'request a record' page + admin queue

Storefront /wantlist form (themed, email-keyed, guest-friendly) posts to
/shop/wantlist → wantlist table (lazy DDL). Release pages deep-link to it
(prefilled; 'not in stock — request this record' when no copies). Admin gets
a Wantlist tab: pending/found/fulfilled queue with one-click status actions.
Default storefront menu → Records + Wanted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 01:26:16 +10:00
parent 28a46615a5
commit 48f8579bff
7 changed files with 197 additions and 6 deletions

View File

@ -275,6 +275,40 @@ async def customer_edit(cid: int, body: CustEditIn, ident=Depends(require_token)
return {"ok": True, "updated": r.rowcount} return {"ok": True, "updated": r.rowcount}
@router.get("/wantlist")
async def wantlist_list(status: str = Query("pending"), ident=Depends(require_token), db=Depends(get_db)):
"""Customer record-requests captured by the public storefront /wantlist form."""
where, params = "", {}
if status and status != "all":
where = "WHERE w.status = :s"; params = {"s": status}
rows = await db.execute(text(f"""
SELECT w.id, w.release_id, w.artist, w.title, w.format, w.max_price::float AS max_price,
w.name, w.email, w.phone, w.delivery_preference, w.postcode, w.notes, w.status,
w.created_at, w.actioned_at
FROM wantlist w {where} ORDER BY w.created_at DESC LIMIT 500
"""), params)
counts = {r["status"]: r["n"] for r in (await db.execute(text(
"SELECT status, count(*) AS n FROM wantlist GROUP BY status"))).mappings()}
return {"requests": [dict(r) for r in rows.mappings()], "counts": counts}
class WantStatusIn(BaseModel):
status: str # pending | found | fulfilled | cancelled
@router.post("/wantlist/{wid}")
async def wantlist_action(wid: int, body: WantStatusIn, ident=Depends(require_token), db=Depends(get_db)):
if body.status not in ("pending", "found", "fulfilled", "cancelled"):
raise HTTPException(422, "bad status")
actioned = "now()" if body.status != "pending" else "NULL"
r = await db.execute(text(
f"UPDATE wantlist SET status=:s, actioned_at={actioned} WHERE id=:i"), {"s": body.status, "i": wid})
await db.commit()
if not r.rowcount:
raise HTTPException(404, "not found")
return {"ok": True}
@router.get("/crates") @router.get("/crates")
async def crates(ident=Depends(require_token), db=Depends(get_db)): async def crates(ident=Depends(require_token), db=Depends(get_db)):
rows = await db.execute(text(""" rows = await db.execute(text("""

View File

@ -19,9 +19,7 @@ DEFAULTS = {
"theme": {"primary": "#ff5db1", "accent": "#46d18a", "bg": "#0c0c0e", "theme": {"primary": "#ff5db1", "accent": "#46d18a", "bg": "#0c0c0e",
"panel": "#141418", "text": "#f0f0f2", "font": "system-ui", "logo": "", "panel": "#141418", "text": "#f0f0f2", "font": "system-ui", "logo": "",
"radius": 12, "cardCols": 4}, "radius": 12, "cardCols": 4},
"menu": [{"label": "Home", "href": "/"}, {"label": "Vinyl", "href": "/vinyl"}, "menu": [{"label": "Records", "href": "/records"}, {"label": "Wanted", "href": "/wantlist"}],
{"label": "Genres", "href": "/genres"}, {"label": "New in", "href": "/new"},
{"label": "Cart", "href": "/cart"}],
"card": ["cover", "title", "artist", "price", "condition", "cart"], "card": ["cover", "title", "artist", "price", "condition", "cart"],
"product_page": ["cover", "title", "artist", "price", "condition", "genre", "product_page": ["cover", "title", "artist", "price", "condition", "genre",
"tracklist", "cart", "related"], "tracklist", "cart", "related"],

View File

@ -43,6 +43,9 @@ app.include_router(disc_images_router)
_STARTUP_DDL = [ _STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())", "CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# public 'request a record we don't stock' intake (storefront wantlist; email-keyed, guest or customer)
"CREATE TABLE IF NOT EXISTS wantlist (id bigserial PRIMARY KEY, release_id int, artist text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '', name text, phone text, email text NOT NULL, format text, max_price numeric(10,2), delivery_preference text DEFAULT 'either', postcode text, notes text, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), actioned_at timestamptz)",
"CREATE INDEX IF NOT EXISTS idx_wantlist_status ON wantlist (status, created_at DESC)",
# POS reference tables (populated by migrate.py; created here so prod has them on deploy) # POS reference tables (populated by migrate.py; created here so prod has them on deploy)
"CREATE TABLE IF NOT EXISTS customer (id bigint PRIMARY KEY, wp_user_id bigint, first_name text NOT NULL DEFAULT '', last_name text DEFAULT '', email text, phone text, address text, is_guest boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now())", "CREATE TABLE IF NOT EXISTS customer (id bigint PRIMARY KEY, wp_user_id bigint, first_name text NOT NULL DEFAULT '', last_name text DEFAULT '', email text, phone text, address text, is_guest boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now())",
"CREATE SEQUENCE IF NOT EXISTS customer_id_seq", "CREATE SEQUENCE IF NOT EXISTS customer_id_seq",
@ -103,7 +106,7 @@ def _page(filename):
return _serve return _serve
for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records"): for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist"):
app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False) app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False)
# public storefront release detail — release.html reads the id from the path # public storefront release detail — release.html reads the id from the path
app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False) app.add_api_route("/release/{release_id}", _page("release.html"), methods=["GET"], include_in_schema=False)

View File

@ -1,6 +1,7 @@
import json import json
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from .db import get_db from .db import get_db
@ -139,3 +140,32 @@ async def shop_release(release_id: int, db=Depends(get_db)):
FROM inventory WHERE release_id=:r AND in_stock AND store_id=1 ORDER BY price FROM inventory WHERE release_id=:r AND in_stock AND store_id=1 ORDER BY price
"""), {"r": release_id})).mappings()] """), {"r": release_id})).mappings()]
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies} return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
class WantIn(BaseModel):
email: str
artist: str = ""
title: str = ""
name: str = ""
phone: str = ""
format: str = ""
max_price: float | None = None
delivery_preference: str = "either"
postcode: str = ""
notes: str = ""
release_id: int | None = None
@router.post("/wantlist")
async def shop_wantlist(body: WantIn, db=Depends(get_db)):
"""Public 'request a record we don't stock' intake — no auth, keyed by email (guest or known customer)."""
if "@" not in body.email or not (body.artist.strip() or body.title.strip()):
raise HTTPException(422, "email and an artist or title are required")
pref = body.delivery_preference if body.delivery_preference in ("pickup", "post", "either") else "either"
rid = (await db.execute(text("""
INSERT INTO wantlist (release_id, artist, title, name, phone, email, format, max_price,
delivery_preference, postcode, notes)
VALUES (:release_id, :artist, :title, :name, :phone, :email, :format, :max_price, :pref, :postcode, :notes)
RETURNING id"""), {**body.model_dump(), "pref": pref})).scalar()
await db.commit()
return {"ok": True, "id": rid}

View File

@ -67,6 +67,7 @@
<a data-v="inventory">📦 Inventory</a> <a data-v="inventory">📦 Inventory</a>
<a data-v="orders">🛒 Orders</a> <a data-v="orders">🛒 Orders</a>
<a data-v="customers">👤 Customers</a> <a data-v="customers">👤 Customers</a>
<a data-v="wantlist">📝 Wantlist</a>
<div class="sec">Catalog</div> <div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a> <a data-v="intake">📥 Intake</a>
<a data-v="storemap">🗺️ Store map</a> <a data-v="storemap">🗺️ Store map</a>
@ -99,7 +100,7 @@ async function signin(){
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v)); document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
function go(v){ function go(v){
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v)); document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])(); ({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
} }
// ---------- Dashboard ---------- // ---------- Dashboard ----------
@ -346,6 +347,38 @@ async function custSave(id){
await fetch('/admin/customers/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)}); await fetch('/admin/customers/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)});
const e=$('#cMsg'); if(e){ e.textContent='✓ saved'; setTimeout(()=>{e.textContent='';},1500); } const e=$('#cMsg'); if(e){ e.textContent='✓ saved'; setTimeout(()=>{e.textContent='';},1500); }
} }
// ---------- Wantlist (customer record-requests from the storefront) ----------
let wantState = { status:'pending' };
async function vWantlist(){
$('#content').innerHTML = '<h2>Wantlist</h2><div class="muted">Records customers asked us to find via the storefront request form.</div>'
+ '<div class="bar" id="wantTabs" style="margin:12px 0"></div><div id="wantrows" class="muted">loading…</div>';
loadWant();
}
async function loadWant(){
const d = await api('/wantlist?status='+wantState.status);
const c = d.counts||{};
const tab = (k,lbl)=>`<button class="${wantState.status===k?'':'ghost'}" onclick="wantState.status='${k}';loadWant()">${lbl}${c[k]?` (${c[k]})`:''}</button>`;
$('#wantTabs').innerHTML = tab('pending','Pending')+tab('found','Found')+tab('fulfilled','Fulfilled')+tab('cancelled','Cancelled')+tab('all','All');
const next = { pending:['found','Mark found'], found:['fulfilled','Mark fulfilled'] };
const rows = (d.requests||[]).map(w=>{
const what = (escapeH(w.artist||'')+(w.title?' '+escapeH(w.title):'')) || '<span class=muted></span>';
const meta = [w.format, w.max_price!=null?('≤ '+money(w.max_price)):'', w.delivery_preference, w.postcode].filter(Boolean).map(escapeH).join(' · ');
const act = next[w.status] ? `<button onclick="wantSet(${w.id},'${next[w.status][0]}')">${next[w.status][1]}</button> ` : '';
const link = w.release_id ? ` <a href="/release/${w.release_id}" target="_blank" class="muted"></a>` : '';
return `<tr><td class="muted">${w.created_at?String(w.created_at).slice(0,10):''}</td>
<td><b>${what}</b>${link}${meta?`<div class="muted">${meta}</div>`:''}${w.notes?`<div class="muted">“${escapeH(w.notes)}”</div>`:''}</td>
<td>${escapeH(w.name||'')}<div class="muted">${escapeH(w.email||'')}${w.phone?' · '+escapeH(w.phone):''}</div></td>
<td><span class="pill ${w.status==='pending'?'':w.status==='cancelled'?'out':'in'}">${w.status}</span></td>
<td style="white-space:nowrap">${act}${w.status!=='cancelled'&&w.status!=='fulfilled'?`<button class="ghost" onclick="wantSet(${w.id},'cancelled')"></button>`:''}</td></tr>`;
}).join('');
$('#wantrows').innerHTML = `<table><thead><tr><th>Date</th><th>Wanted</th><th>Customer</th><th>Status</th><th></th></tr></thead>`
+ `<tbody>${rows||'<tr><td colspan=5 class=muted>no requests</td></tr>'}</tbody></table>`;
}
async function wantSet(id, status){
await fetch('/admin/wantlist/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({status})});
loadWant();
}
function invImport(){ function invImport(){
invModalBox(`<h3 style="margin:0 0 6px">Import list (bulk stock-in)</h3> invModalBox(`<h3 style="margin:0 0 6px">Import list (bulk stock-in)</h3>
<div class="muted" style="margin-bottom:8px">One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.</div> <div class="muted" style="margin-bottom:8px">One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.</div>

View File

@ -73,6 +73,7 @@ async function render(){
<div class="meta">${tags.map(t=>`<a class="pill" href="/${t.k}/${encodeURIComponent(t.n)}">${esc(t.n)}</a>`).join('')}</div> <div class="meta">${tags.map(t=>`<a class="pill" href="/${t.k}/${encodeURIComponent(t.n)}">${esc(t.n)}</a>`).join('')}</div>
${show('cart')||show('price')?`<div class="copies">${(d.copies||[]).map(c=>`<div class="copy"> ${show('cart')||show('price')?`<div class="copies">${(d.copies||[]).map(c=>`<div class="copy">
<div class="cd"><div class="pr">${money(c.price)}</div><div class="muted">${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} · ${esc(c.sku)}</div></div>${buy(c)}</div>`).join('')||'<div class="muted">no copies in stock</div>'}</div>`:''} <div class="cd"><div class="pr">${money(c.price)}</div><div class="muted">${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} · ${esc(c.sku)}</div></div>${buy(c)}</div>`).join('')||'<div class="muted">no copies in stock</div>'}</div>`:''}
<div style="margin-top:10px"><a href="/wantlist?artist=${encodeURIComponent(r.artist||'')}&title=${encodeURIComponent(r.title||'')}&format=${encodeURIComponent((r.format||'').split(',')[0]||'')}&release_id=${r.id}" class="muted" style="font-size:13px;text-decoration:underline">${(d.copies||[]).length?"Want a different copy? Request it":"Not in stock — request this record"} →</a></div>
</div> </div>
</div> </div>
${show('tracklist')&&d.tracks.length?`<div class="sec"><h3>Tracklist</h3><table>${d.tracks.map(t=>`<tr><td class="muted" style="width:40px">${esc(t.position||'')}</td><td>${esc(t.title||'')}</td><td class="muted" style="width:50px;text-align:right">${esc(t.duration||'')}</td></tr>`).join('')}</table></div>`:''} ${show('tracklist')&&d.tracks.length?`<div class="sec"><h3>Tracklist</h3><table>${d.tracks.map(t=>`<tr><td class="muted" style="width:40px">${esc(t.position||'')}</td><td>${esc(t.title||'')}</td><td class="muted" style="width:50px;text-align:right">${esc(t.duration||'')}</td></tr>`).join('')}</table></div>`:''}

92
site/wantlist.html Normal file
View File

@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Request a record</title>
<style>
:root{--primary:#ff5db1;--accent:#46d18a;--bg:#0c0c0e;--panel:#141418;--text:#f0f0f2;--mut:#8a8a98;--line:#26262c;--radius:12px;--font:system-ui}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif}
a{color:inherit;text-decoration:none}
header{display:flex;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
.logo{font-weight:800;font-size:20px}.logo b{color:var(--primary)}.logo img{height:30px;vertical-align:middle}
nav.menu{display:flex;gap:16px;flex:1}nav.menu a{color:var(--mut);font-size:14px}nav.menu a:hover{color:var(--text)}
.wrap{max-width:560px;margin:0 auto;padding:30px 22px}
h1{font-size:26px;margin:0 0 6px}.sub{color:var(--mut);margin-bottom:22px;line-height:1.5}
label{display:block;font-size:13px;color:var(--mut);margin:14px 0 5px}
input,select,textarea{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--text);font:inherit}
textarea{min-height:70px;resize:vertical}
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
button{cursor:pointer;border:0;border-radius:9px;font:600 15px var(--font),system-ui;padding:13px 18px;background:var(--primary);color:#10070c;width:100%;margin-top:22px}
.ok{background:var(--panel);border:1px solid var(--accent);border-radius:var(--radius);padding:26px;text-align:center}
.ok h2{color:var(--accent);margin:0 0 8px}
.err{color:#ff6b6b;font-size:13px;margin-top:10px;min-height:16px}
.req:after{content:" *";color:var(--primary)}
</style>
</head>
<body>
<header>
<a class="logo" href="/records" id="logo">Record<b>God</b></a>
<nav class="menu" id="menu"></nav>
</header>
<div class="wrap" id="app">
<h1>Request a record</h1>
<div class="sub">Can't find what you're after? Tell us what you want and we'll hunt it down — we'll email you when it lands.</div>
<form id="f" onsubmit="return submitWant(event)">
<div class="row">
<div><label class="req">Artist</label><input id="artist" required></div>
<div><label>Title</label><input id="title"></div>
</div>
<div class="row">
<div><label>Format</label><input id="format" placeholder="LP, 7&quot;, CD…"></div>
<div><label>Max price (AUD)</label><input id="max_price" type="number" step="0.01" min="0" placeholder="optional"></div>
</div>
<label class="req">Your email</label><input id="email" type="email" required>
<div class="row">
<div><label>Your name</label><input id="name"></div>
<div><label>Phone</label><input id="phone"></div>
</div>
<div class="row">
<div><label>Delivery</label><select id="delivery_preference"><option value="either">Either</option><option value="pickup">Pickup</option><option value="post">Post</option></select></div>
<div><label>Postcode</label><input id="postcode"></div>
</div>
<label>Notes</label><textarea id="notes" placeholder="Pressing, condition, year — anything that helps us find the right copy."></textarea>
<button type="submit" id="btn">Send request</button>
<div class="err" id="err"></div>
</form>
</div>
<script>
const $=s=>document.querySelector(s);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const QP=new URLSearchParams(location.search);
let CFG={};
async function boot(){
CFG=await fetch('/shop/config').then(r=>r.json()).catch(()=>({}));
const t=CFG.theme||{}, R=document.documentElement.style;
for(const [k,v] of Object.entries({'--primary':t.primary,'--accent':t.accent,'--bg':t.bg,'--panel':t.panel,'--text':t.text,'--font':t.font})) if(v) R.setProperty(k,v);
if(t.radius) R.setProperty('--radius',t.radius+'px');
if(t.logo) $('#logo').innerHTML=`<img src="${esc(t.logo)}">`;
$('#menu').innerHTML=(CFG.menu||[]).map(m=>`<a href="${esc(m.href||'#')}">${esc(m.label||'')}</a>`).join('');
// prefill from a release page deep-link (/wantlist?artist=…&title=…&format=…&release_id=…)
['artist','title','format','postcode'].forEach(k=>{ if(QP.get(k)) $('#'+k).value=QP.get(k); });
}
async function submitWant(e){
e.preventDefault();
$('#err').textContent=''; $('#btn').disabled=true; $('#btn').textContent='Sending…';
const body={ release_id: QP.get('release_id')?+QP.get('release_id'):null,
delivery_preference: $('#delivery_preference').value };
['artist','title','format','email','name','phone','postcode','notes'].forEach(k=> body[k]=$('#'+k).value.trim());
const mp=$('#max_price').value; if(mp) body.max_price=+mp;
try{
const r=await fetch('/shop/wantlist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.detail||'Something went wrong'); }
$('#app').innerHTML=`<div class="ok"><h2>Request received ✓</h2><p class="sub">We'll email <b>${esc(body.email)}</b> as soon as we track down <b>${esc(body.artist)}${body.title?' '+esc(body.title):''}</b>.</p><a href="/records"><button>Back to records</button></a></div>`;
}catch(err){ $('#err').textContent=err.message; $('#btn').disabled=false; $('#btn').textContent='Send request'; }
return false;
}
boot();
</script>
</body>
</html>