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
394
index.html
394
index.html
|
|
@ -14,7 +14,6 @@
|
|||
<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">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme:light;
|
||||
|
|
@ -177,7 +176,6 @@ nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);b
|
|||
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--blue)}
|
||||
.field textarea{resize:none;height:70px}
|
||||
.hint{font-size:11px;color:var(--muted);margin:-6px 0 12px}
|
||||
.pay-box{border:1px solid var(--line);border-radius:var(--radius);background:var(--bg2);padding:16px;text-align:center;margin-bottom:14px}
|
||||
.pay-amount{font-family:var(--display);font-weight:900;font-size:28px;margin:6px 0 2px}
|
||||
.pay-btc{font-family:var(--display);font-weight:700;font-size:13px;color:var(--blue);margin-bottom:12px}
|
||||
.qr-wrap{background:#fff;border:1px solid var(--line);border-radius:16px;padding:12px;display:inline-block}
|
||||
|
|
@ -185,12 +183,9 @@ nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);b
|
|||
.addr-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-top:12px}
|
||||
.addr-row code{flex:1;font-size:10px;word-break:break-all;color:var(--ink2);font-family:ui-monospace,Menlo,monospace}
|
||||
.copy-btn{border:1px solid var(--line2);background:var(--bg2);border-radius:9px;font-size:11px;font-weight:800;padding:7px 12px;cursor:pointer;white-space:nowrap}
|
||||
.pay-status{display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;font-weight:700;color:var(--ink2);margin:12px 0}
|
||||
.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--blue);border-radius:99px;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.pay-status.ok{color:#15803d}
|
||||
.pay-status.err{color:#b91c1c}
|
||||
.pay-tabs{display:flex;gap:6px;margin-bottom:12px}
|
||||
.pay-tab{flex:1;border:1px solid var(--line);background:#fff;border-radius:11px;padding:9px;font-size:12px;font-weight:800;cursor:pointer;color:var(--muted)}
|
||||
.pay-tab.active{background:var(--black);color:#fff;border-color:var(--black)}
|
||||
.demo-note{background:var(--gold-soft);border:1px solid #fde68a;color:#854d0e;font-size:11px;font-weight:600;border-radius:12px;padding:10px 12px;margin-bottom:12px}
|
||||
|
|
@ -224,9 +219,6 @@ footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-s
|
|||
<div class="header-right">
|
||||
<a class="icon-btn" href="home.html" aria-label="Home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/></svg></a>
|
||||
<div class="clock" id="clock">--:--</div>
|
||||
<button class="icon-btn" id="btn-settings" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -238,10 +230,10 @@ footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-s
|
|||
<span class="chip c1">NO SEED OILS</span>
|
||||
<span class="chip c2">GRASS FED</span>
|
||||
<span class="chip c3">VEG FRIENDLY</span>
|
||||
<span class="chip c4">CASHLESS · BTC</span>
|
||||
<span class="chip c4">PRE-ORDER · FREE</span>
|
||||
</div>
|
||||
<h1>WHEN'S <em>FOOD</em> ON?</h1>
|
||||
<p>Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.</p>
|
||||
<p>Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and we'll tell you the moment your food is ready — and exactly where to grab it.</p>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
<div class="timeline" id="timeline">
|
||||
|
|
@ -256,12 +248,12 @@ footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-s
|
|||
<section id="view-orders" hidden>
|
||||
<div class="orders">
|
||||
<h2>My orders</h2>
|
||||
<div class="sub">Show the code at the pickup stand. Paid orders are confirmed on-chain.</div>
|
||||
<div class="sub">Live status for every order on this device. When it flips to READY, head to the stand shown.</div>
|
||||
<div id="orders-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay</footer>
|
||||
<footer>KITCHEN 484 · Off-grid · Zero waste · Food's ready when we say it's ready</footer>
|
||||
</main>
|
||||
|
||||
<nav class="bottom">
|
||||
|
|
@ -284,26 +276,6 @@ footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-s
|
|||
<!-- SHEET -->
|
||||
<div class="overlay" id="sheet-overlay"><div class="sheet" id="sheet"></div></div>
|
||||
|
||||
<!-- SETTINGS MODAL -->
|
||||
<div class="modal" id="settings-modal">
|
||||
<div class="modal-box">
|
||||
<h3>⚙️ PAYMENT SETTINGS</h3>
|
||||
<p style="font-size:12px;color:var(--ink2);margin-bottom:14px">Connect a <b>BTCPay Server</b> store to receive real Bitcoin payments. Find your Store ID in the store's settings on your BTCPay instance, and generate a store API key with "Create invoice" permission.</p>
|
||||
<div class="field"><label>BTCPay server URL</label><input id="set-url" type="url" placeholder="https://btcpay.yourhost.com"></div>
|
||||
<div class="field"><label>Store ID</label><input id="set-store" type="text" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></div>
|
||||
<div class="field"><label>Store API key (optional but recommended)</label><input id="set-key" type="password" placeholder="lk_… / bk_…"></div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px">
|
||||
<button class="btn btn-ghost btn-sm" id="btn-test">Test connection</button>
|
||||
<button class="btn btn-primary btn-sm" id="btn-save-settings" style="margin-left:auto">Save</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost btn-sm" id="btn-clear-orders">Clear orders</button>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:auto" id="btn-close-settings">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
|
|
@ -373,20 +345,17 @@ const FEST={
|
|||
FEST.days.forEach((d,i)=>{ const L=dayLabel(i); d.name=L.name; d.date=L.date; d.iso=L.iso; });
|
||||
|
||||
/* ============================== STATE ============================== */
|
||||
const LS={cart:'fest484_cart',orders:'fest484_orders',btcpay:'fest484_btcpay'};
|
||||
const LS={cart:'fest484_cart',orders:'fest484_orders'};
|
||||
let state={
|
||||
view:'schedule',
|
||||
dayIdx:0,
|
||||
cart:JSON.parse(localStorage.getItem(LS.cart)||'{}'),
|
||||
orders:JSON.parse(localStorage.getItem(LS.orders)||'[]'),
|
||||
settings:JSON.parse(localStorage.getItem(LS.btcpay)||'null'),
|
||||
sheetStep:null,
|
||||
btcPrice:null,
|
||||
pay:null
|
||||
};
|
||||
const saveCart=()=>localStorage.setItem(LS.cart,JSON.stringify(state.cart));
|
||||
const saveOrders=()=>localStorage.setItem(LS.orders,JSON.stringify(state.orders));
|
||||
const saveSettings=()=>localStorage.setItem(LS.btcpay,JSON.stringify(state.settings));
|
||||
|
||||
const $=s=>document.querySelector(s);
|
||||
const fmt$=n=>'$'+n.toFixed(2);
|
||||
|
|
@ -499,7 +468,6 @@ function renderSheet(){
|
|||
const s=$('#sheet');
|
||||
if(state.sheetStep==='cart')s.innerHTML=stepCart();
|
||||
else if(state.sheetStep==='checkout')s.innerHTML=stepCheckout();
|
||||
else if(state.sheetStep==='payment')s.innerHTML=stepPayment();
|
||||
else if(state.sheetStep==='success')s.innerHTML=stepSuccess();
|
||||
wireSheet();
|
||||
}
|
||||
|
|
@ -514,12 +482,11 @@ function stepCart(){
|
|||
'<div class="lp">'+fmt$(it.price*state.cart[id])+'</div></div>';
|
||||
}).join('');
|
||||
const total=cartTotal();
|
||||
const btc=state.btcPrice?'≈ '+(total/state.btcPrice).toFixed(6)+' BTC <small>@ $'+state.btcPrice.toFixed(0)+'</small>':'';
|
||||
return '<div class="grab"></div><h3>YOUR ORDER</h3><div class="sub">SOLARPUNK SUMMIT · pickup at the stand window</div>'+
|
||||
(rows||'<div class="empty" style="padding:30px"><div class="big">🧺</div>Nothing here yet — add something from the schedule.</div>')+
|
||||
'<div class="total-row"><span class="t">Total</span><span class="v">'+fmt$(total)+'</span></div>'+
|
||||
'<div class="btc-est">'+(btc||'Fetching BTC price…')+'</div>'+
|
||||
'<button class="btn btn-primary" id="to-checkout" '+(cartCount()===0?'disabled':'')+'>Checkout →</button>';
|
||||
'<div class="btc-est">Free pre-order — pay at the stand when you pick up.</div>'+
|
||||
'<button class="btn btn-primary" id="to-checkout" '+(cartCount()===0?'disabled':'')+'>Reserve pickup →</button>';
|
||||
}
|
||||
|
||||
function stepCheckout(){
|
||||
|
|
@ -531,50 +498,25 @@ function stepCheckout(){
|
|||
const def=windows.find(w=>w.end>n)||windows[0];
|
||||
const opts=windows.map(w=>'<option value="'+w.start+'-'+w.end+'" '+(def&&w.start===def.start&&w.end===def.end?'selected':'')+'>'+
|
||||
fmtT(w.start)+' – '+fmtT(w.end)+' · '+w.name+'</option>').join('');
|
||||
return '<div class="grab"></div><h3>CHECKOUT</h3><div class="sub">Who is picking this up?</div>'+
|
||||
return '<div class="grab"></div><h3>RESERVE PICKUP</h3><div class="sub">Who is picking this up?</div>'+
|
||||
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
|
||||
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
|
||||
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
|
||||
'<div class="total-row"><span class="t">Total · '+cartCount()+' items</span><span class="v">'+fmt$(cartTotal())+'</span></div>'+
|
||||
'<button class="btn btn-pink" id="to-payment" style="margin-top:8px">₿ Pay with Bitcoin</button>'+
|
||||
'<div id="reserve-err" style="color:#b91c1c;font-size:12px;font-weight:700;margin-top:6px;display:none"></div>'+
|
||||
'<button class="btn btn-pink" id="to-reserve" style="margin-top:8px">🍽️ Place pre-order</button>'+
|
||||
'<button class="btn btn-ghost" style="width:100%;margin-top:8px" id="back-cart">← Back to cart</button>';
|
||||
}
|
||||
|
||||
function stepPayment(){
|
||||
const p=state.pay;
|
||||
if(!p)return'<div class="grab"></div><div class="empty">Payment not started.</div>';
|
||||
const addr=p.method==='bolt11'?p.bolt11:p.address;
|
||||
const tabs=(p.real&&p.bolt11)?'<div class="pay-tabs">'+
|
||||
'<button class="pay-tab'+(p.method==='btc'?' active':'')+'" data-pm="btc">₿ Bitcoin</button>'+
|
||||
'<button class="pay-tab'+(p.method==='bolt11'?' active':'')+'" data-pm="bolt11">⚡ Lightning</button></div>':'';
|
||||
return '<div class="grab"></div><h3>PAYMENT</h3>'+
|
||||
'<div class="sub">'+(p.real?'BTCPay invoice '+p.invoiceId:'Demo invoice — no real transaction')+'</div>'+
|
||||
(p.real?'':'<div class="demo-note">⚠️ <b>Demo mode.</b> No BTCPay Server configured — open ⚙️ settings to connect a real store. This QR is a placeholder.</div>')+
|
||||
tabs+
|
||||
'<div class="pay-box">'+
|
||||
'<div style="font-size:11px;letter-spacing:1px;color:var(--muted);text-transform:uppercase">Send exactly</div>'+
|
||||
'<div class="pay-amount">'+fmt$(p.amountUsd)+'</div>'+
|
||||
'<div class="pay-btc">'+(state.btcPrice?'≈ '+(p.amountUsd/state.btcPrice).toFixed(6)+' BTC':'≈ BTC price unavailable')+'</div>'+
|
||||
'<div class="qr-wrap" id="qr-wrap"></div>'+
|
||||
'<div class="addr-row"><code id="pay-addr">'+esc(addr)+'</code><button class="copy-btn" id="copy-addr">Copy</button></div>'+
|
||||
'</div>'+
|
||||
'<div class="pay-status" id="pay-status"><span class="spinner"></span> Waiting for payment…</div>'+
|
||||
(p.real?'<a class="btn btn-ghost" style="width:100%;text-decoration:none;color:var(--ink)" href="'+p.checkoutUrl+'" target="_blank" rel="noopener">Open BTCPay checkout →</a>':
|
||||
'<button class="btn btn-blue" id="simulate-pay" style="margin-top:4px">⚡ Simulate payment received (demo)</button>')+
|
||||
'<button class="btn btn-ghost" style="width:100%;margin-top:8px" id="cancel-pay">Cancel</button>';
|
||||
}
|
||||
|
||||
function stepSuccess(){
|
||||
const o=state.pay&&state.pay.order;
|
||||
if(!o)return'';
|
||||
return '<div class="grab"></div><div class="success">'+
|
||||
'<div class="check">✓</div>'+
|
||||
'<h3>ORDER CONFIRMED</h3>'+
|
||||
'<div class="sub" style="margin-bottom:0">'+(o.status==='PAID (DEMO)'?'Demo payment recorded — connect BTCPay for real orders':'Payment detected')+'</div>'+
|
||||
'<div class="sub" style="margin-bottom:0">The kitchen has your order. We\'ll flip it to READY the moment it\'s up.</div>'+
|
||||
'<div class="code">'+o.code+'</div>'+
|
||||
'<div class="pick">Pickup: <b>'+o.pickupLabel+'</b><br>'+o.itemsSummary+'</div>'+
|
||||
'<div class="pay-amount" style="font-size:20px">'+fmt$(o.totalUsd)+'</div>'+
|
||||
'<div class="pay-btc" style="margin-bottom:18px">'+(o.btcEstimate||'')+'</div>'+
|
||||
'<div class="pick">Pickup: <b>'+o.pickupLabel+'</b><br>'+esc(o.itemsSummary)+'<br>Stand: <b>'+esc(o.stand)+'</b><br>'+esc(o.standLocation)+'</div>'+
|
||||
'<button class="btn btn-primary" id="success-done">Done</button>'+
|
||||
'</div>';
|
||||
}
|
||||
|
|
@ -589,33 +531,16 @@ function wireSheet(){
|
|||
});
|
||||
const tc=s.querySelector('#to-checkout');if(tc)tc.onclick=()=>openSheet('checkout');
|
||||
const bc=s.querySelector('#back-cart');if(bc)bc.onclick=()=>openSheet('cart');
|
||||
const tp=s.querySelector('#to-payment');
|
||||
if(tp)tp.onclick=async()=>{
|
||||
const tr=s.querySelector('#to-reserve');
|
||||
if(tr)tr.onclick=async()=>{
|
||||
const name=$('#co-name').value.trim();
|
||||
if(!name){toast('Please add a pickup name');$('#co-name').focus();return;}
|
||||
const win=$('#co-window').value.split('-').map(Number);
|
||||
const notes=$('#co-notes').value.trim();
|
||||
await startPayment({name,window:win,notes});
|
||||
};
|
||||
s.querySelectorAll('[data-pm]').forEach(b=>b.onclick=()=>{
|
||||
state.pay.method=b.dataset.pm;
|
||||
const addr=state.pay.method==='bolt11'?state.pay.bolt11:state.pay.address;
|
||||
$('#pay-addr').textContent=addr;
|
||||
s.querySelectorAll('[data-pm]').forEach(x=>x.classList.toggle('active',x===b));
|
||||
drawQR($('#qr-wrap'),addr);
|
||||
});
|
||||
const cp=s.querySelector('#copy-addr');
|
||||
if(cp)cp.onclick=async()=>{
|
||||
const t=$('#pay-addr').textContent;
|
||||
try{await navigator.clipboard.writeText(t);toast('Copied');}
|
||||
catch(e){const ta=document.createElement('textarea');ta.value=t;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();toast('Copied');}
|
||||
};
|
||||
const sim=s.querySelector('#simulate-pay');
|
||||
if(sim)sim.onclick=()=>completeOrder('PAID (DEMO)');
|
||||
const cx=s.querySelector('#cancel-pay');
|
||||
if(cx)cx.onclick=()=>{
|
||||
if(state.pay&&state.pay.timer)clearInterval(state.pay.timer);
|
||||
state.pay=null;openSheet('checkout');
|
||||
tr.disabled=true;tr.textContent='Placing order…';
|
||||
const err=$('#reserve-err');
|
||||
try{await placeOrder({name,window:win,notes});}
|
||||
catch(e){tr.disabled=false;tr.textContent='🍽️ Place pre-order';if(err){err.style.display='block';err.textContent='Could not place order: '+e.message;}}
|
||||
};
|
||||
const done=s.querySelector('#success-done');
|
||||
if(done)done.onclick=()=>{
|
||||
|
|
@ -625,209 +550,117 @@ function wireSheet(){
|
|||
}
|
||||
function afterCartChange(){saveCart();renderFab();renderTimeline();renderSheet();}
|
||||
|
||||
/* ============================== QR ============================== */
|
||||
function drawQR(el,data){
|
||||
if(!el)return;
|
||||
el.innerHTML='';
|
||||
try{
|
||||
if(typeof QRCode!=='undefined'){
|
||||
new QRCode(el,{text:data,width:190,height:190,colorDark:'#0A0A0A',colorLight:'#ffffff',correctLevel:QRCode.CorrectLevel.M});
|
||||
}else{throw new Error('no lib');}
|
||||
}catch(e){
|
||||
el.innerHTML='<div style="width:190px;height:190px;display:grid;place-items:center;background:var(--bg2);border-radius:8px;color:var(--muted);font-size:11px">QR unavailable<br>offline</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================== PAYMENT ============================== */
|
||||
function demoAddress(){
|
||||
const chars='023456789acdefghjklmnpqrstuvwxyz';
|
||||
let s='bc1q';
|
||||
for(let i=0;i<38;i++)s+=chars[Math.floor(Math.random()*chars.length)];
|
||||
return s;
|
||||
}
|
||||
async function fetchBtcPrice(){
|
||||
try{
|
||||
const r=await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',{cache:'no-store'});
|
||||
const j=await r.json();
|
||||
if(j.bitcoin&&j.bitcoin.usd){state.btcPrice=j.bitcoin.usd;return;}
|
||||
}catch(e){}
|
||||
if(!state.btcPrice)state.btcPrice=65000; // offline fallback
|
||||
}
|
||||
function btcpayHeaders(){
|
||||
const s=state.settings||{};
|
||||
return s.apiKey?{'Authorization':'Bearer '+s.apiKey}:{};
|
||||
}
|
||||
async function startPayment(info){
|
||||
/* ============================== ORDERS (server-backed) ============================== */
|
||||
async function placeOrder(info){
|
||||
const usd=cartTotal();
|
||||
const day=FEST.days.find(d=>d.items.some(it=>info.window&&Math.abs(it.start-info.window[0])<1&&Math.abs(it.end-info.window[1])<1))||FEST.days[state.dayIdx];
|
||||
const pickupLabel=fmtT(info.window[0])+' – '+fmtT(info.window[1]);
|
||||
const itemsSummary=Object.keys(state.cart).map(id=>{const it=findItem(id);return state.cart[id]+'× '+it.name;}).join(', ');
|
||||
const order={
|
||||
id:crypto.randomUUID?crypto.randomUUID():String(Date.now()),
|
||||
code:'F484-'+Math.random().toString(36).slice(2,6).toUpperCase(),
|
||||
name:info.name,window:info.window,notes:info.notes,
|
||||
items:JSON.parse(JSON.stringify(state.cart)),
|
||||
itemsSummary, totalUsd:usd,
|
||||
btcEstimate:state.btcPrice?((usd/state.btcPrice).toFixed(6)+' BTC'):null,
|
||||
pickupLabel, day:day.name+' '+day.date,
|
||||
status:'PENDING', ts:Date.now(), real:false, address:null
|
||||
};
|
||||
const p=state.pay={order,amountUsd:usd,method:'btc',address:null,bolt11:null,real:false,invoiceId:null,checkoutUrl:null,timer:null};
|
||||
|
||||
const s=state.settings;
|
||||
if(s&&s.url&&s.store){
|
||||
try{
|
||||
const res=await fetch(s.url.replace(/\/+$/,'')+'/v1/stores/'+s.store+'/invoices',{
|
||||
method:'POST',
|
||||
headers:Object.assign({'Content-Type':'application/json'},btcpayHeaders()),
|
||||
body:JSON.stringify({
|
||||
amount:Math.round(usd*100)/100, currency:'USD',
|
||||
description:'KITCHEN 484 order '+order.code+' — pickup '+pickupLabel,
|
||||
metadata:{orderId:order.code,pickup:pickupLabel}
|
||||
})
|
||||
});
|
||||
if(!res.ok)throw new Error('HTTP '+res.status);
|
||||
const inv=await res.json();
|
||||
p.real=true;order.real=true;
|
||||
p.invoiceId=inv.id;
|
||||
p.checkoutUrl=inv.checkoutUrl||inv.paymentUrl||'';
|
||||
let addr=null,bolt11=null;
|
||||
if(Array.isArray(inv.paymentMethods)){
|
||||
const btcPM=(inv.paymentMethods.find(pm=>pm.cryptoCode==='BTC')||{});
|
||||
const lnPM=(inv.paymentMethods.find(pm=>pm.cryptoCode==='LIGHTNING')||{});
|
||||
addr=btcPM.address||null; bolt11=lnPM.bolt11||lnPM.address||null;
|
||||
}
|
||||
if(!addr&&inv.paymentAddresses){
|
||||
addr=typeof inv.paymentAddresses==='string'?inv.paymentAddresses:(inv.paymentAddresses.BTC||null);
|
||||
}
|
||||
if(!addr)throw new Error('No BTC address in invoice');
|
||||
p.address=addr;order.address=addr;p.bolt11=bolt11;
|
||||
startPolling(order);
|
||||
}catch(e){
|
||||
p.real=false;order.real=false;
|
||||
p.address=demoAddress();order.address=p.address;
|
||||
toast('BTCPay unavailable ('+e.message+') — demo invoice shown');
|
||||
}
|
||||
}else{
|
||||
p.address=demoAddress();order.address=p.address;
|
||||
}
|
||||
openSheet('payment');
|
||||
}
|
||||
function startPolling(order){
|
||||
if(state.pay.timer)clearInterval(state.pay.timer);
|
||||
const s=state.settings;
|
||||
let ticks=0;
|
||||
state.pay.timer=setInterval(async()=>{
|
||||
ticks++;
|
||||
try{
|
||||
const r=await fetch(s.url.replace(/\/+$/,'')+'/v1/invoices/'+state.pay.invoiceId,{headers:btcpayHeaders()});
|
||||
if(!r.ok)return;
|
||||
const inv=await r.json();
|
||||
const st=(inv.status||'').toLowerCase();
|
||||
if(['paid','confirmed','settled'].includes(st)){
|
||||
clearInterval(state.pay.timer);state.pay.timer=null;
|
||||
completeOrder('PAID');
|
||||
}else if(st==='expired'||st==='failed'){
|
||||
clearInterval(state.pay.timer);state.pay.timer=null;
|
||||
const ps=$('#pay-status');
|
||||
if(ps){ps.className='pay-status err';ps.textContent='Invoice expired — please go back and retry.';}
|
||||
}
|
||||
}catch(e){ if(ticks%12===0){const ps=$('#pay-status');if(ps)ps.textContent='Still waiting… (reconnecting)';} }
|
||||
},5000);
|
||||
}
|
||||
function completeOrder(status){
|
||||
const p=state.pay;if(!p||!p.order)return;
|
||||
clearInterval(p.timer);p.timer=null;
|
||||
p.order.status=status;
|
||||
if(!state.orders.some(o=>o.id===p.order.id))state.orders.unshift(p.order);
|
||||
const pickupLabel=fmtT(info.window[0])+' \u2013 '+fmtT(info.window[1]);
|
||||
const ids=Object.keys(state.cart);
|
||||
const itemsSummary=ids.map(id=>{const it=findItem(id);return state.cart[id]+'\u00d7 '+it.name;}).join(', ');
|
||||
const stands=[...new Set(ids.map(id=>findItem(id).name))];
|
||||
const stand=stands.length===1?stands[0]:'Kitchen 484';
|
||||
const res=await fetch('/api/orders',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
name:info.name,notes:info.notes,items:state.cart,
|
||||
window:info.window,pickupLabel,itemsSummary,
|
||||
stand,totalUsd:usd,
|
||||
day:day.name+' '+day.date
|
||||
})
|
||||
});
|
||||
const rec=await res.json().catch(()=>({}));
|
||||
if(!res.ok)throw new Error(rec.error||('HTTP '+res.status));
|
||||
// the server record is the source of truth; keep our local list in sync with it
|
||||
if(!state.orders.some(o=>o.code===rec.code))state.orders.unshift(rec);
|
||||
saveOrders();
|
||||
state.pay={order:rec};
|
||||
state.cart={};saveCart();renderFab();renderTimeline();
|
||||
openSheet('success');
|
||||
watchOrder(rec.code);
|
||||
}
|
||||
const watch={};
|
||||
function watchOrder(code){
|
||||
if(watch[code])return;
|
||||
const entry={};
|
||||
try{
|
||||
const es=new EventSource('/api/orders/'+encodeURIComponent(code)+'/events');
|
||||
entry.es=es;
|
||||
es.onmessage=e=>{
|
||||
let d;try{d=JSON.parse(e.data);}catch{return;}
|
||||
if(d.status)applyRemoteStatus(code,d.status);
|
||||
if(d.status==='COLLECTED'||d.status==='CANCELLED'){es.close();if(entry.timer)clearInterval(entry.timer);}
|
||||
};
|
||||
}catch(e){/* polling still covers us */}
|
||||
entry.timer=setInterval(async()=>{
|
||||
try{
|
||||
const r=await fetch('/api/orders/'+encodeURIComponent(code));
|
||||
if(r.status===404){applyRemoteStatus(code,'COLLECTED');clearInterval(entry.timer);return;}
|
||||
if(!r.ok)return;
|
||||
const rec=await r.json();
|
||||
applyRemoteStatus(code,rec.status,rec);
|
||||
if(rec.status==='COLLECTED'||rec.status==='CANCELLED')clearInterval(entry.timer);
|
||||
}catch(e){}
|
||||
},20000);
|
||||
watch[code]=entry;
|
||||
}
|
||||
function applyRemoteStatus(code,status,detail){
|
||||
let o=state.orders.find(x=>x.code===code);
|
||||
if(!o&&detail){o=detail;state.orders.unshift(o);}
|
||||
if(!o)return;
|
||||
if(detail){
|
||||
o.stand=detail.stand||o.stand;o.standLocation=detail.standLocation||o.standLocation;
|
||||
o.itemsSummary=detail.itemsSummary||o.itemsSummary;o.pickupLabel=detail.pickupLabel||o.pickupLabel;
|
||||
o.day=detail.day||o.day;o.totalUsd=(detail.totalUsd!==undefined?detail.totalUsd:o.totalUsd);
|
||||
o.name=detail.name||o.name;o.notes=detail.notes!==undefined?detail.notes:o.notes;
|
||||
}
|
||||
if(o.status!==status){
|
||||
o.status=status;saveOrders();
|
||||
if(status==='READY')toast('\ud83c\udf7d\ufe0f '+code+' is READY \u2014 '+o.stand);
|
||||
if(status==='COLLECTED')toast(code+' collected. Enjoy!');
|
||||
}else{saveOrders();}
|
||||
if(state.view==='orders')renderOrders();
|
||||
if(state.sheetStep==='success'&&state.pay&&state.pay.order&&state.pay.order.code===code)renderSheet();
|
||||
}
|
||||
|
||||
/* ============================== ORDERS VIEW ============================== */
|
||||
const STATUS_BADGE={
|
||||
PREPARING:'<span class="badge b-pending">PREPARING</span>',
|
||||
READY:'<span class="badge b-paid">READY \u2014 GRAB IT</span>',
|
||||
COLLECTED:'<span class="badge b-demo">COLLECTED</span>',
|
||||
CANCELLED:'<span class="badge b-failed">CANCELLED</span>'
|
||||
};
|
||||
function renderOrders(){
|
||||
const list=$('#orders-list');
|
||||
if(!state.orders.length){
|
||||
list.innerHTML='<div class="empty"><div class="big">🧾</div>No orders yet.<br>Check the schedule and grab something good.</div>';
|
||||
list.innerHTML='<div class="empty"><div class="big">\ud83e\uddfe</div>No orders yet.<br>Check the schedule and grab something good.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML=state.orders.map(o=>{
|
||||
const badge=o.status==='PAID'?'<span class="badge b-paid">PAID</span>'
|
||||
:o.status==='PAID (DEMO)'?'<span class="badge b-demo">PAID · DEMO</span>'
|
||||
:o.status==='FAILED'?'<span class="badge b-failed">FAILED</span>'
|
||||
:'<span class="badge b-pending">PENDING</span>';
|
||||
const badge=STATUS_BADGE[o.status]||STATUS_BADGE.PREPARING;
|
||||
const readyBanner=o.status==='READY'
|
||||
?'<div style="background:var(--green-soft);border-radius:12px;padding:10px 12px;margin-top:8px;font-size:12px;font-weight:700;color:#15803d">\ud83c\udf7d\ufe0f Ready now at <b>'+esc(o.stand||'the stand')+'</b> \u2014 '+esc(o.standLocation||'')+'</div>':'';
|
||||
return '<div class="order-card">'+
|
||||
'<div class="order-top"><span class="order-code">'+o.code+'</span>'+badge+'</div>'+
|
||||
'<div class="order-meta">'+o.day+' · pickup '+o.pickupLabel+' · '+esc(o.name)+(o.notes?' · “'+esc(o.notes)+'”':'')+'</div>'+
|
||||
'<div class="order-items">'+o.itemsSummary+'</div>'+
|
||||
'<div class="order-total"><span>'+fmt$(o.totalUsd)+'</span><span class="btc">'+(o.btcEstimate||'')+'</span></div>'+
|
||||
(o.real&&o.status!=='PAID'&&o.status!=='FAILED'?'<div class="order-actions"><button class="btn btn-ghost btn-sm" data-recheck="'+o.id+'">Re-check status</button></div>':'')+
|
||||
'<div class="order-top"><span class="order-code">'+esc(o.code)+'</span>'+badge+'</div>'+
|
||||
'<div class="order-meta">'+esc(o.day||'')+' \u00b7 pickup '+esc(o.pickupLabel||'')+' \u00b7 '+esc(o.name||'')+(o.notes?' \u00b7 \u201c'+esc(o.notes)+'\u201d':'')+'</div>'+
|
||||
'<div class="order-items">'+esc(o.itemsSummary||'')+'</div>'+
|
||||
'<div class="order-total"><span>'+fmt$(o.totalUsd||0)+'</span><span class="btc">'+esc(o.stand||'')+'</span></div>'+
|
||||
readyBanner+
|
||||
(o.status!=='COLLECTED'&&o.status!=='CANCELLED'?'<div class="order-actions"><button class="btn btn-ghost btn-sm" data-recheck="'+esc(o.code)+'">Re-check status</button></div>':'')+
|
||||
'</div>';
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-recheck]').forEach(b=>b.onclick=async()=>{
|
||||
const o=state.orders.find(x=>x.id===b.dataset.recheck);
|
||||
const s=state.settings;if(!s)return;
|
||||
b.disabled=true;b.textContent='Checking…';
|
||||
b.disabled=true;b.textContent='Checking\u2026';
|
||||
try{
|
||||
const r=await fetch(s.url.replace(/\/+$/,'')+'/v1/invoices/'+o.invoiceId,{headers:btcpayHeaders()});
|
||||
const inv=await r.json();
|
||||
const st=(inv.status||'').toLowerCase();
|
||||
if(['paid','confirmed','settled'].includes(st)){o.status='PAID';saveOrders();toast('Payment confirmed 🎉');}
|
||||
}catch(e){}
|
||||
const r=await fetch('/api/orders/'+encodeURIComponent(b.dataset.recheck));
|
||||
if(r.status===404){applyRemoteStatus(b.dataset.recheck,'COLLECTED');}
|
||||
else if(r.ok){const rec=await r.json();applyRemoteStatus(rec.code,rec.status,rec);}
|
||||
else toast('Server said: HTTP '+r.status);
|
||||
}catch(e){toast('Could not reach the kitchen server');}
|
||||
renderOrders();
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================== SETTINGS ============================== */
|
||||
$('#btn-settings').onclick=()=>{
|
||||
const s=state.settings||{};
|
||||
$('#set-url').value=s.url||'';$('#set-store').value=s.store||'';$('#set-key').value=s.apiKey||'';
|
||||
const tr=$('#test-result');tr.className='test-result';
|
||||
$('#settings-modal').classList.add('show');
|
||||
};
|
||||
$('#btn-close-settings').onclick=()=>$('#settings-modal').classList.remove('show');
|
||||
$('#settings-modal').addEventListener('mousedown',e=>{$('#settings-modal')._down=e.target.id==='settings-modal';});
|
||||
$('#settings-modal').addEventListener('click',e=>{if(e.target.id==='settings-modal'&&$('#settings-modal')._down)e.target.classList.remove('show');});
|
||||
$('#btn-save-settings').onclick=()=>{
|
||||
state.settings={url:$('#set-url').value.trim(),store:$('#set-store').value.trim(),apiKey:$('#set-key').value.trim()};
|
||||
saveSettings();
|
||||
$('#settings-modal').classList.remove('show');
|
||||
toast(state.settings.url?'BTCPay saved':'Settings cleared');
|
||||
};
|
||||
/* Legacy direct-BTCPay test handler retained below only as migration reference.
|
||||
$('#btn-test').onclick=async()=>{
|
||||
const tr=$('#test-result');
|
||||
tr.className='test-result';
|
||||
tr.textContent='Testing payment proxy…';
|
||||
try{
|
||||
const r=await fetch(url.replace(/\/+$/,'')+'/v1/stores/'+store,{headers:$('#set-key').value.trim()?{'Authorization':'Bearer '+$('#set-key').value.trim()}:{}});
|
||||
if(r.ok){
|
||||
const j=await r.json();
|
||||
tr.className='test-result ok';tr.textContent='✓ Connected — store: '+(j.name||j.id);
|
||||
}else{
|
||||
tr.className='test-result err';tr.textContent='✗ HTTP '+r.status+(r.status===401?' — add a store API key with read access.':'');
|
||||
}
|
||||
}catch(e){tr.className='test-result err';tr.textContent='✗ Could not reach server: '+e.message;}
|
||||
};
|
||||
*/
|
||||
/* Test the local proxy; BTCPay credentials must never be sent from this page. */
|
||||
$('#btn-test').onclick=async()=>{
|
||||
const tr=$('#test-result');tr.className='test-result';tr.textContent='Testing payment proxy…';
|
||||
try{
|
||||
const r=await fetch('/api/settings');
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
tr.className=j.configured?'test-result ok':'test-result err';
|
||||
tr.textContent=j.configured?'✓ Payment proxy connected — '+(j.store||'store configured'):'✗ Payment proxy is not configured.';
|
||||
}catch(e){tr.className='test-result err';tr.textContent='✗ Could not reach payment proxy: '+e.message;}
|
||||
};
|
||||
$('#btn-clear-orders').onclick=()=>{
|
||||
if(!state.orders.length)return;
|
||||
if(confirm('Clear all saved orders?')){state.orders=[];saveOrders();$('#settings-modal').classList.remove('show');renderOrders();}
|
||||
};
|
||||
|
||||
|
||||
/* ============================== CLOCK / LOOP ============================== */
|
||||
function tickClock(){
|
||||
const d=new Date();
|
||||
|
|
@ -841,12 +674,15 @@ setInterval(()=>{tickClock();if(!$('#view-schedule').hidden)refreshStatuses();},
|
|||
renderDayTabs();
|
||||
renderTimeline();
|
||||
renderFab();
|
||||
try{
|
||||
const r=await fetch('/api/settings');
|
||||
if(r.ok){const j=await r.json();if(j.configured){state.settings={configured:true,url:j.url,store:j.store};saveSettings();}}
|
||||
}catch(e){}
|
||||
await fetchBtcPrice();
|
||||
setInterval(fetchBtcPrice,5*60*1000);
|
||||
// reconcile locally-saved orders against the server, then live-watch the active ones
|
||||
for(const o of state.orders){
|
||||
if(o.status==='COLLECTED'||o.status==='CANCELLED')continue;
|
||||
try{
|
||||
const r=await fetch('/api/orders/'+encodeURIComponent(o.code));
|
||||
if(r.ok){const rec=await r.json();applyRemoteStatus(o.code,rec.status,rec);}
|
||||
}catch(e){}
|
||||
if(o.status!=='COLLECTED'&&o.status!=='CANCELLED')watchOrder(o.code);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue