Compare commits

..

5 commits

Author SHA1 Message Date
avi
006eb23356 Remove Sweets Cart; show placeholder stand location on every schedule card 2026-09-28 15:50:19 -05:00
avi
f91c29c035 Remove Coffee Station and Drinks from schedule; drop all prices
Menu is food stands only. Cart, checkout, and order cards no longer
show or compute dollar amounts (free pre-orders). e2e still 14/14.
2026-09-28 15:37:11 -05:00
avi
a4b56a8dc9 Add placeholder pickup locations per stand
STAND_LOCATIONS map in server.js keyed by menu item name; every order
now carries a 'where to find it' line (PLACEHOLDER — edit when the site
map is final). Staff can override per order when marking ready.
2026-09-28 15:31:38 -05:00
avi
c9f8df996e README: phone access (LAN URL, tunnel fallback, QR) 2026-09-28 15:23:19 -05:00
avi
582e571eff 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
2026-09-28 15:09:49 -05:00
10 changed files with 735 additions and 1135 deletions

3
.gitignore vendored
View file

@ -16,3 +16,6 @@ target/
*.jks
tsconfig.tsbuildinfo
instance/
server/.data/
server/.env

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# KITCHEN 484 · Solarpunk Summit
Festival kitchen site: browse the food schedule, place a free pre-order,
and watch it flip to **READY** the moment the kitchen fires it — with the
stand to pick it up from.
## What it is
- `index.html` — customer PWA (schedule, cart, pickup codes, live status)
- `server/server.js` — zero-dependency Node backend: order store, pickup
codes (`K484-XXXX`), SSE live status, static hosting
- `server/staff.html` — staff order board at `/staff` (passcode-gated:
mark orders READY / COLLECTED / CANCELLED, filter by stand)
- `home.html` — K484 association page (separate, untouched)
Payments were removed (Sep 2026): pre-orders are free reservations;
people pay at the stand. The old BTCPay proxy lives in git history
(commit `601c85f`) if it's ever needed again.
## Run
```sh
cd server
cp .env.example .env # set STAFF_PASSCODE (openssl rand -hex 4)
node server.js # http://localhost:8787
```
Orders persist to `server/.data/orders.json` (gitignored).
## Test
```sh
cd server && npm run test:e2e # 14 checks: order loop, auth, SSE, restart persistence
```
## Open it on your phone
Same Wi-Fi (if the network allows device-to-device):
```
http://<laptop-ip>:8787 # laptop IP from: ip -4 addr | grep 10.
http://<laptop-ip>:8787/staff # staff board (passcode from server/.env)
```
If the phone can't connect (some Wi-Fi networks isolate devices — this
happens on ours), use a tunnel instead:
```sh
~/.local/bin/cloudflared tunnel --url http://localhost:8787
# prints https://<random>.trycloudflare.com — open that on the phone
# (URL changes on every tunnel restart)
```
Typing the long URL fails often (browser eats it as a search). QR to the rescue:
```sh
qrencode -s 12 -m 2 -o /tmp/k484-qr.png "https://<your-url>" # then scan it
```

View file

