Initial commit: SolLunar Kitchen under version control
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.gradle/
|
||||
target/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
*.key
|
||||
*.pem
|
||||
*.jks
|
||||
tsconfig.tsbuildinfo
|
||||
instance/
|
||||
BIN
SolLunar_svg_bw_notext.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
972
_shot_cosmic.html
Normal file
|
|
@ -0,0 +1,972 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>KITCHEN 484 · SOLARPUNK SUMMIT</title>
|
||||
<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">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap" rel="stylesheet">
|
||||
<script>/* Start in the white theme. */try{localStorage.removeItem('kitchen484-theme');}catch(e){}</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--blue:#3b82f6; --blue-soft:#eff6ff;
|
||||
--pink:#d946ef; --pink-soft:#fdf4ff;
|
||||
--gold:#eab308; --gold-soft:#fefce8;
|
||||
--green:#22c55e; --green-soft:#f0fdf4;
|
||||
--red:#ef4444; --red-soft:#fef2f2;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
/* ============ COSMIC THEME — Stardust (kaymade/cosmic) · Deep Space · Deep sidebar ============ */
|
||||
:root[data-theme="cosmic"]{
|
||||
--bg:#171109; --bg2:#0d0a06;
|
||||
--ink:#fff5dc; --ink2:#d5bd8d; --muted:#917b58;
|
||||
--line:#3a2d19; --line2:#54411f;
|
||||
--blue:#87E6FB; --blue-soft:#10313b; /* Light Blue */
|
||||
--pink:#F3B407; --pink-soft:#33270d; /* Gold (was magenta) */
|
||||
--gold:#F3B407; --gold-soft:#33270d; /* Gold */
|
||||
--green:#87E6FB; --green-soft:#10313b;
|
||||
--red:#F3B407; --red-soft:#33270d;
|
||||
--black:#100b05;
|
||||
--logo-frame:rgba(255,245,220,.10);
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(0 0 0 / .4), 0 14px 34px -14px rgb(0 0 0 / .8);
|
||||
--font:'Source Serif 4',Georgia,'Times New Roman',serif;
|
||||
--display:'Cormorant Garamond','Orbitron',Georgia,serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5}
|
||||
body{font-family:var(--font);background:var(--bg2);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
#app{max-width:520px;margin:0 auto;min-height:100dvh;background:var(--bg);display:flex;flex-direction:column;position:relative;box-shadow:0 0 40px rgb(0 0 0/.06)}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header{position:sticky;top:0;z-index:40;background:rgb(255 255 255/.85);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);padding:12px 16px;display:flex;align-items:center;gap:10px}
|
||||
.logo{width:44px;height:44px;flex:0 0 44px;object-fit:contain}
|
||||
.brand{display:flex;flex-direction:column;line-height:1.15}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px}
|
||||
.brand span{font-size:10px;color:var(--muted);letter-spacing:1.5px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.clock{font-family:var(--display);font-size:12px;font-weight:700;color:var(--ink2);letter-spacing:1px;background:var(--bg2);border:1px solid var(--line);border-radius:999px;padding:4px 10px}
|
||||
.icon-btn{width:36px;height:36px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink2);display:grid;place-items:center;cursor:pointer;transition:all .15s}
|
||||
.icon-btn:active{transform:scale(.92)}
|
||||
.icon-btn svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:26px 18px 18px;background:linear-gradient(180deg,#fff 0%,var(--bg2) 100%)}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:26px;letter-spacing:3px;margin:10px 0 4px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(90deg,var(--blue),var(--pink));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero p{color:var(--ink2);font-size:13px;max-width:360px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0 16px}
|
||||
.chip{font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;border-radius:999px;border:1px solid}
|
||||
.chip.c1{color:var(--ink);border-color:var(--ink);background:var(--black);color:#fff}
|
||||
.chip.c2{color:#166534;border-color:#bbf7d0;background:var(--green-soft)}
|
||||
.chip.c3{color:#86198f;border-color:#f5d0fe;background:var(--pink-soft)}
|
||||
.chip.c4{color:#854d0e;border-color:#fde68a;background:var(--gold-soft)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:14px;padding:13px 22px;transition:transform .12s,box-shadow .12s}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff;width:100%;box-shadow:var(--shadow)}
|
||||
.btn-primary:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line2)}
|
||||
.btn-blue{background:var(--blue);color:#fff;width:100%}
|
||||
.btn-pink{background:linear-gradient(90deg,var(--blue),var(--pink));color:#fff;width:100%}
|
||||
.btn-sm{padding:9px 14px;font-size:12px;border-radius:11px;width:auto}
|
||||
|
||||
/* ---------- day tabs ---------- */
|
||||
.daytabs{display:flex;gap:8px;padding:4px 16px 12px;position:sticky;top:69px;background:rgb(255 255 255/.9);backdrop-filter:blur(8px);z-index:30}
|
||||
.daytab{flex:1;border:1px solid var(--line);background:#fff;border-radius:14px;padding:9px 6px;cursor:pointer;text-align:center;transition:all .15s}
|
||||
.daytab .d1{font-family:var(--display);font-weight:700;font-size:11px;letter-spacing:1px}
|
||||
.daytab .d2{font-size:10px;color:var(--muted)}
|
||||
.daytab.active{background:var(--black);border-color:var(--black);color:#fff;box-shadow:var(--shadow)}
|
||||
.daytab.active .d2{color:#a3a3a3}
|
||||
.daytab.today::after{content:'TODAY';display:block;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--pink);margin-top:2px}
|
||||
.daytab.active.today::after{color:var(--pink)}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.timeline{padding:8px 16px 24px;position:relative}
|
||||
.tl-head{display:flex;justify-content:space-between;align-items:baseline;margin:6px 2px 10px}
|
||||
.tl-head h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase}
|
||||
.tl-head span{font-size:11px;color:var(--muted)}
|
||||
.tl-wrap{position:relative;padding-left:26px}
|
||||
.tl-rail{position:absolute;left:9px;top:6px;bottom:6px;width:2px;background:var(--line);border-radius:2px}
|
||||
.nowline{position:absolute;left:-4px;right:-8px;height:2px;background:var(--red);border-radius:2px;z-index:5}
|
||||
.nowline::before{content:'NOW';position:absolute;left:-2px;top:-16px;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--red);background:#fff;padding:1px 4px;border-radius:4px}
|
||||
.nowline::after{content:'';position:absolute;left:-5px;top:-2px;width:8px;height:8px;border-radius:99px;background:var(--red);animation:pulse 1.6s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(1.5)}}
|
||||
.item-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04);transition:border-color .2s,box-shadow .2s}
|
||||
.item-card.is-open{border-color:var(--green);box-shadow:0 0 0 3px var(--green-soft),var(--shadow)}
|
||||
.item-card.is-soon{border-color:#bfdbfe}
|
||||
.item-card.is-ended{opacity:.62}
|
||||
.tl-dot{position:absolute;left:-21px;top:22px;width:12px;height:12px;border-radius:99px;background:#fff;border:3px solid var(--line2);z-index:6}
|
||||
.item-card.is-open .tl-dot{border-color:var(--green);background:var(--green)}
|
||||
.item-card.is-soon .tl-dot{border-color:var(--blue)}
|
||||
.tl-time{position:absolute;left:-26px;top:18px;transform:translateX(-100%);width:52px;text-align:right;font-family:var(--display);font-size:9px;font-weight:700;color:var(--muted);letter-spacing:.5px;line-height:1.3}
|
||||
.item-top{display:flex;gap:12px;align-items:flex-start}
|
||||
.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-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}
|
||||
.sp-open{background:var(--green-soft);color:#15803d}
|
||||
.sp-open .dot{animation:pulse 1.4s infinite}
|
||||
.sp-soon{background:var(--blue-soft);color:#1d4ed8}
|
||||
.sp-upcoming{background:var(--bg2);color:var(--muted);border:1px solid var(--line)}
|
||||
.sp-ended{background:var(--bg2);color:var(--muted)}
|
||||
.tags{display:flex;gap:4px}
|
||||
.tag{font-size:9px;font-weight:800;letter-spacing:.5px;padding:3px 7px;border-radius:7px;background:var(--bg2);border:1px solid var(--line);color:var(--ink2)}
|
||||
.add-btn{margin-left:auto;border:1px solid var(--black);background:#fff;color:var(--ink);font-weight:800;font-size:12px;border-radius:11px;padding:8px 14px;cursor:pointer;transition:all .12s;white-space:nowrap}
|
||||
.add-btn:active{transform:scale(.94);background:var(--black);color:#fff}
|
||||
.add-btn.in-cart{background:var(--green-soft);border-color:#bbf7d0;color:#15803d}
|
||||
|
||||
/* ---------- orders ---------- */
|
||||
.orders{padding:16px}
|
||||
.orders h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
|
||||
.orders .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.empty{text-align:center;padding:56px 20px;color:var(--muted)}
|
||||
.empty .big{font-size:40px;margin-bottom:10px}
|
||||
.order-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04)}
|
||||
.order-top{display:flex;align-items:center;gap:10px}
|
||||
.order-code{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1px}
|
||||
.badge{font-size:9px;font-weight:900;letter-spacing:1px;padding:4px 9px;border-radius:99px;text-transform:uppercase}
|
||||
.b-paid{background:var(--green-soft);color:#15803d}
|
||||
.b-pending{background:var(--gold-soft);color:#854d0e}
|
||||
.b-failed{background:var(--red-soft);color:#b91c1c}
|
||||
.b-demo{background:var(--pink-soft);color:#86198f}
|
||||
.order-meta{font-size:11px;color:var(--muted);margin:6px 0}
|
||||
.order-items{font-size:12px;color:var(--ink2);border-top:1px dashed var(--line);padding-top:8px;margin-top:8px}
|
||||
.order-total{display:flex;justify-content:space-between;font-size:13px;font-weight:800;margin-top:6px}
|
||||
.order-total .btc{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700}
|
||||
.order-actions{display:flex;gap:8px;margin-top:10px}
|
||||
|
||||
/* ---------- bottom nav + fab ---------- */
|
||||
nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);backdrop-filter:blur(12px);border-top:1px solid var(--line);display:flex;padding:8px 12px calc(10px + env(safe-area-inset-bottom))}
|
||||
.nav-btn{flex:1;border:none;background:none;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--muted);transition:all .15s}
|
||||
.nav-btn svg{width:20px;height:20px}
|
||||
.nav-btn.active{color:var(--ink);background:var(--bg2)}
|
||||
.fab{position:absolute;right:16px;bottom:calc(74px + env(safe-area-inset-bottom));z-index:45;width:58px;height:58px;border-radius:20px;border:none;cursor:pointer;background:var(--black);color:#fff;display:grid;place-items:center;box-shadow:0 10px 24px -8px rgb(10 10 10/.5);transition:transform .15s}
|
||||
.fab:active{transform:scale(.92)}
|
||||
.fab svg{width:24px;height:24px}
|
||||
.fab .count{position:absolute;top:-7px;right:-7px;min-width:22px;height:22px;border-radius:99px;background:var(--pink);color:#fff;font-size:11px;font-weight:900;display:grid;place-items:center;padding:0 5px;box-shadow:0 2px 6px rgb(0 0 0/.3)}
|
||||
.fab.hidden{display:none}
|
||||
|
||||
/* ---------- sheet / modal ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:100;display:none;align-items:flex-end;justify-content:center}
|
||||
.overlay.show{display:flex}
|
||||
.sheet{width:100%;max-width:520px;background:#fff;border-radius:24px 24px 0 0;padding:10px 18px calc(22px + env(safe-area-inset-bottom));max-height:88dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes slideUp{from{transform:translateY(60px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
.grab{width:40px;height:4px;border-radius:4px;background:var(--line2);margin:4px auto 14px}
|
||||
.sheet h3{font-family:var(--display);font-weight:900;font-size:16px;letter-spacing:1.5px;margin-bottom:4px}
|
||||
.sheet .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.cart-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.cart-row .em{width:40px;height:40px;border-radius:12px;background:var(--bg2);display:grid;place-items:center;font-size:20px;border:1px solid var(--line)}
|
||||
.cart-row .nm{font-size:13px;font-weight:700}
|
||||
.cart-row .pt{font-size:11px;color:var(--muted)}
|
||||
.qty{display:flex;align-items:center;gap:10px;margin-left:auto}
|
||||
.qty button{width:28px;height:28px;border-radius:9px;border:1px solid var(--line2);background:#fff;font-size:15px;font-weight:800;cursor:pointer;color:var(--ink)}
|
||||
.qty button:active{transform:scale(.9)}
|
||||
.qty span{font-family:var(--display);font-weight:700;font-size:13px;min-width:18px;text-align:center}
|
||||
.cart-row .lp{font-family:var(--display);font-weight:900;font-size:13px;width:52px;text-align:right}
|
||||
.total-row{display:flex;justify-content:space-between;align-items:baseline;padding:14px 0 4px}
|
||||
.total-row .t{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:1px}
|
||||
.total-row .v{font-family:var(--display);font-weight:900;font-size:20px}
|
||||
.btc-est{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700;margin-bottom:14px;text-align:right}
|
||||
.btc-est small{color:var(--muted);font-family:var(--font);font-weight:400}
|
||||
.field{margin-bottom:12px}
|
||||
.field label{display:block;font-size:11px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input,.field select,.field textarea{width:100%;border:1px solid var(--line2);border-radius:12px;padding:12px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.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}
|
||||
.qr-wrap canvas,.qr-wrap img{display:block;border-radius:8px}
|
||||
.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}
|
||||
.success{text-align:center;padding:18px 0 6px}
|
||||
.success .check{width:74px;height:74px;border-radius:99px;background:var(--green-soft);border:2px solid #bbf7d0;display:grid;place-items:center;margin:0 auto 14px;font-size:34px;animation:pop .5s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
.success h3{margin-bottom:6px}
|
||||
.success .code{font-family:var(--display);font-weight:900;font-size:24px;letter-spacing:3px;background:var(--bg2);border:1px dashed var(--line2);border-radius:14px;padding:12px;margin:14px 0}
|
||||
.success .pick{font-size:13px;color:var(--ink2);margin-bottom:16px}
|
||||
.success .pick b{color:var(--ink)}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.modal{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:110;display:none;align-items:center;justify-content:center;padding:20px}
|
||||
.modal.show{display:flex}
|
||||
.modal-box{width:100%;max-width:460px;background:#fff;border-radius:22px;padding:20px;max-height:86dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
.modal-box h3{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1.5px;margin-bottom:10px}
|
||||
.test-result{font-size:11px;font-weight:700;border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.test-result.ok{display:block;background:var(--green-soft);color:#15803d}
|
||||
.test-result.err{display:block;background:var(--red-soft);color:#b91c1c}
|
||||
footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-spacing:1px;text-transform:uppercase;border-top:1px solid var(--line);margin-top:auto}
|
||||
.toast{position:fixed;left:50%;bottom:100px;transform:translateX(-50%) translateY(20px);background:var(--black);color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none;white-space:nowrap}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ============ COSMIC THEME — component overrides (Deep Space atmosphere) ============
|
||||
White theme stays byte-for-byte; only :root[data-theme="cosmic"] re-skins. */
|
||||
:root[data-theme="cosmic"] body{
|
||||
background:
|
||||
radial-gradient(circle at 8% 13%, rgba(255,245,220,.80) 0 1px, transparent 1.7px),
|
||||
radial-gradient(circle at 27% 7%, rgba(135,230,251,.85) 0 1px, transparent 1.8px),
|
||||
radial-gradient(circle at 73% 19%, rgba(255,245,220,.75) 0 1.2px, transparent 1.9px),
|
||||
radial-gradient(circle at 91% 42%, rgba(243,180,7,.85) 0 1px, transparent 1.8px),
|
||||
radial-gradient(circle at 61% 78%, rgba(255,245,220,.70) 0 1px, transparent 1.7px),
|
||||
radial-gradient(circle at 18% 84%, rgba(243,180,7,.85) 0 1.2px, transparent 2px),
|
||||
radial-gradient(circle at 12% 4%, rgba(243,180,7,.30), transparent 55rem),
|
||||
radial-gradient(circle at 88% 12%, rgba(135,230,251,.24), transparent 44rem),
|
||||
radial-gradient(circle at 52% 100%,rgba(243,180,7,.22), transparent 48rem),
|
||||
radial-gradient(ellipse at center, transparent 34%, rgba(13,10,6,.55) 100%),
|
||||
linear-gradient(145deg, var(--bg), var(--bg2));
|
||||
background-blend-mode:screen,screen,screen,screen,screen,screen,screen,screen,screen,multiply,normal;
|
||||
background-attachment:fixed;
|
||||
}
|
||||
:root[data-theme="cosmic"] #app{box-shadow:0 0 60px rgb(0 0 0/.6)}
|
||||
:root[data-theme="cosmic"] header{background:rgba(13,10,6,.72)}
|
||||
:root[data-theme="cosmic"] .hero{background:linear-gradient(180deg,rgba(23,17,9,.9) 0%,var(--bg) 100%)}
|
||||
:root[data-theme="cosmic"] .icon-btn{background:rgba(255,245,220,.06);border-color:var(--line)}
|
||||
:root[data-theme="cosmic"] .clock{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .chip.c1{background:var(--blue-soft);border-color:#1c4a56;color:#cdeffb}
|
||||
:root[data-theme="cosmic"] .chip.c2{background:var(--green-soft);border-color:#1d4a44;color:#a7e6dd}
|
||||
:root[data-theme="cosmic"] .chip.c3{background:var(--pink-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .chip.c4{background:var(--gold-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .btn-ghost{background:rgba(255,245,220,.05);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .btn-blue{background:var(--blue);color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .btn-pink{background:linear-gradient(90deg,var(--blue),var(--gold));color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .btn-primary{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .daytabs{background:rgba(13,10,6,.7)}
|
||||
:root[data-theme="cosmic"] .daytab{background:rgba(255,245,220,.045)}
|
||||
:root[data-theme="cosmic"] .daytab.active{background:linear-gradient(135deg,#2a1e0e,#1c1309);border-color:var(--gold);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .daytab.today::after,
|
||||
:root[data-theme="cosmic"] .daytab.active.today::after{color:var(--gold)}
|
||||
:root[data-theme="cosmic"] .nowline::before{background:#171109}
|
||||
:root[data-theme="cosmic"] .item-card{background:linear-gradient(160deg,rgba(33,24,12,.92),rgba(20,15,8,.92))}
|
||||
:root[data-theme="cosmic"] .item-card.is-open{box-shadow:0 0 0 3px rgba(135,230,251,.16),0 0 26px -6px rgba(135,230,251,.35),var(--shadow)}
|
||||
:root[data-theme="cosmic"] .item-card.is-soon{border-color:#1c4a56}
|
||||
:root[data-theme="cosmic"] .tl-dot{background:var(--bg)}
|
||||
:root[data-theme="cosmic"] .item-emoji{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .tag{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .add-btn{background:transparent;border-color:var(--line2);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .add-btn:active{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .add-btn.in-cart{background:var(--green-soft);border-color:#1d4a44;color:#a7e6dd}
|
||||
:root[data-theme="cosmic"] .order-card{background:linear-gradient(160deg,rgba(33,24,12,.92),rgba(20,15,8,.92))}
|
||||
:root[data-theme="cosmic"] .sp-upcoming,
|
||||
:root[data-theme="cosmic"] .sp-ended{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .b-pending{background:var(--gold-soft);color:#f6d488}
|
||||
:root[data-theme="cosmic"] .b-demo{background:var(--pink-soft);color:#f6d488}
|
||||
:root[data-theme="cosmic"] .b-failed{background:var(--red-soft);color:#f5b3a9}
|
||||
:root[data-theme="cosmic"] nav.bottom{background:rgba(13,10,6,.8)}
|
||||
:root[data-theme="cosmic"] .nav-btn.active{background:rgba(255,245,220,.07)}
|
||||
:root[data-theme="cosmic"] .fab{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .fab .count{background:var(--blue);color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .sheet{background:linear-gradient(180deg,#1c140b,#140f08)}
|
||||
:root[data-theme="cosmic"] .sheet h3,
|
||||
:root[data-theme="cosmic"] .modal-box h3{color:var(--gold);font-weight:700;letter-spacing:.5px}
|
||||
:root[data-theme="cosmic"] .cart-row .em{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .qty button{background:rgba(255,245,220,.05);border-color:var(--line2)}
|
||||
:root[data-theme="cosmic"] .field input,
|
||||
:root[data-theme="cosmic"] .field select,
|
||||
:root[data-theme="cosmic"] .field textarea{background:rgba(13,10,6,.6);border-color:var(--line2)}
|
||||
:root[data-theme="cosmic"] .pay-box{background:rgba(13,10,6,.55)}
|
||||
:root[data-theme="cosmic"] .qr-wrap{background:#fff}
|
||||
:root[data-theme="cosmic"] .addr-row{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .copy-btn{background:rgba(255,245,220,.06)}
|
||||
:root[data-theme="cosmic"] .demo-note{background:var(--gold-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .success .check{background:var(--green-soft);border-color:#1d4a44}
|
||||
:root[data-theme="cosmic"] .success .code{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .modal-box{background:linear-gradient(180deg,#1c140b,#140f08)}
|
||||
:root[data-theme="cosmic"] .pay-tab{background:rgba(255,245,220,.045)}
|
||||
:root[data-theme="cosmic"] .pay-tab.active{background:var(--gold);color:#171109;border-color:var(--gold)}
|
||||
:root[data-theme="cosmic"] .toast{background:var(--ink);color:#171109}
|
||||
/* Keep the logo pure black & white in both themes (it is a white tile w/ black mark).
|
||||
Give it a hairline frame only in cosmic so the white tile reads on the starfield. */
|
||||
:root[data-theme="cosmic"] .logo{border-radius:12px;box-shadow:0 0 0 1px var(--logo-frame),0 6px 20px -8px rgba(0,0,0,.7)}
|
||||
:root[data-theme="cosmic"] .hero h1{color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .hero h1 em{background:linear-gradient(90deg,var(--blue),var(--gold))}
|
||||
:root[data-theme="cosmic"] footer{border-top-color:var(--line);background:rgba(13,10,6,.5)}
|
||||
/* theme toggle button */
|
||||
.theme-toggle{font-family:var(--display);font-size:11px;font-weight:800;letter-spacing:1px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--ink2);padding:6px 12px;cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||
.theme-toggle:active{transform:scale(.95)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<img class="logo" src="logo.png" alt="KITCHEN 484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<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="theme-toggle" id="btn-theme" aria-label="Toggle theme" title="Switch theme">✦ COSMIC</button>
|
||||
<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>
|
||||
|
||||
<main style="flex:1;display:flex;flex-direction:column">
|
||||
<!-- SCHEDULE VIEW -->
|
||||
<section id="view-schedule">
|
||||
<div class="hero">
|
||||
<div class="chips">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="tl-head"><h2>Serving schedule</h2><span id="tl-sub"></span></div>
|
||||
<div class="tl-wrap" id="tl-wrap">
|
||||
<div class="tl-rail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ORDERS VIEW -->
|
||||
<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 id="orders-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay</footer>
|
||||
</main>
|
||||
|
||||
<nav class="bottom">
|
||||
<button class="nav-btn active" data-view="schedule">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||
SCHEDULE
|
||||
</button>
|
||||
<button class="nav-btn" data-view="orders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
|
||||
ORDERS
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="fab hidden" id="fab" aria-label="Cart">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
<span class="count" id="fab-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
"use strict";
|
||||
window.__errs=[];
|
||||
window.addEventListener('error',e=>{
|
||||
window.__errs.push(String(e.message)+' @'+(e.lineno||'?'));
|
||||
let el=document.getElementById('__errbox');
|
||||
if(!el){el=document.createElement('div');el.id='__errbox';el.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;background:#7f1d1d;color:#fff;font:11px monospace;padding:8px;max-height:40vh;overflow:auto';document.body.appendChild(el);}
|
||||
el.textContent=window.__errs.join('\n');
|
||||
});
|
||||
/* ============================== DATA ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with first page) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]}
|
||||
]
|
||||
};
|
||||
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'};
|
||||
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);
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
||||
|
||||
/* ============================== STATUS ============================== */
|
||||
function nowMin(){const d=new Date();return d.getHours()*60+d.getMinutes();}
|
||||
function itemStatus(i,item){
|
||||
if(i>0)return{state:'upcoming',label:FEST.days[i].name+' · '+FEST.days[i].date};
|
||||
const n=nowMin();
|
||||
if(n<item.start)return{state:'soon',label:'Starts in '+fmtDur(item.start-n)};
|
||||
if(n<item.end)return{state:'open',label:'Available now'};
|
||||
return{state:'ended',label:'Ended'};
|
||||
}
|
||||
|
||||
/* ============================== SCHEDULE RENDER ============================== */
|
||||
function renderDayTabs(){
|
||||
$('#daytabs').innerHTML=FEST.days.map((d,i)=>
|
||||
'<button class="daytab'+(i===state.dayIdx?' active':'')+(i===0?' today':'')+'" data-day="'+i+'">'+
|
||||
'<div class="d1">'+d.tag+'</div><div class="d2">'+d.name+' '+d.date+'</div></button>').join('');
|
||||
document.querySelectorAll('.daytab').forEach(b=>b.onclick=()=>{state.dayIdx=+b.dataset.day;renderDayTabs();renderTimeline();});
|
||||
}
|
||||
function renderTimeline(){
|
||||
const day=FEST.days[state.dayIdx];
|
||||
const items=[...day.items].sort((a,b)=>a.start-b.start);
|
||||
const d0=Math.min(...items.map(x=>x.start))-30;
|
||||
const d1=Math.max(...items.map(x=>x.end))+30;
|
||||
const isToday=state.dayIdx===0;
|
||||
const n=nowMin();
|
||||
const nowLine=(isToday&&n>=d0&&n<=d1)
|
||||
?'<div class="nowline" style="top:'+(((n-d0)/(d1-d0))*100).toFixed(2)+'%"></div>':'';
|
||||
$('#tl-sub').textContent=isToday?'Live · updates automatically':'Pre-orders open';
|
||||
$('#tl-wrap').innerHTML='<div class="tl-rail"></div>'+nowLine+items.map(item=>{
|
||||
const st=itemStatus(state.dayIdx,item);
|
||||
const cls=st.state==='open'?'is-open':st.state==='soon'?'is-soon':(st.state==='upcoming'?'':'is-ended');
|
||||
const pill={open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state];
|
||||
const inCart=state.cart[item.id]>0;
|
||||
return '<div class="item-card '+cls+'" data-id="'+item.id+'">'+
|
||||
'<div class="tl-dot"></div>'+
|
||||
'<div class="tl-time">'+fmtT(item.start).replace(' ','<br>')+'</div>'+
|
||||
'<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>'+
|
||||
'<div class="item-foot">'+
|
||||
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
|
||||
'<span class="tag" style="margin-left:auto">'+fmtT(item.start)+' – '+fmtT(item.end)+'</span>'+
|
||||
'<button class="add-btn'+(inCart?' in-cart':'')+'" data-add="'+item.id+'">'+(inCart?'✓ Added · '+state.cart[item.id]:'+ Add to order')+'</button>'+
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.add;
|
||||
state.cart[id]=(state.cart[id]||0)+1;
|
||||
saveCart();renderFab();renderTimeline();
|
||||
toast(findItem(id).emoji+' '+findItem(id).name+' added');
|
||||
});
|
||||
}
|
||||
function refreshStatuses(){
|
||||
document.querySelectorAll('#tl-wrap .item-card').forEach(card=>{
|
||||
const id=card.dataset.id, it=findItem(id);
|
||||
const st=itemStatus(state.dayIdx,it);
|
||||
const pill=card.querySelector('.status-pill');
|
||||
pill.className='status-pill '+( {open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state] );
|
||||
pill.innerHTML='<span class="dot"></span>'+st.label;
|
||||
card.classList.remove('is-open','is-soon','is-ended');
|
||||
if(st.state==='open')card.classList.add('is-open');
|
||||
else if(st.state==='soon')card.classList.add('is-soon');
|
||||
else if(st.state==='ended')card.classList.add('is-ended');
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================== FAB / NAV ============================== */
|
||||
function renderFab(){
|
||||
const c=cartCount();
|
||||
$('#fab').classList.toggle('hidden',c===0);
|
||||
$('#fab-count').textContent=c;
|
||||
document.title=c>0?('KITCHEN 484 · '+c+' in cart'):('KITCHEN 484 · SOLARPUNK SUMMIT');
|
||||
let d=document.getElementById('debugbar');
|
||||
if(!d){d=document.createElement('div');d.id='debugbar';d.style.cssText='position:fixed;bottom:0;left:0;right:0;z-index:9998;background:#0A0A0A;color:#4ade80;font:10px monospace;padding:4px 8px;pointer-events:none';document.body.appendChild(d);}
|
||||
d.textContent='cart='+JSON.stringify(state.cart)+' errs='+(window.__errs?window.__errs.length:0);
|
||||
}
|
||||
document.querySelectorAll('.nav-btn').forEach(b=>b.onclick=()=>{
|
||||
state.view=b.dataset.view;
|
||||
document.querySelectorAll('.nav-btn').forEach(x=>x.classList.toggle('active',x===b));
|
||||
$('#view-schedule').hidden=state.view!=='schedule';
|
||||
$('#view-orders').hidden=state.view!=='orders';
|
||||
if(state.view==='orders')renderOrders();
|
||||
});
|
||||
$('#fab').onclick=()=>openSheet('cart');
|
||||
|
||||
/* ============================== SHEET STEPS ============================== */
|
||||
function openSheet(step){state.sheetStep=step;renderSheet();$('#sheet-overlay').classList.add('show');}
|
||||
function closeSheet(){
|
||||
if(state.pay&&state.pay.timer){clearInterval(state.pay.timer);state.pay=null;}
|
||||
state.sheetStep=null;$('#sheet-overlay').classList.remove('show');
|
||||
}
|
||||
$('#sheet-overlay').addEventListener('mousedown',e=>{$('#sheet-overlay')._down=e.target.id==='sheet-overlay';});
|
||||
$('#sheet-overlay').addEventListener('click',e=>{if($('#sheet-overlay')._down&&e.target.id==='sheet-overlay')closeSheet();});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function stepCart(){
|
||||
const rows=Object.keys(state.cart).map(id=>{
|
||||
const it=findItem(id);if(!it)return'';
|
||||
return '<div class="cart-row" data-row="'+id+'">'+
|
||||
'<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>';
|
||||
}).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>';
|
||||
}
|
||||
|
||||
function stepCheckout(){
|
||||
const days=[...new Set(Object.keys(state.cart).map(findDayOf))].map(i=>FEST.days[i]);
|
||||
const windows=[...new Set(days.flatMap(d=>d.items.map(it=>it.id)))]
|
||||
.map(id=>({id,start:findItem(id).start,end:findItem(id).end,name:findItem(id).name}))
|
||||
.sort((a,b)=>a.start-b.start);
|
||||
const n=nowMin();
|
||||
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>'+
|
||||
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
|
||||
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
|
||||
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
|
||||
'<div class="total-row"><span class="t">Total · '+cartCount()+' items</span><span class="v">'+fmt$(cartTotal())+'</span></div>'+
|
||||
'<button class="btn btn-pink" id="to-payment" style="margin-top:8px">₿ Pay with Bitcoin</button>'+
|
||||
'<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="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>'+
|
||||
'<button class="btn btn-primary" id="success-done">Done</button>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireSheet(){
|
||||
const s=$('#sheet');
|
||||
s.querySelectorAll('[data-inc]').forEach(b=>b.onclick=()=>{state.cart[b.dataset.inc]++;afterCartChange();});
|
||||
s.querySelectorAll('[data-dec]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.dec;state.cart[id]--;
|
||||
if(state.cart[id]<=0)delete state.cart[id];
|
||||
afterCartChange();
|
||||
});
|
||||
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 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');
|
||||
};
|
||||
const done=s.querySelector('#success-done');
|
||||
if(done)done.onclick=()=>{
|
||||
closeSheet();
|
||||
document.querySelector('[data-view="orders"]').click();
|
||||
};
|
||||
}
|
||||
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();
|
||||
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);
|
||||
saveOrders();
|
||||
state.cart={};saveCart();renderFab();renderTimeline();
|
||||
openSheet('success');
|
||||
}
|
||||
|
||||
/* ============================== ORDERS VIEW ============================== */
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
}).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…';
|
||||
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){}
|
||||
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();}
|
||||
};
|
||||
|
||||
/* ============================== THEME (White ⇄ Cosmic) ============================== */
|
||||
const THEME_KEY='kitchen484-theme';
|
||||
function applyTheme(t){
|
||||
t=(t==='cosmic')?'cosmic':'white';
|
||||
if(t==='cosmic')document.documentElement.setAttribute('data-theme','cosmic');
|
||||
else document.documentElement.removeAttribute('data-theme');
|
||||
const b=$('#btn-theme');
|
||||
if(b){b.textContent=(t==='cosmic')?'☾ WHITE':'✦ COSMIC';b.title=(t==='cosmic')?'Switch to white theme':'Switch to cosmic theme';}
|
||||
const meta=document.querySelector('meta[name="theme-color"]');
|
||||
if(meta)meta.setAttribute('content',t==='cosmic'?'#0d0a06':'#FFFFFF');
|
||||
}
|
||||
function currentTheme(){return document.documentElement.getAttribute('data-theme')==='cosmic'?'cosmic':'white';}
|
||||
function initTheme(){
|
||||
applyTheme('white');
|
||||
const b=$('#btn-theme');
|
||||
if(b)b.onclick=()=>{const next=currentTheme()==='cosmic'?'white':'cosmic';applyTheme(next);try{localStorage.setItem(THEME_KEY,next);}catch(e){}};
|
||||
}
|
||||
initTheme();
|
||||
|
||||
/* ============================== CLOCK / LOOP ============================== */
|
||||
function tickClock(){
|
||||
const d=new Date();
|
||||
$('#clock').textContent=String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
setInterval(()=>{tickClock();if(!$('#view-schedule').hidden)refreshStatuses();},30000);
|
||||
|
||||
/* ============================== INIT ============================== */
|
||||
(async function init(){
|
||||
tickClock();
|
||||
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);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
583
backups/home.html.pre-reorder-20260831-061852
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>K484 · A Private Membership Association</title>
|
||||
<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:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5;scroll-behavior:smooth}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
a{color:inherit}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:0 22px}
|
||||
|
||||
/* ============ HEADER ============ */
|
||||
header{position:sticky;top:0;z-index:50;background:rgba(255,255,255,.9);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||||
.header-in{display:flex;align-items:center;gap:12px;padding:12px 22px;max-width:1040px;margin:0 auto}
|
||||
.logo{width:46px;height:46px;flex:0 0 46px;object-fit:contain}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px;display:block;line-height:1.2}
|
||||
.brand span{font-size:11px;color:var(--muted);letter-spacing:1px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
.member-chip{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;border:1px solid var(--line2);border-radius:99px;padding:6px 12px;color:var(--ink2)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:99px;padding:12px 22px;transition:transform .12s,opacity .12s;text-decoration:none}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff}
|
||||
.btn-primary:hover{opacity:.88}
|
||||
.btn-ghost{background:var(--bg);color:var(--ink);border:1.5px solid var(--line2)}
|
||||
.btn-ghost:hover{border-color:var(--ink)}
|
||||
.btn-sm{padding:9px 16px;font-size:13px}
|
||||
|
||||
/* ============ HERO ============ */
|
||||
.hero{text-align:center;padding:72px 22px 56px;position:relative;overflow:hidden}
|
||||
.hero-logo{width:140px;height:140px;object-fit:contain;margin:0 auto 26px;display:block;animation:fadeup 1s ease both}
|
||||
.hero-kicker{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:4px;color:var(--muted);animation:fadeup .9s ease both .1s}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:clamp(40px,8vw,72px);letter-spacing:6px;margin:10px 0 6px;animation:fadeup .9s ease both .2s}
|
||||
.hero .assoc{font-size:clamp(15px,2.4vw,19px);color:var(--ink2);letter-spacing:2px;text-transform:uppercase;animation:fadeup .9s ease both .3s}
|
||||
.hero-sub{max-width:560px;margin:18px auto 0;font-size:14px;color:var(--ink2);animation:fadeup .9s ease both .4s}
|
||||
.tagline-row{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin:28px auto 6px;animation:fadeup .9s ease both .5s}
|
||||
.tagline{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:2px;border:1.5px solid var(--ink);border-radius:99px;padding:8px 16px;text-transform:uppercase}
|
||||
.hero-cta{display:flex;flex-wrap:wrap;justify-content:center;gap:12px;margin-top:30px;animation:fadeup .9s ease both .6s}
|
||||
@keyframes fadeup{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* ============ SECTIONS ============ */
|
||||
section{padding:64px 0}
|
||||
.sec-head{text-align:center;margin-bottom:36px}
|
||||
.sec-head h2{font-family:var(--display);font-weight:900;font-size:clamp(22px,4vw,30px);letter-spacing:3px;text-transform:uppercase}
|
||||
.sec-head .sub{font-size:12px;color:var(--muted);letter-spacing:2px;text-transform:uppercase;margin-top:8px}
|
||||
.divider{border:none;border-top:1px solid var(--line);max-width:1040px;margin:0 auto}
|
||||
|
||||
/* ============ MENU ============ */
|
||||
#menu{background:var(--bg2)}
|
||||
.day-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px;align-items:start}
|
||||
.day-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
||||
.day-card.today{border-color:var(--ink);border-width:2px}
|
||||
.day-head{padding:16px 18px 12px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:10px}
|
||||
.day-tag{font-family:var(--display);font-size:11px;font-weight:900;letter-spacing:2px}
|
||||
.day-date{font-size:12px;color:var(--muted);letter-spacing:1px;margin-left:auto;text-transform:uppercase}
|
||||
.today-chip{font-family:var(--display);font-size:9px;font-weight:700;letter-spacing:1px;background:var(--black);color:#fff;border-radius:99px;padding:3px 8px}
|
||||
.day-list{padding:6px 0}
|
||||
.day-row{display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--line)}
|
||||
.day-row:last-child{border-bottom:none}
|
||||
.day-em{font-size:18px;width:26px;text-align:center;flex:0 0 26px}
|
||||
.day-info{min-width:0}
|
||||
.day-name{font-weight:700;font-size:14px}
|
||||
.day-time{font-family:var(--display);font-size:10px;color:var(--muted);letter-spacing:.5px;margin-top:1px}
|
||||
.day-price{font-family:var(--display);font-weight:900;font-size:14px;margin-left:auto}
|
||||
.day-foot{padding:14px 18px}
|
||||
.day-foot a{display:block;text-align:center}
|
||||
.tags{display:inline-flex;gap:4px;margin-left:6px;vertical-align:1px}
|
||||
.tag{font-family:var(--display);font-size:8px;font-weight:700;letter-spacing:.5px;border:1px solid var(--line2);border-radius:99px;padding:1px 6px;color:var(--muted)}
|
||||
|
||||
/* ============ HOW IT WORKS ============ */
|
||||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
|
||||
.step{border:1px solid var(--line);border-radius:var(--radius);padding:24px 20px;background:var(--bg)}
|
||||
.step .n{font-family:var(--display);font-weight:900;font-size:26px;color:var(--muted)}
|
||||
.step h3{font-family:var(--display);font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin:8px 0 6px}
|
||||
.step p{font-size:13px;color:var(--ink2)}
|
||||
|
||||
/* ============ MEMBERSHIP ============ */
|
||||
#join{background:var(--bg2)}
|
||||
.join-wrap{max-width:640px;margin:0 auto}
|
||||
.note-box{border:1px solid var(--line2);border-radius:14px;padding:14px 18px;font-size:13px;color:var(--ink2);background:var(--bg);margin-bottom:22px}
|
||||
.note-box b{color:var(--ink)}
|
||||
.form-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:26px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input{width:100%;border:1px solid var(--line2);border-radius:12px;padding:13px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.field input:focus{border-color:var(--ink)}
|
||||
.sig-wrap{border:1.5px dashed var(--line2);border-radius:12px;position:relative;background:var(--bg2)}
|
||||
.sig-canvas{display:block;width:100%;height:140px;touch-action:none;cursor:crosshair;border-radius:12px}
|
||||
.sig-hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--muted);letter-spacing:1px;pointer-events:none;text-transform:uppercase}
|
||||
.sig-tools{display:flex;align-items:center;justify-content:space-between;margin-top:8px}
|
||||
.sig-status{font-size:11px;color:var(--muted);letter-spacing:.5px}
|
||||
.sig-status.ok{font-weight:700;color:var(--ink)}
|
||||
.link-btn{background:none;border:none;cursor:pointer;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);text-decoration:underline}
|
||||
.link-btn:hover{color:var(--ink)}
|
||||
.covenant{background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:18px 0;max-height:190px;overflow-y:auto;font-size:12.5px;color:var(--ink2)}
|
||||
.covenant h4{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink);margin-bottom:8px}
|
||||
.covenant ol{padding-left:18px;display:grid;gap:6px}
|
||||
.agree-row{display:flex;gap:10px;align-items:flex-start;margin:16px 0}
|
||||
.agree-row input{width:18px;height:18px;margin-top:1px;accent-color:var(--black);flex:0 0 18px}
|
||||
.agree-row label{font-size:12.5px;color:var(--ink2)}
|
||||
.form-err{font-size:12px;font-weight:700;color:var(--ink);background:var(--bg2);border:1.5px solid var(--ink);border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.form-err.show{display:block}
|
||||
.form-actions{display:grid;gap:10px}
|
||||
.member-done{text-align:center;padding:10px 0}
|
||||
.member-done .check{width:56px;height:56px;border-radius:50%;background:var(--black);color:#fff;font-size:26px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
|
||||
.member-done h3{font-family:var(--display);font-weight:900;font-size:17px;letter-spacing:2px}
|
||||
.member-code{font-family:var(--display);font-weight:900;font-size:22px;letter-spacing:3px;background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:12px;margin:16px 0}
|
||||
.member-meta{font-size:13px;color:var(--ink2);margin-bottom:6px}
|
||||
|
||||
/* ============ FOOTER ============ */
|
||||
footer{border-top:1px solid var(--line);padding:28px 22px 34px;text-align:center}
|
||||
footer .f-logo{width:52px;height:52px;object-fit:contain;margin:0 auto 10px;display:block}
|
||||
footer .f-line{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--muted)}
|
||||
footer .f-sub{font-size:11px;color:var(--muted);margin-top:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-in">
|
||||
<img class="logo" src="logo.png" alt="K484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<div class="header-right">
|
||||
<span class="member-chip" id="member-chip" hidden></span>
|
||||
<a class="btn btn-ghost btn-sm" href="#join" id="nav-join">Join</a>
|
||||
<a class="btn btn-primary btn-sm" href="index.html">Food Menu</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<img class="hero-logo" src="logo.png" alt="K484">
|
||||
<div class="hero-kicker">SOLARPUNK SUMMIT · Oct 8–12</div>
|
||||
<h1>K484</h1>
|
||||
<div class="assoc">A Private Membership Association</div>
|
||||
<p class="hero-sub">Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.</p>
|
||||
<div class="tagline-row">
|
||||
<span class="tagline">No Seed Oils</span>
|
||||
<span class="tagline">All Organic Grass Fed</span>
|
||||
<span class="tagline">Gluten Free & Vegetarian Friendly</span>
|
||||
<span class="tagline">Off Grid — Zero Waste</span>
|
||||
</div>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary" href="index.html">Food Menu →</a>
|
||||
<a class="btn btn-ghost" href="#join">Become a Member</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="menu">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<h2>Food Menu</h2>
|
||||
<div class="sub">Off Grid — Zero Waste · What's on, and when</div>
|
||||
</div>
|
||||
<div class="day-grid" id="day-grid"></div>
|
||||
<div style="text-align:center;margin-top:28px">
|
||||
<a class="btn btn-primary" href="index.html">Open the Full Schedule & Order →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<h2>How It Works</h2>
|
||||
<div class="sub">Five days, three steps</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">01</div>
|
||||
<h3>Check the Schedule</h3>
|
||||
<p>Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">02</div>
|
||||
<h3>Pre-Order Your Window</h3>
|
||||
<p>Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">03</div>
|
||||
<h3>Pay with Bitcoin</h3>
|
||||
<p>Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<section id="join">
|
||||
<div class="wrap join-wrap">
|
||||
<div class="sec-head">
|
||||
<h2>Membership</h2>
|
||||
<div class="sub">Kitchen 484 · Private Membership Association</div>
|
||||
</div>
|
||||
|
||||
<div id="join-form-wrap">
|
||||
<div class="note-box">
|
||||
<b>Note:</b> This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application.
|
||||
</div>
|
||||
|
||||
<form class="form-card" id="join-form" novalidate>
|
||||
<div class="form-err" id="join-err"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="m-name">Full Name</label>
|
||||
<input id="m-name" type="text" placeholder="Your full name" maxlength="80" autocomplete="name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-email">Email</label>
|
||||
<input id="m-email" type="email" placeholder="you@example.com" maxlength="120" autocomplete="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-phone">Phone</label>
|
||||
<input id="m-phone" type="tel" placeholder="+1 (555) 000-0000" maxlength="30" autocomplete="tel">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Digital Signature</label>
|
||||
<div class="sig-wrap">
|
||||
<canvas class="sig-canvas" id="sig-canvas"></canvas>
|
||||
<div class="sig-hint" id="sig-hint">Sign here with mouse or finger</div>
|
||||
</div>
|
||||
<div class="sig-tools">
|
||||
<span class="sig-status" id="sig-status">Signature required to apply</span>
|
||||
<button type="button" class="link-btn" id="sig-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="covenant">
|
||||
<h4>K484 Membership Covenant</h4>
|
||||
<p style="margin-bottom:8px">By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>
|
||||
<ol>
|
||||
<li>I am joining a private, members-only association, not a public business.</li>
|
||||
<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>
|
||||
<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>
|
||||
<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>
|
||||
<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>
|
||||
<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="agree-row">
|
||||
<input type="checkbox" id="m-agree">
|
||||
<label for="m-agree">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Sign & Apply for Membership</button>
|
||||
<a class="btn btn-ghost" href="index.html" style="text-decoration:none">Browse the Food Menu First</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card member-done" id="join-done" hidden>
|
||||
<div class="check">✓</div>
|
||||
<h3>APPLICATION SUBMITTED</h3>
|
||||
<div class="member-code" id="done-code">K484-XXXX</div>
|
||||
<div class="member-meta" id="done-name"></div>
|
||||
<div class="member-meta" id="done-date"></div>
|
||||
<p class="member-meta" style="margin-top:10px;font-size:12px;color:var(--muted)">Keep this code — it is your member reference. Your signed covenant is available for download and printing.</p>
|
||||
<div class="form-actions" style="margin-top:18px">
|
||||
<button class="btn btn-primary" id="btn-download">Download Signed Agreement</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
<a class="btn btn-ghost" href="index.html" style="flex:1;text-decoration:none">Open Food Menu</a>
|
||||
<button class="btn btn-ghost" id="btn-new-apply" style="flex:1">New Application</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<img class="f-logo" src="logo.png" alt="K484">
|
||||
<div class="f-line">Off Grid — Zero Waste</div>
|
||||
<div class="f-sub">KITCHEN 484 · SOLARPUNK SUMMIT · Oct 8–12 · No cash, no cards — Bitcoin only</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* ============================== DATA (mirrors index.html) ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with hero dates) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
const FEST={
|
||||
days:[
|
||||
{ tag:'DAY 1', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1080, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF']},
|
||||
{name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:930, tags:[]},
|
||||
{name:'Chicken Grill', emoji:'🍗', price:9, start:1050, end:1230, tags:['GF']},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:1080, end:1200, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 2', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:570, end:1020, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1260, tags:['V','GF']},
|
||||
{name:'Garden Burgers', emoji:'🍔', price:11, start:720, end:900, tags:[]},
|
||||
{name:'Grill 484', emoji:'🔥', price:9, start:750, end:960, tags:['GF']},
|
||||
{name:'Ramen Tent', emoji:'🍜', price:12, start:1020, end:1200, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:570, end:1290, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 3', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1050, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF']},
|
||||
{name:'Flatbread Oven', emoji:'🫓', price:9, start:720, end:930, tags:['V','GF']},
|
||||
{name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:960, tags:[]},
|
||||
{name:'Dumpling Bar', emoji:'🥟', price:10, start:1020, end:1230, tags:['GF']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 4', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1020, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1290, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF']},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:900, end:1140, tags:['V']},
|
||||
{name:'Grill 484', emoji:'🔥', price:9, start:960, end:1170, tags:['GF']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1290, tags:['VG']}
|
||||
]},
|
||||
{ tag:'FINAL DAY', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:600, end:960, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1200, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:840, tags:['GF']},
|
||||
{name:'Farewell Feast · BBQ', emoji:'🍖', price:14, start:720, end:960, tags:[]},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:1020, end:1170, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1230, tags:['VG']}
|
||||
]}
|
||||
]
|
||||
};
|
||||
FEST.days.forEach((d,i)=>{ const L=dayLabel(i); d.name=L.name; d.date=L.date; d.iso=L.iso; });
|
||||
|
||||
const $=s=>document.querySelector(s);
|
||||
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
|
||||
/* ============================== MENU CARDS ============================== */
|
||||
function renderMenu(){
|
||||
$('#day-grid').innerHTML=FEST.days.map((d,i)=>{
|
||||
const rows=[...d.items].sort((a,b)=>a.start-b.start).map(it=>
|
||||
'<div class="day-row">'+
|
||||
'<div class="day-em">'+it.emoji+'</div>'+
|
||||
'<div class="day-info">'+
|
||||
'<div class="day-name">'+it.name+'<span class="tags">'+it.tags.map(t=>'<span class="tag">'+t+'</span>').join('')+'</span></div>'+
|
||||
'<div class="day-time">'+fmtT(it.start)+' – '+fmtT(it.end)+'</div>'+
|
||||
'</div>'+
|
||||
'<div class="day-price">$'+it.price+'</div>'+
|
||||
'</div>').join('');
|
||||
return '<div class="day-card'+(i===0?' today':'')+'">'+
|
||||
'<div class="day-head">'+
|
||||
'<span class="day-tag">'+d.tag+'</span>'+
|
||||
(i===0?'<span class="today-chip">TODAY</span>':'')+
|
||||
'<span class="day-date">'+d.name+' '+d.date+'</span>'+
|
||||
'</div>'+
|
||||
'<div class="day-list">'+rows+'</div>'+
|
||||
'<div class="day-foot"><a class="btn btn-ghost btn-sm" href="index.html">Order for '+d.tag+' →</a></div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
renderMenu();
|
||||
|
||||
/* ============================== SIGNATURE PAD ============================== */
|
||||
const canvas=$('#sig-canvas');
|
||||
const ctx=canvas.getContext('2d');
|
||||
let drawing=false, lastPt=null, hasInk=false;
|
||||
|
||||
function sizeCanvas(){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const dpr=window.devicePixelRatio||1;
|
||||
const img=canvas.width?canvas.toDataURL():null;
|
||||
canvas.width=Math.max(1,r.width*dpr);
|
||||
canvas.height=140*dpr;
|
||||
ctx.scale(dpr,dpr);
|
||||
ctx.lineWidth=2.2;
|
||||
ctx.lineCap='round';
|
||||
ctx.lineJoin='round';
|
||||
ctx.strokeStyle='#0A0A0A';
|
||||
if(img){const im=new Image();im.onload=()=>ctx.drawImage(im,0,0,r.width,140);im.src=img;}
|
||||
}
|
||||
function pt(e){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
return {x:t.clientX-r.left,y:t.clientY-r.top};
|
||||
}
|
||||
function startDraw(e){
|
||||
drawing=true; lastPt=pt(e);
|
||||
// draw a dot so taps (touch users) register ink, not just drags
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastPt.x,lastPt.y,1.2,0,Math.PI*2);
|
||||
ctx.fillStyle='#0A0A0A';
|
||||
ctx.fill();
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function moveDraw(e){
|
||||
if(!drawing)return;
|
||||
const p=pt(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPt.x,lastPt.y);
|
||||
ctx.lineTo(p.x,p.y);
|
||||
ctx.stroke();
|
||||
lastPt=p;
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function endDraw(){drawing=false;lastPt=null;}
|
||||
|
||||
canvas.addEventListener('mousedown',startDraw);
|
||||
canvas.addEventListener('mousemove',moveDraw);
|
||||
window.addEventListener('mouseup',endDraw);
|
||||
canvas.addEventListener('touchstart',startDraw,{passive:false});
|
||||
canvas.addEventListener('touchmove',moveDraw,{passive:false});
|
||||
canvas.addEventListener('touchend',endDraw);
|
||||
$('#sig-clear').onclick=()=>{
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
hasInk=false;
|
||||
$('#sig-hint').style.display='flex';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='Signature required to apply';
|
||||
st.classList.remove('ok');
|
||||
};
|
||||
window.addEventListener('resize',sizeCanvas);
|
||||
sizeCanvas();
|
||||
|
||||
/* ============================== MEMBERSHIP ============================== */
|
||||
const LS_MEMBER='slk_member';
|
||||
const LS_MEMBERS='slk_members';
|
||||
|
||||
function loadMember(){try{return JSON.parse(localStorage.getItem(LS_MEMBER)||'null');}catch(e){return null;}}
|
||||
function saveMember(m){
|
||||
localStorage.setItem(LS_MEMBER,JSON.stringify(m));
|
||||
const all=JSON.parse(localStorage.getItem(LS_MEMBERS)||'[]');
|
||||
all.push(m);
|
||||
localStorage.setItem(LS_MEMBERS,JSON.stringify(all));
|
||||
}
|
||||
function showMember(m){
|
||||
$('#join-form-wrap').hidden=true;
|
||||
$('#join-done').hidden=false;
|
||||
$('#done-code').textContent=m.code;
|
||||
$('#done-name').textContent=m.fullName;
|
||||
$('#done-date').textContent='Applied '+new Date(m.ts).toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'});
|
||||
const chip=$('#member-chip');
|
||||
chip.hidden=false;
|
||||
chip.textContent=m.code;
|
||||
document.getElementById('nav-join').textContent='Member';
|
||||
$('#join').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
const existing=loadMember();
|
||||
if(existing)showMember(existing);
|
||||
|
||||
$('#join-form').addEventListener('submit',e=>{
|
||||
e.preventDefault();
|
||||
const err=$('#join-err');
|
||||
err.classList.remove('show');
|
||||
const name=$('#m-name').value.trim();
|
||||
const email=$('#m-email').value.trim();
|
||||
const phone=$('#m-phone').value.trim();
|
||||
const agree=$('#m-agree').checked;
|
||||
|
||||
let problem=null;
|
||||
if(!name)problem='Please enter your full name.';
|
||||
else if(!email||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))problem='Please enter a valid email address.';
|
||||
else if(!phone)problem='Please enter a phone number.';
|
||||
else if(!hasInk)problem='Please sign in the signature box.';
|
||||
else if(!agree)problem='Please check the covenant agreement box.';
|
||||
|
||||
if(problem){
|
||||
err.textContent=problem;
|
||||
err.classList.add('show');
|
||||
err.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
return;
|
||||
}
|
||||
|
||||
const member={
|
||||
code:'K484-'+Math.random().toString(36).slice(2,6).toUpperCase(),
|
||||
fullName:name, email, phone,
|
||||
ts:Date.now(),
|
||||
signature:canvas.toDataURL('image/png')
|
||||
};
|
||||
saveMember(member);
|
||||
showMember(member);
|
||||
});
|
||||
|
||||
$('#btn-new-apply').onclick=()=>{
|
||||
localStorage.removeItem(LS_MEMBER);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
/* ============================== AGREEMENT DOWNLOAD ============================== */
|
||||
$('#btn-download').onclick=()=>{
|
||||
const m=loadMember();
|
||||
if(!m){toast('No application found');return;}
|
||||
const d=new Date(m.ts);
|
||||
const html='<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Kitchen 484 Membership Agreement - '+esc(m.fullName)+'</title>'+
|
||||
'<style>'+
|
||||
'body{font-family:Georgia,serif;color:#000;max-width:720px;margin:40px auto;padding:0 24px;line-height:1.6;font-size:14px}'+
|
||||
'h1{font-size:22px;letter-spacing:2px;text-transform:uppercase;text-align:center}'+
|
||||
'h2{font-size:14px;letter-spacing:1px;text-transform:uppercase;text-align:center;font-weight:normal;color:#333;margin-top:4px}'+
|
||||
'.box{border:1px solid #000;padding:18px 22px;margin:22px 0}'+
|
||||
'.info p{margin:6px 0}'+
|
||||
'.info b{display:inline-block;width:110px}'+
|
||||
'ol{padding-left:20px}li{margin:8px 0}'+
|
||||
'.sig{margin-top:30px;text-align:center}'+
|
||||
'.sig img{height:90px;border:1px solid #999;border-radius:6px;padding:6px;background:#fff}'+
|
||||
'.sig .line{margin-top:6px;font-size:12px;color:#333}'+
|
||||
'.declare{margin-top:24px;font-style:italic}'+
|
||||
'@media print{body{margin:20px auto}}'+
|
||||
'</style></head><body>'+
|
||||
'<h1>Kitchen 484</h1><h2>Membership Agreement — '+esc(m.code)+'</h2>'+
|
||||
'<div class="box info">'+
|
||||
'<p><b>Member:</b> '+esc(m.fullName)+'</p>'+
|
||||
'<p><b>Email:</b> '+esc(m.email)+'</p>'+
|
||||
'<p><b>Phone:</b> '+esc(m.phone)+'</p>'+
|
||||
'<p><b>Applied:</b> '+d.toLocaleString('en-US')+'</p>'+
|
||||
'</div>'+
|
||||
'<p>Note: This is a private, members-only association. Participation is absolutely voluntary.</p>'+
|
||||
'<h2 style="text-align:left;margin-top:20px">K484 Membership Covenant</h2>'+
|
||||
'<p>By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>'+
|
||||
'<ol>'+
|
||||
'<li>I am joining a private, members-only association, not a public business.</li>'+
|
||||
'<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>'+
|
||||
'<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>'+
|
||||
'<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>'+
|
||||
'<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>'+
|
||||
'<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>'+
|
||||
'</ol>'+
|
||||
'<p class="declare">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</p>'+
|
||||
'<div class="sig"><img src="'+m.signature+'" alt="signature"><div class="line">Digital signature — '+esc(m.fullName)+' · '+d.toLocaleDateString('en-US')+'</div></div>'+
|
||||
'<script>setTimeout(function(){window.print()},600)</'+'script>'+
|
||||
'</body></html>';
|
||||
const w=window.open('','_blank');
|
||||
if(!w){toast('Pop-up blocked — allow pop-ups to download');return;}
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
function toast(msg){
|
||||
let t=document.getElementById('h-toast');
|
||||
if(!t){t=document.createElement('div');t.id='h-toast';t.style.cssText='position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#0A0A0A;color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none';document.body.appendChild(t);}
|
||||
t.textContent=msg;t.style.opacity='1';t.style.transform='translateX(-50%) translateY(0)';
|
||||
clearTimeout(t._x);t._x=setTimeout(()=>{t.style.opacity='0';t.style.transform='translateX(-50%) translateY(20px)';},2200);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
583
backups/home.html.pre-reorder-20260831-061942
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>K484 · A Private Membership Association</title>
|
||||
<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:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5;scroll-behavior:smooth}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
a{color:inherit}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:0 22px}
|
||||
|
||||
/* ============ HEADER ============ */
|
||||
header{position:sticky;top:0;z-index:50;background:rgba(255,255,255,.9);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||||
.header-in{display:flex;align-items:center;gap:12px;padding:12px 22px;max-width:1040px;margin:0 auto}
|
||||
.logo{width:46px;height:46px;flex:0 0 46px;object-fit:contain}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px;display:block;line-height:1.2}
|
||||
.brand span{font-size:11px;color:var(--muted);letter-spacing:1px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
.member-chip{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;border:1px solid var(--line2);border-radius:99px;padding:6px 12px;color:var(--ink2)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:99px;padding:12px 22px;transition:transform .12s,opacity .12s;text-decoration:none}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff}
|
||||
.btn-primary:hover{opacity:.88}
|
||||
.btn-ghost{background:var(--bg);color:var(--ink);border:1.5px solid var(--line2)}
|
||||
.btn-ghost:hover{border-color:var(--ink)}
|
||||
.btn-sm{padding:9px 16px;font-size:13px}
|
||||
|
||||
/* ============ HERO ============ */
|
||||
.hero{text-align:center;padding:72px 22px 56px;position:relative;overflow:hidden}
|
||||
.hero-logo{width:140px;height:140px;object-fit:contain;margin:0 auto 26px;display:block;animation:fadeup 1s ease both}
|
||||
.hero-kicker{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:4px;color:var(--muted);animation:fadeup .9s ease both .1s}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:clamp(40px,8vw,72px);letter-spacing:6px;margin:10px 0 6px;animation:fadeup .9s ease both .2s}
|
||||
.hero .assoc{font-size:clamp(15px,2.4vw,19px);color:var(--ink2);letter-spacing:2px;text-transform:uppercase;animation:fadeup .9s ease both .3s}
|
||||
.hero-sub{max-width:560px;margin:18px auto 0;font-size:14px;color:var(--ink2);animation:fadeup .9s ease both .4s}
|
||||
.tagline-row{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin:28px auto 6px;animation:fadeup .9s ease both .5s}
|
||||
.tagline{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:2px;border:1.5px solid var(--ink);border-radius:99px;padding:8px 16px;text-transform:uppercase}
|
||||
.hero-cta{display:flex;flex-wrap:wrap;justify-content:center;gap:12px;margin-top:30px;animation:fadeup .9s ease both .6s}
|
||||
@keyframes fadeup{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* ============ SECTIONS ============ */
|
||||
section{padding:64px 0}
|
||||
.sec-head{text-align:center;margin-bottom:36px}
|
||||
.sec-head h2{font-family:var(--display);font-weight:900;font-size:clamp(22px,4vw,30px);letter-spacing:3px;text-transform:uppercase}
|
||||
.sec-head .sub{font-size:12px;color:var(--muted);letter-spacing:2px;text-transform:uppercase;margin-top:8px}
|
||||
.divider{border:none;border-top:1px solid var(--line);max-width:1040px;margin:0 auto}
|
||||
|
||||
/* ============ MENU ============ */
|
||||
#menu{background:var(--bg2)}
|
||||
.day-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px;align-items:start}
|
||||
.day-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
||||
.day-card.today{border-color:var(--ink);border-width:2px}
|
||||
.day-head{padding:16px 18px 12px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:10px}
|
||||
.day-tag{font-family:var(--display);font-size:11px;font-weight:900;letter-spacing:2px}
|
||||
.day-date{font-size:12px;color:var(--muted);letter-spacing:1px;margin-left:auto;text-transform:uppercase}
|
||||
.today-chip{font-family:var(--display);font-size:9px;font-weight:700;letter-spacing:1px;background:var(--black);color:#fff;border-radius:99px;padding:3px 8px}
|
||||
.day-list{padding:6px 0}
|
||||
.day-row{display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--line)}
|
||||
.day-row:last-child{border-bottom:none}
|
||||
.day-em{font-size:18px;width:26px;text-align:center;flex:0 0 26px}
|
||||
.day-info{min-width:0}
|
||||
.day-name{font-weight:700;font-size:14px}
|
||||
.day-time{font-family:var(--display);font-size:10px;color:var(--muted);letter-spacing:.5px;margin-top:1px}
|
||||
.day-price{font-family:var(--display);font-weight:900;font-size:14px;margin-left:auto}
|
||||
.day-foot{padding:14px 18px}
|
||||
.day-foot a{display:block;text-align:center}
|
||||
.tags{display:inline-flex;gap:4px;margin-left:6px;vertical-align:1px}
|
||||
.tag{font-family:var(--display);font-size:8px;font-weight:700;letter-spacing:.5px;border:1px solid var(--line2);border-radius:99px;padding:1px 6px;color:var(--muted)}
|
||||
|
||||
/* ============ HOW IT WORKS ============ */
|
||||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
|
||||
.step{border:1px solid var(--line);border-radius:var(--radius);padding:24px 20px;background:var(--bg)}
|
||||
.step .n{font-family:var(--display);font-weight:900;font-size:26px;color:var(--muted)}
|
||||
.step h3{font-family:var(--display);font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin:8px 0 6px}
|
||||
.step p{font-size:13px;color:var(--ink2)}
|
||||
|
||||
/* ============ MEMBERSHIP ============ */
|
||||
#join{background:var(--bg2)}
|
||||
.join-wrap{max-width:640px;margin:0 auto}
|
||||
.note-box{border:1px solid var(--line2);border-radius:14px;padding:14px 18px;font-size:13px;color:var(--ink2);background:var(--bg);margin-bottom:22px}
|
||||
.note-box b{color:var(--ink)}
|
||||
.form-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:26px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input{width:100%;border:1px solid var(--line2);border-radius:12px;padding:13px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.field input:focus{border-color:var(--ink)}
|
||||
.sig-wrap{border:1.5px dashed var(--line2);border-radius:12px;position:relative;background:var(--bg2)}
|
||||
.sig-canvas{display:block;width:100%;height:140px;touch-action:none;cursor:crosshair;border-radius:12px}
|
||||
.sig-hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--muted);letter-spacing:1px;pointer-events:none;text-transform:uppercase}
|
||||
.sig-tools{display:flex;align-items:center;justify-content:space-between;margin-top:8px}
|
||||
.sig-status{font-size:11px;color:var(--muted);letter-spacing:.5px}
|
||||
.sig-status.ok{font-weight:700;color:var(--ink)}
|
||||
.link-btn{background:none;border:none;cursor:pointer;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);text-decoration:underline}
|
||||
.link-btn:hover{color:var(--ink)}
|
||||
.covenant{background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:18px 0;max-height:190px;overflow-y:auto;font-size:12.5px;color:var(--ink2)}
|
||||
.covenant h4{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink);margin-bottom:8px}
|
||||
.covenant ol{padding-left:18px;display:grid;gap:6px}
|
||||
.agree-row{display:flex;gap:10px;align-items:flex-start;margin:16px 0}
|
||||
.agree-row input{width:18px;height:18px;margin-top:1px;accent-color:var(--black);flex:0 0 18px}
|
||||
.agree-row label{font-size:12.5px;color:var(--ink2)}
|
||||
.form-err{font-size:12px;font-weight:700;color:var(--ink);background:var(--bg2);border:1.5px solid var(--ink);border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.form-err.show{display:block}
|
||||
.form-actions{display:grid;gap:10px}
|
||||
.member-done{text-align:center;padding:10px 0}
|
||||
.member-done .check{width:56px;height:56px;border-radius:50%;background:var(--black);color:#fff;font-size:26px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
|
||||
.member-done h3{font-family:var(--display);font-weight:900;font-size:17px;letter-spacing:2px}
|
||||
.member-code{font-family:var(--display);font-weight:900;font-size:22px;letter-spacing:3px;background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:12px;margin:16px 0}
|
||||
.member-meta{font-size:13px;color:var(--ink2);margin-bottom:6px}
|
||||
|
||||
/* ============ FOOTER ============ */
|
||||
footer{border-top:1px solid var(--line);padding:28px 22px 34px;text-align:center}
|
||||
footer .f-logo{width:52px;height:52px;object-fit:contain;margin:0 auto 10px;display:block}
|
||||
footer .f-line{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--muted)}
|
||||
footer .f-sub{font-size:11px;color:var(--muted);margin-top:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-in">
|
||||
<img class="logo" src="logo.png" alt="K484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<div class="header-right">
|
||||
<span class="member-chip" id="member-chip" hidden></span>
|
||||
<a class="btn btn-ghost btn-sm" href="#join" id="nav-join">Join</a>
|
||||
<a class="btn btn-primary btn-sm" href="index.html">Food Menu</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<img class="hero-logo" src="logo.png" alt="K484">
|
||||
<div class="hero-kicker">SOLARPUNK SUMMIT · Oct 8–12</div>
|
||||
<h1>K484</h1>
|
||||
<div class="assoc">A Private Membership Association</div>
|
||||
<p class="hero-sub">Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.</p>
|
||||
<div class="tagline-row">
|
||||
<span class="tagline">No Seed Oils</span>
|
||||
<span class="tagline">All Organic Grass Fed</span>
|
||||
<span class="tagline">Gluten Free & Vegetarian Friendly</span>
|
||||
<span class="tagline">Off Grid — Zero Waste</span>
|
||||
</div>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary" href="index.html">Food Menu →</a>
|
||||
<a class="btn btn-ghost" href="#join">Become a Member</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="menu">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<h2>Food Menu</h2>
|
||||
<div class="sub">Off Grid — Zero Waste · What's on, and when</div>
|
||||
</div>
|
||||
<div class="day-grid" id="day-grid"></div>
|
||||
<div style="text-align:center;margin-top:28px">
|
||||
<a class="btn btn-primary" href="index.html">Open the Full Schedule & Order →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<h2>How It Works</h2>
|
||||
<div class="sub">Five days, three steps</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">01</div>
|
||||
<h3>Check the Schedule</h3>
|
||||
<p>Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">02</div>
|
||||
<h3>Pre-Order Your Window</h3>
|
||||
<p>Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">03</div>
|
||||
<h3>Pay with Bitcoin</h3>
|
||||
<p>Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<section id="join">
|
||||
<div class="wrap join-wrap">
|
||||
<div class="sec-head">
|
||||
<h2>Membership</h2>
|
||||
<div class="sub">Kitchen 484 · Private Membership Association</div>
|
||||
</div>
|
||||
|
||||
<div id="join-form-wrap">
|
||||
<div class="note-box">
|
||||
<b>Note:</b> This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application.
|
||||
</div>
|
||||
|
||||
<form class="form-card" id="join-form" novalidate>
|
||||
<div class="form-err" id="join-err"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="m-name">Full Name</label>
|
||||
<input id="m-name" type="text" placeholder="Your full name" maxlength="80" autocomplete="name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-email">Email</label>
|
||||
<input id="m-email" type="email" placeholder="you@example.com" maxlength="120" autocomplete="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-phone">Phone</label>
|
||||
<input id="m-phone" type="tel" placeholder="+1 (555) 000-0000" maxlength="30" autocomplete="tel">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Digital Signature</label>
|
||||
<div class="sig-wrap">
|
||||
<canvas class="sig-canvas" id="sig-canvas"></canvas>
|
||||
<div class="sig-hint" id="sig-hint">Sign here with mouse or finger</div>
|
||||
</div>
|
||||
<div class="sig-tools">
|
||||
<span class="sig-status" id="sig-status">Signature required to apply</span>
|
||||
<button type="button" class="link-btn" id="sig-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="covenant">
|
||||
<h4>K484 Membership Covenant</h4>
|
||||
<p style="margin-bottom:8px">By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>
|
||||
<ol>
|
||||
<li>I am joining a private, members-only association, not a public business.</li>
|
||||
<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>
|
||||
<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>
|
||||
<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>
|
||||
<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>
|
||||
<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="agree-row">
|
||||
<input type="checkbox" id="m-agree">
|
||||
<label for="m-agree">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Sign & Apply for Membership</button>
|
||||
<a class="btn btn-ghost" href="index.html" style="text-decoration:none">Browse the Food Menu First</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card member-done" id="join-done" hidden>
|
||||
<div class="check">✓</div>
|
||||
<h3>APPLICATION SUBMITTED</h3>
|
||||
<div class="member-code" id="done-code">K484-XXXX</div>
|
||||
<div class="member-meta" id="done-name"></div>
|
||||
<div class="member-meta" id="done-date"></div>
|
||||
<p class="member-meta" style="margin-top:10px;font-size:12px;color:var(--muted)">Keep this code — it is your member reference. Your signed covenant is available for download and printing.</p>
|
||||
<div class="form-actions" style="margin-top:18px">
|
||||
<button class="btn btn-primary" id="btn-download">Download Signed Agreement</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
<a class="btn btn-ghost" href="index.html" style="flex:1;text-decoration:none">Open Food Menu</a>
|
||||
<button class="btn btn-ghost" id="btn-new-apply" style="flex:1">New Application</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<img class="f-logo" src="logo.png" alt="K484">
|
||||
<div class="f-line">Off Grid — Zero Waste</div>
|
||||
<div class="f-sub">KITCHEN 484 · SOLARPUNK SUMMIT · Oct 8–12 · No cash, no cards — Bitcoin only</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* ============================== DATA (mirrors index.html) ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with hero dates) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
const FEST={
|
||||
days:[
|
||||
{ tag:'DAY 1', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1080, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF']},
|
||||
{name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:930, tags:[]},
|
||||
{name:'Chicken Grill', emoji:'🍗', price:9, start:1050, end:1230, tags:['GF']},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:1080, end:1200, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 2', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:570, end:1020, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1260, tags:['V','GF']},
|
||||
{name:'Garden Burgers', emoji:'🍔', price:11, start:720, end:900, tags:[]},
|
||||
{name:'Grill 484', emoji:'🔥', price:9, start:750, end:960, tags:['GF']},
|
||||
{name:'Ramen Tent', emoji:'🍜', price:12, start:1020, end:1200, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:570, end:1290, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 3', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1050, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1320, tags:['V','GF']},
|
||||
{name:'Flatbread Oven', emoji:'🫓', price:9, start:720, end:930, tags:['V','GF']},
|
||||
{name:'BBQ Pit', emoji:'🍖', price:12, start:780, end:960, tags:[]},
|
||||
{name:'Dumpling Bar', emoji:'🥟', price:10, start:1020, end:1230, tags:['GF']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1320, tags:['VG']}
|
||||
]},
|
||||
{ tag:'DAY 4', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:540, end:1020, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1290, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:870, tags:['GF']},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:900, end:1140, tags:['V']},
|
||||
{name:'Grill 484', emoji:'🔥', price:9, start:960, end:1170, tags:['GF']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1290, tags:['VG']}
|
||||
]},
|
||||
{ tag:'FINAL DAY', items:[
|
||||
{name:'Coffee Station', emoji:'☕', price:4, start:600, end:960, tags:['VG']},
|
||||
{name:'Sweets Cart', emoji:'🍭', price:5, start:660, end:1200, tags:['V','GF']},
|
||||
{name:'Taco Stand', emoji:'🌮', price:8, start:720, end:840, tags:['GF']},
|
||||
{name:'Farewell Feast · BBQ', emoji:'🍖', price:14, start:720, end:960, tags:[]},
|
||||
{name:'Pasta Bar', emoji:'🍝', price:10, start:1020, end:1170, tags:['V']},
|
||||
{name:'Drinks', emoji:'🥤', price:3, start:600, end:1230, tags:['VG']}
|
||||
]}
|
||||
]
|
||||
};
|
||||
FEST.days.forEach((d,i)=>{ const L=dayLabel(i); d.name=L.name; d.date=L.date; d.iso=L.iso; });
|
||||
|
||||
const $=s=>document.querySelector(s);
|
||||
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
|
||||
/* ============================== MENU CARDS ============================== */
|
||||
function renderMenu(){
|
||||
$('#day-grid').innerHTML=FEST.days.map((d,i)=>{
|
||||
const rows=[...d.items].sort((a,b)=>a.start-b.start).map(it=>
|
||||
'<div class="day-row">'+
|
||||
'<div class="day-em">'+it.emoji+'</div>'+
|
||||
'<div class="day-info">'+
|
||||
'<div class="day-name">'+it.name+'<span class="tags">'+it.tags.map(t=>'<span class="tag">'+t+'</span>').join('')+'</span></div>'+
|
||||
'<div class="day-time">'+fmtT(it.start)+' – '+fmtT(it.end)+'</div>'+
|
||||
'</div>'+
|
||||
'<div class="day-price">$'+it.price+'</div>'+
|
||||
'</div>').join('');
|
||||
return '<div class="day-card'+(i===0?' today':'')+'">'+
|
||||
'<div class="day-head">'+
|
||||
'<span class="day-tag">'+d.tag+'</span>'+
|
||||
(i===0?'<span class="today-chip">TODAY</span>':'')+
|
||||
'<span class="day-date">'+d.name+' '+d.date+'</span>'+
|
||||
'</div>'+
|
||||
'<div class="day-list">'+rows+'</div>'+
|
||||
'<div class="day-foot"><a class="btn btn-ghost btn-sm" href="index.html">Order for '+d.tag+' →</a></div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
renderMenu();
|
||||
|
||||
/* ============================== SIGNATURE PAD ============================== */
|
||||
const canvas=$('#sig-canvas');
|
||||
const ctx=canvas.getContext('2d');
|
||||
let drawing=false, lastPt=null, hasInk=false;
|
||||
|
||||
function sizeCanvas(){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const dpr=window.devicePixelRatio||1;
|
||||
const img=canvas.width?canvas.toDataURL():null;
|
||||
canvas.width=Math.max(1,r.width*dpr);
|
||||
canvas.height=140*dpr;
|
||||
ctx.scale(dpr,dpr);
|
||||
ctx.lineWidth=2.2;
|
||||
ctx.lineCap='round';
|
||||
ctx.lineJoin='round';
|
||||
ctx.strokeStyle='#0A0A0A';
|
||||
if(img){const im=new Image();im.onload=()=>ctx.drawImage(im,0,0,r.width,140);im.src=img;}
|
||||
}
|
||||
function pt(e){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
return {x:t.clientX-r.left,y:t.clientY-r.top};
|
||||
}
|
||||
function startDraw(e){
|
||||
drawing=true; lastPt=pt(e);
|
||||
// draw a dot so taps (touch users) register ink, not just drags
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastPt.x,lastPt.y,1.2,0,Math.PI*2);
|
||||
ctx.fillStyle='#0A0A0A';
|
||||
ctx.fill();
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function moveDraw(e){
|
||||
if(!drawing)return;
|
||||
const p=pt(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPt.x,lastPt.y);
|
||||
ctx.lineTo(p.x,p.y);
|
||||
ctx.stroke();
|
||||
lastPt=p;
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function endDraw(){drawing=false;lastPt=null;}
|
||||
|
||||
canvas.addEventListener('mousedown',startDraw);
|
||||
canvas.addEventListener('mousemove',moveDraw);
|
||||
window.addEventListener('mouseup',endDraw);
|
||||
canvas.addEventListener('touchstart',startDraw,{passive:false});
|
||||
canvas.addEventListener('touchmove',moveDraw,{passive:false});
|
||||
canvas.addEventListener('touchend',endDraw);
|
||||
$('#sig-clear').onclick=()=>{
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
hasInk=false;
|
||||
$('#sig-hint').style.display='flex';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='Signature required to apply';
|
||||
st.classList.remove('ok');
|
||||
};
|
||||
window.addEventListener('resize',sizeCanvas);
|
||||
sizeCanvas();
|
||||
|
||||
/* ============================== MEMBERSHIP ============================== */
|
||||
const LS_MEMBER='slk_member';
|
||||
const LS_MEMBERS='slk_members';
|
||||
|
||||
function loadMember(){try{return JSON.parse(localStorage.getItem(LS_MEMBER)||'null');}catch(e){return null;}}
|
||||
function saveMember(m){
|
||||
localStorage.setItem(LS_MEMBER,JSON.stringify(m));
|
||||
const all=JSON.parse(localStorage.getItem(LS_MEMBERS)||'[]');
|
||||
all.push(m);
|
||||
localStorage.setItem(LS_MEMBERS,JSON.stringify(all));
|
||||
}
|
||||
function showMember(m){
|
||||
$('#join-form-wrap').hidden=true;
|
||||
$('#join-done').hidden=false;
|
||||
$('#done-code').textContent=m.code;
|
||||
$('#done-name').textContent=m.fullName;
|
||||
$('#done-date').textContent='Applied '+new Date(m.ts).toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'});
|
||||
const chip=$('#member-chip');
|
||||
chip.hidden=false;
|
||||
chip.textContent=m.code;
|
||||
document.getElementById('nav-join').textContent='Member';
|
||||
$('#join').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
const existing=loadMember();
|
||||
if(existing)showMember(existing);
|
||||
|
||||
$('#join-form').addEventListener('submit',e=>{
|
||||
e.preventDefault();
|
||||
const err=$('#join-err');
|
||||
err.classList.remove('show');
|
||||
const name=$('#m-name').value.trim();
|
||||
const email=$('#m-email').value.trim();
|
||||
const phone=$('#m-phone').value.trim();
|
||||
const agree=$('#m-agree').checked;
|
||||
|
||||
let problem=null;
|
||||
if(!name)problem='Please enter your full name.';
|
||||
else if(!email||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))problem='Please enter a valid email address.';
|
||||
else if(!phone)problem='Please enter a phone number.';
|
||||
else if(!hasInk)problem='Please sign in the signature box.';
|
||||
else if(!agree)problem='Please check the covenant agreement box.';
|
||||
|
||||
if(problem){
|
||||
err.textContent=problem;
|
||||
err.classList.add('show');
|
||||
err.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
return;
|
||||
}
|
||||
|
||||
const member={
|
||||
code:'K484-'+Math.random().toString(36).slice(2,6).toUpperCase(),
|
||||
fullName:name, email, phone,
|
||||
ts:Date.now(),
|
||||
signature:canvas.toDataURL('image/png')
|
||||
};
|
||||
saveMember(member);
|
||||
showMember(member);
|
||||
});
|
||||
|
||||
$('#btn-new-apply').onclick=()=>{
|
||||
localStorage.removeItem(LS_MEMBER);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
/* ============================== AGREEMENT DOWNLOAD ============================== */
|
||||
$('#btn-download').onclick=()=>{
|
||||
const m=loadMember();
|
||||
if(!m){toast('No application found');return;}
|
||||
const d=new Date(m.ts);
|
||||
const html='<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Kitchen 484 Membership Agreement - '+esc(m.fullName)+'</title>'+
|
||||
'<style>'+
|
||||
'body{font-family:Georgia,serif;color:#000;max-width:720px;margin:40px auto;padding:0 24px;line-height:1.6;font-size:14px}'+
|
||||
'h1{font-size:22px;letter-spacing:2px;text-transform:uppercase;text-align:center}'+
|
||||
'h2{font-size:14px;letter-spacing:1px;text-transform:uppercase;text-align:center;font-weight:normal;color:#333;margin-top:4px}'+
|
||||
'.box{border:1px solid #000;padding:18px 22px;margin:22px 0}'+
|
||||
'.info p{margin:6px 0}'+
|
||||
'.info b{display:inline-block;width:110px}'+
|
||||
'ol{padding-left:20px}li{margin:8px 0}'+
|
||||
'.sig{margin-top:30px;text-align:center}'+
|
||||
'.sig img{height:90px;border:1px solid #999;border-radius:6px;padding:6px;background:#fff}'+
|
||||
'.sig .line{margin-top:6px;font-size:12px;color:#333}'+
|
||||
'.declare{margin-top:24px;font-style:italic}'+
|
||||
'@media print{body{margin:20px auto}}'+
|
||||
'</style></head><body>'+
|
||||
'<h1>Kitchen 484</h1><h2>Membership Agreement — '+esc(m.code)+'</h2>'+
|
||||
'<div class="box info">'+
|
||||
'<p><b>Member:</b> '+esc(m.fullName)+'</p>'+
|
||||
'<p><b>Email:</b> '+esc(m.email)+'</p>'+
|
||||
'<p><b>Phone:</b> '+esc(m.phone)+'</p>'+
|
||||
'<p><b>Applied:</b> '+d.toLocaleString('en-US')+'</p>'+
|
||||
'</div>'+
|
||||
'<p>Note: This is a private, members-only association. Participation is absolutely voluntary.</p>'+
|
||||
'<h2 style="text-align:left;margin-top:20px">K484 Membership Covenant</h2>'+
|
||||
'<p>By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>'+
|
||||
'<ol>'+
|
||||
'<li>I am joining a private, members-only association, not a public business.</li>'+
|
||||
'<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>'+
|
||||
'<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>'+
|
||||
'<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>'+
|
||||
'<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>'+
|
||||
'<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>'+
|
||||
'</ol>'+
|
||||
'<p class="declare">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</p>'+
|
||||
'<div class="sig"><img src="'+m.signature+'" alt="signature"><div class="line">Digital signature — '+esc(m.fullName)+' · '+d.toLocaleDateString('en-US')+'</div></div>'+
|
||||
'<script>setTimeout(function(){window.print()},600)</'+'script>'+
|
||||
'</body></html>';
|
||||
const w=window.open('','_blank');
|
||||
if(!w){toast('Pop-up blocked — allow pop-ups to download');return;}
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
function toast(msg){
|
||||
let t=document.getElementById('h-toast');
|
||||
if(!t){t=document.createElement('div');t.id='h-toast';t.style.cssText='position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#0A0A0A;color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none';document.body.appendChild(t);}
|
||||
t.textContent=msg;t.style.opacity='1';t.style.transform='translateX(-50%) translateY(0)';
|
||||
clearTimeout(t._x);t._x=setTimeout(()=>{t.style.opacity='0';t.style.transform='translateX(-50%) translateY(20px)';},2200);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
backups/icon-192.png.20260830-220331
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
backups/icon-192.png.20260830-221227
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backups/icon-192.png.crisp-20260829-195243
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
backups/icon-512-maskable.png.20260830-220331
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
backups/icon-512-maskable.png.20260830-221227
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
backups/icon-512.png.20260830-220331
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
backups/icon-512.png.20260830-221227
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
backups/icon.png.20260830-220331
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
backups/icon.png.20260830-221227
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
backups/icon.png.crisp-20260829-195243
Normal file
|
After Width: | Height: | Size: 30 KiB |
848
backups/index.html.pre-cosmic-20260831-050953
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>FEST 484 · SolLunar KITCHEN</title>
|
||||
<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{
|
||||
--bg:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--blue:#3b82f6; --blue-soft:#eff6ff;
|
||||
--pink:#d946ef; --pink-soft:#fdf4ff;
|
||||
--gold:#eab308; --gold-soft:#fefce8;
|
||||
--green:#22c55e; --green-soft:#f0fdf4;
|
||||
--red:#ef4444; --red-soft:#fef2f2;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5}
|
||||
body{font-family:var(--font);background:var(--bg2);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
#app{max-width:520px;margin:0 auto;min-height:100dvh;background:var(--bg);display:flex;flex-direction:column;position:relative;box-shadow:0 0 40px rgb(0 0 0/.06)}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header{position:sticky;top:0;z-index:40;background:rgb(255 255 255/.85);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);padding:12px 16px;display:flex;align-items:center;gap:10px}
|
||||
.logo{width:44px;height:44px;flex:0 0 44px;object-fit:contain}
|
||||
.brand{display:flex;flex-direction:column;line-height:1.15}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px}
|
||||
.brand span{font-size:10px;color:var(--muted);letter-spacing:1.5px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.clock{font-family:var(--display);font-size:12px;font-weight:700;color:var(--ink2);letter-spacing:1px;background:var(--bg2);border:1px solid var(--line);border-radius:999px;padding:4px 10px}
|
||||
.icon-btn{width:36px;height:36px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink2);display:grid;place-items:center;cursor:pointer;transition:all .15s}
|
||||
.icon-btn:active{transform:scale(.92)}
|
||||
.icon-btn svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:26px 18px 18px;background:linear-gradient(180deg,#fff 0%,var(--bg2) 100%)}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:26px;letter-spacing:3px;margin:10px 0 4px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(90deg,var(--blue),var(--pink));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero p{color:var(--ink2);font-size:13px;max-width:360px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0 16px}
|
||||
.chip{font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;border-radius:999px;border:1px solid}
|
||||
.chip.c1{color:var(--ink);border-color:var(--ink);background:var(--black);color:#fff}
|
||||
.chip.c2{color:#166534;border-color:#bbf7d0;background:var(--green-soft)}
|
||||
.chip.c3{color:#86198f;border-color:#f5d0fe;background:var(--pink-soft)}
|
||||
.chip.c4{color:#854d0e;border-color:#fde68a;background:var(--gold-soft)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:14px;padding:13px 22px;transition:transform .12s,box-shadow .12s}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff;width:100%;box-shadow:var(--shadow)}
|
||||
.btn-primary:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line2)}
|
||||
.btn-blue{background:var(--blue);color:#fff;width:100%}
|
||||
.btn-pink{background:linear-gradient(90deg,var(--blue),var(--pink));color:#fff;width:100%}
|
||||
.btn-sm{padding:9px 14px;font-size:12px;border-radius:11px;width:auto}
|
||||
|
||||
/* ---------- day tabs ---------- */
|
||||
.daytabs{display:flex;gap:8px;padding:4px 16px 12px;position:sticky;top:69px;background:rgb(255 255 255/.9);backdrop-filter:blur(8px);z-index:30}
|
||||
.daytab{flex:1;border:1px solid var(--line);background:#fff;border-radius:14px;padding:9px 6px;cursor:pointer;text-align:center;transition:all .15s}
|
||||
.daytab .d1{font-family:var(--display);font-weight:700;font-size:11px;letter-spacing:1px}
|
||||
.daytab .d2{font-size:10px;color:var(--muted)}
|
||||
.daytab.active{background:var(--black);border-color:var(--black);color:#fff;box-shadow:var(--shadow)}
|
||||
.daytab.active .d2{color:#a3a3a3}
|
||||
.daytab.today::after{content:'TODAY';display:block;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--pink);margin-top:2px}
|
||||
.daytab.active.today::after{color:var(--pink)}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.timeline{padding:8px 16px 24px;position:relative}
|
||||
.tl-head{display:flex;justify-content:space-between;align-items:baseline;margin:6px 2px 10px}
|
||||
.tl-head h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase}
|
||||
.tl-head span{font-size:11px;color:var(--muted)}
|
||||
.tl-wrap{position:relative;padding-left:26px}
|
||||
.tl-rail{position:absolute;left:9px;top:6px;bottom:6px;width:2px;background:var(--line);border-radius:2px}
|
||||
.nowline{position:absolute;left:-4px;right:-8px;height:2px;background:var(--red);border-radius:2px;z-index:5}
|
||||
.nowline::before{content:'NOW';position:absolute;left:-2px;top:-16px;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--red);background:#fff;padding:1px 4px;border-radius:4px}
|
||||
.nowline::after{content:'';position:absolute;left:-5px;top:-2px;width:8px;height:8px;border-radius:99px;background:var(--red);animation:pulse 1.6s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(1.5)}}
|
||||
.item-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04);transition:border-color .2s,box-shadow .2s}
|
||||
.item-card.is-open{border-color:var(--green);box-shadow:0 0 0 3px var(--green-soft),var(--shadow)}
|
||||
.item-card.is-soon{border-color:#bfdbfe}
|
||||
.item-card.is-ended{opacity:.62}
|
||||
.tl-dot{position:absolute;left:-21px;top:22px;width:12px;height:12px;border-radius:99px;background:#fff;border:3px solid var(--line2);z-index:6}
|
||||
.item-card.is-open .tl-dot{border-color:var(--green);background:var(--green)}
|
||||
.item-card.is-soon .tl-dot{border-color:var(--blue)}
|
||||
.tl-time{position:absolute;left:-26px;top:18px;transform:translateX(-100%);width:52px;text-align:right;font-family:var(--display);font-size:9px;font-weight:700;color:var(--muted);letter-spacing:.5px;line-height:1.3}
|
||||
.item-top{display:flex;gap:12px;align-items:flex-start}
|
||||
.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-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}
|
||||
.sp-open{background:var(--green-soft);color:#15803d}
|
||||
.sp-open .dot{animation:pulse 1.4s infinite}
|
||||
.sp-soon{background:var(--blue-soft);color:#1d4ed8}
|
||||
.sp-upcoming{background:var(--bg2);color:var(--muted);border:1px solid var(--line)}
|
||||
.sp-ended{background:var(--bg2);color:var(--muted)}
|
||||
.tags{display:flex;gap:4px}
|
||||
.tag{font-size:9px;font-weight:800;letter-spacing:.5px;padding:3px 7px;border-radius:7px;background:var(--bg2);border:1px solid var(--line);color:var(--ink2)}
|
||||
.add-btn{margin-left:auto;border:1px solid var(--black);background:#fff;color:var(--ink);font-weight:800;font-size:12px;border-radius:11px;padding:8px 14px;cursor:pointer;transition:all .12s;white-space:nowrap}
|
||||
.add-btn:active{transform:scale(.94);background:var(--black);color:#fff}
|
||||
.add-btn.in-cart{background:var(--green-soft);border-color:#bbf7d0;color:#15803d}
|
||||
|
||||
/* ---------- orders ---------- */
|
||||
.orders{padding:16px}
|
||||
.orders h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
|
||||
.orders .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.empty{text-align:center;padding:56px 20px;color:var(--muted)}
|
||||
.empty .big{font-size:40px;margin-bottom:10px}
|
||||
.order-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04)}
|
||||
.order-top{display:flex;align-items:center;gap:10px}
|
||||
.order-code{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1px}
|
||||
.badge{font-size:9px;font-weight:900;letter-spacing:1px;padding:4px 9px;border-radius:99px;text-transform:uppercase}
|
||||
.b-paid{background:var(--green-soft);color:#15803d}
|
||||
.b-pending{background:var(--gold-soft);color:#854d0e}
|
||||
.b-failed{background:var(--red-soft);color:#b91c1c}
|
||||
.b-demo{background:var(--pink-soft);color:#86198f}
|
||||
.order-meta{font-size:11px;color:var(--muted);margin:6px 0}
|
||||
.order-items{font-size:12px;color:var(--ink2);border-top:1px dashed var(--line);padding-top:8px;margin-top:8px}
|
||||
.order-total{display:flex;justify-content:space-between;font-size:13px;font-weight:800;margin-top:6px}
|
||||
.order-total .btc{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700}
|
||||
.order-actions{display:flex;gap:8px;margin-top:10px}
|
||||
|
||||
/* ---------- bottom nav + fab ---------- */
|
||||
nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);backdrop-filter:blur(12px);border-top:1px solid var(--line);display:flex;padding:8px 12px calc(10px + env(safe-area-inset-bottom))}
|
||||
.nav-btn{flex:1;border:none;background:none;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--muted);transition:all .15s}
|
||||
.nav-btn svg{width:20px;height:20px}
|
||||
.nav-btn.active{color:var(--ink);background:var(--bg2)}
|
||||
.fab{position:absolute;right:16px;bottom:calc(74px + env(safe-area-inset-bottom));z-index:45;width:58px;height:58px;border-radius:20px;border:none;cursor:pointer;background:var(--black);color:#fff;display:grid;place-items:center;box-shadow:0 10px 24px -8px rgb(10 10 10/.5);transition:transform .15s}
|
||||
.fab:active{transform:scale(.92)}
|
||||
.fab svg{width:24px;height:24px}
|
||||
.fab .count{position:absolute;top:-7px;right:-7px;min-width:22px;height:22px;border-radius:99px;background:var(--pink);color:#fff;font-size:11px;font-weight:900;display:grid;place-items:center;padding:0 5px;box-shadow:0 2px 6px rgb(0 0 0/.3)}
|
||||
.fab.hidden{display:none}
|
||||
|
||||
/* ---------- sheet / modal ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:100;display:none;align-items:flex-end;justify-content:center}
|
||||
.overlay.show{display:flex}
|
||||
.sheet{width:100%;max-width:520px;background:#fff;border-radius:24px 24px 0 0;padding:10px 18px calc(22px + env(safe-area-inset-bottom));max-height:88dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes slideUp{from{transform:translateY(60px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
.grab{width:40px;height:4px;border-radius:4px;background:var(--line2);margin:4px auto 14px}
|
||||
.sheet h3{font-family:var(--display);font-weight:900;font-size:16px;letter-spacing:1.5px;margin-bottom:4px}
|
||||
.sheet .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.cart-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.cart-row .em{width:40px;height:40px;border-radius:12px;background:var(--bg2);display:grid;place-items:center;font-size:20px;border:1px solid var(--line)}
|
||||
.cart-row .nm{font-size:13px;font-weight:700}
|
||||
.cart-row .pt{font-size:11px;color:var(--muted)}
|
||||
.qty{display:flex;align-items:center;gap:10px;margin-left:auto}
|
||||
.qty button{width:28px;height:28px;border-radius:9px;border:1px solid var(--line2);background:#fff;font-size:15px;font-weight:800;cursor:pointer;color:var(--ink)}
|
||||
.qty button:active{transform:scale(.9)}
|
||||
.qty span{font-family:var(--display);font-weight:700;font-size:13px;min-width:18px;text-align:center}
|
||||
.cart-row .lp{font-family:var(--display);font-weight:900;font-size:13px;width:52px;text-align:right}
|
||||
.total-row{display:flex;justify-content:space-between;align-items:baseline;padding:14px 0 4px}
|
||||
.total-row .t{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:1px}
|
||||
.total-row .v{font-family:var(--display);font-weight:900;font-size:20px}
|
||||
.btc-est{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700;margin-bottom:14px;text-align:right}
|
||||
.btc-est small{color:var(--muted);font-family:var(--font);font-weight:400}
|
||||
.field{margin-bottom:12px}
|
||||
.field label{display:block;font-size:11px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input,.field select,.field textarea{width:100%;border:1px solid var(--line2);border-radius:12px;padding:12px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.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}
|
||||
.qr-wrap canvas,.qr-wrap img{display:block;border-radius:8px}
|
||||
.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}
|
||||
.success{text-align:center;padding:18px 0 6px}
|
||||
.success .check{width:74px;height:74px;border-radius:99px;background:var(--green-soft);border:2px solid #bbf7d0;display:grid;place-items:center;margin:0 auto 14px;font-size:34px;animation:pop .5s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
.success h3{margin-bottom:6px}
|
||||
.success .code{font-family:var(--display);font-weight:900;font-size:24px;letter-spacing:3px;background:var(--bg2);border:1px dashed var(--line2);border-radius:14px;padding:12px;margin:14px 0}
|
||||
.success .pick{font-size:13px;color:var(--ink2);margin-bottom:16px}
|
||||
.success .pick b{color:var(--ink)}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.modal{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:110;display:none;align-items:center;justify-content:center;padding:20px}
|
||||
.modal.show{display:flex}
|
||||
.modal-box{width:100%;max-width:460px;background:#fff;border-radius:22px;padding:20px;max-height:86dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
.modal-box h3{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1.5px;margin-bottom:10px}
|
||||
.test-result{font-size:11px;font-weight:700;border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.test-result.ok{display:block;background:var(--green-soft);color:#15803d}
|
||||
.test-result.err{display:block;background:var(--red-soft);color:#b91c1c}
|
||||
footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-spacing:1px;text-transform:uppercase;border-top:1px solid var(--line);margin-top:auto}
|
||||
.toast{position:fixed;left:50%;bottom:100px;transform:translateX(-50%) translateY(20px);background:var(--black);color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none;white-space:nowrap}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<img class="logo" src="logo.png" alt="FEST 484">
|
||||
<div class="brand"><b>FEST 484</b><span>SolLunar KITCHEN</span></div>
|
||||
<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>
|
||||
|
||||
<main style="flex:1;display:flex;flex-direction:column">
|
||||
<!-- SCHEDULE VIEW -->
|
||||
<section id="view-schedule">
|
||||
<div class="hero">
|
||||
<div class="chips">
|
||||
<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>
|
||||
</div>
|
||||
<h1>WHEN'S <em>FOOD</em> ON?</h1>
|
||||
<p>Live schedule for the SolLunar kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.</p>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="tl-head"><h2>Serving schedule</h2><span id="tl-sub"></span></div>
|
||||
<div class="tl-wrap" id="tl-wrap">
|
||||
<div class="tl-rail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ORDERS VIEW -->
|
||||
<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 id="orders-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>FEST 484 · Off-grid · Zero waste · Powered by BTCPay</footer>
|
||||
</main>
|
||||
|
||||
<nav class="bottom">
|
||||
<button class="nav-btn active" data-view="schedule">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||
SCHEDULE
|
||||
</button>
|
||||
<button class="nav-btn" data-view="orders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
|
||||
ORDERS
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="fab hidden" id="fab" aria-label="Cart">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
<span class="count" id="fab-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
"use strict";
|
||||
window.__errs=[];
|
||||
window.addEventListener('error',e=>{
|
||||
window.__errs.push(String(e.message)+' @'+(e.lineno||'?'));
|
||||
let el=document.getElementById('__errbox');
|
||||
if(!el){el=document.createElement('div');el.id='__errbox';el.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;background:#7f1d1d;color:#fff;font:11px monospace;padding:8px;max-height:40vh;overflow:auto';document.body.appendChild(el);}
|
||||
el.textContent=window.__errs.join('\n');
|
||||
});
|
||||
/* ============================== DATA ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with first page) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]}
|
||||
]
|
||||
};
|
||||
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'};
|
||||
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);
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
||||
|
||||
/* ============================== STATUS ============================== */
|
||||
function nowMin(){const d=new Date();return d.getHours()*60+d.getMinutes();}
|
||||
function itemStatus(i,item){
|
||||
if(i>0)return{state:'upcoming',label:FEST.days[i].name+' · '+FEST.days[i].date};
|
||||
const n=nowMin();
|
||||
if(n<item.start)return{state:'soon',label:'Starts in '+fmtDur(item.start-n)};
|
||||
if(n<item.end)return{state:'open',label:'Available now'};
|
||||
return{state:'ended',label:'Ended'};
|
||||
}
|
||||
|
||||
/* ============================== SCHEDULE RENDER ============================== */
|
||||
function renderDayTabs(){
|
||||
$('#daytabs').innerHTML=FEST.days.map((d,i)=>
|
||||
'<button class="daytab'+(i===state.dayIdx?' active':'')+(i===0?' today':'')+'" data-day="'+i+'">'+
|
||||
'<div class="d1">'+d.tag+'</div><div class="d2">'+d.name+' '+d.date+'</div></button>').join('');
|
||||
document.querySelectorAll('.daytab').forEach(b=>b.onclick=()=>{state.dayIdx=+b.dataset.day;renderDayTabs();renderTimeline();});
|
||||
}
|
||||
function renderTimeline(){
|
||||
const day=FEST.days[state.dayIdx];
|
||||
const items=[...day.items].sort((a,b)=>a.start-b.start);
|
||||
const d0=Math.min(...items.map(x=>x.start))-30;
|
||||
const d1=Math.max(...items.map(x=>x.end))+30;
|
||||
const isToday=state.dayIdx===0;
|
||||
const n=nowMin();
|
||||
const nowLine=(isToday&&n>=d0&&n<=d1)
|
||||
?'<div class="nowline" style="top:'+(((n-d0)/(d1-d0))*100).toFixed(2)+'%"></div>':'';
|
||||
$('#tl-sub').textContent=isToday?'Live · updates automatically':'Pre-orders open';
|
||||
$('#tl-wrap').innerHTML='<div class="tl-rail"></div>'+nowLine+items.map(item=>{
|
||||
const st=itemStatus(state.dayIdx,item);
|
||||
const cls=st.state==='open'?'is-open':st.state==='soon'?'is-soon':(st.state==='upcoming'?'':'is-ended');
|
||||
const pill={open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state];
|
||||
const inCart=state.cart[item.id]>0;
|
||||
return '<div class="item-card '+cls+'" data-id="'+item.id+'">'+
|
||||
'<div class="tl-dot"></div>'+
|
||||
'<div class="tl-time">'+fmtT(item.start).replace(' ','<br>')+'</div>'+
|
||||
'<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>'+
|
||||
'<div class="item-foot">'+
|
||||
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
|
||||
'<span class="tag" style="margin-left:auto">'+fmtT(item.start)+' – '+fmtT(item.end)+'</span>'+
|
||||
'<button class="add-btn'+(inCart?' in-cart':'')+'" data-add="'+item.id+'">'+(inCart?'✓ Added · '+state.cart[item.id]:'+ Add to order')+'</button>'+
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.add;
|
||||
state.cart[id]=(state.cart[id]||0)+1;
|
||||
saveCart();renderFab();renderTimeline();
|
||||
toast(findItem(id).emoji+' '+findItem(id).name+' added');
|
||||
});
|
||||
}
|
||||
function refreshStatuses(){
|
||||
document.querySelectorAll('#tl-wrap .item-card').forEach(card=>{
|
||||
const id=card.dataset.id, it=findItem(id);
|
||||
const st=itemStatus(state.dayIdx,it);
|
||||
const pill=card.querySelector('.status-pill');
|
||||
pill.className='status-pill '+( {open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state] );
|
||||
pill.innerHTML='<span class="dot"></span>'+st.label;
|
||||
card.classList.remove('is-open','is-soon','is-ended');
|
||||
if(st.state==='open')card.classList.add('is-open');
|
||||
else if(st.state==='soon')card.classList.add('is-soon');
|
||||
else if(st.state==='ended')card.classList.add('is-ended');
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================== FAB / NAV ============================== */
|
||||
function renderFab(){
|
||||
const c=cartCount();
|
||||
$('#fab').classList.toggle('hidden',c===0);
|
||||
$('#fab-count').textContent=c;
|
||||
document.title=c>0?('FEST 484 · '+c+' in cart'):('FEST 484 · SolLunar KITCHEN');
|
||||
let d=document.getElementById('debugbar');
|
||||
if(!d){d=document.createElement('div');d.id='debugbar';d.style.cssText='position:fixed;bottom:0;left:0;right:0;z-index:9998;background:#0A0A0A;color:#4ade80;font:10px monospace;padding:4px 8px;pointer-events:none';document.body.appendChild(d);}
|
||||
d.textContent='cart='+JSON.stringify(state.cart)+' errs='+(window.__errs?window.__errs.length:0);
|
||||
}
|
||||
document.querySelectorAll('.nav-btn').forEach(b=>b.onclick=()=>{
|
||||
state.view=b.dataset.view;
|
||||
document.querySelectorAll('.nav-btn').forEach(x=>x.classList.toggle('active',x===b));
|
||||
$('#view-schedule').hidden=state.view!=='schedule';
|
||||
$('#view-orders').hidden=state.view!=='orders';
|
||||
if(state.view==='orders')renderOrders();
|
||||
});
|
||||
$('#fab').onclick=()=>openSheet('cart');
|
||||
|
||||
/* ============================== SHEET STEPS ============================== */
|
||||
function openSheet(step){state.sheetStep=step;renderSheet();$('#sheet-overlay').classList.add('show');}
|
||||
function closeSheet(){
|
||||
if(state.pay&&state.pay.timer){clearInterval(state.pay.timer);state.pay=null;}
|
||||
state.sheetStep=null;$('#sheet-overlay').classList.remove('show');
|
||||
}
|
||||
$('#sheet-overlay').addEventListener('mousedown',e=>{$('#sheet-overlay')._down=e.target.id==='sheet-overlay';});
|
||||
$('#sheet-overlay').addEventListener('click',e=>{if($('#sheet-overlay')._down&&e.target.id==='sheet-overlay')closeSheet();});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function stepCart(){
|
||||
const rows=Object.keys(state.cart).map(id=>{
|
||||
const it=findItem(id);if(!it)return'';
|
||||
return '<div class="cart-row" data-row="'+id+'">'+
|
||||
'<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>';
|
||||
}).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">SolLunar KITCHEN · 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>';
|
||||
}
|
||||
|
||||
function stepCheckout(){
|
||||
const days=[...new Set(Object.keys(state.cart).map(findDayOf))].map(i=>FEST.days[i]);
|
||||
const windows=[...new Set(days.flatMap(d=>d.items.map(it=>it.id)))]
|
||||
.map(id=>({id,start:findItem(id).start,end:findItem(id).end,name:findItem(id).name}))
|
||||
.sort((a,b)=>a.start-b.start);
|
||||
const n=nowMin();
|
||||
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>'+
|
||||
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
|
||||
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
|
||||
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
|
||||
'<div class="total-row"><span class="t">Total · '+cartCount()+' items</span><span class="v">'+fmt$(cartTotal())+'</span></div>'+
|
||||
'<button class="btn btn-pink" id="to-payment" style="margin-top:8px">₿ Pay with Bitcoin</button>'+
|
||||
'<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="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>'+
|
||||
'<button class="btn btn-primary" id="success-done">Done</button>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireSheet(){
|
||||
const s=$('#sheet');
|
||||
s.querySelectorAll('[data-inc]').forEach(b=>b.onclick=()=>{state.cart[b.dataset.inc]++;afterCartChange();});
|
||||
s.querySelectorAll('[data-dec]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.dec;state.cart[id]--;
|
||||
if(state.cart[id]<=0)delete state.cart[id];
|
||||
afterCartChange();
|
||||
});
|
||||
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 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');
|
||||
};
|
||||
const done=s.querySelector('#success-done');
|
||||
if(done)done.onclick=()=>{
|
||||
closeSheet();
|
||||
document.querySelector('[data-view="orders"]').click();
|
||||
};
|
||||
}
|
||||
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();
|
||||
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:'FEST 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);
|
||||
saveOrders();
|
||||
state.cart={};saveCart();renderFab();renderTimeline();
|
||||
openSheet('success');
|
||||
}
|
||||
|
||||
/* ============================== ORDERS VIEW ============================== */
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
}).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…';
|
||||
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){}
|
||||
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();
|
||||
$('#clock').textContent=String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
setInterval(()=>{tickClock();if(!$('#view-schedule').hidden)refreshStatuses();},30000);
|
||||
|
||||
/* ============================== INIT ============================== */
|
||||
(async function init(){
|
||||
tickClock();
|
||||
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);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
972
backups/index.html.pre-cosmic-removal-20260831-061257
Normal file
|
|
@ -0,0 +1,972 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>KITCHEN 484 · SOLARPUNK SUMMIT</title>
|
||||
<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">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap" rel="stylesheet">
|
||||
<script>/* Always start in the white theme. */try{localStorage.removeItem('kitchen484-theme');}catch(e){}</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--blue:#3b82f6; --blue-soft:#eff6ff;
|
||||
--pink:#d946ef; --pink-soft:#fdf4ff;
|
||||
--gold:#eab308; --gold-soft:#fefce8;
|
||||
--green:#22c55e; --green-soft:#f0fdf4;
|
||||
--red:#ef4444; --red-soft:#fef2f2;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
/* ============ COSMIC THEME — Stardust (kaymade/cosmic) · Deep Space · Deep sidebar ============ */
|
||||
:root[data-theme="cosmic"]{
|
||||
--bg:#171109; --bg2:#0d0a06;
|
||||
--ink:#fff5dc; --ink2:#d5bd8d; --muted:#917b58;
|
||||
--line:#3a2d19; --line2:#54411f;
|
||||
--blue:#87E6FB; --blue-soft:#10313b; /* Light Blue */
|
||||
--pink:#F3B407; --pink-soft:#33270d; /* Gold (was magenta) */
|
||||
--gold:#F3B407; --gold-soft:#33270d; /* Gold */
|
||||
--green:#87E6FB; --green-soft:#10313b;
|
||||
--red:#F3B407; --red-soft:#33270d;
|
||||
--black:#100b05;
|
||||
--logo-frame:rgba(255,245,220,.10);
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(0 0 0 / .4), 0 14px 34px -14px rgb(0 0 0 / .8);
|
||||
--font:'Source Serif 4',Georgia,'Times New Roman',serif;
|
||||
--display:'Cormorant Garamond','Orbitron',Georgia,serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5}
|
||||
body{font-family:var(--font);background:var(--bg2);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
#app{max-width:520px;margin:0 auto;min-height:100dvh;background:var(--bg);display:flex;flex-direction:column;position:relative;box-shadow:0 0 40px rgb(0 0 0/.06)}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header{position:sticky;top:0;z-index:40;background:rgb(255 255 255/.85);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);padding:12px 16px;display:flex;align-items:center;gap:10px}
|
||||
.logo{width:44px;height:44px;flex:0 0 44px;object-fit:contain}
|
||||
.brand{display:flex;flex-direction:column;line-height:1.15}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px}
|
||||
.brand span{font-size:10px;color:var(--muted);letter-spacing:1.5px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.clock{font-family:var(--display);font-size:12px;font-weight:700;color:var(--ink2);letter-spacing:1px;background:var(--bg2);border:1px solid var(--line);border-radius:999px;padding:4px 10px}
|
||||
.icon-btn{width:36px;height:36px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink2);display:grid;place-items:center;cursor:pointer;transition:all .15s}
|
||||
.icon-btn:active{transform:scale(.92)}
|
||||
.icon-btn svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:26px 18px 18px;background:linear-gradient(180deg,#fff 0%,var(--bg2) 100%)}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:26px;letter-spacing:3px;margin:10px 0 4px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(90deg,var(--blue),var(--pink));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero p{color:var(--ink2);font-size:13px;max-width:360px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0 16px}
|
||||
.chip{font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;border-radius:999px;border:1px solid}
|
||||
.chip.c1{color:var(--ink);border-color:var(--ink);background:var(--black);color:#fff}
|
||||
.chip.c2{color:#166534;border-color:#bbf7d0;background:var(--green-soft)}
|
||||
.chip.c3{color:#86198f;border-color:#f5d0fe;background:var(--pink-soft)}
|
||||
.chip.c4{color:#854d0e;border-color:#fde68a;background:var(--gold-soft)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:14px;padding:13px 22px;transition:transform .12s,box-shadow .12s}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff;width:100%;box-shadow:var(--shadow)}
|
||||
.btn-primary:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line2)}
|
||||
.btn-blue{background:var(--blue);color:#fff;width:100%}
|
||||
.btn-pink{background:linear-gradient(90deg,var(--blue),var(--pink));color:#fff;width:100%}
|
||||
.btn-sm{padding:9px 14px;font-size:12px;border-radius:11px;width:auto}
|
||||
|
||||
/* ---------- day tabs ---------- */
|
||||
.daytabs{display:flex;gap:8px;padding:4px 16px 12px;position:sticky;top:69px;background:rgb(255 255 255/.9);backdrop-filter:blur(8px);z-index:30}
|
||||
.daytab{flex:1;border:1px solid var(--line);background:#fff;border-radius:14px;padding:9px 6px;cursor:pointer;text-align:center;transition:all .15s}
|
||||
.daytab .d1{font-family:var(--display);font-weight:700;font-size:11px;letter-spacing:1px}
|
||||
.daytab .d2{font-size:10px;color:var(--muted)}
|
||||
.daytab.active{background:var(--black);border-color:var(--black);color:#fff;box-shadow:var(--shadow)}
|
||||
.daytab.active .d2{color:#a3a3a3}
|
||||
.daytab.today::after{content:'TODAY';display:block;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--pink);margin-top:2px}
|
||||
.daytab.active.today::after{color:var(--pink)}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.timeline{padding:8px 16px 24px;position:relative}
|
||||
.tl-head{display:flex;justify-content:space-between;align-items:baseline;margin:6px 2px 10px}
|
||||
.tl-head h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase}
|
||||
.tl-head span{font-size:11px;color:var(--muted)}
|
||||
.tl-wrap{position:relative;padding-left:26px}
|
||||
.tl-rail{position:absolute;left:9px;top:6px;bottom:6px;width:2px;background:var(--line);border-radius:2px}
|
||||
.nowline{position:absolute;left:-4px;right:-8px;height:2px;background:var(--red);border-radius:2px;z-index:5}
|
||||
.nowline::before{content:'NOW';position:absolute;left:-2px;top:-16px;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--red);background:#fff;padding:1px 4px;border-radius:4px}
|
||||
.nowline::after{content:'';position:absolute;left:-5px;top:-2px;width:8px;height:8px;border-radius:99px;background:var(--red);animation:pulse 1.6s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(1.5)}}
|
||||
.item-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04);transition:border-color .2s,box-shadow .2s}
|
||||
.item-card.is-open{border-color:var(--green);box-shadow:0 0 0 3px var(--green-soft),var(--shadow)}
|
||||
.item-card.is-soon{border-color:#bfdbfe}
|
||||
.item-card.is-ended{opacity:.62}
|
||||
.tl-dot{position:absolute;left:-21px;top:22px;width:12px;height:12px;border-radius:99px;background:#fff;border:3px solid var(--line2);z-index:6}
|
||||
.item-card.is-open .tl-dot{border-color:var(--green);background:var(--green)}
|
||||
.item-card.is-soon .tl-dot{border-color:var(--blue)}
|
||||
.tl-time{position:absolute;left:-26px;top:18px;transform:translateX(-100%);width:52px;text-align:right;font-family:var(--display);font-size:9px;font-weight:700;color:var(--muted);letter-spacing:.5px;line-height:1.3}
|
||||
.item-top{display:flex;gap:12px;align-items:flex-start}
|
||||
.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-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}
|
||||
.sp-open{background:var(--green-soft);color:#15803d}
|
||||
.sp-open .dot{animation:pulse 1.4s infinite}
|
||||
.sp-soon{background:var(--blue-soft);color:#1d4ed8}
|
||||
.sp-upcoming{background:var(--bg2);color:var(--muted);border:1px solid var(--line)}
|
||||
.sp-ended{background:var(--bg2);color:var(--muted)}
|
||||
.tags{display:flex;gap:4px}
|
||||
.tag{font-size:9px;font-weight:800;letter-spacing:.5px;padding:3px 7px;border-radius:7px;background:var(--bg2);border:1px solid var(--line);color:var(--ink2)}
|
||||
.add-btn{margin-left:auto;border:1px solid var(--black);background:#fff;color:var(--ink);font-weight:800;font-size:12px;border-radius:11px;padding:8px 14px;cursor:pointer;transition:all .12s;white-space:nowrap}
|
||||
.add-btn:active{transform:scale(.94);background:var(--black);color:#fff}
|
||||
.add-btn.in-cart{background:var(--green-soft);border-color:#bbf7d0;color:#15803d}
|
||||
|
||||
/* ---------- orders ---------- */
|
||||
.orders{padding:16px}
|
||||
.orders h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
|
||||
.orders .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.empty{text-align:center;padding:56px 20px;color:var(--muted)}
|
||||
.empty .big{font-size:40px;margin-bottom:10px}
|
||||
.order-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04)}
|
||||
.order-top{display:flex;align-items:center;gap:10px}
|
||||
.order-code{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1px}
|
||||
.badge{font-size:9px;font-weight:900;letter-spacing:1px;padding:4px 9px;border-radius:99px;text-transform:uppercase}
|
||||
.b-paid{background:var(--green-soft);color:#15803d}
|
||||
.b-pending{background:var(--gold-soft);color:#854d0e}
|
||||
.b-failed{background:var(--red-soft);color:#b91c1c}
|
||||
.b-demo{background:var(--pink-soft);color:#86198f}
|
||||
.order-meta{font-size:11px;color:var(--muted);margin:6px 0}
|
||||
.order-items{font-size:12px;color:var(--ink2);border-top:1px dashed var(--line);padding-top:8px;margin-top:8px}
|
||||
.order-total{display:flex;justify-content:space-between;font-size:13px;font-weight:800;margin-top:6px}
|
||||
.order-total .btc{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700}
|
||||
.order-actions{display:flex;gap:8px;margin-top:10px}
|
||||
|
||||
/* ---------- bottom nav + fab ---------- */
|
||||
nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);backdrop-filter:blur(12px);border-top:1px solid var(--line);display:flex;padding:8px 12px calc(10px + env(safe-area-inset-bottom))}
|
||||
.nav-btn{flex:1;border:none;background:none;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--muted);transition:all .15s}
|
||||
.nav-btn svg{width:20px;height:20px}
|
||||
.nav-btn.active{color:var(--ink);background:var(--bg2)}
|
||||
.fab{position:absolute;right:16px;bottom:calc(74px + env(safe-area-inset-bottom));z-index:45;width:58px;height:58px;border-radius:20px;border:none;cursor:pointer;background:var(--black);color:#fff;display:grid;place-items:center;box-shadow:0 10px 24px -8px rgb(10 10 10/.5);transition:transform .15s}
|
||||
.fab:active{transform:scale(.92)}
|
||||
.fab svg{width:24px;height:24px}
|
||||
.fab .count{position:absolute;top:-7px;right:-7px;min-width:22px;height:22px;border-radius:99px;background:var(--pink);color:#fff;font-size:11px;font-weight:900;display:grid;place-items:center;padding:0 5px;box-shadow:0 2px 6px rgb(0 0 0/.3)}
|
||||
.fab.hidden{display:none}
|
||||
|
||||
/* ---------- sheet / modal ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:100;display:none;align-items:flex-end;justify-content:center}
|
||||
.overlay.show{display:flex}
|
||||
.sheet{width:100%;max-width:520px;background:#fff;border-radius:24px 24px 0 0;padding:10px 18px calc(22px + env(safe-area-inset-bottom));max-height:88dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes slideUp{from{transform:translateY(60px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
.grab{width:40px;height:4px;border-radius:4px;background:var(--line2);margin:4px auto 14px}
|
||||
.sheet h3{font-family:var(--display);font-weight:900;font-size:16px;letter-spacing:1.5px;margin-bottom:4px}
|
||||
.sheet .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.cart-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.cart-row .em{width:40px;height:40px;border-radius:12px;background:var(--bg2);display:grid;place-items:center;font-size:20px;border:1px solid var(--line)}
|
||||
.cart-row .nm{font-size:13px;font-weight:700}
|
||||
.cart-row .pt{font-size:11px;color:var(--muted)}
|
||||
.qty{display:flex;align-items:center;gap:10px;margin-left:auto}
|
||||
.qty button{width:28px;height:28px;border-radius:9px;border:1px solid var(--line2);background:#fff;font-size:15px;font-weight:800;cursor:pointer;color:var(--ink)}
|
||||
.qty button:active{transform:scale(.9)}
|
||||
.qty span{font-family:var(--display);font-weight:700;font-size:13px;min-width:18px;text-align:center}
|
||||
.cart-row .lp{font-family:var(--display);font-weight:900;font-size:13px;width:52px;text-align:right}
|
||||
.total-row{display:flex;justify-content:space-between;align-items:baseline;padding:14px 0 4px}
|
||||
.total-row .t{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:1px}
|
||||
.total-row .v{font-family:var(--display);font-weight:900;font-size:20px}
|
||||
.btc-est{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700;margin-bottom:14px;text-align:right}
|
||||
.btc-est small{color:var(--muted);font-family:var(--font);font-weight:400}
|
||||
.field{margin-bottom:12px}
|
||||
.field label{display:block;font-size:11px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input,.field select,.field textarea{width:100%;border:1px solid var(--line2);border-radius:12px;padding:12px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.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}
|
||||
.qr-wrap canvas,.qr-wrap img{display:block;border-radius:8px}
|
||||
.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}
|
||||
.success{text-align:center;padding:18px 0 6px}
|
||||
.success .check{width:74px;height:74px;border-radius:99px;background:var(--green-soft);border:2px solid #bbf7d0;display:grid;place-items:center;margin:0 auto 14px;font-size:34px;animation:pop .5s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
.success h3{margin-bottom:6px}
|
||||
.success .code{font-family:var(--display);font-weight:900;font-size:24px;letter-spacing:3px;background:var(--bg2);border:1px dashed var(--line2);border-radius:14px;padding:12px;margin:14px 0}
|
||||
.success .pick{font-size:13px;color:var(--ink2);margin-bottom:16px}
|
||||
.success .pick b{color:var(--ink)}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.modal{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:110;display:none;align-items:center;justify-content:center;padding:20px}
|
||||
.modal.show{display:flex}
|
||||
.modal-box{width:100%;max-width:460px;background:#fff;border-radius:22px;padding:20px;max-height:86dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
.modal-box h3{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1.5px;margin-bottom:10px}
|
||||
.test-result{font-size:11px;font-weight:700;border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.test-result.ok{display:block;background:var(--green-soft);color:#15803d}
|
||||
.test-result.err{display:block;background:var(--red-soft);color:#b91c1c}
|
||||
footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-spacing:1px;text-transform:uppercase;border-top:1px solid var(--line);margin-top:auto}
|
||||
.toast{position:fixed;left:50%;bottom:100px;transform:translateX(-50%) translateY(20px);background:var(--black);color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none;white-space:nowrap}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* ============ COSMIC THEME — component overrides (Deep Space atmosphere) ============
|
||||
White theme stays byte-for-byte; only :root[data-theme="cosmic"] re-skins. */
|
||||
:root[data-theme="cosmic"] body{
|
||||
background:
|
||||
radial-gradient(circle at 8% 13%, rgba(255,245,220,.80) 0 1px, transparent 1.7px),
|
||||
radial-gradient(circle at 27% 7%, rgba(135,230,251,.85) 0 1px, transparent 1.8px),
|
||||
radial-gradient(circle at 73% 19%, rgba(255,245,220,.75) 0 1.2px, transparent 1.9px),
|
||||
radial-gradient(circle at 91% 42%, rgba(243,180,7,.85) 0 1px, transparent 1.8px),
|
||||
radial-gradient(circle at 61% 78%, rgba(255,245,220,.70) 0 1px, transparent 1.7px),
|
||||
radial-gradient(circle at 18% 84%, rgba(243,180,7,.85) 0 1.2px, transparent 2px),
|
||||
radial-gradient(circle at 12% 4%, rgba(243,180,7,.30), transparent 55rem),
|
||||
radial-gradient(circle at 88% 12%, rgba(135,230,251,.24), transparent 44rem),
|
||||
radial-gradient(circle at 52% 100%,rgba(243,180,7,.22), transparent 48rem),
|
||||
radial-gradient(ellipse at center, transparent 34%, rgba(13,10,6,.55) 100%),
|
||||
linear-gradient(145deg, var(--bg), var(--bg2));
|
||||
background-blend-mode:screen,screen,screen,screen,screen,screen,screen,screen,screen,multiply,normal;
|
||||
background-attachment:fixed;
|
||||
}
|
||||
:root[data-theme="cosmic"] #app{box-shadow:0 0 60px rgb(0 0 0/.6)}
|
||||
:root[data-theme="cosmic"] header{background:rgba(13,10,6,.72)}
|
||||
:root[data-theme="cosmic"] .hero{background:linear-gradient(180deg,rgba(23,17,9,.9) 0%,var(--bg) 100%)}
|
||||
:root[data-theme="cosmic"] .icon-btn{background:rgba(255,245,220,.06);border-color:var(--line)}
|
||||
:root[data-theme="cosmic"] .clock{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .chip.c1{background:var(--blue-soft);border-color:#1c4a56;color:#cdeffb}
|
||||
:root[data-theme="cosmic"] .chip.c2{background:var(--green-soft);border-color:#1d4a44;color:#a7e6dd}
|
||||
:root[data-theme="cosmic"] .chip.c3{background:var(--pink-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .chip.c4{background:var(--gold-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .btn-ghost{background:rgba(255,245,220,.05);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .btn-blue{background:var(--blue);color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .btn-pink{background:linear-gradient(90deg,var(--blue),var(--gold));color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .btn-primary{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .daytabs{background:rgba(13,10,6,.7)}
|
||||
:root[data-theme="cosmic"] .daytab{background:rgba(255,245,220,.045)}
|
||||
:root[data-theme="cosmic"] .daytab.active{background:linear-gradient(135deg,#2a1e0e,#1c1309);border-color:var(--gold);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .daytab.today::after,
|
||||
:root[data-theme="cosmic"] .daytab.active.today::after{color:var(--gold)}
|
||||
:root[data-theme="cosmic"] .nowline::before{background:#171109}
|
||||
:root[data-theme="cosmic"] .item-card{background:linear-gradient(160deg,rgba(33,24,12,.92),rgba(20,15,8,.92))}
|
||||
:root[data-theme="cosmic"] .item-card.is-open{box-shadow:0 0 0 3px rgba(135,230,251,.16),0 0 26px -6px rgba(135,230,251,.35),var(--shadow)}
|
||||
:root[data-theme="cosmic"] .item-card.is-soon{border-color:#1c4a56}
|
||||
:root[data-theme="cosmic"] .tl-dot{background:var(--bg)}
|
||||
:root[data-theme="cosmic"] .item-emoji{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .tag{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .add-btn{background:transparent;border-color:var(--line2);color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .add-btn:active{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .add-btn.in-cart{background:var(--green-soft);border-color:#1d4a44;color:#a7e6dd}
|
||||
:root[data-theme="cosmic"] .order-card{background:linear-gradient(160deg,rgba(33,24,12,.92),rgba(20,15,8,.92))}
|
||||
:root[data-theme="cosmic"] .sp-upcoming,
|
||||
:root[data-theme="cosmic"] .sp-ended{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .b-pending{background:var(--gold-soft);color:#f6d488}
|
||||
:root[data-theme="cosmic"] .b-demo{background:var(--pink-soft);color:#f6d488}
|
||||
:root[data-theme="cosmic"] .b-failed{background:var(--red-soft);color:#f5b3a9}
|
||||
:root[data-theme="cosmic"] nav.bottom{background:rgba(13,10,6,.8)}
|
||||
:root[data-theme="cosmic"] .nav-btn.active{background:rgba(255,245,220,.07)}
|
||||
:root[data-theme="cosmic"] .fab{background:var(--gold);color:#171109}
|
||||
:root[data-theme="cosmic"] .fab .count{background:var(--blue);color:#0a0f14}
|
||||
:root[data-theme="cosmic"] .sheet{background:linear-gradient(180deg,#1c140b,#140f08)}
|
||||
:root[data-theme="cosmic"] .sheet h3,
|
||||
:root[data-theme="cosmic"] .modal-box h3{color:var(--gold);font-weight:700;letter-spacing:.5px}
|
||||
:root[data-theme="cosmic"] .cart-row .em{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .qty button{background:rgba(255,245,220,.05);border-color:var(--line2)}
|
||||
:root[data-theme="cosmic"] .field input,
|
||||
:root[data-theme="cosmic"] .field select,
|
||||
:root[data-theme="cosmic"] .field textarea{background:rgba(13,10,6,.6);border-color:var(--line2)}
|
||||
:root[data-theme="cosmic"] .pay-box{background:rgba(13,10,6,.55)}
|
||||
:root[data-theme="cosmic"] .qr-wrap{background:#fff}
|
||||
:root[data-theme="cosmic"] .addr-row{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .copy-btn{background:rgba(255,245,220,.06)}
|
||||
:root[data-theme="cosmic"] .demo-note{background:var(--gold-soft);border-color:#4a3810;color:#f6d488}
|
||||
:root[data-theme="cosmic"] .success .check{background:var(--green-soft);border-color:#1d4a44}
|
||||
:root[data-theme="cosmic"] .success .code{background:rgba(255,245,220,.05)}
|
||||
:root[data-theme="cosmic"] .modal-box{background:linear-gradient(180deg,#1c140b,#140f08)}
|
||||
:root[data-theme="cosmic"] .pay-tab{background:rgba(255,245,220,.045)}
|
||||
:root[data-theme="cosmic"] .pay-tab.active{background:var(--gold);color:#171109;border-color:var(--gold)}
|
||||
:root[data-theme="cosmic"] .toast{background:var(--ink);color:#171109}
|
||||
/* Keep the logo pure black & white in both themes (it is a white tile w/ black mark).
|
||||
Give it a hairline frame only in cosmic so the white tile reads on the starfield. */
|
||||
:root[data-theme="cosmic"] .logo{border-radius:12px;box-shadow:0 0 0 1px var(--logo-frame),0 6px 20px -8px rgba(0,0,0,.7)}
|
||||
:root[data-theme="cosmic"] .hero h1{color:var(--ink)}
|
||||
:root[data-theme="cosmic"] .hero h1 em{background:linear-gradient(90deg,var(--blue),var(--gold))}
|
||||
:root[data-theme="cosmic"] footer{border-top-color:var(--line);background:rgba(13,10,6,.5)}
|
||||
/* theme toggle button */
|
||||
.theme-toggle{font-family:var(--display);font-size:11px;font-weight:800;letter-spacing:1px;border-radius:999px;border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--ink2);padding:6px 12px;cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||
.theme-toggle:active{transform:scale(.95)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<img class="logo" src="logo.png" alt="KITCHEN 484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<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="theme-toggle" id="btn-theme" aria-label="Toggle theme" title="Switch theme">✦ COSMIC</button>
|
||||
<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>
|
||||
|
||||
<main style="flex:1;display:flex;flex-direction:column">
|
||||
<!-- SCHEDULE VIEW -->
|
||||
<section id="view-schedule">
|
||||
<div class="hero">
|
||||
<div class="chips">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="tl-head"><h2>Serving schedule</h2><span id="tl-sub"></span></div>
|
||||
<div class="tl-wrap" id="tl-wrap">
|
||||
<div class="tl-rail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ORDERS VIEW -->
|
||||
<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 id="orders-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay</footer>
|
||||
</main>
|
||||
|
||||
<nav class="bottom">
|
||||
<button class="nav-btn active" data-view="schedule">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||
SCHEDULE
|
||||
</button>
|
||||
<button class="nav-btn" data-view="orders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
|
||||
ORDERS
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="fab hidden" id="fab" aria-label="Cart">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
<span class="count" id="fab-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
"use strict";
|
||||
window.__errs=[];
|
||||
window.addEventListener('error',e=>{
|
||||
window.__errs.push(String(e.message)+' @'+(e.lineno||'?'));
|
||||
let el=document.getElementById('__errbox');
|
||||
if(!el){el=document.createElement('div');el.id='__errbox';el.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;background:#7f1d1d;color:#fff;font:11px monospace;padding:8px;max-height:40vh;overflow:auto';document.body.appendChild(el);}
|
||||
el.textContent=window.__errs.join('\n');
|
||||
});
|
||||
/* ============================== DATA ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with first page) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]}
|
||||
]
|
||||
};
|
||||
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'};
|
||||
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);
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
||||
|
||||
/* ============================== STATUS ============================== */
|
||||
function nowMin(){const d=new Date();return d.getHours()*60+d.getMinutes();}
|
||||
function itemStatus(i,item){
|
||||
if(i>0)return{state:'upcoming',label:FEST.days[i].name+' · '+FEST.days[i].date};
|
||||
const n=nowMin();
|
||||
if(n<item.start)return{state:'soon',label:'Starts in '+fmtDur(item.start-n)};
|
||||
if(n<item.end)return{state:'open',label:'Available now'};
|
||||
return{state:'ended',label:'Ended'};
|
||||
}
|
||||
|
||||
/* ============================== SCHEDULE RENDER ============================== */
|
||||
function renderDayTabs(){
|
||||
$('#daytabs').innerHTML=FEST.days.map((d,i)=>
|
||||
'<button class="daytab'+(i===state.dayIdx?' active':'')+(i===0?' today':'')+'" data-day="'+i+'">'+
|
||||
'<div class="d1">'+d.tag+'</div><div class="d2">'+d.name+' '+d.date+'</div></button>').join('');
|
||||
document.querySelectorAll('.daytab').forEach(b=>b.onclick=()=>{state.dayIdx=+b.dataset.day;renderDayTabs();renderTimeline();});
|
||||
}
|
||||
function renderTimeline(){
|
||||
const day=FEST.days[state.dayIdx];
|
||||
const items=[...day.items].sort((a,b)=>a.start-b.start);
|
||||
const d0=Math.min(...items.map(x=>x.start))-30;
|
||||
const d1=Math.max(...items.map(x=>x.end))+30;
|
||||
const isToday=state.dayIdx===0;
|
||||
const n=nowMin();
|
||||
const nowLine=(isToday&&n>=d0&&n<=d1)
|
||||
?'<div class="nowline" style="top:'+(((n-d0)/(d1-d0))*100).toFixed(2)+'%"></div>':'';
|
||||
$('#tl-sub').textContent=isToday?'Live · updates automatically':'Pre-orders open';
|
||||
$('#tl-wrap').innerHTML='<div class="tl-rail"></div>'+nowLine+items.map(item=>{
|
||||
const st=itemStatus(state.dayIdx,item);
|
||||
const cls=st.state==='open'?'is-open':st.state==='soon'?'is-soon':(st.state==='upcoming'?'':'is-ended');
|
||||
const pill={open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state];
|
||||
const inCart=state.cart[item.id]>0;
|
||||
return '<div class="item-card '+cls+'" data-id="'+item.id+'">'+
|
||||
'<div class="tl-dot"></div>'+
|
||||
'<div class="tl-time">'+fmtT(item.start).replace(' ','<br>')+'</div>'+
|
||||
'<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>'+
|
||||
'<div class="item-foot">'+
|
||||
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
|
||||
'<span class="tag" style="margin-left:auto">'+fmtT(item.start)+' – '+fmtT(item.end)+'</span>'+
|
||||
'<button class="add-btn'+(inCart?' in-cart':'')+'" data-add="'+item.id+'">'+(inCart?'✓ Added · '+state.cart[item.id]:'+ Add to order')+'</button>'+
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.add;
|
||||
state.cart[id]=(state.cart[id]||0)+1;
|
||||
saveCart();renderFab();renderTimeline();
|
||||
toast(findItem(id).emoji+' '+findItem(id).name+' added');
|
||||
});
|
||||
}
|
||||
function refreshStatuses(){
|
||||
document.querySelectorAll('#tl-wrap .item-card').forEach(card=>{
|
||||
const id=card.dataset.id, it=findItem(id);
|
||||
const st=itemStatus(state.dayIdx,it);
|
||||
const pill=card.querySelector('.status-pill');
|
||||
pill.className='status-pill '+( {open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state] );
|
||||
pill.innerHTML='<span class="dot"></span>'+st.label;
|
||||
card.classList.remove('is-open','is-soon','is-ended');
|
||||
if(st.state==='open')card.classList.add('is-open');
|
||||
else if(st.state==='soon')card.classList.add('is-soon');
|
||||
else if(st.state==='ended')card.classList.add('is-ended');
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================== FAB / NAV ============================== */
|
||||
function renderFab(){
|
||||
const c=cartCount();
|
||||
$('#fab').classList.toggle('hidden',c===0);
|
||||
$('#fab-count').textContent=c;
|
||||
document.title=c>0?('KITCHEN 484 · '+c+' in cart'):('KITCHEN 484 · SOLARPUNK SUMMIT');
|
||||
let d=document.getElementById('debugbar');
|
||||
if(!d){d=document.createElement('div');d.id='debugbar';d.style.cssText='position:fixed;bottom:0;left:0;right:0;z-index:9998;background:#0A0A0A;color:#4ade80;font:10px monospace;padding:4px 8px;pointer-events:none';document.body.appendChild(d);}
|
||||
d.textContent='cart='+JSON.stringify(state.cart)+' errs='+(window.__errs?window.__errs.length:0);
|
||||
}
|
||||
document.querySelectorAll('.nav-btn').forEach(b=>b.onclick=()=>{
|
||||
state.view=b.dataset.view;
|
||||
document.querySelectorAll('.nav-btn').forEach(x=>x.classList.toggle('active',x===b));
|
||||
$('#view-schedule').hidden=state.view!=='schedule';
|
||||
$('#view-orders').hidden=state.view!=='orders';
|
||||
if(state.view==='orders')renderOrders();
|
||||
});
|
||||
$('#fab').onclick=()=>openSheet('cart');
|
||||
|
||||
/* ============================== SHEET STEPS ============================== */
|
||||
function openSheet(step){state.sheetStep=step;renderSheet();$('#sheet-overlay').classList.add('show');}
|
||||
function closeSheet(){
|
||||
if(state.pay&&state.pay.timer){clearInterval(state.pay.timer);state.pay=null;}
|
||||
state.sheetStep=null;$('#sheet-overlay').classList.remove('show');
|
||||
}
|
||||
$('#sheet-overlay').addEventListener('mousedown',e=>{$('#sheet-overlay')._down=e.target.id==='sheet-overlay';});
|
||||
$('#sheet-overlay').addEventListener('click',e=>{if($('#sheet-overlay')._down&&e.target.id==='sheet-overlay')closeSheet();});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function stepCart(){
|
||||
const rows=Object.keys(state.cart).map(id=>{
|
||||
const it=findItem(id);if(!it)return'';
|
||||
return '<div class="cart-row" data-row="'+id+'">'+
|
||||
'<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>';
|
||||
}).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>';
|
||||
}
|
||||
|
||||
function stepCheckout(){
|
||||
const days=[...new Set(Object.keys(state.cart).map(findDayOf))].map(i=>FEST.days[i]);
|
||||
const windows=[...new Set(days.flatMap(d=>d.items.map(it=>it.id)))]
|
||||
.map(id=>({id,start:findItem(id).start,end:findItem(id).end,name:findItem(id).name}))
|
||||
.sort((a,b)=>a.start-b.start);
|
||||
const n=nowMin();
|
||||
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>'+
|
||||
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
|
||||
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
|
||||
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
|
||||
'<div class="total-row"><span class="t">Total · '+cartCount()+' items</span><span class="v">'+fmt$(cartTotal())+'</span></div>'+
|
||||
'<button class="btn btn-pink" id="to-payment" style="margin-top:8px">₿ Pay with Bitcoin</button>'+
|
||||
'<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="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>'+
|
||||
'<button class="btn btn-primary" id="success-done">Done</button>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireSheet(){
|
||||
const s=$('#sheet');
|
||||
s.querySelectorAll('[data-inc]').forEach(b=>b.onclick=()=>{state.cart[b.dataset.inc]++;afterCartChange();});
|
||||
s.querySelectorAll('[data-dec]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.dec;state.cart[id]--;
|
||||
if(state.cart[id]<=0)delete state.cart[id];
|
||||
afterCartChange();
|
||||
});
|
||||
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 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');
|
||||
};
|
||||
const done=s.querySelector('#success-done');
|
||||
if(done)done.onclick=()=>{
|
||||
closeSheet();
|
||||
document.querySelector('[data-view="orders"]').click();
|
||||
};
|
||||
}
|
||||
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();
|
||||
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);
|
||||
saveOrders();
|
||||
state.cart={};saveCart();renderFab();renderTimeline();
|
||||
openSheet('success');
|
||||
}
|
||||
|
||||
/* ============================== ORDERS VIEW ============================== */
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
}).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…';
|
||||
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){}
|
||||
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();}
|
||||
};
|
||||
|
||||
/* ============================== THEME (White ⇄ Cosmic) ============================== */
|
||||
const THEME_KEY='kitchen484-theme';
|
||||
function applyTheme(t){
|
||||
t=(t==='cosmic')?'cosmic':'white';
|
||||
if(t==='cosmic')document.documentElement.setAttribute('data-theme','cosmic');
|
||||
else document.documentElement.removeAttribute('data-theme');
|
||||
const b=$('#btn-theme');
|
||||
if(b){b.textContent=(t==='cosmic')?'☾ WHITE':'✦ COSMIC';b.title=(t==='cosmic')?'Switch to white theme':'Switch to cosmic theme';}
|
||||
const meta=document.querySelector('meta[name="theme-color"]');
|
||||
if(meta)meta.setAttribute('content',t==='cosmic'?'#0d0a06':'#FFFFFF');
|
||||
}
|
||||
function currentTheme(){return document.documentElement.getAttribute('data-theme')==='cosmic'?'cosmic':'white';}
|
||||
function initTheme(){
|
||||
applyTheme('white');
|
||||
const b=$('#btn-theme');
|
||||
if(b)b.onclick=()=>{const next=currentTheme()==='cosmic'?'white':'cosmic';applyTheme(next);try{localStorage.setItem(THEME_KEY,next);}catch(e){}};
|
||||
}
|
||||
initTheme();
|
||||
|
||||
/* ============================== CLOCK / LOOP ============================== */
|
||||
function tickClock(){
|
||||
const d=new Date();
|
||||
$('#clock').textContent=String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
setInterval(()=>{tickClock();if(!$('#view-schedule').hidden)refreshStatuses();},30000);
|
||||
|
||||
/* ============================== INIT ============================== */
|
||||
(async function init(){
|
||||
tickClock();
|
||||
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);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
backups/logo.png.20260830-220331
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
backups/logo.png.20260830-221227
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
backups/logo.png.crisp-20260829-195243
Normal file
|
After Width: | Height: | Size: 30 KiB |
470
home.html
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>K484 · A Private Membership Association</title>
|
||||
<script>document.documentElement.style.colorScheme='light';try{localStorage.removeItem('kitchen484-theme');}catch(e){}</script>
|
||||
<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{
|
||||
color-scheme:light;
|
||||
--bg:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5;scroll-behavior:smooth}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
a{color:inherit}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:0 22px}
|
||||
|
||||
/* ============ HEADER ============ */
|
||||
header{position:sticky;top:0;z-index:50;background:rgba(255,255,255,.9);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||||
.header-in{display:flex;align-items:center;gap:12px;padding:12px 22px;max-width:1040px;margin:0 auto}
|
||||
.logo{width:46px;height:46px;flex:0 0 46px;object-fit:contain}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px;display:block;line-height:1.2}
|
||||
.brand span{font-size:11px;color:var(--muted);letter-spacing:1px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||
.member-chip{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;border:1px solid var(--line2);border-radius:99px;padding:6px 12px;color:var(--ink2)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:99px;padding:12px 22px;transition:transform .12s,opacity .12s;text-decoration:none}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff}
|
||||
.btn-primary:hover{opacity:.88}
|
||||
.btn-ghost{background:var(--bg);color:var(--ink);border:1.5px solid var(--line2)}
|
||||
.btn-ghost:hover{border-color:var(--ink)}
|
||||
.btn-sm{padding:9px 16px;font-size:13px}
|
||||
|
||||
/* ============ HERO ============ */
|
||||
.hero{text-align:center;padding:72px 22px 56px;position:relative;overflow:hidden}
|
||||
.hero-logo{width:140px;height:140px;object-fit:contain;margin:0 auto 26px;display:block;animation:fadeup 1s ease both}
|
||||
.hero-kicker{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:4px;color:var(--muted);animation:fadeup .9s ease both .1s}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:clamp(40px,8vw,72px);letter-spacing:6px;margin:10px 0 6px;animation:fadeup .9s ease both .2s}
|
||||
.hero .assoc{font-size:clamp(15px,2.4vw,19px);color:var(--ink2);letter-spacing:2px;text-transform:uppercase;animation:fadeup .9s ease both .3s}
|
||||
.hero-sub{max-width:560px;margin:18px auto 0;font-size:14px;color:var(--ink2);animation:fadeup .9s ease both .4s}
|
||||
.tagline-row{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin:28px auto 6px;animation:fadeup .9s ease both .5s}
|
||||
.tagline{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:2px;border:1.5px solid var(--ink);border-radius:99px;padding:8px 16px;text-transform:uppercase}
|
||||
.hero-cta{display:flex;flex-wrap:wrap;justify-content:center;gap:12px;margin-top:30px;animation:fadeup .9s ease both .6s}
|
||||
@keyframes fadeup{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* ============ SECTIONS ============ */
|
||||
section{padding:64px 0}
|
||||
.sec-head{text-align:center;margin-bottom:36px}
|
||||
.sec-head h2{font-family:var(--display);font-weight:900;font-size:clamp(22px,4vw,30px);letter-spacing:3px;text-transform:uppercase}
|
||||
.sec-head .sub{font-size:12px;color:var(--muted);letter-spacing:2px;text-transform:uppercase;margin-top:8px}
|
||||
.divider{border:none;border-top:1px solid var(--line);max-width:1040px;margin:0 auto}
|
||||
|
||||
|
||||
/* ============ HOW IT WORKS ============ */
|
||||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
|
||||
.step{border:1px solid var(--line);border-radius:var(--radius);padding:24px 20px;background:var(--bg)}
|
||||
.step .n{font-family:var(--display);font-weight:900;font-size:26px;color:var(--muted)}
|
||||
.step h3{font-family:var(--display);font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin:8px 0 6px}
|
||||
.step p{font-size:13px;color:var(--ink2)}
|
||||
|
||||
/* ============ MEMBERSHIP ============ */
|
||||
#join{background:var(--bg2)}
|
||||
.join-wrap{max-width:640px;margin:0 auto}
|
||||
.note-box{border:1px solid var(--line2);border-radius:14px;padding:14px 18px;font-size:13px;color:var(--ink2);background:var(--bg);margin-bottom:22px}
|
||||
.note-box b{color:var(--ink)}
|
||||
.form-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:26px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input{width:100%;border:1px solid var(--line2);border-radius:12px;padding:13px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.field input:focus{border-color:var(--ink)}
|
||||
.sig-wrap{border:1.5px dashed var(--line2);border-radius:12px;position:relative;background:var(--bg2)}
|
||||
.sig-canvas{display:block;width:100%;height:140px;touch-action:none;cursor:crosshair;border-radius:12px}
|
||||
.sig-hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--muted);letter-spacing:1px;pointer-events:none;text-transform:uppercase}
|
||||
.sig-tools{display:flex;align-items:center;justify-content:space-between;margin-top:8px}
|
||||
.sig-status{font-size:11px;color:var(--muted);letter-spacing:.5px}
|
||||
.sig-status.ok{font-weight:700;color:var(--ink)}
|
||||
.link-btn{background:none;border:none;cursor:pointer;font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);text-decoration:underline}
|
||||
.link-btn:hover{color:var(--ink)}
|
||||
.covenant{background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:18px 0;max-height:190px;overflow-y:auto;font-size:12.5px;color:var(--ink2)}
|
||||
.covenant h4{font-family:var(--display);font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--ink);margin-bottom:8px}
|
||||
.covenant ol{padding-left:18px;display:grid;gap:6px}
|
||||
.agree-row{display:flex;gap:10px;align-items:flex-start;margin:16px 0}
|
||||
.agree-row input{width:18px;height:18px;margin-top:1px;accent-color:var(--black);flex:0 0 18px}
|
||||
.agree-row label{font-size:12.5px;color:var(--ink2)}
|
||||
.form-err{font-size:12px;font-weight:700;color:var(--ink);background:var(--bg2);border:1.5px solid var(--ink);border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.form-err.show{display:block}
|
||||
.form-actions{display:grid;gap:10px}
|
||||
.member-done{text-align:center;padding:10px 0}
|
||||
.member-done .check{width:56px;height:56px;border-radius:50%;background:var(--black);color:#fff;font-size:26px;display:flex;align-items:center;justify-content:center;margin:0 auto 14px}
|
||||
.member-done h3{font-family:var(--display);font-weight:900;font-size:17px;letter-spacing:2px}
|
||||
.member-code{font-family:var(--display);font-weight:900;font-size:22px;letter-spacing:3px;background:var(--bg2);border:1px solid var(--line);border-radius:12px;padding:12px;margin:16px 0}
|
||||
.member-meta{font-size:13px;color:var(--ink2);margin-bottom:6px}
|
||||
|
||||
/* ============ FOOTER ============ */
|
||||
footer{border-top:1px solid var(--line);padding:28px 22px 34px;text-align:center}
|
||||
footer .f-logo{width:52px;height:52px;object-fit:contain;margin:0 auto 10px;display:block}
|
||||
footer .f-line{font-family:var(--display);font-size:10px;font-weight:700;letter-spacing:3px;text-transform:uppercase;color:var(--muted)}
|
||||
footer .f-sub{font-size:11px;color:var(--muted);margin-top:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-in">
|
||||
<img class="logo" src="logo.png" alt="K484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<div class="header-right">
|
||||
<span class="member-chip" id="member-chip" hidden></span>
|
||||
<a class="btn btn-ghost btn-sm" href="#join" id="nav-join">Join</a>
|
||||
<a class="btn btn-primary btn-sm" href="index.html">Food Menu</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="hero">
|
||||
<img class="hero-logo" src="logo.png" alt="K484">
|
||||
<div class="hero-kicker">SOLARPUNK SUMMIT · Oct 8–12</div>
|
||||
<h1>K484</h1>
|
||||
<div class="assoc">A Private Membership Association</div>
|
||||
<p class="hero-sub">Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.</p>
|
||||
<div class="tagline-row">
|
||||
<span class="tagline">No Seed Oils</span>
|
||||
<span class="tagline">All Organic Grass Fed</span>
|
||||
<span class="tagline">Gluten Free & Vegetarian Friendly</span>
|
||||
<span class="tagline">Off Grid — Zero Waste</span>
|
||||
</div>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary" href="#join">Become a Member</a>
|
||||
<a class="btn btn-ghost" href="index.html">Food Menu →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<section id="join">
|
||||
<div class="wrap join-wrap">
|
||||
<div class="sec-head">
|
||||
<h2>Membership</h2>
|
||||
<div class="sub">Kitchen 484 · Private Membership Association</div>
|
||||
</div>
|
||||
|
||||
<div id="join-form-wrap">
|
||||
<div class="note-box">
|
||||
<b>Note:</b> This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application.
|
||||
</div>
|
||||
|
||||
<form class="form-card" id="join-form" novalidate>
|
||||
<div class="form-err" id="join-err"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="m-name">Full Name</label>
|
||||
<input id="m-name" type="text" placeholder="Your full name" maxlength="80" autocomplete="name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-email">Email</label>
|
||||
<input id="m-email" type="email" placeholder="you@example.com" maxlength="120" autocomplete="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="m-phone">Phone</label>
|
||||
<input id="m-phone" type="tel" placeholder="+1 (555) 000-0000" maxlength="30" autocomplete="tel">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Digital Signature</label>
|
||||
<div class="sig-wrap">
|
||||
<canvas class="sig-canvas" id="sig-canvas"></canvas>
|
||||
<div class="sig-hint" id="sig-hint">Sign here with mouse or finger</div>
|
||||
</div>
|
||||
<div class="sig-tools">
|
||||
<span class="sig-status" id="sig-status">Signature required to apply</span>
|
||||
<button type="button" class="link-btn" id="sig-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="covenant">
|
||||
<h4>K484 Membership Covenant</h4>
|
||||
<p style="margin-bottom:8px">By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>
|
||||
<ol>
|
||||
<li>I am joining a private, members-only association, not a public business.</li>
|
||||
<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>
|
||||
<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>
|
||||
<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>
|
||||
<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>
|
||||
<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="agree-row">
|
||||
<input type="checkbox" id="m-agree">
|
||||
<label for="m-agree">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Sign & Apply for Membership</button>
|
||||
<a class="btn btn-ghost" href="index.html" style="text-decoration:none">Browse the Food Menu First</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card member-done" id="join-done" hidden>
|
||||
<div class="check">✓</div>
|
||||
<h3>APPLICATION SUBMITTED</h3>
|
||||
<div class="member-code" id="done-code">K484-XXXX</div>
|
||||
<div class="member-meta" id="done-name"></div>
|
||||
<div class="member-meta" id="done-date"></div>
|
||||
<p class="member-meta" style="margin-top:10px;font-size:12px;color:var(--muted)">Keep this code — it is your member reference. Your signed covenant is available for download and printing.</p>
|
||||
<div class="form-actions" style="margin-top:18px">
|
||||
<button class="btn btn-primary" id="btn-download">Download Signed Agreement</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
<a class="btn btn-ghost" href="index.html" style="flex:1;text-decoration:none">Open Food Menu</a>
|
||||
<button class="btn btn-ghost" id="btn-new-apply" style="flex:1">New Application</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="divider"></div>
|
||||
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<h2>How It Works</h2>
|
||||
<div class="sub">Five days, three steps</div>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">01</div>
|
||||
<h3>Check the Schedule</h3>
|
||||
<p>Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">02</div>
|
||||
<h3>Pre-Order Your Window</h3>
|
||||
<p>Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">03</div>
|
||||
<h3>Pay with Bitcoin</h3>
|
||||
<p>Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
|
||||
<footer>
|
||||
<img class="f-logo" src="logo.png" alt="K484">
|
||||
<div class="f-line">Off Grid — Zero Waste</div>
|
||||
<div class="f-sub">KITCHEN 484 · SOLARPUNK SUMMIT · Oct 8–12 · No cash, no cards — Bitcoin only</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
|
||||
/* ============================== SIGNATURE PAD ============================== */
|
||||
const canvas=$('#sig-canvas');
|
||||
const ctx=canvas.getContext('2d');
|
||||
let drawing=false, lastPt=null, hasInk=false;
|
||||
|
||||
function sizeCanvas(){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const dpr=window.devicePixelRatio||1;
|
||||
const img=canvas.width?canvas.toDataURL():null;
|
||||
canvas.width=Math.max(1,r.width*dpr);
|
||||
canvas.height=140*dpr;
|
||||
ctx.scale(dpr,dpr);
|
||||
ctx.lineWidth=2.2;
|
||||
ctx.lineCap='round';
|
||||
ctx.lineJoin='round';
|
||||
ctx.strokeStyle='#0A0A0A';
|
||||
if(img){const im=new Image();im.onload=()=>ctx.drawImage(im,0,0,r.width,140);im.src=img;}
|
||||
}
|
||||
function pt(e){
|
||||
const r=canvas.getBoundingClientRect();
|
||||
const t=e.touches?e.touches[0]:e;
|
||||
return {x:t.clientX-r.left,y:t.clientY-r.top};
|
||||
}
|
||||
function startDraw(e){
|
||||
drawing=true; lastPt=pt(e);
|
||||
// draw a dot so taps (touch users) register ink, not just drags
|
||||
ctx.beginPath();
|
||||
ctx.arc(lastPt.x,lastPt.y,1.2,0,Math.PI*2);
|
||||
ctx.fillStyle='#0A0A0A';
|
||||
ctx.fill();
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function moveDraw(e){
|
||||
if(!drawing)return;
|
||||
const p=pt(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPt.x,lastPt.y);
|
||||
ctx.lineTo(p.x,p.y);
|
||||
ctx.stroke();
|
||||
lastPt=p;
|
||||
if(!hasInk){
|
||||
hasInk=true;
|
||||
$('#sig-hint').style.display='none';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='✓ Signed — you can apply now';
|
||||
st.classList.add('ok');
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
function endDraw(){drawing=false;lastPt=null;}
|
||||
|
||||
canvas.addEventListener('mousedown',startDraw);
|
||||
canvas.addEventListener('mousemove',moveDraw);
|
||||
window.addEventListener('mouseup',endDraw);
|
||||
canvas.addEventListener('touchstart',startDraw,{passive:false});
|
||||
canvas.addEventListener('touchmove',moveDraw,{passive:false});
|
||||
canvas.addEventListener('touchend',endDraw);
|
||||
$('#sig-clear').onclick=()=>{
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
hasInk=false;
|
||||
$('#sig-hint').style.display='flex';
|
||||
const st=$('#sig-status');
|
||||
st.textContent='Signature required to apply';
|
||||
st.classList.remove('ok');
|
||||
};
|
||||
window.addEventListener('resize',sizeCanvas);
|
||||
sizeCanvas();
|
||||
|
||||
/* ============================== MEMBERSHIP ============================== */
|
||||
const LS_MEMBER='slk_member';
|
||||
const LS_MEMBERS='slk_members';
|
||||
|
||||
function loadMember(){try{return JSON.parse(localStorage.getItem(LS_MEMBER)||'null');}catch(e){return null;}}
|
||||
function saveMember(m){
|
||||
localStorage.setItem(LS_MEMBER,JSON.stringify(m));
|
||||
const all=JSON.parse(localStorage.getItem(LS_MEMBERS)||'[]');
|
||||
all.push(m);
|
||||
localStorage.setItem(LS_MEMBERS,JSON.stringify(all));
|
||||
}
|
||||
function showMember(m){
|
||||
$('#join-form-wrap').hidden=true;
|
||||
$('#join-done').hidden=false;
|
||||
$('#done-code').textContent=m.code;
|
||||
$('#done-name').textContent=m.fullName;
|
||||
$('#done-date').textContent='Applied '+new Date(m.ts).toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'});
|
||||
const chip=$('#member-chip');
|
||||
chip.hidden=false;
|
||||
chip.textContent=m.code;
|
||||
document.getElementById('nav-join').textContent='Member';
|
||||
$('#join').scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
const existing=loadMember();
|
||||
if(existing)showMember(existing);
|
||||
|
||||
$('#join-form').addEventListener('submit',e=>{
|
||||
e.preventDefault();
|
||||
const err=$('#join-err');
|
||||
err.classList.remove('show');
|
||||
const name=$('#m-name').value.trim();
|
||||
const email=$('#m-email').value.trim();
|
||||
const phone=$('#m-phone').value.trim();
|
||||
const agree=$('#m-agree').checked;
|
||||
|
||||
let problem=null;
|
||||
if(!name)problem='Please enter your full name.';
|
||||
else if(!email||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))problem='Please enter a valid email address.';
|
||||
else if(!phone)problem='Please enter a phone number.';
|
||||
else if(!hasInk)problem='Please sign in the signature box.';
|
||||
else if(!agree)problem='Please check the covenant agreement box.';
|
||||
|
||||
if(problem){
|
||||
err.textContent=problem;
|
||||
err.classList.add('show');
|
||||
err.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
return;
|
||||
}
|
||||
|
||||
const member={
|
||||
code:'K484-'+Math.random().toString(36).slice(2,6).toUpperCase(),
|
||||
fullName:name, email, phone,
|
||||
ts:Date.now(),
|
||||
signature:canvas.toDataURL('image/png')
|
||||
};
|
||||
saveMember(member);
|
||||
showMember(member);
|
||||
});
|
||||
|
||||
$('#btn-new-apply').onclick=()=>{
|
||||
localStorage.removeItem(LS_MEMBER);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
/* ============================== AGREEMENT DOWNLOAD ============================== */
|
||||
$('#btn-download').onclick=()=>{
|
||||
const m=loadMember();
|
||||
if(!m){toast('No application found');return;}
|
||||
const d=new Date(m.ts);
|
||||
const html='<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Kitchen 484 Membership Agreement - '+esc(m.fullName)+'</title>'+
|
||||
'<style>'+
|
||||
'body{font-family:Georgia,serif;color:#000;max-width:720px;margin:40px auto;padding:0 24px;line-height:1.6;font-size:14px}'+
|
||||
'h1{font-size:22px;letter-spacing:2px;text-transform:uppercase;text-align:center}'+
|
||||
'h2{font-size:14px;letter-spacing:1px;text-transform:uppercase;text-align:center;font-weight:normal;color:#333;margin-top:4px}'+
|
||||
'.box{border:1px solid #000;padding:18px 22px;margin:22px 0}'+
|
||||
'.info p{margin:6px 0}'+
|
||||
'.info b{display:inline-block;width:110px}'+
|
||||
'ol{padding-left:20px}li{margin:8px 0}'+
|
||||
'.sig{margin-top:30px;text-align:center}'+
|
||||
'.sig img{height:90px;border:1px solid #999;border-radius:6px;padding:6px;background:#fff}'+
|
||||
'.sig .line{margin-top:6px;font-size:12px;color:#333}'+
|
||||
'.declare{margin-top:24px;font-style:italic}'+
|
||||
'@media print{body{margin:20px auto}}'+
|
||||
'</style></head><body>'+
|
||||
'<h1>Kitchen 484</h1><h2>Membership Agreement — '+esc(m.code)+'</h2>'+
|
||||
'<div class="box info">'+
|
||||
'<p><b>Member:</b> '+esc(m.fullName)+'</p>'+
|
||||
'<p><b>Email:</b> '+esc(m.email)+'</p>'+
|
||||
'<p><b>Phone:</b> '+esc(m.phone)+'</p>'+
|
||||
'<p><b>Applied:</b> '+d.toLocaleString('en-US')+'</p>'+
|
||||
'</div>'+
|
||||
'<p>Note: This is a private, members-only association. Participation is absolutely voluntary.</p>'+
|
||||
'<h2 style="text-align:left;margin-top:20px">K484 Membership Covenant</h2>'+
|
||||
'<p>By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:</p>'+
|
||||
'<ol>'+
|
||||
'<li>I am joining a private, members-only association, not a public business.</li>'+
|
||||
'<li>Access to kitchen activities, meals, and services is limited to accepted members only.</li>'+
|
||||
'<li>I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.</li>'+
|
||||
'<li>Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.</li>'+
|
||||
'<li>I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.</li>'+
|
||||
'<li>I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.</li>'+
|
||||
'</ol>'+
|
||||
'<p class="declare">"I have read and agree to the Kitchen 484 Membership Covenant, and I voluntarily apply for membership."</p>'+
|
||||
'<div class="sig"><img src="'+m.signature+'" alt="signature"><div class="line">Digital signature — '+esc(m.fullName)+' · '+d.toLocaleDateString('en-US')+'</div></div>'+
|
||||
'<script>setTimeout(function(){window.print()},600)</'+'script>'+
|
||||
'</body></html>';
|
||||
const w=window.open('','_blank');
|
||||
if(!w){toast('Pop-up blocked — allow pop-ups to download');return;}
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
function toast(msg){
|
||||
let t=document.getElementById('h-toast');
|
||||
if(!t){t=document.createElement('div');t.id='h-toast';t.style.cssText='position:fixed;left:50%;bottom:36px;transform:translateX(-50%) translateY(20px);background:#0A0A0A;color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none';document.body.appendChild(t);}
|
||||
t.textContent=msg;t.style.opacity='1';t.style.transform='translateX(-50%) translateY(0)';
|
||||
clearTimeout(t._x);t._x=setTimeout(()=>{t.style.opacity='0';t.style.transform='translateX(-50%) translateY(20px)';},2200);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
icon-192.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
icon-512.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
icon.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
853
index.html
Normal file
|
|
@ -0,0 +1,853 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="icon.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<title>KITCHEN 484 · SOLARPUNK SUMMIT</title>
|
||||
<script>document.documentElement.style.colorScheme='light';try{localStorage.removeItem('kitchen484-theme');}catch(e){}</script>
|
||||
<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;
|
||||
--bg:#FFFFFF; --bg2:#f8fafc;
|
||||
--ink:#0A0A0A; --ink2:#475569; --muted:#94a3b8;
|
||||
--line:#e2e8f0; --line2:#cbd5e1;
|
||||
--blue:#3b82f6; --blue-soft:#eff6ff;
|
||||
--pink:#d946ef; --pink-soft:#fdf4ff;
|
||||
--gold:#eab308; --gold-soft:#fefce8;
|
||||
--green:#22c55e; --green-soft:#f0fdf4;
|
||||
--red:#ef4444; --red-soft:#fef2f2;
|
||||
--black:#0a0a0a;
|
||||
--radius:18px;
|
||||
--shadow:0 1px 2px rgb(10 10 10 / .05), 0 8px 24px -12px rgb(10 10 10 / .12);
|
||||
--font:-apple-system,'Avenir Next','Avenir',system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
||||
--display:'Orbitron',var(--font);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{line-height:1.5}
|
||||
body{font-family:var(--font);background:var(--bg2);color:var(--ink);-webkit-font-smoothing:antialiased}
|
||||
#app{max-width:520px;margin:0 auto;min-height:100dvh;background:var(--bg);display:flex;flex-direction:column;position:relative;box-shadow:0 0 40px rgb(0 0 0/.06)}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header{position:sticky;top:0;z-index:40;background:rgb(255 255 255/.85);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);padding:12px 16px;display:flex;align-items:center;gap:10px}
|
||||
.logo{width:44px;height:44px;flex:0 0 44px;object-fit:contain}
|
||||
.brand{display:flex;flex-direction:column;line-height:1.15}
|
||||
.brand b{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:2px}
|
||||
.brand span{font-size:10px;color:var(--muted);letter-spacing:1.5px}
|
||||
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.clock{font-family:var(--display);font-size:12px;font-weight:700;color:var(--ink2);letter-spacing:1px;background:var(--bg2);border:1px solid var(--line);border-radius:999px;padding:4px 10px}
|
||||
.icon-btn{width:36px;height:36px;border-radius:999px;border:1px solid var(--line);background:#fff;color:var(--ink2);display:grid;place-items:center;cursor:pointer;transition:all .15s}
|
||||
.icon-btn:active{transform:scale(.92)}
|
||||
.icon-btn svg{width:17px;height:17px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:26px 18px 18px;background:linear-gradient(180deg,#fff 0%,var(--bg2) 100%)}
|
||||
.hero h1{font-family:var(--display);font-weight:900;font-size:26px;letter-spacing:3px;margin:10px 0 4px}
|
||||
.hero h1 em{font-style:normal;background:linear-gradient(90deg,var(--blue),var(--pink));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.hero p{color:var(--ink2);font-size:13px;max-width:360px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0 16px}
|
||||
.chip{font-size:10px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:5px 10px;border-radius:999px;border:1px solid}
|
||||
.chip.c1{color:var(--ink);border-color:var(--ink);background:var(--black);color:#fff}
|
||||
.chip.c2{color:#166534;border-color:#bbf7d0;background:var(--green-soft)}
|
||||
.chip.c3{color:#86198f;border-color:#f5d0fe;background:var(--pink-soft)}
|
||||
.chip.c4{color:#854d0e;border-color:#fde68a;background:var(--gold-soft)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;font-family:var(--font);font-weight:700;font-size:14px;border-radius:14px;padding:13px 22px;transition:transform .12s,box-shadow .12s}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn-primary{background:var(--black);color:#fff;width:100%;box-shadow:var(--shadow)}
|
||||
.btn-primary:disabled{opacity:.35;cursor:not-allowed}
|
||||
.btn-ghost{background:#fff;color:var(--ink);border:1px solid var(--line2)}
|
||||
.btn-blue{background:var(--blue);color:#fff;width:100%}
|
||||
.btn-pink{background:linear-gradient(90deg,var(--blue),var(--pink));color:#fff;width:100%}
|
||||
.btn-sm{padding:9px 14px;font-size:12px;border-radius:11px;width:auto}
|
||||
|
||||
/* ---------- day tabs ---------- */
|
||||
.daytabs{display:flex;gap:8px;padding:4px 16px 12px;position:sticky;top:69px;background:rgb(255 255 255/.9);backdrop-filter:blur(8px);z-index:30}
|
||||
.daytab{flex:1;border:1px solid var(--line);background:#fff;border-radius:14px;padding:9px 6px;cursor:pointer;text-align:center;transition:all .15s}
|
||||
.daytab .d1{font-family:var(--display);font-weight:700;font-size:11px;letter-spacing:1px}
|
||||
.daytab .d2{font-size:10px;color:var(--muted)}
|
||||
.daytab.active{background:var(--black);border-color:var(--black);color:#fff;box-shadow:var(--shadow)}
|
||||
.daytab.active .d2{color:#a3a3a3}
|
||||
.daytab.today::after{content:'TODAY';display:block;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--pink);margin-top:2px}
|
||||
.daytab.active.today::after{color:var(--pink)}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.timeline{padding:8px 16px 24px;position:relative}
|
||||
.tl-head{display:flex;justify-content:space-between;align-items:baseline;margin:6px 2px 10px}
|
||||
.tl-head h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase}
|
||||
.tl-head span{font-size:11px;color:var(--muted)}
|
||||
.tl-wrap{position:relative;padding-left:26px}
|
||||
.tl-rail{position:absolute;left:9px;top:6px;bottom:6px;width:2px;background:var(--line);border-radius:2px}
|
||||
.nowline{position:absolute;left:-4px;right:-8px;height:2px;background:var(--red);border-radius:2px;z-index:5}
|
||||
.nowline::before{content:'NOW';position:absolute;left:-2px;top:-16px;font-size:8px;font-weight:900;letter-spacing:1px;color:var(--red);background:#fff;padding:1px 4px;border-radius:4px}
|
||||
.nowline::after{content:'';position:absolute;left:-5px;top:-2px;width:8px;height:8px;border-radius:99px;background:var(--red);animation:pulse 1.6s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(1.5)}}
|
||||
.item-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04);transition:border-color .2s,box-shadow .2s}
|
||||
.item-card.is-open{border-color:var(--green);box-shadow:0 0 0 3px var(--green-soft),var(--shadow)}
|
||||
.item-card.is-soon{border-color:#bfdbfe}
|
||||
.item-card.is-ended{opacity:.62}
|
||||
.tl-dot{position:absolute;left:-21px;top:22px;width:12px;height:12px;border-radius:99px;background:#fff;border:3px solid var(--line2);z-index:6}
|
||||
.item-card.is-open .tl-dot{border-color:var(--green);background:var(--green)}
|
||||
.item-card.is-soon .tl-dot{border-color:var(--blue)}
|
||||
.tl-time{position:absolute;left:-26px;top:18px;transform:translateX(-100%);width:52px;text-align:right;font-family:var(--display);font-size:9px;font-weight:700;color:var(--muted);letter-spacing:.5px;line-height:1.3}
|
||||
.item-top{display:flex;gap:12px;align-items:flex-start}
|
||||
.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-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}
|
||||
.sp-open{background:var(--green-soft);color:#15803d}
|
||||
.sp-open .dot{animation:pulse 1.4s infinite}
|
||||
.sp-soon{background:var(--blue-soft);color:#1d4ed8}
|
||||
.sp-upcoming{background:var(--bg2);color:var(--muted);border:1px solid var(--line)}
|
||||
.sp-ended{background:var(--bg2);color:var(--muted)}
|
||||
.tags{display:flex;gap:4px}
|
||||
.tag{font-size:9px;font-weight:800;letter-spacing:.5px;padding:3px 7px;border-radius:7px;background:var(--bg2);border:1px solid var(--line);color:var(--ink2)}
|
||||
.add-btn{margin-left:auto;border:1px solid var(--black);background:#fff;color:var(--ink);font-weight:800;font-size:12px;border-radius:11px;padding:8px 14px;cursor:pointer;transition:all .12s;white-space:nowrap}
|
||||
.add-btn:active{transform:scale(.94);background:var(--black);color:#fff}
|
||||
.add-btn.in-cart{background:var(--green-soft);border-color:#bbf7d0;color:#15803d}
|
||||
|
||||
/* ---------- orders ---------- */
|
||||
.orders{padding:16px}
|
||||
.orders h2{font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
|
||||
.orders .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.empty{text-align:center;padding:56px 20px;color:var(--muted)}
|
||||
.empty .big{font-size:40px;margin-bottom:10px}
|
||||
.order-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:12px;box-shadow:0 1px 2px rgb(10 10 10/.04)}
|
||||
.order-top{display:flex;align-items:center;gap:10px}
|
||||
.order-code{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1px}
|
||||
.badge{font-size:9px;font-weight:900;letter-spacing:1px;padding:4px 9px;border-radius:99px;text-transform:uppercase}
|
||||
.b-paid{background:var(--green-soft);color:#15803d}
|
||||
.b-pending{background:var(--gold-soft);color:#854d0e}
|
||||
.b-failed{background:var(--red-soft);color:#b91c1c}
|
||||
.b-demo{background:var(--pink-soft);color:#86198f}
|
||||
.order-meta{font-size:11px;color:var(--muted);margin:6px 0}
|
||||
.order-items{font-size:12px;color:var(--ink2);border-top:1px dashed var(--line);padding-top:8px;margin-top:8px}
|
||||
.order-total{display:flex;justify-content:space-between;font-size:13px;font-weight:800;margin-top:6px}
|
||||
.order-total .btc{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700}
|
||||
.order-actions{display:flex;gap:8px;margin-top:10px}
|
||||
|
||||
/* ---------- bottom nav + fab ---------- */
|
||||
nav.bottom{position:sticky;bottom:0;z-index:40;background:rgb(255 255 255/.92);backdrop-filter:blur(12px);border-top:1px solid var(--line);display:flex;padding:8px 12px calc(10px + env(safe-area-inset-bottom))}
|
||||
.nav-btn{flex:1;border:none;background:none;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:3px;padding:7px;border-radius:12px;font-size:10px;font-weight:700;letter-spacing:.5px;color:var(--muted);transition:all .15s}
|
||||
.nav-btn svg{width:20px;height:20px}
|
||||
.nav-btn.active{color:var(--ink);background:var(--bg2)}
|
||||
.fab{position:absolute;right:16px;bottom:calc(74px + env(safe-area-inset-bottom));z-index:45;width:58px;height:58px;border-radius:20px;border:none;cursor:pointer;background:var(--black);color:#fff;display:grid;place-items:center;box-shadow:0 10px 24px -8px rgb(10 10 10/.5);transition:transform .15s}
|
||||
.fab:active{transform:scale(.92)}
|
||||
.fab svg{width:24px;height:24px}
|
||||
.fab .count{position:absolute;top:-7px;right:-7px;min-width:22px;height:22px;border-radius:99px;background:var(--pink);color:#fff;font-size:11px;font-weight:900;display:grid;place-items:center;padding:0 5px;box-shadow:0 2px 6px rgb(0 0 0/.3)}
|
||||
.fab.hidden{display:none}
|
||||
|
||||
/* ---------- sheet / modal ---------- */
|
||||
.overlay{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:100;display:none;align-items:flex-end;justify-content:center}
|
||||
.overlay.show{display:flex}
|
||||
.sheet{width:100%;max-width:520px;background:#fff;border-radius:24px 24px 0 0;padding:10px 18px calc(22px + env(safe-area-inset-bottom));max-height:88dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes slideUp{from{transform:translateY(60px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||
.grab{width:40px;height:4px;border-radius:4px;background:var(--line2);margin:4px auto 14px}
|
||||
.sheet h3{font-family:var(--display);font-weight:900;font-size:16px;letter-spacing:1.5px;margin-bottom:4px}
|
||||
.sheet .sub{font-size:12px;color:var(--muted);margin-bottom:14px}
|
||||
.cart-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.cart-row .em{width:40px;height:40px;border-radius:12px;background:var(--bg2);display:grid;place-items:center;font-size:20px;border:1px solid var(--line)}
|
||||
.cart-row .nm{font-size:13px;font-weight:700}
|
||||
.cart-row .pt{font-size:11px;color:var(--muted)}
|
||||
.qty{display:flex;align-items:center;gap:10px;margin-left:auto}
|
||||
.qty button{width:28px;height:28px;border-radius:9px;border:1px solid var(--line2);background:#fff;font-size:15px;font-weight:800;cursor:pointer;color:var(--ink)}
|
||||
.qty button:active{transform:scale(.9)}
|
||||
.qty span{font-family:var(--display);font-weight:700;font-size:13px;min-width:18px;text-align:center}
|
||||
.cart-row .lp{font-family:var(--display);font-weight:900;font-size:13px;width:52px;text-align:right}
|
||||
.total-row{display:flex;justify-content:space-between;align-items:baseline;padding:14px 0 4px}
|
||||
.total-row .t{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:1px}
|
||||
.total-row .v{font-family:var(--display);font-weight:900;font-size:20px}
|
||||
.btc-est{font-family:var(--display);font-size:11px;color:var(--blue);font-weight:700;margin-bottom:14px;text-align:right}
|
||||
.btc-est small{color:var(--muted);font-family:var(--font);font-weight:400}
|
||||
.field{margin-bottom:12px}
|
||||
.field label{display:block;font-size:11px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--ink2);margin-bottom:6px}
|
||||
.field input,.field select,.field textarea{width:100%;border:1px solid var(--line2);border-radius:12px;padding:12px 14px;font-family:var(--font);font-size:14px;background:var(--bg2);color:var(--ink);outline:none;transition:border-color .15s}
|
||||
.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}
|
||||
.qr-wrap canvas,.qr-wrap img{display:block;border-radius:8px}
|
||||
.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}
|
||||
.success{text-align:center;padding:18px 0 6px}
|
||||
.success .check{width:74px;height:74px;border-radius:99px;background:var(--green-soft);border:2px solid #bbf7d0;display:grid;place-items:center;margin:0 auto 14px;font-size:34px;animation:pop .5s cubic-bezier(.16,1,.3,1)}
|
||||
@keyframes pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
|
||||
.success h3{margin-bottom:6px}
|
||||
.success .code{font-family:var(--display);font-weight:900;font-size:24px;letter-spacing:3px;background:var(--bg2);border:1px dashed var(--line2);border-radius:14px;padding:12px;margin:14px 0}
|
||||
.success .pick{font-size:13px;color:var(--ink2);margin-bottom:16px}
|
||||
.success .pick b{color:var(--ink)}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
.modal{position:fixed;inset:0;background:rgb(10 10 10/.45);backdrop-filter:blur(3px);z-index:110;display:none;align-items:center;justify-content:center;padding:20px}
|
||||
.modal.show{display:flex}
|
||||
.modal-box{width:100%;max-width:460px;background:#fff;border-radius:22px;padding:20px;max-height:86dvh;overflow-y:auto;animation:slideUp .3s cubic-bezier(.16,1,.3,1)}
|
||||
.modal-box h3{font-family:var(--display);font-weight:900;font-size:15px;letter-spacing:1.5px;margin-bottom:10px}
|
||||
.test-result{font-size:11px;font-weight:700;border-radius:10px;padding:9px 12px;margin-bottom:12px;display:none}
|
||||
.test-result.ok{display:block;background:var(--green-soft);color:#15803d}
|
||||
.test-result.err{display:block;background:var(--red-soft);color:#b91c1c}
|
||||
footer{padding:18px;text-align:center;font-size:10px;color:var(--muted);letter-spacing:1px;text-transform:uppercase;border-top:1px solid var(--line);margin-top:auto}
|
||||
.toast{position:fixed;left:50%;bottom:100px;transform:translateX(-50%) translateY(20px);background:var(--black);color:#fff;font-size:12px;font-weight:700;padding:10px 18px;border-radius:99px;opacity:0;transition:all .25s;z-index:200;pointer-events:none;white-space:nowrap}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<img class="logo" src="logo.png" alt="KITCHEN 484">
|
||||
<div class="brand"><b>KITCHEN 484</b><span>SOLARPUNK SUMMIT</span></div>
|
||||
<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>
|
||||
|
||||
<main style="flex:1;display:flex;flex-direction:column">
|
||||
<!-- SCHEDULE VIEW -->
|
||||
<section id="view-schedule">
|
||||
<div class="hero">
|
||||
<div class="chips">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="daytabs" id="daytabs"></div>
|
||||
<div class="timeline" id="timeline">
|
||||
<div class="tl-head"><h2>Serving schedule</h2><span id="tl-sub"></span></div>
|
||||
<div class="tl-wrap" id="tl-wrap">
|
||||
<div class="tl-rail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ORDERS VIEW -->
|
||||
<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 id="orders-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay</footer>
|
||||
</main>
|
||||
|
||||
<nav class="bottom">
|
||||
<button class="nav-btn active" data-view="schedule">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
|
||||
SCHEDULE
|
||||
</button>
|
||||
<button class="nav-btn" data-view="orders">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><path d="M9 12h6M9 16h4"/></svg>
|
||||
ORDERS
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="fab hidden" id="fab" aria-label="Cart">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>
|
||||
<span class="count" id="fab-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
"use strict";
|
||||
window.__errs=[];
|
||||
window.addEventListener('error',e=>{
|
||||
window.__errs.push(String(e.message)+' @'+(e.lineno||'?'));
|
||||
let el=document.getElementById('__errbox');
|
||||
if(!el){el=document.createElement('div');el.id='__errbox';el.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;background:#7f1d1d;color:#fff;font:11px monospace;padding:8px;max-height:40vh;overflow:auto';document.body.appendChild(el);}
|
||||
el.textContent=window.__errs.join('\n');
|
||||
});
|
||||
/* ============================== DATA ============================== */
|
||||
const FEST_START=new Date(2026,9,8); /* Oct 8, 2026 — festival start (coincides with first page) */
|
||||
function dayLabel(offset){
|
||||
const d=new Date(FEST_START); d.setDate(d.getDate()+offset);
|
||||
return {
|
||||
name:d.toLocaleDateString('en-US',{weekday:'short'}),
|
||||
date:d.toLocaleDateString('en-US',{month:'short',day:'numeric'}),
|
||||
iso:d.toISOString().slice(0,10)
|
||||
};
|
||||
}
|
||||
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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]},
|
||||
{ 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.'}
|
||||
]}
|
||||
]
|
||||
};
|
||||
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'};
|
||||
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);
|
||||
const fmtT=min=>{let h=Math.floor(min/60),m=min%60,ap=h>=12?'PM':'AM';h=h%12||12;return h+':'+String(m).padStart(2,'0')+' '+ap;};
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
||||
|
||||
/* ============================== STATUS ============================== */
|
||||
function nowMin(){const d=new Date();return d.getHours()*60+d.getMinutes();}
|
||||
function itemStatus(i,item){
|
||||
if(i>0)return{state:'upcoming',label:FEST.days[i].name+' · '+FEST.days[i].date};
|
||||
const n=nowMin();
|
||||
if(n<item.start)return{state:'soon',label:'Starts in '+fmtDur(item.start-n)};
|
||||
if(n<item.end)return{state:'open',label:'Available now'};
|
||||
return{state:'ended',label:'Ended'};
|
||||
}
|
||||
|
||||
/* ============================== SCHEDULE RENDER ============================== */
|
||||
function renderDayTabs(){
|
||||
$('#daytabs').innerHTML=FEST.days.map((d,i)=>
|
||||
'<button class="daytab'+(i===state.dayIdx?' active':'')+(i===0?' today':'')+'" data-day="'+i+'">'+
|
||||
'<div class="d1">'+d.tag+'</div><div class="d2">'+d.name+' '+d.date+'</div></button>').join('');
|
||||
document.querySelectorAll('.daytab').forEach(b=>b.onclick=()=>{state.dayIdx=+b.dataset.day;renderDayTabs();renderTimeline();});
|
||||
}
|
||||
function renderTimeline(){
|
||||
const day=FEST.days[state.dayIdx];
|
||||
const items=[...day.items].sort((a,b)=>a.start-b.start);
|
||||
const d0=Math.min(...items.map(x=>x.start))-30;
|
||||
const d1=Math.max(...items.map(x=>x.end))+30;
|
||||
const isToday=state.dayIdx===0;
|
||||
const n=nowMin();
|
||||
const nowLine=(isToday&&n>=d0&&n<=d1)
|
||||
?'<div class="nowline" style="top:'+(((n-d0)/(d1-d0))*100).toFixed(2)+'%"></div>':'';
|
||||
$('#tl-sub').textContent=isToday?'Live · updates automatically':'Pre-orders open';
|
||||
$('#tl-wrap').innerHTML='<div class="tl-rail"></div>'+nowLine+items.map(item=>{
|
||||
const st=itemStatus(state.dayIdx,item);
|
||||
const cls=st.state==='open'?'is-open':st.state==='soon'?'is-soon':(st.state==='upcoming'?'':'is-ended');
|
||||
const pill={open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state];
|
||||
const inCart=state.cart[item.id]>0;
|
||||
return '<div class="item-card '+cls+'" data-id="'+item.id+'">'+
|
||||
'<div class="tl-dot"></div>'+
|
||||
'<div class="tl-time">'+fmtT(item.start).replace(' ','<br>')+'</div>'+
|
||||
'<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>'+
|
||||
'<div class="item-foot">'+
|
||||
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
|
||||
'<span class="tag" style="margin-left:auto">'+fmtT(item.start)+' – '+fmtT(item.end)+'</span>'+
|
||||
'<button class="add-btn'+(inCart?' in-cart':'')+'" data-add="'+item.id+'">'+(inCart?'✓ Added · '+state.cart[item.id]:'+ Add to order')+'</button>'+
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.add;
|
||||
state.cart[id]=(state.cart[id]||0)+1;
|
||||
saveCart();renderFab();renderTimeline();
|
||||
toast(findItem(id).emoji+' '+findItem(id).name+' added');
|
||||
});
|
||||
}
|
||||
function refreshStatuses(){
|
||||
document.querySelectorAll('#tl-wrap .item-card').forEach(card=>{
|
||||
const id=card.dataset.id, it=findItem(id);
|
||||
const st=itemStatus(state.dayIdx,it);
|
||||
const pill=card.querySelector('.status-pill');
|
||||
pill.className='status-pill '+( {open:'sp-open',soon:'sp-soon',upcoming:'sp-upcoming',ended:'sp-ended'}[st.state] );
|
||||
pill.innerHTML='<span class="dot"></span>'+st.label;
|
||||
card.classList.remove('is-open','is-soon','is-ended');
|
||||
if(st.state==='open')card.classList.add('is-open');
|
||||
else if(st.state==='soon')card.classList.add('is-soon');
|
||||
else if(st.state==='ended')card.classList.add('is-ended');
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================== FAB / NAV ============================== */
|
||||
function renderFab(){
|
||||
const c=cartCount();
|
||||
$('#fab').classList.toggle('hidden',c===0);
|
||||
$('#fab-count').textContent=c;
|
||||
document.title=c>0?('KITCHEN 484 · '+c+' in cart'):('KITCHEN 484 · SOLARPUNK SUMMIT');
|
||||
let d=document.getElementById('debugbar');
|
||||
if(!d){d=document.createElement('div');d.id='debugbar';d.style.cssText='position:fixed;bottom:0;left:0;right:0;z-index:9998;background:#0A0A0A;color:#4ade80;font:10px monospace;padding:4px 8px;pointer-events:none';document.body.appendChild(d);}
|
||||
d.textContent='cart='+JSON.stringify(state.cart)+' errs='+(window.__errs?window.__errs.length:0);
|
||||
}
|
||||
document.querySelectorAll('.nav-btn').forEach(b=>b.onclick=()=>{
|
||||
state.view=b.dataset.view;
|
||||
document.querySelectorAll('.nav-btn').forEach(x=>x.classList.toggle('active',x===b));
|
||||
$('#view-schedule').hidden=state.view!=='schedule';
|
||||
$('#view-orders').hidden=state.view!=='orders';
|
||||
if(state.view==='orders')renderOrders();
|
||||
});
|
||||
$('#fab').onclick=()=>openSheet('cart');
|
||||
|
||||
/* ============================== SHEET STEPS ============================== */
|
||||
function openSheet(step){state.sheetStep=step;renderSheet();$('#sheet-overlay').classList.add('show');}
|
||||
function closeSheet(){
|
||||
if(state.pay&&state.pay.timer){clearInterval(state.pay.timer);state.pay=null;}
|
||||
state.sheetStep=null;$('#sheet-overlay').classList.remove('show');
|
||||
}
|
||||
$('#sheet-overlay').addEventListener('mousedown',e=>{$('#sheet-overlay')._down=e.target.id==='sheet-overlay';});
|
||||
$('#sheet-overlay').addEventListener('click',e=>{if($('#sheet-overlay')._down&&e.target.id==='sheet-overlay')closeSheet();});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function stepCart(){
|
||||
const rows=Object.keys(state.cart).map(id=>{
|
||||
const it=findItem(id);if(!it)return'';
|
||||
return '<div class="cart-row" data-row="'+id+'">'+
|
||||
'<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>';
|
||||
}).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>';
|
||||
}
|
||||
|
||||
function stepCheckout(){
|
||||
const days=[...new Set(Object.keys(state.cart).map(findDayOf))].map(i=>FEST.days[i]);
|
||||
const windows=[...new Set(days.flatMap(d=>d.items.map(it=>it.id)))]
|
||||
.map(id=>({id,start:findItem(id).start,end:findItem(id).end,name:findItem(id).name}))
|
||||
.sort((a,b)=>a.start-b.start);
|
||||
const n=nowMin();
|
||||
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>'+
|
||||
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
|
||||
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
|
||||
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
|
||||
'<div class="total-row"><span class="t">Total · '+cartCount()+' items</span><span class="v">'+fmt$(cartTotal())+'</span></div>'+
|
||||
'<button class="btn btn-pink" id="to-payment" style="margin-top:8px">₿ Pay with Bitcoin</button>'+
|
||||
'<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="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>'+
|
||||
'<button class="btn btn-primary" id="success-done">Done</button>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireSheet(){
|
||||
const s=$('#sheet');
|
||||
s.querySelectorAll('[data-inc]').forEach(b=>b.onclick=()=>{state.cart[b.dataset.inc]++;afterCartChange();});
|
||||
s.querySelectorAll('[data-dec]').forEach(b=>b.onclick=()=>{
|
||||
const id=b.dataset.dec;state.cart[id]--;
|
||||
if(state.cart[id]<=0)delete state.cart[id];
|
||||
afterCartChange();
|
||||
});
|
||||
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 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');
|
||||
};
|
||||
const done=s.querySelector('#success-done');
|
||||
if(done)done.onclick=()=>{
|
||||
closeSheet();
|
||||
document.querySelector('[data-view="orders"]').click();
|
||||
};
|
||||
}
|
||||
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();
|
||||
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);
|
||||
saveOrders();
|
||||
state.cart={};saveCart();renderFab();renderTimeline();
|
||||
openSheet('success');
|
||||
}
|
||||
|
||||
/* ============================== ORDERS VIEW ============================== */
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
}).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…';
|
||||
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){}
|
||||
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();
|
||||
$('#clock').textContent=String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
setInterval(()=>{tickClock();if(!$('#view-schedule').hidden)refreshStatuses();},30000);
|
||||
|
||||
/* ============================== INIT ============================== */
|
||||
(async function init(){
|
||||
tickClock();
|
||||
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);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
logo.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
14
manifest.webmanifest
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "KITCHEN 484 · SOLARPUNK SUMMIT",
|
||||
"short_name": "KITCHEN 484",
|
||||
"description": "Solarpunk Summit kitchen — see when food is on, pre-order, and pay with Bitcoin.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#FFFFFF",
|
||||
"icons": [
|
||||
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
15
server/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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.
|
||||
PORT=8787
|
||||
|
||||
# Your BTCPay Server (regtest: http://localhost:15808, mainnet: https://btcpay.yourhost.com)
|
||||
BTCPAY_URL=
|
||||
BTCPAY_STORE=
|
||||
BTCPAY_API_KEY=
|
||||
|
||||
*** 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=
|
||||
188
server/docker-compose.btcpay.yml
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# 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:
|
||||
121
server/e2e-test.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
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(() => ({})) };
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
try {
|
||||
/* 1 health */
|
||||
const h = await j('GET', '/health');
|
||||
check('health ok', h.status === 200 && h.body.ok === true);
|
||||
|
||||
/* 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));
|
||||
|
||||
/* 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));
|
||||
|
||||
/* 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'));
|
||||
|
||||
/* 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)));
|
||||
}
|
||||
}
|
||||
}).catch(() => { });
|
||||
await sleep(300);
|
||||
check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents));
|
||||
|
||||
/* 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();
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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);
|
||||
|
||||
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');
|
||||
}
|
||||
124
server/mock-btcpay.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* 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)`));
|
||||
16
server/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "kitchen484-btc-pay",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "BTCPay proxy backend for KITCHEN 484 / SOLARPUNK SUMMIT — keeps the BTCPay API key server-side",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"mock": "node mock-btcpay.js",
|
||||
"test:e2e": "node e2e-test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
425
server/server.js
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STATIC_DIR = path.resolve(__dirname, '..');
|
||||
const ENV_FILE = path.join(__dirname, '.env');
|
||||
|
||||
/* ----------------------------- 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'),
|
||||
};
|
||||
Object.assign(cfg, loadEnv());
|
||||
|
||||
function configured() {
|
||||
return Boolean(cfg.url && cfg.store && cfg.apiKey);
|
||||
}
|
||||
|
||||
/* --------------------------- 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);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------ http ------------------------------- */
|
||||
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.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')); } });
|
||||
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`);
|
||||
});
|
||||
BIN
sol_lunar_bw.png
Normal file
|
After Width: | Height: | Size: 667 KiB |
BIN
sol_lunar_bw_emblem.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
4
sol_lunar_icon.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |