From 48f8579bff40b14a8f376ed1d56877742ab6859d Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 23 Jun 2026 01:26:16 +1000 Subject: [PATCH] =?UTF-8?q?feat(shop):=20P4=20wantlist=20=E2=80=94=20publi?= =?UTF-8?q?c=20'request=20a=20record'=20page=20+=20admin=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/admin_routes.py | 34 ++++++++++++++++ app/layout_routes.py | 4 +- app/main.py | 5 ++- app/shop_routes.py | 32 ++++++++++++++- site/admin.html | 35 ++++++++++++++++- site/release.html | 1 + site/wantlist.html | 92 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 site/wantlist.html 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 = `` + + `${rows||''}
DateWantedCustomerStatus
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(){
${tags.map(t=>`${esc(t.n)}`).join('')}
${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
'}
`:''} +
${(d.copies||[]).length?"Want a different copy? Request it":"Not in stock — request this record"} →
${show('tracklist')&&d.tracks.length?`

Tracklist

${d.tracks.map(t=>``).join('')}
${esc(t.position||'')}${esc(t.title||'')}${esc(t.duration||'')}
`:''} 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.
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ + +