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:
parent
28a46615a5
commit
48f8579bff
@ -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("""
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
92
site/wantlist.html
Normal 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", 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
Loading…
Reference in New Issue
Block a user