feat(shop): in-store kiosk page — touch browse, locate-in-store, scan-to-buy QR
site/kiosk.html: full-screen themed kiosk (idle attract cover-wall → tap → browse with big search + genre chips + record grid → detail with tracklist, in-store locator (crate · rack) and a QR to buy on the customer's phone). 75s idle returns to attract. /shop/release copies enriched with crate/rack location (public-safe). Route /kiosk. Better than WowPlatter's kiosk: locate + phone-QR front and centre. Audio previews deferred (no internal audio yet). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d6c08f05ce
commit
bab0826fea
@ -113,7 +113,7 @@ def _page(filename):
|
||||
return _serve
|
||||
|
||||
|
||||
for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist"):
|
||||
for _stub in ("shop", "builder", "admin", "dash", "pos", "search", "records", "wantlist", "kiosk"):
|
||||
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)
|
||||
|
||||
@ -136,8 +136,12 @@ async def shop_release(release_id: int, db=Depends(get_db)):
|
||||
SELECT position, title, duration FROM disc_release_track WHERE release_id=:r ORDER BY sequence NULLS LAST, position
|
||||
"""), {"r": release_id})).mappings()]
|
||||
copies = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT sku, price::float AS price, condition, sleeve_cond, product_url
|
||||
FROM inventory WHERE release_id=:r AND in_stock AND store_id=1 ORDER BY price
|
||||
SELECT i.sku, i.price::float AS price, i.condition, i.sleeve_cond, i.product_url,
|
||||
c.label_text AS crate, r.name AS rack
|
||||
FROM inventory i
|
||||
LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
||||
LEFT JOIN virtual_rack r ON r.id = c.rack_id
|
||||
WHERE i.release_id=:r AND i.in_stock AND i.store_id=1 ORDER BY i.price
|
||||
"""), {"r": release_id})).mappings()]
|
||||
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
|
||||
|
||||
|
||||
188
site/kiosk.html
Normal file
188
site/kiosk.html
Normal file
@ -0,0 +1,188 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>RecordGod — browse the crates</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
:root{--primary:#ff2e93;--accent:#46d18a;--bg:#0b0b0e;--panel:#16161c;--text:#f4f4f6;--mut:#9a9aa8;--line:#26262e;--font:system-ui}
|
||||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:var(--font),system-ui,sans-serif;overflow:hidden;user-select:none}
|
||||
img{display:block}
|
||||
.screen{position:fixed;inset:0;display:none;flex-direction:column}
|
||||
.screen.on{display:flex}
|
||||
/* ATTRACT */
|
||||
#attract{align-items:center;justify-content:center;cursor:pointer;overflow:hidden}
|
||||
.wall{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;gap:18px;opacity:.5;filter:saturate(1.1)}
|
||||
.wallrow{display:flex;gap:18px;flex-shrink:0}
|
||||
.wallrow img{width:200px;height:200px;border-radius:14px;object-fit:cover;background:#222}
|
||||
.row-a{animation:scrollL 60s linear infinite}.row-b{animation:scrollR 75s linear infinite}.row-c{animation:scrollL 90s linear infinite}
|
||||
@keyframes scrollL{from{transform:translateX(0)}to{transform:translateX(-50%)}}
|
||||
@keyframes scrollR{from{transform:translateX(-50%)}to{transform:translateX(0)}}
|
||||
.attract-mid{position:relative;z-index:2;text-align:center;background:rgba(11,11,14,.55);backdrop-filter:blur(8px);padding:48px 60px;border-radius:28px}
|
||||
.attract-mid .logo{font-weight:800;font-size:64px;letter-spacing:-1px}.attract-mid .logo b{color:var(--primary)}
|
||||
.attract-mid .logo img{height:96px}
|
||||
.attract-mid .sub{font-size:26px;color:var(--mut);margin-top:10px}
|
||||
.pulse{margin-top:28px;display:inline-block;font-size:22px;font-weight:600;color:var(--primary);animation:pulse 1.6s ease-in-out infinite}
|
||||
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
|
||||
/* BROWSE */
|
||||
#browse{padding:0}
|
||||
.top{display:flex;align-items:center;gap:20px;padding:22px 30px;border-bottom:1px solid var(--line);flex-shrink:0}
|
||||
.top .logo{font-weight:800;font-size:30px;flex-shrink:0}.top .logo b{color:var(--primary)}.top .logo img{height:46px}
|
||||
.search{flex:1;display:flex;align-items:center;gap:14px;background:var(--panel);border:2px solid var(--line);border-radius:18px;padding:14px 22px}
|
||||
.search:focus-within{border-color:var(--primary)}
|
||||
.search input{flex:1;background:none;border:0;color:var(--text);font:500 26px var(--font),system-ui;outline:none}
|
||||
.search .ic{font-size:28px;color:var(--mut)}
|
||||
.chips{display:flex;gap:12px;overflow-x:auto;padding:16px 30px;flex-shrink:0;scrollbar-width:none}
|
||||
.chips::-webkit-scrollbar{display:none}
|
||||
.chip{flex-shrink:0;padding:13px 24px;border-radius:30px;background:var(--panel);border:1px solid var(--line);font-size:22px;font-weight:600;color:var(--text)}
|
||||
.chip.on{background:var(--primary);color:#11070c;border-color:var(--primary)}
|
||||
.grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:22px;padding:14px 30px 40px;align-content:start}
|
||||
.card{background:var(--panel);border-radius:16px;overflow:hidden;border:1px solid var(--line)}
|
||||
.card .cov{width:100%;aspect-ratio:1;object-fit:cover;background:#222}
|
||||
.card .cb{padding:12px 14px}
|
||||
.card .ti{font-weight:700;font-size:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .ar{color:var(--mut);font-size:18px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .pr{color:var(--primary);font-weight:800;font-size:22px;margin-top:6px}
|
||||
.empty{grid-column:1/-1;text-align:center;color:var(--mut);font-size:24px;padding:60px}
|
||||
/* DETAIL */
|
||||
#detail{z-index:30;background:rgba(7,7,10,.97);flex-direction:row;padding:46px;gap:46px;align-items:stretch}
|
||||
#detail .left{flex:0 0 42%;display:flex;flex-direction:column;gap:24px}
|
||||
#detail .cover{width:100%;aspect-ratio:1;border-radius:22px;object-fit:cover;background:#222;box-shadow:0 24px 70px rgba(0,0,0,.6)}
|
||||
#detail .qrbox{background:#fff;border-radius:18px;padding:18px;display:flex;gap:18px;align-items:center}
|
||||
#detail .qrbox #qr{width:120px;height:120px;flex-shrink:0}
|
||||
#detail .qrbox .qt{color:#111}.qrbox .qt b{color:#d10f7a;font-size:30px}
|
||||
#detail .right{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
|
||||
#detail h1{font-size:48px;margin:0 0 4px;line-height:1.05}
|
||||
#detail .artist{font-size:32px;color:var(--mut);margin-bottom:14px}
|
||||
#detail .meta{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}
|
||||
#detail .pill{background:var(--panel);border:1px solid var(--line);border-radius:24px;padding:8px 18px;font-size:20px;color:var(--mut)}
|
||||
#detail .locate{background:linear-gradient(90deg,rgba(255,46,147,.18),transparent);border:1px solid var(--primary);border-radius:16px;padding:18px 22px;margin-bottom:18px;font-size:26px}
|
||||
#detail .locate b{color:var(--primary)}
|
||||
#detail .tracks{flex:1;overflow-y:auto;border-top:1px solid var(--line);padding-top:14px}
|
||||
#detail .tk{display:flex;justify-content:space-between;font-size:21px;padding:7px 0;border-bottom:1px solid #1c1c22;color:#d6d6de}
|
||||
#detail .tk .n{color:var(--mut);width:54px}
|
||||
.closeX{position:absolute;top:30px;right:36px;font-size:40px;color:var(--mut);z-index:40;width:70px;height:70px;display:flex;align-items:center;justify-content:center;background:var(--panel);border-radius:50%}
|
||||
.bigprice{font-size:46px;font-weight:800;color:var(--primary)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ATTRACT -->
|
||||
<div id="attract" class="screen on" onclick="enterBrowse()">
|
||||
<div class="wall" id="wall"></div>
|
||||
<div class="attract-mid">
|
||||
<div class="logo" id="aLogo">Record<b>God</b></div>
|
||||
<div class="sub" id="aSub">Browse the crates · find it in store</div>
|
||||
<div class="pulse">▸ tap anywhere to start ◂</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BROWSE -->
|
||||
<div id="browse" class="screen">
|
||||
<div class="top">
|
||||
<div class="logo" id="bLogo" onclick="goAttract()">Record<b>God</b></div>
|
||||
<div class="search"><span class="ic">🔍</span><input id="q" placeholder="search artist, title, label…" autocomplete="off"></div>
|
||||
</div>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- DETAIL -->
|
||||
<div id="detail" class="screen">
|
||||
<div class="closeX" onclick="closeDetail()">✕</div>
|
||||
<div class="left">
|
||||
<img class="cover" id="dCover" onerror="this.style.visibility='hidden'">
|
||||
<div class="qrbox" id="dQrbox" style="display:none"><div id="qr"></div>
|
||||
<div class="qt"><div>Scan to buy<br>on your phone</div><b id="dQrPrice"></b></div></div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h1 id="dTitle"></h1>
|
||||
<div class="artist" id="dArtist"></div>
|
||||
<div class="meta" id="dMeta"></div>
|
||||
<div class="locate" id="dLocate"></div>
|
||||
<div class="tracks" id="dTracks"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const money=n=>n==null?'':'$'+Number(n).toFixed(2);
|
||||
let CFG={}, IDLE=null;
|
||||
|
||||
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.logo){ const im=`<img src="${esc(t.logo)}">`; $('#aLogo').innerHTML=im; $('#bLogo').innerHTML=im; }
|
||||
buildWall(); loadChips();
|
||||
document.addEventListener('pointerdown', resetIdle, true);
|
||||
}
|
||||
async function buildWall(){
|
||||
const d=await fetch('/shop/browse?sort=new&page=1').then(r=>r.json()).catch(()=>({items:[]}));
|
||||
const ids=(d.items||[]).map(r=>r.release_id);
|
||||
if(!ids.length) return;
|
||||
const imgs=ids.concat(ids).map(id=>`<img src="/img/r/${id}" loading="lazy" onerror="this.style.visibility='hidden'">`).join('');
|
||||
$('#wall').innerHTML=`<div class="wallrow row-a">${imgs}</div><div class="wallrow row-b">${imgs}</div><div class="wallrow row-c">${imgs}</div>`;
|
||||
}
|
||||
|
||||
/* idle: return to attract after 75s of no touch */
|
||||
function resetIdle(){ clearTimeout(IDLE); IDLE=setTimeout(goAttract, 75000); }
|
||||
function goAttract(){ clearTimeout(IDLE); show('attract'); $('#q').value=''; }
|
||||
function enterBrowse(){ show('browse'); resetIdle(); load(''); setTimeout(()=>$('#q').focus(),100); }
|
||||
function show(id){ document.querySelectorAll('.screen').forEach(s=>s.classList.remove('on')); $('#'+id).classList.add('on'); }
|
||||
|
||||
let GEN=[];
|
||||
async function loadChips(){
|
||||
const f=await fetch('/shop/facets').then(r=>r.json()).catch(()=>({genres:[]}));
|
||||
GEN=(f.genres||[]).slice(0,14);
|
||||
$('#chips').innerHTML=`<span class="chip on" data-g="" onclick="pickGenre('')">✨ New in</span>`
|
||||
+GEN.map(g=>`<span class="chip" data-g="${esc(g.name)}" onclick="pickGenre('${esc(g.name).replace(/'/g,"'")}')">${esc(g.name)}</span>`).join('');
|
||||
}
|
||||
function pickGenre(g){ document.querySelectorAll('.chip').forEach(c=>c.classList.toggle('on',c.dataset.g===g)); load('', g); }
|
||||
|
||||
let qt;
|
||||
document.addEventListener('input',e=>{ if(e.target.id==='q'){ clearTimeout(qt); qt=setTimeout(()=>load(e.target.value.trim()),250); } });
|
||||
async function load(q, genre){
|
||||
const p=new URLSearchParams({sort:q?'title':'new', page:1});
|
||||
if(q) p.set('q',q); if(genre) p.set('genre',genre);
|
||||
const d=await fetch('/shop/browse?'+p).then(r=>r.json()).catch(()=>({items:[]}));
|
||||
$('#grid').innerHTML=(d.items||[]).map(r=>`<div class="card" onclick="openDetail(${r.release_id})">
|
||||
<img class="cov" src="/img/r/${r.release_id}" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<div class="cb"><div class="ti">${esc(r.title||'')}</div><div class="ar">${esc(r.artist||'')}</div>
|
||||
<div class="pr">${money(r.price)}${r.copies>1?` <span style="color:var(--mut);font-size:16px">· ${r.copies}</span>`:''}</div></div></div>`).join('')
|
||||
|| '<div class="empty">No records match — try another search</div>';
|
||||
}
|
||||
|
||||
async function openDetail(rid){
|
||||
resetIdle();
|
||||
const d=await fetch('/shop/release/'+rid).then(r=>r.json()).catch(()=>({}));
|
||||
const r=d.release; if(!r) return;
|
||||
show('detail');
|
||||
$('#dCover').src='/img/r/'+r.id; $('#dCover').style.visibility='visible';
|
||||
$('#dTitle').textContent=r.title||'';
|
||||
$('#dArtist').textContent=r.artist||'';
|
||||
const meta=[r.label,r.format,r.country,r.year,r.genre].filter(Boolean);
|
||||
$('#dMeta').innerHTML=meta.map(m=>`<span class="pill">${esc(String(m))}</span>`).join('');
|
||||
const copies=d.copies||[], c0=copies[0];
|
||||
if(c0){
|
||||
const loc = c0.crate ? `📍 <b>Find it:</b> ${esc(c0.crate)}${c0.rack?' · '+esc(c0.rack):''} · <span class="bigprice">${money(c0.price)}</span>`
|
||||
: `<b>In stock</b> — ask our staff to grab it · <span class="bigprice">${money(c0.price)}</span>`;
|
||||
$('#dLocate').innerHTML=loc; $('#dLocate').style.display='block';
|
||||
} else { $('#dLocate').innerHTML='Not currently on the floor — ask us to track it down'; $('#dLocate').style.display='block'; }
|
||||
$('#dTracks').innerHTML=(d.tracks||[]).map(t=>`<div class="tk"><span class="n">${esc(t.position||'')}</span><span style="flex:1">${esc(t.title||'')}</span><span class="n" style="text-align:right">${esc(t.duration||'')}</span></div>`).join('')
|
||||
|| '<div class="tk" style="color:var(--mut)">Tracklist not available</div>';
|
||||
// QR to buy online (if this copy is linked to the web store)
|
||||
const qb=$('#dQrbox'), qel=$('#qr'); qel.innerHTML='';
|
||||
if(c0 && c0.product_url){ qb.style.display='flex'; $('#dQrPrice').textContent=money(c0.price);
|
||||
try{ new QRCode(qel,{text:c0.product_url,width:120,height:120,correctLevel:QRCode.CorrectLevel.M}); }catch(e){ qb.style.display='none'; } }
|
||||
else qb.style.display='none';
|
||||
}
|
||||
function closeDetail(){ show('browse'); resetIdle(); }
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user