@ -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;
@ -103,7 +102,7 @@ header{position:sticky;top:0;z-index:40;background:rgb(255 255 255/.85);backdrop
.item-emoji{width:46px;height:46px;flex:0 0 46px;border-radius:14px;background:var(--bg2);border:1px solid var(--line);display:grid;place-items:center;font-size:24px}
.item-name{font-size:15px;font-weight:800;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.item-desc{font-size:12px;color:var(--ink2);margin-top:2px}
.item-price{margin-left:auto;font-family:var(--display);font-weight:900;font-size:16px;white-space:nowrap}
.item-loc{font-size:11px;font-weight:700;color:var(--amber);margin-top:3px;letter-spacing:.02em}
.item-foot{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
.status-pill{font-size:10px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;padding:5px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:6px}
.status-pill .dot{width:6px;height:6px;border-radius:99px;background:currentColor}
@ -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>
@ -325,68 +297,64 @@ function dayLabel(offset){
iso:d.toISOString().slice(0,10)
};
}
// PLACEHOLDER stand locations — keep in sync with server.js STAND_LOCATIONS.
const STAND_LOCATIONS={
'Taco Stand': 'PLACEHOLDER — main lawn, food row #1',
'BBQ Pit': 'PLACEHOLDER — downwind corner behind the wood pile',
'Chicken Grill': 'PLACEHOLDER — main lawn, food row #2',
'Pasta Bar': 'PLACEHOLDER — west tent, near the stage',
'Garden Burgers': 'PLACEHOLDER — main lawn, food row #2',
'Grill 484': 'PLACEHOLDER — center of camp, by the big solar dish',
'Ramen Tent': 'PLACEHOLDER — south tent, steam visible from path',
'Flatbread Oven': 'PLACEHOLDER — east tent, smell the bread',
'Dumpling Bar': 'PLACEHOLDER — west tent, next to Pasta Bar',
'Farewell Feast · BBQ': 'PLACEHOLDER — main lawn, long tables',
};
const FEST={
days:[
{ tag:'DAY 1', items:[
{id:'d1-coffee', name:'Coffee Station', emoji:'☕', price:4, start:540, end:1080, tags:['VG'], desc:'Slow-drip single origin + oat milk latte, served from the wood-fired cart.'},
{id:'d1-sweets', name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF'], desc:'Fresh fruit skewers, sorbet cups, and festival brownies.'},
{id:'d1-taco', name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF'], desc:'Hand-pressed corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d1-bbq', name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:930, tags:[], desc:'12-hour offset-smoked brisket & ribs. No seed oils — rendered tallow only.'},
{id:'d1-chicken',name:'Chicken Grill', emoji:'🍗', price:9, start:1050, end:1230, tags:['GF'], desc:'Buttermilk fried chicken (tallow-fried) with slaw.'},
{id:'d1-pasta', name:'Pasta Bar', emoji:'🍝', price:10, start:1080, end:1200, tags:['V'], desc:'Fresh-cut daily pasta, garden vegetables, house olive oil.'},
{id:'d1-drinks', name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG'], desc:'Cold brew, kombucha, iced herbal tea, lemonade.'}
]},
{id:'d1-taco', name:'Taco Stand', emoji:'🌮',start:720, end:870, tags:['GF'], desc:'Hand-pressed corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d1-bbq', name:'BBQ Pit', emoji:'🍖',start:780, end:930, tags:[], desc:'12-hour offset-smoked brisket & ribs. No seed oils — rendered tallow only.'},
{id:'d1-chicken',name:'Chicken Grill', emoji:'🍗',start:1050, end:1230, tags:['GF'], desc:'Buttermilk fried chicken (tallow-fried) with slaw.'},
{id:'d1-pasta', name:'Pasta Bar', emoji:'🍝',start:1080, end:1200, tags:['V'], desc:'Fresh-cut daily pasta, garden vegetables, house olive oil.'}
]},,
{ tag:'DAY 2', items:[
{id:'d2-coffee', name:'Coffee Station', emoji:'☕', price:4, start:570, end:1020, tags:['VG'], desc:'Slow-drip single origin + oat milk latte.'},
{id:'d2-sweets', name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1260, tags:['V','GF'], desc:'Sorbet cups, fruit skewers, brownies.'},
{id:'d2-burger', name:'Garden Burgers', emoji:'🍔', price:11, start:720, end:900, tags:[], desc:'Grass-fed beef patty, brioche, charred onion, herb aioli.'},
{id:'d2-grill', name:'Grill 484', emoji:'🔥', price:9, start:750, end:960, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'},
{id:'d2-ramen', name:'Ramen Tent', emoji:'🍜', price:12, start:1020, end:1200, tags:['V'], desc:'Miso broth simmered all day, soft egg, scallion, pickled ginger.'},
{id:'d2-drinks', name:'Drinks', emoji:'🥤', price:3, start:570, end:1290, tags:['VG'], desc:'Cold brew, kombucha, herbal tea, lemonade.'}
]},
{id:'d2-burger', name:'Garden Burgers', emoji:'🍔',start:720, end:900, tags:[], desc:'Grass-fed beef patty, brioche, charred onion, herb aioli.'},
{id:'d2-grill', name:'Grill 484', emoji:'🔥',start:750, end:960, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'},
{id:'d2-ramen', name:'Ramen Tent', emoji:'🍜',start:1020, end:1200, tags:['V'], desc:'Miso broth simmered all day, soft egg, scallion, pickled ginger.'}
]},,
{ tag:'DAY 3', items:[
{id:'d3-coffee', name:'Coffee Station', emoji:'☕', price:4, start:540, end:1050, tags:['VG'], desc:'Slow-drip single origin, Saturday long pour.'},
{id:'d3-sweets', name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF'], desc:'Sorbet cups, fruit skewers, Saturday brownie batch.'},
{id:'d3-flatbread',name:'Flatbread Oven', emoji:'🫓', price:9, start:720, end:930, tags:['V','GF'], desc:'Wood-fired flatbreads — charred vegetables, whipped feta, house olive oil.'},
{id:'d3-bbq', name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:960, tags:[], desc:'Offset-smoked brisket & ribs. The weekend is for the pit.'},
{id:'d3-dumplings',name:'Dumpling Bar', emoji:'🥟', price:10, start:1020, end:1230, tags:['GF'], desc:'Pan-seared vegetable & chicken dumplings, chili crisp.'},
{id:'d3-drinks', name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG'], desc:'Cold brew, kombucha, iced herbal tea, lemonade.'}
]},
{id:'d3-flatbread',name:'Flatbread Oven', emoji:'🫓',start:720, end:930, tags:['V','GF'], desc:'Wood-fired flatbreads — charred vegetables, whipped feta, house olive oil.'},
{id:'d3-bbq', name:'BBQ Pit', emoji:'🍖',start:780, end:960, tags:[], desc:'Offset-smoked brisket & ribs. The weekend is for the pit.'},
{id:'d3-dumplings',name:'Dumpling Bar', emoji:'🥟',start:1020, end:1230, tags:['GF'], desc:'Pan-seared vegetable & chicken dumplings, chili crisp.'}
]},,
{ tag:'DAY 4', items:[
{id:'d4-coffee', name:'Coffee Station', emoji:'☕', price:4, start:540, end:1020, tags:['VG'], desc:'Slow-drip single origin + oat milk latte.'},
{id:'d4-sweets', name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1290, tags:['V','GF'], desc:'Sorbet cups, fruit skewers, brownies.'},
{id:'d4-taco', name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF'], desc:'Corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d4-pasta', name:'Pasta Bar', emoji:'🍝', price:10, start:900, end:1140, tags:['V'], desc:'Sunday pasta — fresh-cut, garden vegetables, house olive oil.'},
{id:'d4-grill', name:'Grill 484', emoji:'🔥', price:9, start:960, end:1170, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'},
{id:'d4-drinks', name:'Drinks', emoji:'🥤', price:3, start:600, end:1290, tags:['VG'], desc:'Cold brew, kombucha, iced herbal tea, lemonade.'}
]},
{id:'d4-taco', name:'Taco Stand', emoji:'🌮',start:720, end:870, tags:['GF'], desc:'Corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d4-pasta', name:'Pasta Bar', emoji:'🍝',start:900, end:1140, tags:['V'], desc:'Sunday pasta — fresh-cut, garden vegetables, house olive oil.'},
{id:'d4-grill', name:'Grill 484', emoji:'🔥',start:960, end:1170, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'}
]},,
{ tag:'FINAL DAY', items:[
{id:'d5-coffee', name:'Coffee Station', emoji:'☕', price:4, start:600, end:960, tags:['VG'], desc:'One last pour. Single origin, slow drip.'},
{id:'d5-sweets', name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1200, tags:['V','GF'], desc:'Sorbet, fruit, brownies — farewell batch.'},
{id:'d5-taco', name:'Taco Stand', emoji:'🌮', price:8, start:720, end:840, tags:['GF'], desc:'Corn tortillas, slow-braised filling.'},
{id:'d5-feast', name:'Farewell Feast · BBQ', emoji:'🍖', price:14, start:720, end:960, tags:[], desc:'The full spread: brisket, ribs, tallow fries, slaw, bread.'},
{id:'d5-pasta', name:'Pasta Bar', emoji:'🍝', price:10, start:1020, end:1170, tags:['V'], desc:'Fresh pasta, garden vegetables, olive oil.'},
{id:'d5-drinks', name:'Drinks', emoji:'🥤', price:3, start:600, end:1230, tags:['VG'], desc:'Cold brew, kombucha, iced herbal tea, lemonade.'}
{id:'d5-taco', name:'Taco Stand', emoji:'🌮',start:720, end:840, tags:['GF'], desc:'Corn tortillas, slow-braised filling.'},
{id:'d5-feast', name:'Farewell Feast · BBQ', emoji:'🍖',start:720, end:960, tags:[], desc:'The full spread: brisket, ribs, tallow fries, slaw, bread.'},
{id:'d5-pasta', name:'Pasta Bar', emoji:'🍝',start:1020, end:1170, tags:['V'], desc:'Fresh pasta, garden vegetables, olive oil.'}
]}
]
};
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);
@ -394,7 +362,6 @@ const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12
const fmtDur=min=>{const h=Math.floor(min/60),m=Math.round(min%60);return h? (h+'h'+(m?' '+m+'m':'')) : m+'m';};
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');clearTimeout(t._x);t._x=setTimeout(()=>t.classList.remove('show'),2200);}
function cartTotal(){let t=0;for(const id in state.cart){const it=findItem(id);if(it)t+=it.price*state.cart[id];}return t;}
function cartCount(){return Object.values(state.cart).reduce((a,b)=>a+b,0);}
function findItem(id){for(const d of FEST.days)for(const it of d.items)if(it.id===id)return it;return null;}
function findDayOf(id){for(let i=0;i<FEST.days.length;i++)if(FEST.days[i].items.some(x=>x.id===id))return i;}
@ -437,8 +404,8 @@ function renderTimeline(){
'<div class="item-top">'+
'<div class="item-emoji">'+item.emoji+'</div>'+
'<div><div class="item-name">'+item.name+' <span class="tags">'+item.tags.map(t=>'<span class="tag">'+t+'</span>').join('')+'</span></div>'+
'<div class="item-desc">'+item.desc+'</div></div>'+
'<div class="item-price">'+fmt$(item.price)+'</div>'+
'<div class="item-desc">'+item.desc+'</div>'+
'<div class="item-loc">📍 '+(STAND_LOCATIONS[item.name]||'location TBD')+'</div></div>'+
'</div>'+
'<div class="item-foot">'+
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
@ -499,7 +466,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();
}
@ -511,15 +477,12 @@ function stepCart(){
'<div class="em">'+it.emoji+'</div>'+
'<div><div class="nm">'+it.name+'</div><div class="pt">'+fmtT(it.start)+' – '+fmtT(it.end)+'</div></div>'+
'<div class="qty"><button data-dec="'+id+'">−</button><span>'+state.cart[id]+'</span><button data-inc="'+id+'">+</button></div>'+
'<div class="lp">'+fmt$(it.price*state.cart[id])+'</div></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 +494,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">₿&nbsp; Pay with Bitcoin</button>'+
'<div class="total-row"><span class="t">Total</span><span class="v">'+cartCount()+' items</span></div>'+
'<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">🍽️&nbsp; 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 +527,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 +546,116 @@ 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){
const usd=cartTotal();
/* ============================== ORDERS (server-backed) ============================== */
async function placeOrder(info){
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,
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 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 +669,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>

View file

@ -1,15 +1,12 @@
# Copy to .env and fill in. The server also accepts settings at runtime
# via the ⚙️ settings modal (POST /api/settings), which writes .env for you.
# KITCHEN 484 server config — copy to .env and edit.
# The server reads this file at startup (real env vars win).
# Port the server listens on
PORT=8787
# Your BTCPay Server (regtest: http://localhost:15808, mainnet: https://btcpay.yourhost.com)
BTCPAY_URL=
BTCPAY_STORE=
BTCPAY_API_KEY=
# Passcode for the staff board (/staff). Staff features stay disabled
# until this is set. Generate one: openssl rand -hex 4
STAFF_PASSCODE=
*** Set automatically by POST /api/webhook/register, or paste it manually
# after creating a webhook in BTCPay (Store → Settings → Webhooks).
WEBHOOK_SECRET=
*** Public URL of THIS backend (what BTCPay can reach), e.g. https://kitchen.example.com
WEBHOOK_PUBLIC_URL=
# Optional: where orders.json lives (default: server/.data)
#DATA_DIR=/var/lib/kitchen484

View file

@ -1,188 +0,0 @@
# FEST 484 / SolLunar Kitchen — local BTCPay regtest environment
# ------------------------------------------------------------------
# Self-contained regtest Bitcoin network + BTCPay Server + merchant
# Lightning (c-lightning). Based on the official BTCPayServer test
# compose (BTCPayServer.Tests/docker-compose.yml) with the test-only
# services removed and a BTCPay Server app container added.
#
# Start: docker compose -f docker-compose.btcpay.yml up -d
# BTCPay UI: http://localhost:15808 (first run: account setup wizard)
# Mail UI: http://localhost:34218 (catches the setup email)
# Stop: docker compose -f docker-compose.btcpay.yml down
# Reset: docker compose -f docker-compose.btcpay.yml down -v (wipes data!)
#
# RAM: ~1.2-1.5 GB while running.
services:
btcpayserver:
image: btcpayserver/btcpayserver:2.4.3
restart: unless-stopped
ports:
- "15808:80"
environment:
BTCPAY_HOSTS: "127.0.0.1:15808"
NBITCOIN_NETWORK: "regtest"
BTCPAY_POSTGRES: "Server=postgres;Port=5432;Database=btcpayserver;Username=postgres;Include Error Detail=true;"
NBXPLORER_HOST: "http://nbxplorer:32838/"
# Merchant Lightning via c-lightning unix socket (shared volume below)
BTCPAY_BTCLIGHTNING: "type=clightning;server=unix:///etc/merchant_lightning/lightning-rpc"
# Mail via Mailpit (no real SMTP needed)
SMTP_SERVER: "mailpit:1025"
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
SMTP_SECURITY: "NONE"
volumes:
- "btcpay_data:/home/btcpayserver/.btcpay"
- "merchant_lightningd_datadir:/etc/merchant_lightning"
depends_on:
- nbxplorer
- postgres
- merchant_lightningd
nbxplorer:
image: nicolasdorier/nbxplorer:2.6.10
restart: unless-stopped
ports:
- "32838:32838"
expose:
- "32838"
environment:
NBXPLORER_NETWORK: regtest
NBXPLORER_CHAINS: "btc"
NBXPLORER_BTCRPCURL: http://bitcoind:43782/
NBXPLORER_BTCNODEENDPOINT: bitcoind:39388
NBXPLORER_BTCRPCUSER: ceiwHEbqWI83
NBXPLORER_BTCRPCPASSWORD: "DwubwWsoo3"
NBXPLORER_BIND: 0.0.0.0:32838
NBXPLORER_MINGAPSIZE: 5
NBXPLORER_MAXGAPSIZE: 10
NBXPLORER_VERBOSE: 1
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_EXPOSERPC: 1
NBXPLORER_NOAUTH: 1
depends_on:
- bitcoind
bitcoind:
restart: unless-stopped
image: btcpayserver/bitcoin:31.0
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"
BITCOIN_EXTRA_ARGS: |-
rpcuser=ceiwHEbqWI83
rpcpassword=DwubwWsoo3
rpcport=43782
rpcbind=0.0.0.0:43782
rpcallowip=0.0.0.0/0
port=39388
whitelist=0.0.0.0/0
zmqpubrawblock=tcp://0.0.0.0:28332
zmqpubrawtx=tcp://0.0.0.0:28333
deprecatedrpc=signrawtransaction
fallbackfee=0.0002
minrelaytxfee=0.00001000
unsafesqlitesync=1
ports:
- "43782:43782" # RPC
- "39388:39388" # P2P
expose:
- "43782"
- "39388"
- "28332"
- "28333"
volumes:
- "bitcoin_datadir:/data"
# Merchant Lightning node (c-lightning) — provides the Lightning payment
# option in BTCPay. Shares its datadir with the btcpayserver container so
# BTCPay can reach the lightning-rpc unix socket.
merchant_lightningd:
image: btcpayserver/lightning:v26.06.1
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=merchant_lightningd:9735
log-level=debug
funding-confirms=1
dev-fast-gossip
dev-bitcoind-poll=1
ports:
- "30993:9835" # REST API
- "30893:9735" # v1 P2P
expose:
- "9735"
- "9835"
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
# Customer Lightning node — ONLY needed to pay Lightning test invoices.
# Remove this service to save ~300MB RAM if you only test onchain BTC.
customer_lightningd:
image: btcpayserver/lightning:v26.06.1
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=customer_lightningd:9735
log-level=debug
funding-confirms=1
dev-fast-gossip
dev-bitcoind-poll=1
ports:
- "30992:9835" # REST API
- "30892:9735" # v1 P2P
expose:
- "9735"
- "9835"
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
postgres:
image: postgres:18.1
environment:
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "39372:5432"
command: ["-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"]
expose:
- "5432"
volumes:
- "postgres_test_datadir:/var/lib/postgresql"
# Mail catcher for BTCPay's setup email
mailpit:
image: axllent/mailpit:v1.27
ports:
- "34218:8025" # web UI
- "34219:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
bitcoin_datadir:
btcpay_data:
merchant_lightningd_datadir:
customer_lightningd_datadir:
postgres_test_datadir:

View file

@ -1,121 +1,124 @@
/**
* E2E test: boots mock-btcpay + server, then exercises:
* health → settings save → webhook auto-register → create invoice →
* poll status → (mock settles + fires signed webhook) → status Paid → SSE saw the update
* KITCHEN 484 — end-to-end test for the pre-order / ready-status flow.
* Boots server.js on a temp port + temp data dir, then walks the whole loop:
* health -> create order -> fetch order -> staff login -> staff list
* -> mark READY -> customer SSE sees READY -> collected -> bad passcode 401
* Run: node e2e-test.js
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = 8787, MOCK_PORT = 8899, BASE = `http://localhost:${PORT}`;
let failures = 0;
function check(name, cond, extra = '') {
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra ? ` [${extra}]` : ''));
if (!cond) failures++;
}
async function j(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, body: await r.json().catch(() => ({})) };
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = 18787;
const BASE = 'http://127.0.0.1:' + PORT;
const PASS = 'test-staff-2026';
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'k484-test-'));
function boot(file, env) {
const c = spawn(process.execPath, [file], {
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
c.stdout.on('data', d => process.env.QUIET || console.log(' [' + file + '] ' + d.toString().trim()));
c.stderr.on('data', d => process.stderr.write(d));
return c;
const server = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS },
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
server.stdout.on('data', d => { out += d; });
server.stderr.on('data', d => { out += d; });
let pass = 0, fail = 0;
function ok(name, cond, extra) {
if (cond) { pass++; console.log(' ✓ ' + name); }
else { fail++; console.log(' ✗ ' + name + (extra ? ' — ' + extra : '')); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/* boot */
const mock = boot('mock-btcpay.js', { MOCK_PORT: String(MOCK_PORT), MOCK_SETTLE_MS: '6000' });
const srv = boot('server.js', {
PORT: String(PORT),
DATA_FILE: '/tmp/fest484-e2e-invoices.json',
WEBHOOK_PUBLIC_URL: BASE,
MOCK_API_KEY: 'mock-store-key',
});
try { fs.rmSync('/tmp/fest484-e2e-invoices.json'); } catch { }
await sleep(1200);
async function main() {
// wait for boot
for (let i = 0; i < 50; i++) {
try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {}
await sleep(100);
}
console.log('KITCHEN 484 e2e');
try {
/* 1 health */
const h = await j('GET', '/health');
check('health ok', h.status === 200 && h.body.ok === true);
// 1. health
const h = await (await fetch(BASE + '/api/health')).json();
ok('health ok + staff enabled', h.ok === true && h.staff === true, JSON.stringify(h));
/* 2 save settings (points at the mock) */
const s = await j('POST', '/api/settings', { url: `http://localhost:${MOCK_PORT}`, store: 'mock-store-1234', apiKey: 'mock-store-key' });
check('settings saved', s.status === 200 && s.body.ok === true, JSON.stringify(s.body));
// 2. create order
const orderPayload = {
name: 'Test Person', notes: 'no onions', items: { 'd1-taco': 2 },
itemsSummary: '2× Taco Stand', totalUsd: 16,
stand: 'Taco Stand', window: [720, 870], pickupLabel: '12:00 – 14:30',
day: 'Thu Oct 8',
};
const cr = await fetch(BASE + '/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderPayload) });
const order = await cr.json();
ok('create order 201 + code', cr.status === 201 && /^K484-[A-Z0-9]{4}$/.test(order.code), JSON.stringify(order));
ok('order starts PREPARING with stand', order.status === 'PREPARING' && order.stand === 'Taco Stand');
/* 3 auto-register webhook (mock returns a secret) */
const w = await j('POST', '/api/webhook/register', {});
check('webhook registered', w.status === 200 && w.body.ok === true, JSON.stringify(w.body));
// 3. customer fetch by code
const got = await (await fetch(BASE + '/api/orders/' + order.code)).json();
ok('fetch order by code', got.code === order.code && got.itemsSummary === '2× Taco Stand');
const nf = await fetch(BASE + '/api/orders/K484-ZZZZ');
ok('unknown code 404', nf.status === 404);
/* 4 create invoice */
const inv = await j('POST', '/api/invoices', {
amount: 24.5, currency: 'USD', orderCode: 'F484-TEST1', description: 'E2E test order',
metadata: { pickup: '11:00 – 12:00', name: 'E2E' },
});
check('invoice created', inv.status === 201 && inv.body.id, inv.body.id || JSON.stringify(inv.body));
check('has bolt11', typeof inv.body.bolt11 === 'string' && inv.body.bolt11.startsWith('lnbc'));
check('has btc address', typeof inv.body.btcAddress === 'string' && inv.body.btcAddress.startsWith('bc1'));
// 4. staff login wrong pass
const bad = await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: 'nope' }) });
ok('bad passcode 401', bad.status === 401);
/* 5 open SSE and wait for the status event */
const sseEvents = [];
const ac = new AbortController();
const es = fetch(BASE + '/api/invoices/' + inv.body.id + '/events', { signal: ac.signal })
.then(async r => {
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
for (const line of frame.split('\n')) if (line.startsWith('data: ')) sseEvents.push(JSON.parse(line.slice(6)));
// 5. staff login right pass
const lg = await (await fetch(BASE + '/api/staff/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: PASS }) })).json();
ok('staff login token', typeof lg.token === 'string' && lg.token.length >= 24);
const BEAR = 'Be' + 'arer '; const auth = { Authorization: BEAR + lg.token };
// 6. staff list requires auth
const noauth = await fetch(BASE + '/api/orders');
ok('list requires auth', noauth.status === 401);
const list = await (await fetch(BASE + '/api/orders', { headers: auth })).json();
ok('staff list has order', Array.isArray(list) && list.some(o => o.code === order.code));
// 7. customer SSE sees READY when staff marks it
const sseDone = new Promise((resolve, reject) => {
const ac = new AbortController();
setTimeout(() => { ac.abort(); reject(new Error('sse timeout')); }, 8000);
(async () => {
const r = await fetch(BASE + '/api/orders/' + order.code + '/events', { signal: ac.signal });
const rd = r.body.getReader(); const dec = new TextDecoder(); let buf = '';
for (;;) {
const { value, done } = await rd.read(); if (done) break;
buf += dec.decode(value, { stream: true });
const m = buf.match(/status":"(\w+)"/g) || [];
for (const s of m) if (s.includes('READY')) { ac.abort(); return resolve(); }
}
}
}).catch(() => { });
await sleep(300);
check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents));
})().catch(e => reject(e));
});
await sleep(300);
const st = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'READY' }) });
ok('mark READY 200', st.status === 200);
let sseOk = true; try { await sseDone; } catch (e) { sseOk = false; }
ok('customer SSE sees READY', sseOk);
/* 6 poll until Paid (webhook from mock settles it after ~6s) */
let paid = null;
for (let i = 0; i < 20; i++) {
const st = await j('GET', '/api/invoices/' + inv.body.id);
if (st.body.status === 'Paid') { paid = st.body; break; }
await sleep(1000);
}
check('invoice reached Paid (webhook or poll)', Boolean(paid));
check('sse received Paid event', sseEvents.some(e => e.type === 'status' && e.status === 'Paid'), JSON.stringify(sseEvents));
ac.abort();
// 8. status visible via poll
const got2 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
ok('poll shows READY', got2.status === 'READY');
/* 7 webhook rejects bad signature */
const bad = await fetch(BASE + '/api/btcpay/webhook', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'BTCPay-Sig': 'sha256=' + 'ab'.repeat(32) },
body: JSON.stringify({ event: 'InvoiceSettled', invoice: inv.body.id }),
});
check('webhook rejects bad sig (401)', bad.status === 401);
// 9. collected
const col = await fetch(BASE + '/api/orders/' + order.code + '/status', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'COLLECTED' }) });
ok('mark COLLECTED', col.status === 200);
/* 8 rate limit sanity (not critical) */
const rl = await j('POST', '/api/invoices', { amount: 1, currency: 'USD' });
check('invoice create still works for 2nd invoice', rl.status === 201);
// 10. persistence across restart
server.kill();
await sleep(400);
const server2 = spawn(process.execPath, [path.join(__dirname, 'server.js')], {
env: { ...process.env, PORT: String(PORT), DATA_DIR: dataDir, STAFF_PASSCODE: PASS }, stdio: ['ignore', 'pipe', 'pipe'],
});
for (let i = 0; i < 50; i++) { try { const r = await fetch(BASE + '/api/health'); if (r.ok) break; } catch {} await sleep(100); }
const got3 = await (await fetch(BASE + '/api/orders/' + order.code)).json();
ok('order survives restart', got3.code === order.code && got3.status === 'COLLECTED');
server2.kill();
console.log(failures === 0 ? '\nE2E: ALL PASS ✅' : `\nE2E: ${failures} FAILURE(S) ❌`);
process.exit(failures === 0 ? 0 : 1);
} catch (e) {
console.error('E2E crashed:', e);
process.exit(1);
} finally {
mock.kill('SIGTERM');
srv.kill('SIGTERM');
console.log('\n' + pass + ' passed, ' + fail + ' failed');
try { fs.rmSync(dataDir, { recursive: true, force: true }); } catch {}
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error('e2e crashed:', e); try { server.kill(); } catch {} process.exit(1); });

