diff --git a/app/admin_routes.py b/app/admin_routes.py
index 5bf89a6..d134dfc 100644
--- a/app/admin_routes.py
+++ b/app/admin_routes.py
@@ -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("""
diff --git a/app/layout_routes.py b/app/layout_routes.py
index d54846c..091beed 100644
--- a/app/layout_routes.py
+++ b/app/layout_routes.py
@@ -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"],
diff --git a/app/main.py b/app/main.py
index 0ff51b1..34ba088 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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)
diff --git a/app/shop_routes.py b/app/shop_routes.py
index 1bdf688..2cedd69 100644
--- a/app/shop_routes.py
+++ b/app/shop_routes.py
@@ -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}
diff --git a/site/admin.html b/site/admin.html
index e43ab7d..1d02e9b 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -67,6 +67,7 @@
π¦ Inventory
π Orders
π€ Customers
+ π Wantlist
Catalog
π₯ Intake
πΊοΈ Store map
@@ -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 = 'Wantlist
Records customers asked us to find via the storefront request form.
'
+ + 'loadingβ¦
';
+ loadWant();
+}
+async function loadWant(){
+ const d = await api('/wantlist?status='+wantState.status);
+ const c = d.counts||{};
+ const tab = (k,lbl)=>``;
+ $('#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):'')) || 'β';
+ 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] ? ` ` : '';
+ const link = w.release_id ? ` β` : '';
+ return `| ${w.created_at?String(w.created_at).slice(0,10):''} |
+ ${what}${link}${meta?` ${meta} `:''}${w.notes?`β${escapeH(w.notes)}β `:''} |
+ ${escapeH(w.name||'')} ${escapeH(w.email||'')}${w.phone?' Β· '+escapeH(w.phone):''} |
+ ${w.status} |
+ ${act}${w.status!=='cancelled'&&w.status!=='fulfilled'?``:''} |
`;
+ }).join('');
+ $('#wantrows').innerHTML = `| Date | Wanted | Customer | Status | |
`
+ + `${rows||'| no requests |
'}
`;
+}
+async function wantSet(id, status){
+ await fetch('/admin/wantlist/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({status})});
+ loadWant();
+}
+
function invImport(){
invModalBox(`Import list (bulk stock-in)
One per line β Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.
diff --git a/site/release.html b/site/release.html
index 9fc6147..6855436 100644
--- a/site/release.html
+++ b/site/release.html
@@ -73,6 +73,7 @@ async function render(){
${show('cart')||show('price')?`${(d.copies||[]).map(c=>`
${money(c.price)}
${esc(c.condition||'')}${c.sleeve_cond?' / '+esc(c.sleeve_cond):''} Β· ${esc(c.sku)}
${buy(c)}
`).join('')||'
no copies in stock
'}
`:''}
+
${show('tracklist')&&d.tracks.length?`Tracklist
${d.tracks.map(t=>`| ${esc(t.position||'')} | ${esc(t.title||'')} | ${esc(t.duration||'')} |
`).join('')}
`:''}
diff --git a/site/wantlist.html b/site/wantlist.html
new file mode 100644
index 0000000..def1dcd
--- /dev/null
+++ b/site/wantlist.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+Request a record
+
+
+
+
+
+
Request a record
+
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.
+
+
+
+
+