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}
@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")
async def crates(ident=Depends(require_token), db=Depends(get_db)):
rows = await db.execute(text("""

View File

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

View File

@ -43,6 +43,9 @@ app.include_router(disc_images_router)
_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())",
# 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)
"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",
@ -103,7 +106,7 @@ def _page(filename):
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)
# 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)

View File

@ -1,6 +1,7 @@
import json
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
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
"""), {"r": release_id})).mappings()]
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="orders">🛒 Orders</a>
<a data-v="customers">👤 Customers</a>
<a data-v="wantlist">📝 Wantlist</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</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));
function go(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 ----------
@ -346,6 +347,38 @@ async function custSave(id){
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); }
}
// ---------- 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(){
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>

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>
${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 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>
${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>