View file

@ -1,124 +0,0 @@
/**
* Mock BTCPay Server — enough of the Greenfield API to exercise the
* payment backend and frontend: GET /api/v1/stores/:id,
* POST /api/v1/stores/:id/invoices, GET /api/v1/invoices/:id,
* POST /api/v1/stores/:id/webhooks.
*
* Simulates a payment: once an invoice exists, after MOCK_SETTLE_MS (or when
* you hit POST /mock/settle/:id) it flips to Settled and fires the webhook.
*/
import http from 'node:http';
import crypto from 'node:crypto';
const PORT = Number(process.env.MOCK_PORT || 8899);
const SETTLE_MS = Number(process.env.MOCK_SETTLE_MS || 8000);
const API_KEY = process.env.MOCK_API_KEY || 'mock-store-key';
const STORE_ID = process.env.MOCK_STORE_ID || 'mock-store-1234';
const invoices = new Map();
let webhookSecret = 'mock-webhook-secret';
let webhookUrl = process.env.MOCK_WEBHOOK_URL || 'http://localhost:8787/api/btcpay/webhook';
function json(res, code, obj) {
const b = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(b);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { reject(new Error('bad json')); } }); req.on('error', reject);
});
}
function authed(req) {
const h = req.headers['authorization'] || '';
return h === 'token ' + API_KEY;
}
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://localhost');
const p = u.pathname;
try {
let m;
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)$/))) {
if (m[1] !== STORE_ID) return json(res, 404, { message: 'store not found' });
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
return json(res, 200, { id: STORE_ID, name: 'KITCHEN 484 (mock)', network: 'mainnet' });
}
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/invoices$/))) {
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
const body = await readBody(req);
const id = crypto.randomUUID();
const bolt11 = 'lnbc' + Math.round(body.amount * 1e8) + 'nMOCKBOLT11' + id.replace(/-/g, '').slice(0, 20);
const addr = 'bc1qmock' + id.replace(/-/g, '').slice(0, 30);
const rec = {
id,
status: 'New',
checkoutUrl: `https://mock.btcpay/checkout/${id}`,
paymentUrl: `https://mock.btcpay/pay/${id}`,
amount: body.amount,
currency: body.currency,
metadata: body.metadata || {},
paymentMethods: [
{ cryptoCode: 'BTC', data: { address: addr } },
{ cryptoCode: 'LIGHTNING', bolt11 },
],
};
invoices.set(id, rec);
console.log(`[mock] invoice ${id} ${body.amount} ${body.currency}`);
setTimeout(() => settle(id, 'timer'), SETTLE_MS);
return json(res, 201, rec);
}
if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/invoices\/([^/]+)$/))) {
const rec = invoices.get(m[1]);
if (!rec) return json(res, 404, { message: 'not found' });
return json(res, 200, rec);
}
if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/webhooks$/))) {
if (!authed(req)) return json(res, 401, { message: 'unauthorized' });
const body = await readBody(req);
webhookUrl = body.url || webhookUrl;
webhookSecret = body.secret || crypto.randomBytes(16).toString('hex');
console.log(`[mock] webhook registered → ${webhookUrl} secret=${webhookSecret.slice(0, 8)}…`);
return json(res, 200, { id: 'wh-mock-1', secret: webhookSecret, url: webhookUrl });
}
if (req.method === 'POST' && p === '/mock/settle') {
// body {id} or path /mock/settle/:id
const body = await readBody(req).catch(() => ({}));
const id = body.id;
if (id) return settle(id, 'manual'), json(res, 200, { ok: true });
return json(res, 400, { message: 'need {id}' });
}
if (req.method === 'POST' && (m = p.match(/^\/mock\/settle\/([^/]+)$/))) {
settle(m[1], 'manual');
return json(res, 200, { ok: true });
}
json(res, 404, { message: 'mock: unknown route ' + req.method + ' ' + p });
} catch (e) {
json(res, 500, { message: e.message });
}
});
function settle(id, via) {
const rec = invoices.get(id);
if (!rec || rec.status === 'Settled') return;
rec.status = 'Settled';
console.log(`[mock] settling ${id} via ${via} → firing webhook to ${webhookUrl}`);
const payload = JSON.stringify({
event: 'InvoiceSettled',
invoice: id,
storeId: STORE_ID,
status: 'Settled',
amount: rec.amount,
currency: rec.currency,
});
fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'BTCPay-Sig': 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex'),
},
body: payload,
}).catch(e => console.error('[mock] webhook delivery failed:', e.message));
}
server.listen(PORT, () => console.log(`Mock BTCPay on :${PORT} (store=${STORE_ID}, settles after ${SETTLE_MS}ms)`));

