Replace BTCPay payments with free pre-orders + live ready-status
- server.js: order store with K484-XXXX codes, stand assignment, SSE status to customers, passcode-gated staff API, persists to .data/ - staff.html at /staff: order board with Mark ready / Collected / Cancel - index.html: drop BTCPay API key, invoices, QR, BTC price; orders now server-backed with live PREPARING -> READY updates and stand location - e2e-test.js rewritten for the order loop (14 checks, all passing) - README added; .gitignore covers server/.data
This commit is contained in:
parent
601c85f236
commit
582e571eff
10 changed files with 656 additions and 1096 deletions
189
server/staff.html
Normal file
189
server/staff.html
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KITCHEN 484 · Staff</title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#faf6ef;--card:#fff;--ink:#0A0A0A;--muted:#8a8378;--line:#e7e0d4;--green:#16a34a;--red:#dc2626}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:'Orbitron',sans-serif;min-height:100vh}
|
||||
header{position:sticky;top:0;background:var(--ink);color:#fff;padding:14px 20px;display:flex;align-items:center;gap:12px;z-index:5}
|
||||
header h1{font-size:15px;font-weight:900;letter-spacing:.08em}
|
||||
header .sub{font-size:10px;color:#b5ac9d}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:18px 14px 60px}
|
||||
.login{max-width:340px;margin:80px auto;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
||||
.login h2{font-size:14px;letter-spacing:.1em;margin-bottom:14px}
|
||||
input[type=password]{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;font-family:inherit;font-size:14px;background:#fff}
|
||||
button{font-family:inherit;cursor:pointer;border:none;border-radius:10px;font-weight:700}
|
||||
.btn{width:100%;padding:12px;margin-top:12px;background:var(--ink);color:#fff;font-size:13px;letter-spacing:.05em}
|
||||
.err{color:var(--red);font-size:11px;margin-top:8px;display:none}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
||||
.toolbar select{padding:8px 10px;border:1px solid var(--line);border-radius:10px;font-family:inherit;font-size:12px;background:#fff}
|
||||
.count{margin-left:auto;font-size:11px;color:var(--muted)}
|
||||
.order{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:10px}
|
||||
.order.ready{border-color:var(--green);box-shadow:0 0 0 1px var(--green)}
|
||||
.top{display:flex;align-items:center;gap:10px}
|
||||
.code{font-weight:900;font-size:15px}
|
||||
.badge{font-size:9px;font-weight:900;padding:3px 8px;border-radius:99px;letter-spacing:.08em}
|
||||
.b-prep{background:#fef3c7;color:#92400e}
|
||||
.b-ready{background:#dcfce7;color:#15803d}
|
||||
.b-done{background:#e5e7eb;color:#4b5563}
|
||||
.meta{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.6}
|
||||
.items{font-size:12px;margin-top:6px;font-weight:700}
|
||||
.stand-tag{display:inline-block;background:#fff7ed;border:1px solid #fdba74;color:#9a3412;font-size:10px;font-weight:900;padding:2px 8px;border-radius:99px;margin-top:6px}
|
||||
.acts{display:flex;gap:8px;margin-top:10px}
|
||||
.acts button{padding:8px 14px;font-size:11px}
|
||||
.go-ready{background:var(--green);color:#fff}
|
||||
.collected{background:#e5e7eb;color:#374151}
|
||||
.cancel{background:transparent;color:var(--red);border:1px solid #fecaca;margin-left:auto}
|
||||
.empty{text-align:center;color:var(--muted);font-size:12px;padding:50px 0;line-height:1.8}
|
||||
.conn{font-size:10px;font-weight:900;padding:3px 8px;border-radius:99px;margin-left:auto}
|
||||
.conn.ok{background:#123c1e;color:#4ade80}
|
||||
.conn.bad{background:#3c1212;color:#f87171}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>KITCHEN 484 · STAFF</h1>
|
||||
<div class="sub">pre-order board — tap READY when the food is up</div>
|
||||
</div>
|
||||
<div class="conn" id="conn"></div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<div class="login" id="login">
|
||||
<h2>STAFF ACCESS</h2>
|
||||
<input type="password" id="pass" placeholder="Staff passcode" autocomplete="current-password">
|
||||
<button class="btn" id="do-login">Unlock</button>
|
||||
<div class="err" id="login-err"></div>
|
||||
</div>
|
||||
<div id="board" style="display:none">
|
||||
<div class="toolbar">
|
||||
<select id="f-stand"><option value="">All stands</option></select>
|
||||
<select id="f-status">
|
||||
<option value="ACTIVE">Active (preparing + ready)</option>
|
||||
<option value="">All statuses</option>
|
||||
<option value="PREPARING">Preparing</option>
|
||||
<option value="READY">Ready</option>
|
||||
<option value="COLLECTED">Collected</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<button id="logout" style="background:none;color:var(--muted);font-size:11px;border:1px solid var(--line)">Lock</button>
|
||||
<div class="count" id="count"></div>
|
||||
</div>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
"use strict";
|
||||
var $=function(s){return document.querySelector(s)};
|
||||
var token=sessionStorage.getItem('k484_staff_token')||null;
|
||||
var orders=[],es=null,poll=null;
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
function api(path,opts){
|
||||
opts=opts||{};
|
||||
opts.headers=Object.assign({'Authorization':'Bearer '+token},opts.headers||{});
|
||||
return fetch(path,opts).then(function(r){if(r.status===401){lock();throw new Error('locked')}return r});
|
||||
}
|
||||
function lock(){
|
||||
token=null;sessionStorage.removeItem('k484_staff_token');
|
||||
if(es){es.close();es=null}
|
||||
if(poll){clearInterval(poll);poll=null}
|
||||
$('#board').style.display='none';
|
||||
$('#login').style.display='block';
|
||||
}
|
||||
function setConn(ok){var c=$('#conn');c.className='conn '+(ok?'ok':'bad');c.textContent=ok?'LIVE':'OFFLINE'}
|
||||
function load(){
|
||||
if(!token)return Promise.resolve();
|
||||
return api('/api/orders').then(function(r){
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
return r.json();
|
||||
}).then(function(j){orders=j;setConn(true);render()}).catch(function(e){
|
||||
if(String(e.message)!=='locked')setConn(false);
|
||||
});
|
||||
}
|
||||
var BADGE={
|
||||
PREPARING:'<span class="badge b-prep">PREPARING</span>',
|
||||
READY:'<span class="badge b-ready">READY</span>',
|
||||
COLLECTED:'<span class="badge b-done">COLLECTED</span>',
|
||||
CANCELLED:'<span class="badge b-done">CANCELLED</span>'
|
||||
};
|
||||
function render(){
|
||||
var st=$('#f-stand').value,fs=$('#f-status').value;
|
||||
var rows=orders.filter(function(o){
|
||||
if(st&&o.stand!==st)return false;
|
||||
if(fs==='ACTIVE')return o.status==='PREPARING'||o.status==='READY';
|
||||
if(fs)return o.status===fs;
|
||||
return true;
|
||||
});
|
||||
var nPrep=orders.filter(function(o){return o.status==='PREPARING'}).length;
|
||||
var nReady=orders.filter(function(o){return o.status==='READY'}).length;
|
||||
$('#count').textContent=nPrep+' preparing · '+nReady+' ready';
|
||||
var stands=[];
|
||||
orders.forEach(function(o){if(o.stand&&stands.indexOf(o.stand)<0)stands.push(o.stand)});
|
||||
var sel=$('#f-stand');
|
||||
if(sel.options.length!==stands.length+1){
|
||||
sel.innerHTML='<option value="">All stands</option>'+stands.map(function(s){return '<option>'+esc(s)+'</option>'}).join('');
|
||||
sel.value=stands.indexOf(st)>=0?st:'';
|
||||
}
|
||||
if(!rows.length){$('#list').innerHTML='<div class="empty">nothing here<br>🌻</div>';return}
|
||||
$('#list').innerHTML=rows.map(function(o){
|
||||
var t=o.createdAt?new Date(o.createdAt):null;
|
||||
var time=t?'<span style="margin-left:auto;font-size:10px;color:var(--muted)">'+t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'</span>':'';
|
||||
var acts='';
|
||||
if(o.status==='PREPARING')acts='<div class="acts"><button class="go-ready" data-act="READY">✓ Mark ready</button><button class="cancel" data-act="CANCELLED">Cancel</button></div>';
|
||||
else if(o.status==='READY')acts='<div class="acts"><button class="collected" data-act="COLLECTED">Handed over</button></div>';
|
||||
return '<div class="order'+(o.status==='READY'?' ready':'')+'" data-code="'+esc(o.code)+'">'+
|
||||
'<div class="top"><span class="code">'+esc(o.code)+'</span>'+(BADGE[o.status]||'')+time+'</div>'+
|
||||
'<div class="items">'+esc(o.itemsSummary)+'</div>'+
|
||||
'<div class="meta">'+esc(o.day||'')+' · pickup '+esc(o.pickupLabel)+' · <b>'+esc(o.name)+'</b>'+(o.notes?' · 📝 '+esc(o.notes):'')+'</div>'+
|
||||
(o.stand?'<span class="stand-tag">📍 '+esc(o.stand)+(o.standLocation?' — '+esc(o.standLocation):'')+'</span>':'')+
|
||||
acts+'</div>';
|
||||
}).join('');
|
||||
Array.prototype.forEach.call($('#list').querySelectorAll('[data-act]'),function(b){
|
||||
b.onclick=function(){
|
||||
var code=b.closest('.order').dataset.code;
|
||||
b.disabled=true;
|
||||
api('/api/orders/'+encodeURIComponent(code)+'/status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:b.dataset.act})})
|
||||
.then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return load()})
|
||||
.catch(function(e){b.disabled=false;if(String(e.message)!=='locked')alert('update failed: '+e.message)});
|
||||
};
|
||||
});
|
||||
}
|
||||
function connect(){
|
||||
if(es){es.close();es=null}
|
||||
try{
|
||||
es=new EventSource('/api/orders/stream?token='+encodeURIComponent(token));
|
||||
es.onmessage=function(e){try{var d=JSON.parse(e.data);if(d.type==='ready')load()}catch(_){}};
|
||||
es.onerror=function(){setConn(false)};
|
||||
}catch(_){setConn(false)}
|
||||
}
|
||||
function startBoard(){
|
||||
$('#login').style.display='none';
|
||||
$('#board').style.display='block';
|
||||
load().then(function(){connect();if(poll)clearInterval(poll);poll=setInterval(load,15000)});
|
||||
}
|
||||
$('#do-login').onclick=function(){
|
||||
var err=$('#login-err');err.style.display='none';
|
||||
fetch('/api/staff/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({passcode:$('#pass').value})})
|
||||
.then(function(r){return r.json().then(function(j){return{ok:r.ok,j:j}})})
|
||||
.then(function(res){
|
||||
if(!res.ok||!res.j.token){err.textContent='Wrong passcode';err.style.display='block';return}
|
||||
token=res.j.token;sessionStorage.setItem('k484_staff_token',token);
|
||||
startBoard();
|
||||
})
|
||||
.catch(function(){err.textContent='Could not reach the server';err.style.display='block'});
|
||||
};
|
||||
$('#pass').addEventListener('keydown',function(e){if(e.key==='Enter')$('#do-login').click()});
|
||||
$('#logout').onclick=lock;
|
||||
$('#f-stand').onchange=render;
|
||||
$('#f-status').onchange=render;
|
||||
if(token)startBoard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue