SolLunar-Kitchen/index.html

684 lines
42 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">
<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-loc{font-size:11px;font-weight:700;color:var(--amber);margin-top:3px;letter-spacing:.02em}
.item-foot{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
.status-pill{font-size:10px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;padding:5px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:6px}
.status-pill .dot{width:6px;height:6px;border-radius:99px;background:currentColor}
.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-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}
.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.err{color:#b91c1c}
.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>
</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">PRE-ORDER · FREE</span>
</div>
<h1>WHEN'S <em>FOOD</em> ON?</h1>
<p>Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and we'll tell you the moment your food is ready — and exactly where to grab it.</p>
</div>
<div class="daytabs" id="daytabs"></div>
<div class="timeline" id="timeline">
<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">Live status for every order on this device. When it flips to READY, head to the stand shown.</div>
<div id="orders-list"></div>
</div>
</section>
<footer>KITCHEN 484 · Off-grid · Zero waste · Food's ready when we say it's ready</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>
<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)
};
}
// PLACEHOLDER stand locations — keep in sync with server.js STAND_LOCATIONS.
const STAND_LOCATIONS={
'Taco Stand': 'PLACEHOLDER — main lawn, food row #1',
'BBQ Pit': 'PLACEHOLDER — downwind corner behind the wood pile',
'Chicken Grill': 'PLACEHOLDER — main lawn, food row #2',
'Pasta Bar': 'PLACEHOLDER — west tent, near the stage',
'Garden Burgers': 'PLACEHOLDER — main lawn, food row #2',
'Grill 484': 'PLACEHOLDER — center of camp, by the big solar dish',
'Ramen Tent': 'PLACEHOLDER — south tent, steam visible from path',
'Flatbread Oven': 'PLACEHOLDER — east tent, smell the bread',
'Dumpling Bar': 'PLACEHOLDER — west tent, next to Pasta Bar',
'Farewell Feast · BBQ': 'PLACEHOLDER — main lawn, long tables',
};
const FEST={
days:[
{ tag:'DAY 1', items:[
{id:'d1-taco', name:'Taco Stand', emoji:'🌮',start:720, end:870, tags:['GF'], desc:'Hand-pressed corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d1-bbq', name:'BBQ Pit', emoji:'🍖',start:780, end:930, tags:[], desc:'12-hour offset-smoked brisket & ribs. No seed oils — rendered tallow only.'},
{id:'d1-chicken',name:'Chicken Grill', emoji:'🍗',start:1050, end:1230, tags:['GF'], desc:'Buttermilk fried chicken (tallow-fried) with slaw.'},
{id:'d1-pasta', name:'Pasta Bar', emoji:'🍝',start:1080, end:1200, tags:['V'], desc:'Fresh-cut daily pasta, garden vegetables, house olive oil.'}
]},,
{ tag:'DAY 2', items:[
{id:'d2-burger', name:'Garden Burgers', emoji:'🍔',start:720, end:900, tags:[], desc:'Grass-fed beef patty, brioche, charred onion, herb aioli.'},
{id:'d2-grill', name:'Grill 484', emoji:'🔥',start:750, end:960, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'},
{id:'d2-ramen', name:'Ramen Tent', emoji:'🍜',start:1020, end:1200, tags:['V'], desc:'Miso broth simmered all day, soft egg, scallion, pickled ginger.'}
]},,
{ tag:'DAY 3', items:[
{id:'d3-flatbread',name:'Flatbread Oven', emoji:'🫓',start:720, end:930, tags:['V','GF'], desc:'Wood-fired flatbreads — charred vegetables, whipped feta, house olive oil.'},
{id:'d3-bbq', name:'BBQ Pit', emoji:'🍖',start:780, end:960, tags:[], desc:'Offset-smoked brisket & ribs. The weekend is for the pit.'},
{id:'d3-dumplings',name:'Dumpling Bar', emoji:'🥟',start:1020, end:1230, tags:['GF'], desc:'Pan-seared vegetable & chicken dumplings, chili crisp.'}
]},,
{ tag:'DAY 4', items:[
{id:'d4-taco', name:'Taco Stand', emoji:'🌮',start:720, end:870, tags:['GF'], desc:'Corn tortillas, slow-braised grass-fed filling, house salsa.'},
{id:'d4-pasta', name:'Pasta Bar', emoji:'🍝',start:900, end:1140, tags:['V'], desc:'Sunday pasta — fresh-cut, garden vegetables, house olive oil.'},
{id:'d4-grill', name:'Grill 484', emoji:'🔥',start:960, end:1170, tags:['GF'], desc:'Skewered vegetables & chicken over open flame.'}
]},,
{ tag:'FINAL DAY', items:[
{id:'d5-taco', name:'Taco Stand', emoji:'🌮',start:720, end:840, tags:['GF'], desc:'Corn tortillas, slow-braised filling.'},
{id:'d5-feast', name:'Farewell Feast · BBQ', emoji:'🍖',start:720, end:960, tags:[], desc:'The full spread: brisket, ribs, tallow fries, slaw, bread.'},
{id:'d5-pasta', name:'Pasta Bar', emoji:'🍝',start:1020, end:1170, tags:['V'], desc:'Fresh pasta, garden vegetables, olive oil.'}
]}
]
};
FEST.days.forEach((d,i)=>{ const L=dayLabel(i); d.name=L.name; d.date=L.date; d.iso=L.iso; });
/* ============================== STATE ============================== */
const LS={cart:'fest484_cart',orders:'fest484_orders'};
let state={
view:'schedule',
dayIdx:0,
cart:JSON.parse(localStorage.getItem(LS.cart)||'{}'),
orders:JSON.parse(localStorage.getItem(LS.orders)||'[]'),
sheetStep:null,
pay:null
};
const saveCart=()=>localStorage.setItem(LS.cart,JSON.stringify(state.cart));
const saveOrders=()=>localStorage.setItem(LS.orders,JSON.stringify(state.orders));
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');clearTimeout(t._x);t._x=setTimeout(()=>t.classList.remove('show'),2200);}
function 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 class="item-loc">📍 '+(STAND_LOCATIONS[item.name]||'location TBD')+'</div></div>'+
'</div>'+
'<div class="item-foot">'+
'<span class="status-pill '+pill+'"><span class="dot"></span>'+st.label+'</span>'+
'<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==='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>';
}).join('');
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="btc-est">Free pre-order — pay at the stand when you pick up.</div>'+
'<button class="btn btn-primary" id="to-checkout" '+(cartCount()===0?'disabled':'')+'>Reserve pickup →</button>';
}
function stepCheckout(){
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>RESERVE PICKUP</h3><div class="sub">Who is picking this up?</div>'+
'<div class="field"><label>Name</label><input id="co-name" type="text" placeholder="Your name at the stand" maxlength="40"></div>'+
'<div class="field"><label>Pickup window</label><select id="co-window">'+opts+'</select></div>'+
'<div class="field"><label>Notes (optional)</label><textarea id="co-notes" placeholder="Allergies, extras, anything the kitchen should know…"></textarea></div>'+
'<div class="total-row"><span class="t">Total</span><span class="v">'+cartCount()+' items</span></div>'+
'<div id="reserve-err" style="color:#b91c1c;font-size:12px;font-weight:700;margin-top:6px;display:none"></div>'+
'<button class="btn btn-pink" id="to-reserve" style="margin-top:8px">🍽️&nbsp; Place pre-order</button>'+
'<button class="btn btn-ghost" style="width:100%;margin-top:8px" id="back-cart">← Back to cart</button>';
}
function 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">The kitchen has your order. We\'ll flip it to READY the moment it\'s up.</div>'+
'<div class="code">'+o.code+'</div>'+
'<div class="pick">Pickup: <b>'+o.pickupLabel+'</b><br>'+esc(o.itemsSummary)+'<br>Stand: <b>'+esc(o.stand)+'</b><br>'+esc(o.standLocation)+'</div>'+
'<button class="btn btn-primary" id="success-done">Done</button>'+
'</div>';
}
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 tr=s.querySelector('#to-reserve');
if(tr)tr.onclick=async()=>{
const name=$('#co-name').value.trim();
if(!name){toast('Please add a pickup name');$('#co-name').focus();return;}
const win=$('#co-window').value.split('-').map(Number);
const notes=$('#co-notes').value.trim();
tr.disabled=true;tr.textContent='Placing order…';
const err=$('#reserve-err');
try{await placeOrder({name,window:win,notes});}
catch(e){tr.disabled=false;tr.textContent='🍽️ Place pre-order';if(err){err.style.display='block';err.textContent='Could not place order: '+e.message;}}
};
const done=s.querySelector('#success-done');
if(done)done.onclick=()=>{
closeSheet();
document.querySelector('[data-view="orders"]').click();
};
}
function afterCartChange(){saveCart();renderFab();renderTimeline();renderSheet();}
/* ============================== ORDERS (server-backed) ============================== */
async function placeOrder(info){
const day=FEST.days.find(d=>d.items.some(it=>info.window&&Math.abs(it.start-info.window[0])<1&&Math.abs(it.end-info.window[1])<1))||FEST.days[state.dayIdx];
const pickupLabel=fmtT(info.window[0])+' \u2013 '+fmtT(info.window[1]);
const ids=Object.keys(state.cart);
const itemsSummary=ids.map(id=>{const it=findItem(id);return state.cart[id]+'\u00d7 '+it.name;}).join(', ');
const stands=[...new Set(ids.map(id=>findItem(id).name))];
const stand=stands.length===1?stands[0]:'Kitchen 484';
const res=await fetch('/api/orders',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
name:info.name,notes:info.notes,items:state.cart,
window:info.window,pickupLabel,itemsSummary,
stand,
day:day.name+' '+day.date
})
});
const rec=await res.json().catch(()=>({}));
if(!res.ok)throw new Error(rec.error||('HTTP '+res.status));
// the server record is the source of truth; keep our local list in sync with it
if(!state.orders.some(o=>o.code===rec.code))state.orders.unshift(rec);
saveOrders();
state.pay={order:rec};
state.cart={};saveCart();renderFab();renderTimeline();
openSheet('success');
watchOrder(rec.code);
}
const watch={};
function watchOrder(code){
if(watch[code])return;
const entry={};
try{
const es=new EventSource('/api/orders/'+encodeURIComponent(code)+'/events');
entry.es=es;
es.onmessage=e=>{
let d;try{d=JSON.parse(e.data);}catch{return;}
if(d.status)applyRemoteStatus(code,d.status);
if(d.status==='COLLECTED'||d.status==='CANCELLED'){es.close();if(entry.timer)clearInterval(entry.timer);}
};
}catch(e){/* polling still covers us */}
entry.timer=setInterval(async()=>{
try{
const r=await fetch('/api/orders/'+encodeURIComponent(code));
if(r.status===404){applyRemoteStatus(code,'COLLECTED');clearInterval(entry.timer);return;}
if(!r.ok)return;
const rec=await r.json();
applyRemoteStatus(code,rec.status,rec);
if(rec.status==='COLLECTED'||rec.status==='CANCELLED')clearInterval(entry.timer);
}catch(e){}
},20000);
watch[code]=entry;
}
function applyRemoteStatus(code,status,detail){
let o=state.orders.find(x=>x.code===code);
if(!o&&detail){o=detail;state.orders.unshift(o);}
if(!o)return;
if(detail){
o.stand=detail.stand||o.stand;o.standLocation=detail.standLocation||o.standLocation;
o.itemsSummary=detail.itemsSummary||o.itemsSummary;o.pickupLabel=detail.pickupLabel||o.pickupLabel;
o.day=detail.day||o.day;o.totalUsd=(detail.totalUsd!==undefined?detail.totalUsd:o.totalUsd);
o.name=detail.name||o.name;o.notes=detail.notes!==undefined?detail.notes:o.notes;
}
if(o.status!==status){
o.status=status;saveOrders();
if(status==='READY')toast('\ud83c\udf7d\ufe0f '+code+' is READY \u2014 '+o.stand);
if(status==='COLLECTED')toast(code+' collected. Enjoy!');
}else{saveOrders();}
if(state.view==='orders')renderOrders();
if(state.sheetStep==='success'&&state.pay&&state.pay.order&&state.pay.order.code===code)renderSheet();
}
/* ============================== ORDERS VIEW ============================== */
const STATUS_BADGE={
PREPARING:'<span class="badge b-pending">PREPARING</span>',
READY:'<span class="badge b-paid">READY \u2014 GRAB IT</span>',
COLLECTED:'<span class="badge b-demo">COLLECTED</span>',
CANCELLED:'<span class="badge b-failed">CANCELLED</span>'
};
function renderOrders(){
const list=$('#orders-list');
if(!state.orders.length){
list.innerHTML='<div class="empty"><div class="big">\ud83e\uddfe</div>No orders yet.<br>Check the schedule and grab something good.</div>';
return;
}
list.innerHTML=state.orders.map(o=>{
const badge=STATUS_BADGE[o.status]||STATUS_BADGE.PREPARING;
const readyBanner=o.status==='READY'
?'<div style="background:var(--green-soft);border-radius:12px;padding:10px 12px;margin-top:8px;font-size:12px;font-weight:700;color:#15803d">\ud83c\udf7d\ufe0f Ready now at <b>'+esc(o.stand||'the stand')+'</b> \u2014 '+esc(o.standLocation||'')+'</div>':'';
return '<div class="order-card">'+
'<div class="order-top"><span class="order-code">'+esc(o.code)+'</span>'+badge+'</div>'+
'<div class="order-meta">'+esc(o.day||'')+' \u00b7 pickup '+esc(o.pickupLabel||'')+' \u00b7 '+esc(o.name||'')+(o.notes?' \u00b7 \u201c'+esc(o.notes)+'\u201d':'')+'</div>'+
'<div class="order-items">'+esc(o.itemsSummary||'')+'</div>'+
'<div class="order-total"><span class="btc">'+esc(o.stand||'')+'</span></div>'+
readyBanner+
(o.status!=='COLLECTED'&&o.status!=='CANCELLED'?'<div class="order-actions"><button class="btn btn-ghost btn-sm" data-recheck="'+esc(o.code)+'">Re-check status</button></div>':'')+
'</div>';
}).join('');
list.querySelectorAll('[data-recheck]').forEach(b=>b.onclick=async()=>{
b.disabled=true;b.textContent='Checking\u2026';
try{
const r=await fetch('/api/orders/'+encodeURIComponent(b.dataset.recheck));
if(r.status===404){applyRemoteStatus(b.dataset.recheck,'COLLECTED');}
else if(r.ok){const rec=await r.json();applyRemoteStatus(rec.code,rec.status,rec);}
else toast('Server said: HTTP '+r.status);
}catch(e){toast('Could not reach the kitchen server');}
renderOrders();
});
}
/* ============================== 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();
// reconcile locally-saved orders against the server, then live-watch the active ones
for(const o of state.orders){
if(o.status==='COLLECTED'||o.status==='CANCELLED')continue;
try{
const r=await fetch('/api/orders/'+encodeURIComponent(o.code));
if(r.ok){const rec=await r.json();applyRemoteStatus(o.code,rec.status,rec);}
}catch(e){}
if(o.status!=='COLLECTED'&&o.status!=='CANCELLED')watchOrder(o.code);
}
})();
</script>
</body>
</html>