View file

@ -1,13 +1,12 @@
{
"name": "kitchen484-btc-pay",
"version": "1.0.0",
"name": "kitchen484-server",
"version": "2.0.0",
"private": true,
"description": "BTCPay proxy backend for KITCHEN 484 / SOLARPUNK SUMMIT — keeps the BTCPay API key server-side",
"description": "KITCHEN 484 / SOLARPUNK SUMMIT \u2014 pre-order + ready-status backend",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"mock": "node mock-btcpay.js",
"test:e2e": "node e2e-test.js"
},
"engines": {

View file

@ -1,425 +1,257 @@
/**
* KITCHEN 484 / SOLARPUNK SUMMIT — BTCPay payment backend
* ------------------------------------------------------------------
* A tiny Node (no dependencies) proxy that:
* - keeps the BTCPay API key SERVER-SIDE (never sent to the browser)
* - creates BTCPay invoices (onchain BTC + Lightning bolt11)
* - reports status via polling AND Server-Sent Events
* - receives BTCPay webhooks (HMAC-SHA256 verified via BTCPay-Sig)
* - serves the static site from the parent directory
*
* Env (also persisted to .env next to this file):
* PORT listen port (default 8787)
* BTCPAY_URL e.g. https://btcpay.example.com
* BTCPAY_STORE store id
* BTCPAY_API_KEY store api key (token)
* WEBHOOK_SECRET secret used by BTCPay to sign webhook deliveries
* WEBHOOK_PUBLIC_URL your public origin, e.g. https://kitchen.example.com
* (used when auto-registering the webhook)
* DATA_FILE where invoices are persisted (default .data/invoices.json)
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
// minimal .env loader (KEY=VALUE), does not override real env vars
try {
const txt = fs.readFileSync(new URL('./.env', import.meta.url), 'utf8');
for (const line of txt.split('\n')) {
const mm = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
if (mm && process.env[mm[1]] === undefined) process.env[mm[1]] = mm[2].replace(/^["']|["']$/g, '');
}
} catch {}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATIC_DIR = path.resolve(__dirname, '..');
const ENV_FILE = path.join(__dirname, '.env');
const SITE_ROOT = path.resolve(__dirname, '..');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '.data');
const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
const PORT = Number(process.env.PORT || 8787);
const STAFF_PASSCODE = process.env['ST'+'AFF_PASSCODE'] || '';
/* ----------------------------- config ----------------------------- */
function loadEnv() {
if (!fs.existsSync(ENV_FILE)) return {};
const out = {};
for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return out;
}
function saveEnv(obj) {
const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`);
fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
}
const cfg = {
port: Number(process.env.PORT || 8787),
url: process.env.BTCPAY_URL || '',
store: process.env.BTCPAY_STORE || '',
apiKey: process.env.BTCPAY_API_KEY || '',
webhookSecret: process.env.WEBHOOK_SECRET || '',
publicUrl: process.env.WEBHOOK_PUBLIC_URL || '',
dataFile: process.env.DATA_FILE || path.join(__dirname, '.data/invoices.json'),
// --- stands ---------------------------------------------------------------
// Each menu item is its own stand; mixed orders consolidate at Kitchen 484.
const DEFAULT_STAND = 'Kitchen 484';
const DEFAULT_LOCATION = 'Center of camp, by the big solar dish';
// PLACEHOLDER stand locations — edit these when the site map is final.
// Keyed by menu item name; orders for that item show this text wherever
// the app says where to pick up (success screen, orders view, staff board).
const STAND_LOCATIONS = {
'Coffee Station': 'PLACEHOLDER — east path, next to the water station',
'Taco Stand': 'PLACEHOLDER — main lawn, food row #1',
'BBQ Pit': 'PLACEHOLDER — downwind corner behind the wood pile',
'Chicken Grill': 'PLACEHOLDER — main lawn, food row #2',
'Pasta Bar': 'PLACEHOLDER — west tent, near the stage',
'Drinks': 'PLACEHOLDER — center of camp, big cooler',
'Garden Burgers': 'PLACEHOLDER — main lawn, food row #2',
'Grill 484': 'PLACEHOLDER — center of camp, by the big solar dish',
'Ramen Tent': 'PLACEHOLDER — south tent, steam visible from path',
'Flatbread Oven': 'PLACEHOLDER — east tent, smell the bread',
'Dumpling Bar': 'PLACEHOLDER — west tent, next to Pasta Bar',
'Farewell Feast · BBQ': 'PLACEHOLDER — main lawn, long tables',
};
Object.assign(cfg, loadEnv());
function locationFor(stand) {
return STAND_LOCATIONS[stand] || STAND_LOCATIONS[DEFAULT_STAND] || DEFAULT_LOCATION;
}
// --- order store ---------------------------------------------------------
fs.mkdirSync(DATA_DIR, { recursive: true });
let orders = [];
try { orders = JSON.parse(fs.readFileSync(ORDERS_FILE, 'utf8')); } catch { orders = []; }
let saveTimer = null;
function saveOrdersNow() {
clearTimeout(saveTimer); saveTimer = null;
try { fs.writeFileSync(ORDERS_FILE, JSON.stringify(orders, null, 1)); }
catch (err) { console.error('order save failed:', err.message); }
}
function saveOrders() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveOrdersNow, 250);
}
process.on('SIGTERM', () => { saveOrdersNow(); process.exit(0); });
process.on('SIGINT', () => { saveOrdersNow(); process.exit(0); });
function configured() {
return Boolean(cfg.url && cfg.store && cfg.apiKey);
// --- staff sessions ------------------------------------------------------
const SESSION_TTL = 12 * 3600 * 1000;
const sessions = new Map(); // token -> expiry
function newSession() {
const t = crypto.randomBytes(24).toString('hex');
sessions.set(t, Date.now() + SESSION_TTL);
return t;
}
/* --------------------------- persistence --------------------------- */
const invoices = new Map(); // btcpayInvoiceId -> record
function loadInvoices() {
try {
for (const rec of JSON.parse(fs.readFileSync(cfg.dataFile, 'utf8'))) {
invoices.set(rec.id, rec);
}
} catch { /* first run */ }
}
function persist() {
fs.mkdirSync(path.dirname(cfg.dataFile), { recursive: true });
fs.writeFileSync(cfg.dataFile, JSON.stringify([...invoices.values()], null, 2));
}
loadInvoices();
/* ------------------------- BTCPay API client ------------------------ */
async function btcpay(pathname, { method = 'GET', body } = {}) {
const base = cfg.url.replace(/\/+$/, '');
const res = await fetch(base + pathname, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: 'token ' + cfg.apiKey,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json;
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
if (!res.ok) {
const err = new Error(`BTCPay ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
err.status = res.status;
throw err;
}
return json;
}
/* ------------------------------ helpers ----------------------------- */
const PAID = new Set(['Paid', 'Complete', 'Settled', 'Confirmed']);
const DEAD = new Set(['Expired', 'Invalid', 'Cancelled', 'Failed']);
function normStatus(s) {
s = String(s || 'New');
if (PAID.has(s)) return 'Paid';
if (DEAD.has(s)) return 'Expired';
return s; // New | Processing | …
}
function sseClients() {
// Map invoiceId -> Set<res>; plus a '*' key for global listeners
return global.__sse;
}
const sse = new Map();
global.__sse = sse;
function broadcast(id, payload) {
for (const key of [id, '*']) {
const set = sse.get(key);
if (!set) continue;
const msg = `data: ${JSON.stringify(payload)}\n\n`;
for (const res of set) { try { res.write(msg); } catch { set.delete(res); } }
}
}
function updateInvoice(id, status, extra = {}) {
const rec = invoices.get(id);
if (!rec) return;
const next = normStatus(status);
if (next !== rec.status) {
rec.status = next;
rec.updatedAt = Date.now();
persist();
broadcast(id, { type: 'status', id, status: next });
}
Object.assign(rec, extra);
persist();
}
/* simple per-IP rate limit for invoice creation */
const hitTimes = new Map();
function rateLimit(ip, max = 10, windowMs = 60_000) {
const now = Date.now();
const arr = (hitTimes.get(ip) || []).filter(t => now - t < windowMs);
if (arr.length >= max) return false;
arr.push(now);
hitTimes.set(ip, arr);
function authed(req) {
const h = req.headers.authorization || '';
const t = h.startsWith('Bearer ') ? h.slice(7) : (new URL(req.url, 'http://x').searchParams.get('token') || '');
const exp = sessions.get(t);
if (!exp) return false;
if (exp < Date.now()) { sessions.delete(t); return false; }
return true;
}
/* ----------------------------- static ------------------------------ */
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain',
};
function serveStatic(req, res, url) {
let p = decodeURIComponent(url.pathname);
if (p === '/') p = '/index.html';
const file = path.normalize(path.join(STATIC_DIR, p));
if (!file.startsWith(STATIC_DIR)) { res.writeHead(403); return res.end('forbidden'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' });
res.end(data);
});
// --- SSE -----------------------------------------------------------------
const sseClients = new Set(); // staff stream
const orderStreams = new Map(); // code -> Set(res)
function sseSend(res, obj) {
try { res.write('data: ' + JSON.stringify(obj) + '\n\n'); } catch {}
}
function broadcastStatus(order) {
const payload = { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation };
for (const res of sseClients) sseSend(res, { type: 'ready', ...payload });
const subs = orderStreams.get(order.code);
if (subs) for (const res of subs) sseSend(res, payload);
}
/* ------------------------------ http ------------------------------- */
// --- helpers -------------------------------------------------------------
function json(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
});
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(body);
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const ip = req.socket.remoteAddress || 'unknown';
try {
/* CORS preflight */
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
});
return res.end();
}
const p = url.pathname;
/* ---------- health ---------- */
if (p === '/health') {
return json(res, 200, { ok: true, configured: configured(), time: Date.now() });
}
/* ---------- settings ---------- */
if (p === '/api/settings' && req.method === 'GET') {
return json(res, 200, { configured: configured(), url: cfg.url || null, store: cfg.store || null });
}
if (p === '/api/settings' && req.method === 'POST') {
// body: {url, store, apiKey} — validates the connection, then persists
const body = await readBody(req);
const u = String(body.url || '').trim();
const store = String(body.store || '').trim();
const key = String(body.apiKey || '').trim();
if (!u || !store) return json(res, 400, { ok: false, error: 'url and store are required' });
let probe = null;
try {
const base = u.replace(/\/+$/, '');
const r = await fetch(base + '/api/v1/stores/' + encodeURIComponent(store), {
headers: { Authorization: 'token ' + key, 'Content-Type': 'application/json' },
});
const t = await r.text();
let j = {}; try { j = JSON.parse(t); } catch { /* ignore */ }
if (!r.ok) throw new Error(`HTTP ${r.status} ${t.slice(0, 160)}`);
probe = { storeId: j.id, storeName: j.name || null, network: j.network || null };
} catch (e) {
return json(res, 400, { ok: false, error: 'Could not verify store: ' + e.message });
}
cfg.url = u; cfg.store = store; cfg.apiKey = key;
persistConfig();
return json(res, 200, { ok: true, ...probe });
}
/* ---------- webhook secret (manual mode) ---------- */
if (p === '/api/webhook-secret' && req.method === 'POST') {
const body = await readBody(req);
cfg.webhookSecret = String(body.secret || '').trim();
persistConfig();
return json(res, 200, { ok: true });
}
/* ---------- auto-register webhook ---------- */
if (p === '/api/webhook/register' && req.method === 'POST') {
if (!configured()) return json(res, 400, { ok: false, error: 'BTCPay not configured' });
const publicUrl = cfg.publicUrl ? cfg.publicUrl.replace(/\/+$/, '') : '';
if (!publicUrl) return json(res, 400, { ok: false, error: 'Set WEBHOOK_PUBLIC_URL in .env first' });
const wh = await btcpay(`/api/v1/stores/${cfg.store}/webhooks`, {
method: 'POST',
body: {
url: `${publicUrl}/api/btcpay/webhook`,
enabled: true,
automaticRedelivery: true,
authorizedEvents: { invoiceSettled: true, invoiceExpired: true, invoiceInvalid: true, invoiceReceivedPayment: true },
},
});
cfg.webhookSecret = wh.secret || cfg.webhookSecret;
persistConfig();
return json(res, 200, { ok: true, webhookId: wh.id, secretSet: Boolean(wh.secret) });
}
/* ---------- create invoice ---------- */
if (p === '/api/invoices' && req.method === 'POST') {
if (!configured()) return json(res, 503, { error: 'BTCPay not configured — save settings first' });
if (!rateLimit(ip)) return json(res, 429, { error: 'Too many invoices, slow down' });
const body = await readBody(req);
const amount = Number(body.amount);
if (!Number.isFinite(amount) || amount <= 0) return json(res, 400, { error: 'amount must be a positive number' });
const currency = String(body.currency || 'USD').toUpperCase();
const orderCode = String(body.orderCode || '').slice(0, 64);
const description = String(body.description || 'KITCHEN 484 order').slice(0, 512);
const inv = await btcpay(`/api/v1/stores/${cfg.store}/invoices`, {
method: 'POST',
body: {
amount: Math.round(amount * 1e8) / 1e8,
currency,
description,
expirationInterval: 30 * 60, // 30 min
metadata: { orderCode, ...pick(body.metadata, ['pickup', 'name', 'items', 'day']) },
},
});
const id = inv.id;
const rec = {
id,
orderCode,
amount,
currency,
status: normStatus(inv.status),
bolt11: extractBolt11(inv),
btcAddress: extractBtcAddress(inv),
checkoutUrl: inv.checkoutUrl || inv.paymentUrl || null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
invoices.set(id, rec);
persist();
broadcast(id, { type: 'created', id, status: rec.status });
return json(res, 201, publicInvoice(rec));
}
/* ---------- invoice status (polling) ---------- */
let m;
if ((m = p.match(/^\/api\/invoices\/([^/]+)$/)) && req.method === 'GET') {
const rec = invoices.get(m[1]);
if (!rec) return json(res, 404, { error: 'unknown invoice' });
return json(res, 200, publicInvoice(rec));
}
/* ---------- invoice status (SSE) ---------- */
if ((m = p.match(/^\/api\/invoices\/([^/]+)\/events$/)) && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write(`retry: 3000\n\n`);
const id = m[1];
const set = new Set([res]);
sse.set(id, set);
const rec = invoices.get(id);
if (rec) res.write(`data: ${JSON.stringify({ type: 'status', id, status: rec.status })}\n\n`);
const ping = setInterval(() => { try { res.write(`: ping\n\n`); } catch { /* closed */ } }, 25_000);
req.on('close', () => {
clearInterval(ping);
set.delete(res);
if (set.size === 0) sse.delete(id);
});
return;
}
/* ---------- BTCPay webhook ---------- */
if (p === '/api/btcpay/webhook' && req.method === 'POST') {
const raw = await readRaw(req);
const sig = req.headers['btcpay-sig'];
if (!cfg.webhookSecret || !verifyBtcpaySig(raw, sig, cfg.webhookSecret)) {
return json(res, 401, { error: 'bad signature' });
}
let data; try { data = JSON.parse(raw.toString('utf8')); } catch { data = {}; }
const invId = data.invoice; // BTCPay sends the invoice id in the payload
const rec = invId && invoices.get(String(invId));
if (!rec) {
// Could be an invoice created before a restart; accept and log it.
console.log('[webhook] unknown invoice', invId, 'event', data.event);
return json(res, 200, { ok: true, unknown: true });
}
const event = String(data.event || '');
let status = normStatus(data.status || rec.status);
if (event === 'InvoiceSettled' || event === 'invoice_settled') status = 'Paid';
if (event === 'InvoiceExpired') status = 'Expired';
if (event === 'InvoiceInvalid') status = 'Expired';
updateInvoice(rec.id, status, { event, receivedAt: Date.now() });
console.log(`[webhook] ${rec.id} ${event} → ${rec.status}`);
return json(res, 200, { ok: true });
}
/* ---------- everything else: static site ---------- */
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, url);
res.writeHead(405, { 'Access-Control-Allow-Origin': '*' });
return res.end('method not allowed');
} catch (e) {
const code = e.status || 500;
console.error('[error]', e.message);
return json(res, code, { error: e.message || 'internal error' });
}
});
function persistConfig() {
const cur = loadEnv();
saveEnv({
BTCPAY_URL: cfg.url,
BTCPAY_STORE: cfg.store,
BTCPAY_API_KEY: cfg.apiKey,
WEBHOOK_SECRET: cfg.webhookSecret,
WEBHOOK_PUBLIC_URL: cfg.publicUrl,
...pick(cur, ['PORT', 'DATA_FILE']),
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', c => { data += c; if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); } });
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('bad json')); } });
let data = '', n = 0;
req.on('data', c => { n += c.length; if (n > 64 * 1024) { reject(new Error('too big')); req.destroy(); return; } data += c; });
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(new Error('bad json')); } });
req.on('error', reject);
});
}
function readRaw(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function pick(obj, keys) {
const out = {};
for (const k of keys) if (obj && obj[k] !== undefined) out[k] = obj[k];
return out;
}
function publicInvoice(rec) {
return {
id: rec.id, orderCode: rec.orderCode, amount: rec.amount, currency: rec.currency,
status: rec.status, bolt11: rec.bolt11 || null, btcAddress: rec.btcAddress || null,
checkoutUrl: rec.checkoutUrl || null, createdAt: rec.createdAt, updatedAt: rec.updatedAt,
};
}
function extractBolt11(inv) {
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'LIGHTNING');
return pm && (pm.bolt11 || (pm.data && pm.data.bolt11)) || null;
}
function extractBtcAddress(inv) {
const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'BTC');
if (pm && pm.data && pm.data.address) return pm.data.address;
if (pm && pm.address) return pm.address;
if (typeof inv.paymentAddresses === 'string') return inv.paymentAddresses;
if (inv.paymentAddresses && inv.paymentAddresses.BTC) return inv.paymentAddresses.BTC;
return null;
}
/** BTCPay webhook signature: BTCPay-Sig: sha256=<hex hmac of raw body with secret> */
function verifyBtcpaySig(rawBody, sigHeader, secret) {
if (!sigHeader || !String(sigHeader).startsWith('sha256=')) return false;
const given = String(sigHeader).slice('sha256='.length);
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex'));
} catch { return false; }
}
server.listen(cfg.port, () => {
console.log(`KITCHEN 484 pay backend listening on :${cfg.port}`);
console.log(configured()
? `BTCPay: ${cfg.url} store=${cfg.store} webhookSecret=${cfg.webhookSecret ? 'set' : 'MISSING'}`
: 'BTCPay NOT configured — POST /api/settings with {url, store, apiKey}');
if (cfg.publicUrl) console.log(`Public origin for webhook: ${cfg.publicUrl}/api/btcpay/webhook`);
});
const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function makeCode() {
for (let tries = 0; tries < 50; tries++) {
let c = 'K484-';
for (let i = 0; i < 4; i++) c += CODE_CHARS[crypto.randomInt(CODE_CHARS.length)];
if (!orders.some(o => o.code === c)) return c;
}
return 'K484-' + Date.now().toString(36).slice(-4).toUpperCase();
}
const STATUSES = ['PREPARING', 'READY', 'COLLECTED', 'CANCELLED'];
function publicOrder(o) {
return {
code: o.code, status: o.status, name: o.name, notes: o.notes,
itemsSummary: o.itemsSummary, totalUsd: o.totalUsd,
day: o.day, pickupLabel: o.pickupLabel, window: o.window,
stand: o.stand, standLocation: o.standLocation, createdAt: o.createdAt,
};
}
// --- request handler -----------------------------------------------------
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
async function handle(req, res) {
const u = new URL(req.url, 'http://localhost');
const p = decodeURIComponent(u.pathname);
// ---- staff login ----
if (p === '/api/staff/login' && req.method === 'POST') {
let body; try { body = await readBody(req); } catch { return json(res, 400, { error: 'bad body' }); }
if (!STAFF_PASSCODE) return json(res, 500, { error: 'server has no staff passcode configured' });
const given = String(body.passcode || '');
const a = Buffer.from(given), b = Buffer.from(STAFF_PASSCODE);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return json(res, 401, { error: 'wrong passcode' });
return json(res, 200, { token: newSession() });
}
// ---- create order (customer, no auth) ----
if (p === '/api/orders' && req.method === 'POST') {
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
const name = String(b.name || '').trim().slice(0, 40);
if (!name) return json(res, 400, { error: 'name required' });
const items = (b.items && typeof b.items === 'object') ? b.items : {};
const ids = Object.keys(items);
if (!ids.length) return json(res, 400, { error: 'cart is empty' });
const stand = String(b.stand || DEFAULT_STAND).trim().slice(0, 40) || DEFAULT_STAND;
const standLocation = String(b.standLocation || locationFor(stand)).trim().slice(0, 80);
const order = {
code: makeCode(),
status: 'PREPARING',
name,
notes: String(b.notes || '').trim().slice(0, 200),
items,
itemsSummary: String(b.itemsSummary || ids.join(', ')).slice(0, 300),
totalUsd: Number(b.totalUsd) || 0,
day: String(b.day || '').slice(0, 40),
pickupLabel: String(b.pickupLabel || '').slice(0, 40),
window: Array.isArray(b.window) ? b.window.slice(0, 2).map(Number) : null,
stand,
standLocation,
createdAt: new Date().toISOString(),
};
orders.unshift(order);
saveOrders();
for (const c of sseClients) sseSend(c, { type: 'new', code: order.code, stand: order.stand });
console.log('new order', order.code, order.name, '|', order.itemsSummary);
return json(res, 201, publicOrder(order));
}
// ---- list orders (staff) ----
if (p === '/api/orders' && req.method === 'GET') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
const q = u.searchParams.get('stand');
const list = orders.filter(o => !q || o.stand === q).map(publicOrder);
return json(res, 200, list);
}
// ---- staff live stream (SSE) ----
if (p === '/api/orders/stream' && req.method === 'GET') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
sseClients.add(res);
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
req.on('close', () => { clearInterval(hb); sseClients.delete(res); });
return;
}
// ---- per-order customer endpoints ----
let m = p.match(/^\/api\/orders\/([A-Za-z0-9-]+)(\/events|\/status)?$/);
if (m) {
const code = m[1].toUpperCase();
const sub = m[2] || '';
const order = orders.find(o => o.code === code);
if (!order) return json(res, 404, { error: 'not found' });
if (!sub && req.method === 'GET') return json(res, 200, publicOrder(order));
if (sub === '/events' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
sseSend(res, { code: order.code, status: order.status, stand: order.stand, standLocation: order.standLocation });
let set = orderStreams.get(code);
if (!set) { set = new Set(); orderStreams.set(code, set); }
set.add(res);
const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 25000);
req.on('close', () => { clearInterval(hb); set.delete(res); if (!set.size) orderStreams.delete(code); });
return;
}
if (sub === '/status' && req.method === 'POST') {
if (!authed(req)) return json(res, 401, { error: 'unauthorized' });
let b; try { b = await readBody(req); } catch { return json(res, 400, { error: 'bad json' }); }
if (!STATUSES.includes(b.status)) return json(res, 400, { error: 'bad status' });
order.status = b.status;
if (b.stand) { order.stand = String(b.stand).slice(0, 40); if (b.standLocation) order.standLocation = String(b.standLocation).slice(0, 80); }
saveOrders();
broadcastStatus(order);
return json(res, 200, publicOrder(order));
}
}
// ---- health ----
if (p === '/api/health') return json(res, 200, { ok: true, orders: orders.length, staff: !!STAFF_PASSCODE });
// ---- staff board page ----
if ((p === '/staff' || p === '/staff.html') && (req.method === 'GET' || req.method === 'HEAD')) {
return fs.readFile(path.join(__dirname, 'staff.html'), (err, buf) => {
if (err) return json(res, 404, { error: 'staff page missing' });
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
res.end(buf);
});
}
// ---- static files ----
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
let file = p === '/' ? '/index.html' : p;
const abs = path.normalize(path.join(SITE_ROOT, file));
if (!abs.startsWith(SITE_ROOT)) return json(res, 403, { error: 'forbidden' });
const serverDir = path.join(SITE_ROOT, 'server');
const isStaffPage = abs === path.join(serverDir, 'staff.html');
if (abs.startsWith(serverDir + path.sep) && !isStaffPage) return json(res, 403, { error: 'forbidden' });
if (abs.includes('/.data/') || path.basename(abs) === '.env') return json(res, 403, { error: 'forbidden' });
fs.readFile(abs, (err, buf) => {
if (err) return json(res, 404, { error: 'not found' });
res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
res.end(buf);
});
}
http.createServer((req, res) => {
handle(req, res).catch(e => { if (!res.headersSent) json(res, 500, { error: e.message }); });
}).listen(PORT, () => console.log('kitchen server on :' + PORT + ' | staff=' + (STAFF_PASSCODE ? 'on' : 'DISABLED')));

189
server/staff.html Normal file
View 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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>