773 lines
70 KiB
HTML
773 lines
70 KiB
HTML
<title>Datewise Prototype</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||
<style>
|
||
/* Layout: app bar with view switch + simulated clock; two working surfaces (storefront, admin) built from one token set. */
|
||
:root{
|
||
--bg:#F2F5F3; --surface:#FFFFFF; --sunk:#E8EEEB; --ink:#13201D; --muted:#5A6965; --line:#D6DFDB;
|
||
--accent:#0D6B5D; --accent-ink:#FFFFFF; --accent-soft:#DDEFEA;
|
||
--peak:#A86400; --peak-soft:#FFF1D6;
|
||
--ok:#1E7A3A; --ok-soft:#E1F3E5; --bad:#B42318; --bad-soft:#FBE4E1; --warn:#9A5B00; --warn-soft:#FDF0D8;
|
||
--display:"Bricolage Grotesque", "Segoe UI", system-ui, sans-serif;
|
||
--body:"IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
|
||
--mono:"IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
--r:10px;
|
||
}
|
||
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
|
||
--bg:#0D1412; --surface:#151F1C; --sunk:#1B2824; --ink:#E4EDEA; --muted:#97A8A3; --line:#2A3A35;
|
||
--accent:#4CC2AD; --accent-ink:#04201A; --accent-soft:#163A33;
|
||
--peak:#F0B44A; --peak-soft:#3A2B0F;
|
||
--ok:#5FCB7E; --ok-soft:#173523; --bad:#F2877A; --bad-soft:#3E1C18; --warn:#F0B44A; --warn-soft:#3A2B0F;
|
||
color-scheme:dark}}
|
||
:root[data-theme="dark"]{
|
||
--bg:#0D1412; --surface:#151F1C; --sunk:#1B2824; --ink:#E4EDEA; --muted:#97A8A3; --line:#2A3A35;
|
||
--accent:#4CC2AD; --accent-ink:#04201A; --accent-soft:#163A33;
|
||
--peak:#F0B44A; --peak-soft:#3A2B0F;
|
||
--ok:#5FCB7E; --ok-soft:#173523; --bad:#F2877A; --bad-soft:#3E1C18; --warn:#F0B44A; --warn-soft:#3A2B0F;
|
||
color-scheme:dark}
|
||
*{box-sizing:border-box}
|
||
body{background:var(--bg);color:var(--ink);font:15px/1.5 var(--body);margin:0}
|
||
button,input,select,textarea{font:inherit;color:inherit}
|
||
button{cursor:pointer}
|
||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
h1,h2,h3{font-family:var(--display);text-wrap:balance;margin:0;letter-spacing:-.01em}
|
||
h2{font-size:1.25rem} h3{font-size:1.02rem}
|
||
.mono,.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||
.muted{color:var(--muted)} .small{font-size:.85rem}
|
||
.label{font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:600}
|
||
|
||
/* App bar */
|
||
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:5;background:var(--surface);border-bottom:1px solid var(--line)}
|
||
.bar-in{max-width:1240px;margin:0 auto;padding:10px 16px;display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center}
|
||
.brand{display:flex;align-items:center;gap:10px}
|
||
.brand svg{flex:none}
|
||
.brand b{font-family:var(--display);font-size:1.2rem}
|
||
.brand span{font-size:.75rem;color:var(--muted);border:1px solid var(--line);border-radius:99px;padding:1px 8px}
|
||
.seg{display:inline-flex;background:var(--sunk);border-radius:99px;padding:3px}
|
||
.seg button{border:0;background:transparent;padding:6px 14px;border-radius:99px;font-weight:500;color:var(--muted)}
|
||
.seg button[aria-pressed="true"]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px rgb(0 0 0/.12)}
|
||
.clock{margin-left:auto;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||
.clock .now{font-family:var(--mono);font-size:.86rem;background:var(--sunk);padding:5px 10px;border-radius:8px}
|
||
select,input[type=text],input[type=date],input[type=number],input[type=email],input[type=tel],input[type=time]{
|
||
background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:6px 9px;min-width:0}
|
||
.btn{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:7px 12px;font-weight:500}
|
||
.btn:hover{border-color:var(--accent)}
|
||
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
||
.btn.primary:disabled{opacity:.45;cursor:not-allowed}
|
||
a.btn{text-decoration:none;color:var(--ink);display:inline-block}
|
||
.btn.ghost{border-color:transparent;background:transparent;color:var(--accent);padding:4px 6px}
|
||
.btn.sm{padding:4px 9px;font-size:.85rem}
|
||
|
||
main{max-width:1240px;margin:0 auto;padding-inline:16px;padding-block:20px 60px}
|
||
.notice{border-radius:var(--r);padding:10px 14px;margin-bottom:14px;display:flex;gap:10px;align-items:flex-start}
|
||
.notice.bad{background:var(--bad-soft);color:var(--bad)}
|
||
.notice.ok{background:var(--ok-soft);color:var(--ok)}
|
||
.notice.peak{background:var(--peak-soft);color:var(--peak)}
|
||
.notice.info{background:var(--accent-soft);color:var(--ink)}
|
||
|
||
/* Storefront */
|
||
.store{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.25fr);gap:22px;align-items:start}
|
||
@media (max-width:900px){.store{grid-template-columns:1fr}}
|
||
.panel{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:18px;min-width:0}
|
||
.storehead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;gap:10px;flex-wrap:wrap}
|
||
.item{display:grid;grid-template-columns:44px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}
|
||
.item:first-of-type{border-top:0}
|
||
.thumb{width:44px;height:44px;border-radius:9px;display:grid;place-items:center;font-family:var(--display);font-weight:700;color:var(--accent-ink)}
|
||
.item .t{font-weight:500}
|
||
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
|
||
.tag{font-size:.72rem;border-radius:99px;padding:1px 8px;background:var(--sunk);color:var(--muted)}
|
||
.tag.fresh{background:var(--ok-soft);color:var(--ok)}
|
||
.qty{display:flex;align-items:center;gap:4px}
|
||
.qty button{width:28px;height:28px;border-radius:7px;border:1px solid var(--line);background:var(--surface)}
|
||
.qty span{min-width:22px;text-align:center;font-family:var(--mono)}
|
||
.totals{display:grid;grid-template-columns:1fr auto;gap:4px 12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--line)}
|
||
.totals .grand{font-weight:600;font-size:1.05rem}
|
||
|
||
.widget h2{display:flex;align-items:center;gap:8px}
|
||
.methods{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:14px 0}
|
||
.mbtn{border:1.5px solid var(--line);background:var(--surface);border-radius:11px;padding:10px;text-align:left;display:grid;gap:2px}
|
||
.mbtn b{font-weight:600}
|
||
.mbtn small{color:var(--muted);font-size:.78rem}
|
||
.mbtn[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
|
||
.mbtn:disabled{opacity:.55;cursor:not-allowed}
|
||
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||
.field{display:grid;gap:4px;min-width:0}
|
||
.loclist{display:grid;gap:6px;margin-top:6px}
|
||
.loc{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--line);border-radius:10px;padding:8px 10px;background:var(--surface);text-align:left}
|
||
.loc[aria-pressed="true"]{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
|
||
.loc:disabled{cursor:not-allowed;background:var(--sunk)}
|
||
.dot{width:9px;height:9px;border-radius:50%;background:var(--ok)}
|
||
.loc:disabled .dot{background:var(--muted)}
|
||
|
||
.cal{margin-top:14px}
|
||
.calhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||
.grid7{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}
|
||
.dow{font-size:.72rem;text-align:center;color:var(--muted);font-weight:600;padding-bottom:2px}
|
||
.day{position:relative;aspect-ratio:1/0.86;border-radius:9px;border:1px solid transparent;background:transparent;display:grid;place-items:center;font-family:var(--mono);font-size:.92rem;padding:0;max-width:100%}
|
||
.day.avail{background:var(--accent-soft);border-color:transparent}
|
||
.day.avail:hover{border-color:var(--accent)}
|
||
.day.full{background:var(--warn-soft);color:var(--warn)}
|
||
.day.closed{color:var(--muted);text-decoration:line-through;text-decoration-color:var(--line)}
|
||
.day.out{color:var(--line);cursor:default}
|
||
.day.sel{background:var(--accent);color:var(--accent-ink)}
|
||
.day .pk{position:absolute;top:4px;right:5px;width:6px;height:6px;border-radius:50%;background:var(--peak)}
|
||
.day .lft{position:absolute;bottom:2px;font-size:.6rem;font-family:var(--body);opacity:.8}
|
||
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:.76rem;color:var(--muted);margin-top:8px}
|
||
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
|
||
|
||
.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;margin-top:10px}
|
||
.slot{border:1.5px solid var(--line);border-radius:10px;padding:8px 10px;background:var(--surface);text-align:left;display:grid;gap:4px}
|
||
.slot b{font-family:var(--mono);font-weight:500;font-size:.9rem}
|
||
.slot[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
|
||
.slot:disabled{background:var(--sunk);cursor:not-allowed;color:var(--muted)}
|
||
.meter{height:5px;border-radius:9px;background:var(--sunk);overflow:hidden}
|
||
.meter i{display:block;height:100%;background:var(--accent)}
|
||
.meter.low i{background:var(--warn)}
|
||
.slot small{font-size:.74rem;color:var(--muted)}
|
||
.hold{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;background:var(--sunk);border-radius:10px;padding:9px 12px;margin-top:12px}
|
||
.hold .mono{font-size:1rem}
|
||
.summary{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 14px;margin-top:12px;font-size:.92rem}
|
||
.summary dt{color:var(--muted)} .summary dd{margin:0}
|
||
.demo{border:1px dashed var(--line);border-radius:10px;padding:10px 12px;margin-top:14px;display:grid;gap:8px}
|
||
.co{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:12px}
|
||
@media (max-width:560px){.co{grid-template-columns:1fr}.methods{grid-template-columns:1fr}}
|
||
.confirm{display:grid;gap:12px}
|
||
.timeline{display:grid;gap:6px}
|
||
.timeline div{display:grid;grid-template-columns:150px minmax(0,1fr);gap:10px;font-size:.88rem}
|
||
|
||
/* Admin */
|
||
.admin{display:grid;grid-template-columns:200px minmax(0,1fr);gap:22px;align-items:start}
|
||
@media (max-width:820px){.admin{grid-template-columns:1fr}}
|
||
.nav{display:grid;gap:2px;position:sticky;top:76px}
|
||
@media (max-width:820px){.nav{position:static;grid-auto-flow:column;overflow-x:auto;grid-auto-columns:max-content}}
|
||
.nav button{border:0;background:transparent;text-align:left;padding:8px 12px;border-radius:8px;color:var(--muted);font-weight:500;display:flex;justify-content:space-between;gap:10px}
|
||
.nav button[aria-current="true"]{background:var(--surface);color:var(--ink);box-shadow:inset 3px 0 0 var(--accent)}
|
||
.badge{font-size:.72rem;border-radius:99px;padding:0 7px;background:var(--bad-soft);color:var(--bad);font-family:var(--mono)}
|
||
.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
||
@media (max-width:700px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
.kpi{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||
.kpi .v{font-family:var(--display);font-size:1.7rem;font-weight:700;line-height:1.1}
|
||
.kpi.alert .v{color:var(--bad)}
|
||
.stack{display:grid;gap:16px}
|
||
.capchart{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(26px,1fr);gap:4px;align-items:end;height:150px;padding-top:8px;overflow-x:auto}
|
||
.cbar{display:grid;grid-template-rows:1fr auto auto;height:100%;gap:3px;text-align:center}
|
||
.cbar .track{position:relative;background:var(--sunk);border-radius:5px;overflow:hidden;align-self:stretch}
|
||
.cbar .fill{position:absolute;left:0;right:0;bottom:0;background:var(--accent)}
|
||
.cbar.peak .fill{background:var(--peak)}
|
||
.cbar.full .fill{background:var(--bad)}
|
||
.cbar span{font-size:.64rem;color:var(--muted);font-family:var(--mono)}
|
||
.tablewrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
|
||
table{border-collapse:collapse;width:100%;font-size:.88rem}
|
||
th{font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;font-weight:600;background:var(--sunk)}
|
||
th,td{padding:8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle}
|
||
tr:last-child td{border-bottom:0}
|
||
td.wrap{white-space:normal;min-width:180px}
|
||
.pill{font-size:.74rem;padding:1px 8px;border-radius:99px;font-weight:500;display:inline-block}
|
||
.pill.confirmed{background:var(--ok-soft);color:var(--ok)}
|
||
.pill.needs_review{background:var(--bad-soft);color:var(--bad)}
|
||
.pill.cancelled{background:var(--sunk);color:var(--muted)}
|
||
.pill.peak{background:var(--peak-soft);color:var(--peak)}
|
||
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px}
|
||
.form{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||
.chip{display:inline-flex;gap:6px;align-items:center;background:var(--sunk);border-radius:99px;padding:3px 6px 3px 10px;font-family:var(--mono);font-size:.82rem}
|
||
.chip button{border:0;background:transparent;color:var(--muted);padding:0 4px}
|
||
.strip{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(30px,1fr);gap:3px;overflow-x:auto}
|
||
.strip div{border-radius:6px;padding:4px 0;text-align:center;font-size:.68rem;font-family:var(--mono)}
|
||
.strip .y{background:var(--ok-soft);color:var(--ok)} .strip .n{background:var(--sunk);color:var(--muted)}
|
||
.reasons{display:grid;gap:6px}
|
||
.reason{display:grid;grid-template-columns:120px minmax(0,1fr);gap:10px;padding:8px 10px;border-radius:8px;background:var(--sunk);font-size:.88rem}
|
||
.reason.ok{background:var(--ok-soft)}
|
||
code{font-family:var(--mono);font-size:.82rem;background:var(--sunk);padding:1px 5px;border-radius:5px}
|
||
textarea{width:100%;min-height:120px;border:1px solid var(--line);border-radius:8px;padding:8px;font-family:var(--mono);font-size:.78rem;background:var(--surface)}
|
||
.switch{display:inline-flex;gap:8px;align-items:center}
|
||
.sectionhead{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
|
||
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
|
||
</style>
|
||
|
||
<header class="bar"><div class="bar-in">
|
||
<div class="brand">
|
||
<svg width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><rect x="2" y="4" width="24" height="21" rx="5" fill="var(--accent)"/><rect x="2" y="4" width="24" height="6" rx="3" fill="var(--ink)" opacity=".25"/><path d="M8.5 16.5l3.6 3.4 7.4-7.4" stroke="var(--accent-ink)" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
<b>Datewise</b><span>prototype · sample data</span>
|
||
</div>
|
||
<div class="seg" role="group" aria-label="View">
|
||
<button id="v-shop" data-act="view" data-v="shop">Customer view</button>
|
||
<button id="v-admin" data-act="view" data-v="admin">Merchant admin</button>
|
||
</div>
|
||
<div class="clock">
|
||
<span class="label">Store time</span>
|
||
<span class="now" id="nowLabel"></span>
|
||
<select id="preset" aria-label="Jump to a moment">
|
||
<option value="live">Live clock</option>
|
||
<option value="2026-10-14T09:00">Wed Oct 14, 9:00 AM</option>
|
||
<option value="2026-10-14T14:05">Wed Oct 14, 2:05 PM: after pickup cutoff</option>
|
||
<option value="2026-11-05T10:00">Thu Nov 5, 10:00 AM: Diwali week</option>
|
||
<option value="2026-11-06T18:01">Fri Nov 6, 6:01 PM: Diwali cutoff passed</option>
|
||
</select>
|
||
<button class="btn sm" data-act="skip">+11 min</button>
|
||
</div>
|
||
</div></header>
|
||
<main id="app"></main>
|
||
|
||
<script>/*ENGINE*/</script>
|
||
<script>
|
||
(function(){
|
||
"use strict";
|
||
var E = window.DatewiseEngine;
|
||
var TZ = "America/Toronto";
|
||
|
||
/* ---------- formatting ---------- */
|
||
function money(c){ return "$" + (c/100).toFixed(2); }
|
||
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g,function(c){return {"&":"&","<":"<",">":">","\"":""","'":"'"}[c];}); }
|
||
function t12(hhmm){ var p=hhmm.split(":"),h=+p[0],m=p[1]; var ap=h>=12?"PM":"AM"; h=h%12||12; return h+(m==="00"?"":":"+m)+" "+ap; }
|
||
function slotLabel(s,e){ return t12(s)+" – "+t12(e); }
|
||
function dLabel(d,opts){ return new Date(d+"T12:00:00Z").toLocaleDateString("en-CA",Object.assign({weekday:"short",month:"short",day:"numeric",timeZone:"UTC"},opts||{})); }
|
||
function nowLabel(ms){ var p=E.localParts(ms,TZ); return dLabel(p.date)+" · "+t12(p.time); }
|
||
var METHOD_NAME={pickup:"Store pickup",delivery:"Local delivery",shipping:"Shipping"};
|
||
|
||
/* ---------- simulated clock ---------- */
|
||
var clock={base:Date.now(),setAt:Date.now()};
|
||
function now(){ return clock.base + (Date.now()-clock.setAt); }
|
||
function setClock(ms){ clock.base=ms; clock.setAt=Date.now(); }
|
||
|
||
/* ---------- sample configuration ---------- */
|
||
function hrs(a,b){ return [{start:a,end:b}]; }
|
||
function week(days,a,b){ var o={}; days.forEach(function(d){o[d]=hrs(a,b);}); return o; }
|
||
var dSlots=[{start:"10:00",end:"13:00"},{start:"13:00",end:"16:00"},{start:"16:00",end:"19:00",surcharge:300}];
|
||
function explicit(days){ var o={}; days.forEach(function(d){o[d]=dSlots;}); return o; }
|
||
|
||
var LOCS=[{
|
||
id:"kit",name:"Kitchener — Guelph St",address:"Unit 7, 900 Guelph St, Kitchener, ON N2H 5Z6",timezone:TZ,lat:43.4643,lng:-80.4844,enabled:true,priority:1,
|
||
pickupInstructions:"Unit 7. Ring the bell at the side door.",
|
||
exceptions:[{date:"2026-11-08",hours:hrs("09:00","15:00"),methods:["pickup"],note:"Diwali Sunday pickup hours"},{date:"2026-10-26",closed:true,methods:["pickup"],note:"Closed for stock count"}],
|
||
zones:[{id:"kw",name:"Kitchener–Waterloo",postalPatterns:["N2A*","N2B*","N2C*","N2E*","N2G*","N2H*","N2K*","N2M*","N2P*","N2R*"],fee:799,freeOver:7500,minOrder:2500},
|
||
{id:"r25",name:"Within 25 km",radiusKm:25,fee:1299,minOrder:4000}],
|
||
methods:{
|
||
pickup:{enabled:true,weeklyHours:week([1,2,3,4,5,6],"10:00","18:00"),slotMode:"generated",slotMinutes:60,prepMinutes:60,prepMode:"elapsed",sameDayCutoff:"14:00",horizonDays:30,slotCapacity:4},
|
||
delivery:{enabled:true,weeklyHours:week([0,2,3,4,5,6],"10:00","19:00"),slotMode:"explicit",slots:explicit([0,2,3,4,5,6]),prepMinutes:120,prepMode:"elapsed",sameDayCutoff:"12:00",horizonDays:30,slotCapacity:5,dailyCapacity:12},
|
||
shipping:{enabled:true,weeklyHours:week([1,2,3,4,5],"10:00","17:00"),slotMode:"dateOnly",prepMinutes:0,prepMode:"operating",sameDayCutoff:"14:00",horizonDays:14}
|
||
}
|
||
},{
|
||
id:"wat",name:"Waterloo — King St (sample)",address:"King St N, Waterloo, ON",timezone:TZ,lat:43.4723,lng:-80.5449,enabled:true,priority:2,
|
||
pickupInstructions:"Front counter.",exceptions:[],
|
||
zones:[{id:"wl",name:"Waterloo",postalPatterns:["N2J*","N2L*","N2T*","N2V*"],fee:599,freeOver:6000,minOrder:2000}],
|
||
methods:{
|
||
pickup:{enabled:true,weeklyHours:week([2,3,4,5,6],"11:00","19:00"),slotMode:"generated",slotMinutes:120,prepMinutes:90,prepMode:"elapsed",sameDayCutoff:"15:00",horizonDays:30,slotCapacity:3},
|
||
delivery:{enabled:true,weeklyHours:week([3,5,6],"12:00","18:00"),slotMode:"generated",slotMinutes:180,prepMinutes:180,prepMode:"elapsed",sameDayCutoff:"11:00",horizonDays:21,slotCapacity:4,dailyCapacity:8}
|
||
}
|
||
}];
|
||
|
||
var PRODUCTS=[
|
||
{productId:"p_marigold",variantId:"v_marigold",title:"Fresh Marigold Garland",price:1999,collectionIds:["c_flowers"],vendor:"C2C Agricorp",productType:"Fresh Flowers",tags:["perishable"],color:"#D98A04",abbr:"MG"},
|
||
{productId:"p_pakku",variantId:"v_pakku",title:"பாக்கு (Pākkŭ) 200 g",price:699,collectionIds:["c_puja"],vendor:"Clicks to Cart",productType:"Puja",tags:[],color:"#7A4E2D",abbr:"PK"},
|
||
{productId:"p_diya",variantId:"v_diya",title:"Brass Diya Set (4)",price:2499,collectionIds:["c_puja","c_diwali"],vendor:"Clicks to Cart",productType:"Puja",tags:[],color:"#9C7A1C",abbr:"DY"},
|
||
{productId:"p_saree",variantId:"v_saree",title:"Kanchipuram Silk Saree",price:15999,collectionIds:["c_apparel"],vendor:"Clicks to Cart",productType:"Apparel",tags:[],color:"#8E1F4A",abbr:"SR"}
|
||
];
|
||
var INVENTORY={kit:{v_marigold:40,v_pakku:80,v_diya:30,v_saree:6},wat:{v_marigold:20,v_pakku:40,v_diya:12,v_saree:0}};
|
||
|
||
var RULES=[
|
||
{id:"r_noship",name:"Fresh flowers can't ship",enabled:true,priority:1,target:{type:"productType",values:["Fresh Flowers"]},action:{type:"prohibitMethods",methods:["shipping"]},message:"Fresh flowers are pickup or local delivery only"},
|
||
{id:"r_prep_fresh",name:"Garland stringing: +4 h",enabled:true,priority:2,target:{type:"tag",values:["perishable"]},action:{type:"addPrep",minutes:240}},
|
||
{id:"r_prep_saree",name:"Saree fall & pico: +1 day",enabled:true,priority:3,target:{type:"collection",values:["c_apparel"]},action:{type:"addPrep",minutes:1440}},
|
||
{id:"r_fresh",name:"Marigold air shipments",enabled:true,priority:4,target:{type:"product",values:["p_marigold"]},
|
||
action:{type:"freshness",arrivals:[{date:"2026-10-13"},{date:"2026-10-20"},{date:"2026-10-27"},{date:"2026-11-03"},{date:"2026-11-05"}],minDaysAfterArrival:1,shelfLifeDays:4}}
|
||
];
|
||
var PEAKS=[{id:"pk_diwali",name:"Diwali",dates:["2026-11-07","2026-11-08"],methods:["delivery","pickup"],dailyCapacity:30,slotCapacity:10,extraPrepMinutes:60,cutoff:{daysBefore:2,time:"18:00"},surcharge:500,waitlist:true,message:"Diwali rush: orders for Nov 7–8 close two days earlier at 6 PM"}];
|
||
var SETTINGS={mixedPrepPolicy:"longest",holdMinutes:10,weekStartsOn:0,
|
||
reschedule:{enabled:true,minHoursBefore:24,maxChanges:2,allowMethodChange:false},
|
||
reminders:[
|
||
{id:"rm1",channel:"whatsapp",minutesBefore:1440,enabled:true,template:"Hi {{name}}, your {{method}} for {{order}} is tomorrow, {{date}} {{time}}. Need to change it? Use the link in your order page."},
|
||
{id:"rm2",channel:"sms",minutesBefore:120,enabled:true,methods:["delivery"],template:"{{name}}, your driver is out for delivery {{time}} today. Order {{order}}."},
|
||
{id:"rm3",channel:"email",minutesBefore:1440,enabled:true,methods:["pickup"],template:"Your pickup for {{order}} is ready {{date}} {{time}}."}]};
|
||
var GEO={N2H:[43.455,-80.49],N2G:[43.445,-80.49],N2M:[43.44,-80.51],N2E:[43.42,-80.47],N2A:[43.45,-80.43],N2B:[43.47,-80.46],N2K:[43.48,-80.47],N2P:[43.40,-80.43],N2R:[43.40,-80.48],N2C:[43.43,-80.45],N2J:[43.47,-80.52],N2L:[43.475,-80.535],N2T:[43.455,-80.565],N2V:[43.50,-80.55],N3C:[43.39,-80.32],N3H:[43.40,-80.33],N1H:[43.545,-80.25],N3A:[43.36,-80.44],M5V:[43.64,-79.39],L5B:[43.59,-79.64]};
|
||
|
||
/* ---------- reservation store + seeded bookings ---------- */
|
||
var store=new E.MemoryReservationStore();
|
||
var NAMES=["Anjali R.","Karthik S.","Meena P.","Ravi K.","Deepa N.","Suresh V.","Lakshmi T.","Arjun M.","Priya D.","Vijay A.","Kavya B.","Hari G.","Nisha J.","Rahul C.","Divya L.","Ganesh O."];
|
||
var STREETS=["King St E","Weber St","Victoria St N","Highland Rd W","Fischer-Hallman Rd","Ottawa St S","Belmont Ave","Queen St S","Frederick St","Lancaster St W"];
|
||
var seed=7; function rnd(){ seed=(seed*16807)%2147483647; return (seed-1)/2147483646; }
|
||
var orderNo=1000;
|
||
function addBooking(b){ var id="bk_s"+(++orderNo); b.id=id; b.orderId="o"+orderNo; b.orderName="#"+orderNo; b.timezone=TZ; b.rescheduleCount=0; store.bookings.set(id,b); return b; }
|
||
(function seedBookings(){
|
||
var start="2026-10-10";
|
||
for(var i=0;i<56;i++){
|
||
var date=E.addDays(start,i), wd=E.weekdayOf(date);
|
||
var peak=PEAKS[0].dates.indexOf(date)>=0;
|
||
if([0,2,3,4,5,6].indexOf(wd)>=0 || peak){
|
||
var n=peak?(date==="2026-11-08"?27:22):Math.floor(3+rnd()*7);
|
||
for(var k=0;k<n;k++){
|
||
var s=peak&&date==="2026-11-08"&&k<10?dSlots[0]:dSlots[Math.floor(rnd()*3)];
|
||
var lat=43.4643+(rnd()-.5)*0.09, lng=-80.4844+(rnd()-.5)*0.13;
|
||
addBooking({status:"confirmed",method:"delivery",locationId:"kit",date:date,slotStart:s.start,slotEnd:s.end,
|
||
customer:{name:NAMES[Math.floor(rnd()*NAMES.length)],email:"customer"+orderNo+"@example.com",phone:"+1519555"+(1000+orderNo)},
|
||
address:{line1:(10+Math.floor(rnd()*400))+" "+STREETS[Math.floor(rnd()*STREETS.length)],city:"Kitchener",postal:"N2H",lat:lat,lng:lng},
|
||
items:[{title:"Fresh Marigold Garland",quantity:1+Math.floor(rnd()*3)}],peakDayId:peak?"pk_diwali":null});
|
||
}
|
||
}
|
||
if(wd>=1&&wd<=6){
|
||
var p=Math.floor(rnd()*5);
|
||
for(var q=0;q<p;q++){
|
||
var h=10+Math.floor(rnd()*8); var hh=(h<10?"0":"")+h+":00", he=(h+1<10?"0":"")+(h+1)+":00";
|
||
addBooking({status:"confirmed",method:"pickup",locationId:"kit",date:date,slotStart:hh,slotEnd:he,customer:{name:NAMES[Math.floor(rnd()*NAMES.length)],email:"pickup"+orderNo+"@example.com"},items:[{title:"பாக்கு (Pākkŭ) 200 g",quantity:1}]});
|
||
}
|
||
}
|
||
}
|
||
addBooking({status:"needs_review",method:"delivery",locationId:"kit",date:"2026-11-08",slotStart:"10:00",slotEnd:"13:00",customer:{name:"Sangeetha R.",email:"sangeetha@example.com",phone:"+15195550177"},address:{line1:"55 Weber St",postal:"N2H"},items:[{title:"Fresh Marigold Garland",quantity:4}],note:"Hold expired while paying and the slot filled up"});
|
||
addBooking({status:"needs_review",method:"pickup",locationId:"",date:"",customer:{name:"Mohan K.",email:"mohan@example.com"},items:[{title:"Brass Diya Set (4)",quantity:1}],note:"Order came through Buy now with no date selected"});
|
||
store.joinWaitlist({locationId:"kit",method:"delivery",date:"2026-11-08",slot:"10:00-13:00"},{name:"Uma S.",email:"uma@example.com"},Date.now());
|
||
store.joinWaitlist({locationId:"kit",method:"delivery",date:"2026-11-08",slot:"10:00-13:00"},{name:"Bala V.",phone:"+15195550190"},Date.now());
|
||
})();
|
||
|
||
/* ---------- state ---------- */
|
||
var S={view:"shop",tab:"overview",cart:{v_marigold:2,v_pakku:1,v_diya:1,v_saree:0},method:"delivery",locId:null,postal:"N2H 5Z6",
|
||
month:null,date:null,sel:null,token:null,notice:null,lastOrder:null,resched:null,
|
||
bf:{status:"all",method:"all",from:"",loc:"all"},run:{date:null},diag:{method:"delivery",loc:"kit",date:null},
|
||
customer:{name:"Priya Raman",email:"priya@example.com",phone:"+1 519 555 0142"},waitEmail:""};
|
||
|
||
function cart(){ var items=[]; PRODUCTS.forEach(function(p){ var q=S.cart[p.variantId]||0; if(q>0) items.push(Object.assign({},p,{quantity:q})); }); return {items:items,subtotal:items.reduce(function(s,i){return s+i.price*i.quantity;},0)}; }
|
||
function geocode(postal){ var n=E.normalizePostal(postal); var g=GEO[n.slice(0,3)]; return g?{postal:postal,lat:g[0],lng:g[1]}:{postal:postal}; }
|
||
function locById(id){ return LOCS.filter(function(l){return l.id===id;})[0]; }
|
||
function usageExcludingMine(){ var mine=S.token&&store.holds.get(S.token); if(mine) store.holds.delete(S.token); var u=store.usage(now()); if(mine) store.holds.set(S.token,mine); return u; }
|
||
function ctx(extra){ return Object.assign({locations:LOCS,method:S.method,cart:cart(),rules:RULES,peakDays:PEAKS,usage:usageExcludingMine(),settings:SETTINGS,now:now(),address:geocode(S.postal),inventory:INVENTORY,ranking:"priority"},extra||{}); }
|
||
function myHold(){ var h=S.token&&store.holds.get(S.token); return h&&h.expiresAt>now()?h:null; }
|
||
|
||
/* ---------- storefront ---------- */
|
||
function computeShop(){
|
||
var c=cart(); if(!c.items.length) return {empty:true};
|
||
var res=E.rankLocations(ctx());
|
||
var chosen=null;
|
||
res.ranked.forEach(function(r){ if(r.location.id===S.locId && r.eligible) chosen=r; });
|
||
if(!chosen) chosen=res.ranked.filter(function(r){return r.eligible;})[0]||null;
|
||
return {ranked:res.ranked,split:res.splitRequired,chosen:chosen,cart:c};
|
||
}
|
||
function methodState(m){
|
||
var c=cart(); if(!c.items.length) return {ok:false};
|
||
var r=E.rankLocations(ctx({method:m}));
|
||
var ok=r.ranked.some(function(x){return x.eligible;});
|
||
var why=ok?"":((r.splitRequired&&r.splitRequired.message)||(r.ranked[0]&&r.ranked[0].reasons[0]&&r.ranked[0].reasons[0].message)||"Unavailable");
|
||
var first=null; r.ranked.forEach(function(x){ if(x.eligible&&x.first&&(!first||x.first.date<first.date)) first=x.first; });
|
||
return {ok:ok,why:why,first:first};
|
||
}
|
||
function revalidate(sh){
|
||
if(!S.sel) return;
|
||
var reason=null;
|
||
if(!sh.chosen || sh.chosen.location.id!==S.sel.locationId || S.method!==S.sel.method) reason="Your delivery choice changed, so the time you picked was released.";
|
||
else {
|
||
var d=sh.chosen.availability.dates.filter(function(x){return x.date===S.sel.date;})[0];
|
||
var s=d&&d.slots.filter(function(x){return x.id===S.sel.slot;})[0];
|
||
if(!s||!s.available){ var rs=(s&&s.reasons[0])||(d&&d.reasons[0]); reason="Your selected time is no longer available"+(rs?": "+rs.message.charAt(0).toLowerCase()+rs.message.slice(1):"")+". Please choose another."; }
|
||
}
|
||
if(reason){ if(S.token) store.release(S.token); S.sel=null; S.notice={kind:"bad",text:reason}; }
|
||
}
|
||
|
||
function renderShop(){
|
||
if(S.lastOrder && !S.resched) return renderConfirmation();
|
||
var sh=computeShop(); var c=cart();
|
||
if(!sh.empty) revalidate(sh);
|
||
var h='';
|
||
if(S.notice) h+='<div class="notice '+S.notice.kind+'" role="status">'+esc(S.notice.text)+'</div>';
|
||
h+='<div class="store">';
|
||
// cart
|
||
h+='<section class="panel" aria-labelledby="cartH"><div class="storehead"><h2 id="cartH">Your cart</h2><span class="muted small">Change items to see dates update</span></div>';
|
||
PRODUCTS.forEach(function(p){
|
||
var q=S.cart[p.variantId]||0; var tags='';
|
||
if(p.tags.indexOf("perishable")>=0) tags+='<span class="tag fresh">Fresh · ships by air from India</span>';
|
||
if(p.productType==="Apparel") tags+='<span class="tag">Tailoring +1 day</span>';
|
||
if(p.variantId==="v_saree") tags+='<span class="tag">Not stocked at Waterloo</span>';
|
||
h+='<div class="item"><div class="thumb" style="background:'+p.color+'">'+p.abbr+'</div><div><div class="t">'+esc(p.title)+'</div><div class="tags"><span class="tag">'+money(p.price)+'</span>'+tags+'</div></div>'+
|
||
'<div class="qty"><button aria-label="Remove one '+esc(p.title)+'" data-act="qty" data-v="'+p.variantId+'" data-d="-1">−</button><span>'+q+'</span><button aria-label="Add one '+esc(p.title)+'" data-act="qty" data-v="'+p.variantId+'" data-d="1">+</button></div></div>';
|
||
});
|
||
var fee=feeQuote(sh);
|
||
h+='<div class="totals"><span>Subtotal</span><span class="num">'+money(c.subtotal)+'</span>';
|
||
if(fee){ h+='<span>'+esc(METHOD_NAME[S.method])+' fee'+(fee.waived?' (free over '+money(sh.chosen.zone.zone.freeOver)+')':'')+'</span><span class="num">'+money(fee.waived?0:fee.baseFee)+'</span>';
|
||
fee.surcharges.forEach(function(x){ h+='<span>'+esc(x.label)+'</span><span class="num">'+money(x.amount)+'</span>'; }); }
|
||
h+='<span class="grand">Total</span><span class="grand num">'+money(c.subtotal+(fee?fee.fee:0))+'</span></div>';
|
||
if(fee) h+='<p class="muted small">Fees are added to the cart as a “Delivery & handling” line, so they are charged at checkout on any Shopify plan.</p>';
|
||
h+='</section>';
|
||
|
||
// widget
|
||
h+='<section class="panel widget" aria-labelledby="wH"><h2 id="wH">When would you like it?</h2>';
|
||
if(sh.empty){ h+='<p class="muted">Add an item to choose a date.</p></section></div>'; return h; }
|
||
h+='<div class="methods" role="group" aria-label="Delivery method">';
|
||
["delivery","pickup","shipping"].forEach(function(m){
|
||
var st=methodState(m);
|
||
h+='<button class="mbtn" data-act="method" data-v="'+m+'" aria-pressed="'+(S.method===m)+'"'+(st.ok||S.method===m?'':' disabled')+'><b>'+METHOD_NAME[m]+'</b><small>'+(st.ok?(st.first?'From '+dLabel(st.first.date):''):esc(st.why))+'</small></button>';
|
||
});
|
||
h+='</div>';
|
||
if(S.method==="delivery"){
|
||
h+='<div class="row"><div class="field" style="flex:1"><label class="label" for="postal">Delivery postal code</label><input type="text" id="postal" value="'+esc(S.postal)+'" autocomplete="postal-code" placeholder="e.g. N2H 5Z6"></div><button class="btn" data-act="postal" style="align-self:end">Check</button></div>';
|
||
h+='<p class="muted small" style="margin:6px 0 0">Try N2L 3G1 (Waterloo), N3C 2V4 (Cambridge, radius zone), or M5V 2T6 (Toronto, outside area).</p>';
|
||
}
|
||
if(sh.split) h+='<div class="notice bad" style="margin-top:12px">'+esc(sh.split.message)+'</div>';
|
||
if(S.method!=="shipping"){
|
||
h+='<div class="label" style="margin-top:14px">'+(S.method==="pickup"?"Pickup location":"Delivering from")+'</div><div class="loclist">';
|
||
sh.ranked.forEach(function(r){
|
||
var sel=sh.chosen&&sh.chosen.location.id===r.location.id;
|
||
var right=r.eligible?(r.fee?(r.fee.waived?'Free':money(r.fee.baseFee)):(r.distanceKm!=null?r.distanceKm.toFixed(1)+' km':'')):'';
|
||
h+='<button class="loc" data-act="loc" data-v="'+r.location.id+'" aria-pressed="'+sel+'"'+(r.eligible?'':' disabled')+'><span class="dot"></span><span><b>'+esc(r.location.name)+'</b><br><span class="small muted">'+(r.eligible?(r.first?'Next available '+dLabel(r.first.date):'')+(r.zone&&r.zone.zone?' · '+esc(r.zone.zone.name):''):esc(r.reasons[0]&&r.reasons[0].message))+'</span></span><span class="small num">'+right+'</span></button>';
|
||
});
|
||
h+='</div>';
|
||
}
|
||
if(!sh.chosen){ h+='<div class="notice bad" style="margin-top:12px">No location can take this order with '+esc(METHOD_NAME[S.method].toLowerCase())+'. Try another method or change your cart.</div></section></div>'; return h; }
|
||
|
||
var av=sh.chosen.availability;
|
||
var cons=E.evaluateRules(c,RULES,SETTINGS.mixedPrepPolicy);
|
||
if(cons.prepDriver) h+='<p class="small muted" style="margin:10px 0 0">Earliest times include '+fmtMins(av.prepMinutes)+' preparation, set by <b>'+esc(cons.prepDriver.itemTitle)+'</b>.</p>';
|
||
var peakDates=av.dates.filter(function(d){return d.peak&&d.peak.message;});
|
||
if(peakDates.length) h+='<div class="notice peak" style="margin:10px 0 0">'+esc(peakDates[0].peak.message)+(peakDates[0].surcharge?'. Peak-day handling +'+money(peakDates[0].surcharge)+'.':'')+'</div>';
|
||
h+=renderCalendar(av);
|
||
h+=renderSlots(av,sh);
|
||
h+='</section></div>';
|
||
return h;
|
||
}
|
||
function fmtMins(m){ if(m>=1440&&m%60===0) return Math.round(m/60)+' h'; if(m>=60) return (m/60).toFixed(m%60?1:0)+' h'; return m+' min'; }
|
||
function feeQuote(sh){
|
||
if(!sh||sh.empty||!sh.chosen) return null;
|
||
var extras=[];
|
||
if(S.sel){
|
||
var d=sh.chosen.availability.dates.filter(function(x){return x.date===S.sel.date;})[0];
|
||
var s=d&&d.slots.filter(function(x){return x.id===S.sel.slot;})[0];
|
||
if(s&&s.surcharge) extras.push({label:"Evening slot",amount:s.surcharge});
|
||
if(d&&d.peak&&d.surcharge) extras.push({label:d.peak.name+" peak handling",amount:d.surcharge});
|
||
}
|
||
if(S.method==="delivery") return E.quoteFee(sh.chosen.zone.zone,sh.cart.subtotal,extras);
|
||
if(extras.length) return E.quoteFee(null,sh.cart.subtotal,extras);
|
||
return null;
|
||
}
|
||
function renderCalendar(av){
|
||
var map={}; av.dates.forEach(function(d){map[d.date]=d;});
|
||
var today=E.localParts(now(),TZ).date;
|
||
if(!S.month) S.month=today.slice(0,7);
|
||
var first=S.month+"-01", wd=E.weekdayOf(first);
|
||
var h='<div class="cal"><div class="calhead"><button class="btn ghost" data-act="month" data-d="-1" aria-label="Previous month">‹</button><b>'+new Date(first+"T12:00:00Z").toLocaleDateString("en-CA",{month:"long",year:"numeric",timeZone:"UTC"})+'</b><button class="btn ghost" data-act="month" data-d="1" aria-label="Next month">›</button></div><div class="grid7">';
|
||
["S","M","T","W","T","F","S"].forEach(function(x,i){ h+='<div class="dow" aria-hidden="true">'+["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][i]+'</div>'; });
|
||
for(var i=0;i<wd;i++) h+='<div></div>';
|
||
for(var day=1;day<=31;day++){
|
||
var date=S.month+"-"+(day<10?"0":"")+day;
|
||
if(E.addDays(first,day-1)!==date) break;
|
||
var d=map[date]; var cls="day", lab=dLabel(date,{weekday:"long",month:"long"});
|
||
var extra='';
|
||
if(!d){ cls+=" out"; lab+=", not bookable"; }
|
||
else if(d.available){ cls+=" avail"; lab+=", available"; if(d.remaining!=null&&d.remaining<=5) extra='<span class="lft">'+d.remaining+' left</span>'; }
|
||
else if(d.waitlist||(d.reasons[0]&&/FULL/.test(d.reasons[0].code))){ cls+=" full"; lab+=", fully booked"; extra='<span class="lft">full</span>'; }
|
||
else { cls+=" closed"; lab+=", "+(d.reasons[0]?d.reasons[0].message:"unavailable"); }
|
||
if(S.date===date) cls+=" sel";
|
||
var pk=d&&d.peak?'<span class="pk" title="'+esc(d.peak.name)+'"></span>':'';
|
||
h+='<button class="'+cls+'" data-act="date" data-v="'+date+'" aria-label="'+esc(lab)+'" title="'+esc(d&&!d.available&&d.reasons[0]?d.reasons[0].message:"")+'"'+(d?'':' disabled')+'>'+day+pk+extra+'</button>';
|
||
}
|
||
h+='</div><div class="legend"><span><i style="background:var(--accent-soft)"></i>Available</span><span><i style="background:var(--warn-soft)"></i>Fully booked</span><span><i style="background:var(--peak);border-radius:50%"></i>Festival day</span></div></div>';
|
||
return h;
|
||
}
|
||
function renderSlots(av,sh){
|
||
if(!S.date) return '<p class="muted small" style="margin-top:12px">Pick a highlighted date.</p>';
|
||
var d=av.dates.filter(function(x){return x.date===S.date;})[0];
|
||
if(!d) return '';
|
||
var h='<div style="margin-top:14px"><div class="sectionhead"><h3>'+dLabel(d.date,{weekday:"long",month:"long"})+(d.peak?' <span class="pill peak">'+esc(d.peak.name)+'</span>':'')+'</h3>';
|
||
if(av.dateOnly) h+='<span class="small muted">Dispatch date, not an arrival promise</span>';
|
||
h+='</div>';
|
||
if(!d.available){
|
||
h+='<div class="notice '+(d.waitlist?'peak':'bad')+'" style="margin-top:8px">'+esc(d.reasons.map(function(r){return r.message;}).join(". "))+'</div>';
|
||
if(d.waitlist){
|
||
h+='<div class="row"><input type="email" id="waitEmail" placeholder="Email for a spot" value="'+esc(S.waitEmail)+'" style="flex:1"><button class="btn" data-act="waitlist">Join the waitlist</button></div><p class="small muted">We hold the next freed place for you for 30 minutes and message you first.</p>';
|
||
}
|
||
return h+'</div>';
|
||
}
|
||
var mine=myHold();
|
||
h+='<div class="slots" role="group" aria-label="Time slots">';
|
||
d.slots.forEach(function(s){
|
||
var selected=S.sel&&S.sel.date===d.date&&S.sel.slot===s.id;
|
||
var cap=s.remaining; var meter='';
|
||
if(cap!=null){ var capTot=E.capsFor(sh.chosen.location,S.method,d.date,s.id,PEAKS); var tot=Math.min(capTot.slotCap||cap,capTot.dayCap||cap)||cap; var pct=tot?Math.round(100*cap/Math.max(tot,cap)):0; meter='<div class="meter'+(cap<=2?' low':'')+'"><i style="width:'+pct+'%"></i></div>'; }
|
||
var sub=s.available?(cap!=null?cap+' left':'Open')+(s.surcharge?' · +'+money(s.surcharge):''):(s.reasons[0]?s.reasons[0].message:'');
|
||
if(selected&&mine) sub='Held for you';
|
||
h+='<button class="slot" data-act="slot" data-v="'+s.id+'" aria-pressed="'+selected+'"'+(s.available||selected?'':' disabled')+'><b>'+(av.dateOnly?'Ships this day':slotLabel(s.start,s.end))+'</b>'+meter+'<small>'+esc(sub)+'</small></button>';
|
||
});
|
||
h+='</div>';
|
||
if(S.sel){
|
||
var held=myHold();
|
||
h+='<div class="hold"><span>'+(held?'Your time is held for <b class="mono" id="holdLeft"></b>':'Your hold expired. Tap the time again to re-check it.')+'</span>'+(held?'<span class="small muted">Other shoppers see this place as taken.</span>':'')+'</div>';
|
||
var loc=sh.chosen.location;
|
||
h+='<dl class="summary"><dt>Method</dt><dd>'+METHOD_NAME[S.method]+'</dd><dt>'+(S.method==="pickup"?"Pick up at":"From")+'</dt><dd>'+esc(loc.name)+'</dd>';
|
||
h+='<dt>'+(av.dateOnly?'Dispatch':'Date')+'</dt><dd>'+dLabel(S.sel.date,{weekday:"long",month:"long"})+'</dd>';
|
||
if(!av.dateOnly) h+='<dt>Time</dt><dd>'+slotLabel(S.sel.slot.split("-")[0],S.sel.slot.split("-")[1])+'</dd>';
|
||
if(S.method==="pickup"&&loc.pickupInstructions) h+='<dt>Note</dt><dd>'+esc(loc.pickupInstructions)+'</dd>';
|
||
h+='</dl>';
|
||
h+='<div class="co"><input type="text" id="cName" aria-label="Name" value="'+esc(S.customer.name)+'"><input type="email" id="cEmail" aria-label="Email" value="'+esc(S.customer.email)+'"><input type="tel" id="cPhone" aria-label="Phone" value="'+esc(S.customer.phone)+'"></div>';
|
||
h+='<button class="btn primary" style="width:100%;margin-top:10px;padding:11px" data-act="checkout">Check out (demo order)</button>';
|
||
h+='<div class="demo"><span class="label">Try the edge cases</span><div class="row"><button class="btn sm" data-act="rush">Other shoppers take every remaining place in this slot</button><button class="btn sm" data-act="skip">Wait 11 minutes</button></div><span class="small muted">While your hold is live, the rush can’t take your place. After it expires, it can.</span></div>';
|
||
}
|
||
return h+'</div>';
|
||
}
|
||
|
||
function renderConfirmation(){
|
||
var b=store.bookings.get(S.lastOrder);
|
||
var loc=locById(b.locationId);
|
||
var rem=E.planReminders(b,SETTINGS.reminders,now(),{date:dLabel(b.date,{weekday:"long",month:"long"}),time:b.slotStart?slotLabel(b.slotStart,b.slotEnd):""});
|
||
var can=E.canCustomerReschedule(b,SETTINGS.reschedule,now());
|
||
var h='<div class="notice ok">Order '+esc(b.orderName)+' placed. This is what the customer sees on the thank-you and order status pages.</div>';
|
||
h+='<div class="store"><section class="panel confirm"><span class="label">Order status page</span><h2>'+METHOD_NAME[b.method]+', '+dLabel(b.date,{weekday:"long",month:"long"})+'</h2>';
|
||
h+='<dl class="summary"><dt>Time</dt><dd>'+(b.slotStart&&b.method!=="shipping"?slotLabel(b.slotStart,b.slotEnd):'Dispatch day')+'</dd><dt>Location</dt><dd>'+esc(loc.name)+'</dd><dt>Status</dt><dd><span class="pill '+b.status+'">'+b.status.replace("_"," ")+'</span>'+(b.rescheduleCount?' · changed '+b.rescheduleCount+'×':'')+'</dd></dl>';
|
||
if(can.ok){ h+='<div><button class="btn primary" data-act="resched-open">Change date or time</button> <span class="small muted">You can change it until '+nowLabel(can.deadline)+'.</span></div>'; }
|
||
else h+='<div class="notice info">'+esc(can.reason)+'</div>';
|
||
h+='<div class="row"><button class="btn" data-act="neworder">Start a new order</button></div></section>';
|
||
h+='<section class="panel"><span class="label">Scheduled reminders</span><div class="timeline" style="margin-top:8px">';
|
||
if(!rem.length) h+='<p class="muted small">No reminders: the slot is too soon, or there are no contact details for the enabled channels.</p>';
|
||
rem.forEach(function(r){ h+='<div><span class="mono small">'+nowLabel(r.sendAt)+'</span><span><span class="pill confirmed">'+r.channel+'</span> '+esc(r.body)+'</span></div>'; });
|
||
h+='</div><p class="small muted">Reminders are planned again automatically whenever the booking is rescheduled.</p></section></div>';
|
||
if(S.resched) h+=renderResched(b);
|
||
return h;
|
||
}
|
||
function renderResched(b){
|
||
var loc=locById(b.locationId);
|
||
var av=E.computeAvailability({location:loc,method:b.method,cart:b.cart||{items:[]},rules:RULES,peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
|
||
var opts=''; av.dates.forEach(function(d){ if(d.available) opts+='<option value="'+d.date+'"'+(S.resched.date===d.date?' selected':'')+'>'+dLabel(d.date)+'</option>'; });
|
||
if(!S.resched.date){ var f=av.dates.filter(function(d){return d.available;})[0]; S.resched.date=f&&f.date; }
|
||
var d=av.dates.filter(function(x){return x.date===S.resched.date;})[0];
|
||
var sl=''; (d?d.slots:[]).forEach(function(s){ if(s.available) sl+='<option value="'+s.id+'">'+slotLabel(s.start,s.end)+' · '+(s.remaining==null?'open':s.remaining+' left')+'</option>'; });
|
||
return '<section class="panel" style="margin-top:18px"><h3>Change your '+b.method+' time</h3><p class="small muted">Same rules as checkout: only dates your items can make are shown.</p><div class="row"><select id="rsDate">'+opts+'</select><select id="rsSlot">'+sl+'</select><button class="btn primary" data-act="resched-save">Save new time</button><button class="btn" data-act="resched-cancel">Cancel</button></div></section>';
|
||
}
|
||
|
||
/* ---------- admin ---------- */
|
||
var TABS=[["overview","Today"],["bookings","Bookings"],["peak","Rush control"],["fresh","Products & freshness"],["run","Driver run sheet"],["diag","Why unavailable?"],["settings","Reminders & policy"]];
|
||
function renderAdmin(){
|
||
var review=Array.from(store.bookings.values()).filter(function(b){return b.status==="needs_review";}).length;
|
||
var h='<div class="admin"><nav class="nav" aria-label="Admin sections">';
|
||
TABS.forEach(function(t){ h+='<button data-act="tab" data-v="'+t[0]+'" aria-current="'+(S.tab===t[0])+'"><span>'+t[1]+'</span>'+(t[0]==="bookings"&&review?'<span class="badge">'+review+'</span>':'')+'</button>'; });
|
||
h+='</nav><div class="stack">'+({overview:adminOverview,bookings:adminBookings,peak:adminPeak,fresh:adminFresh,run:adminRun,diag:adminDiag,settings:adminSettings}[S.tab])()+'</div></div>';
|
||
return h;
|
||
}
|
||
function allBookings(){ return Array.from(store.bookings.values()); }
|
||
function adminOverview(){
|
||
var today=E.localParts(now(),TZ).date, wk=E.addDays(today,7);
|
||
var bs=allBookings();
|
||
var tdy=bs.filter(function(b){return b.date===today&&b.status==="confirmed";}).length;
|
||
var next=bs.filter(function(b){return b.date>today&&b.date<=wk&&b.status==="confirmed";}).length;
|
||
var rev=bs.filter(function(b){return b.status==="needs_review";}).length;
|
||
var wl=store.waitlist.filter(function(w){return !w.notifiedAt;}).length;
|
||
var h='<div class="sectionhead"><h2>Today, '+dLabel(today,{weekday:"long",month:"long"})+'</h2><span class="small muted">Kitchener — Guelph St</span></div>';
|
||
h+='<div class="kpis"><div class="kpi"><div class="label">Bookings today</div><div class="v num">'+tdy+'</div></div><div class="kpi"><div class="label">Next 7 days</div><div class="v num">'+next+'</div></div><div class="kpi'+(rev?' alert':'')+'"><div class="label">Needs review</div><div class="v num">'+rev+'</div></div><div class="kpi"><div class="label">On waitlist</div><div class="v num">'+wl+'</div></div></div>';
|
||
var u=store.usage(now()); var loc=LOCS[0];
|
||
h+='<section class="panel"><div class="sectionhead"><h3>Delivery capacity, next 28 days</h3><div class="legend" style="margin:0"><span><i style="background:var(--accent)"></i>Booked</span><span><i style="background:var(--peak)"></i>Festival day</span><span><i style="background:var(--bad)"></i>Full</span></div></div><div class="capchart" role="img" aria-label="Booked delivery orders against daily capacity per day">';
|
||
for(var i=0;i<28;i++){
|
||
var d=E.addDays(today,i); var hours=E.hoursFor(loc,"delivery",d).hours;
|
||
var caps=E.capsFor(loc,"delivery",d,"10:00-13:00",PEAKS); var used=(u["kit|delivery|"+d]||{day:0}).day;
|
||
var cap=caps.dayCap||12; var pct=hours.length?Math.min(100,Math.round(100*used/cap)):0;
|
||
var pk=E.peakFor(PEAKS,"kit","delivery",d);
|
||
h+='<div class="cbar'+(pk?' peak':'')+(hours.length&&used>=cap?' full':'')+'" title="'+dLabel(d)+': '+used+' of '+cap+'"><div class="track">'+(hours.length?'<div class="fill" style="height:'+pct+'%"></div>':'')+'</div><span>'+(hours.length?used+'/'+cap:'—')+'</span><span>'+d.slice(8)+'</span></div>';
|
||
}
|
||
h+='</div></section>';
|
||
var pk=PEAKS[0]; var pd=pk.dates[pk.dates.length-1];
|
||
var used8=(u["kit|delivery|"+pd]||{day:0}).day;
|
||
var deadline=E.zonedToUtc(E.addDays(pd,-pk.cutoff.daysBefore),pk.cutoff.time,TZ);
|
||
h+='<section class="panel"><div class="sectionhead"><h3>Next festival: '+esc(pk.name)+'</h3><button class="btn sm" data-act="tab" data-v="peak">Adjust rush settings</button></div><p>'+dLabel(pd,{weekday:"long",month:"long"})+' delivery: <b class="num">'+used8+' of '+pk.dailyCapacity+'</b> booked. '+(now()<deadline?'Orders close '+nowLabel(deadline)+'.':'Orders closed '+nowLabel(deadline)+'.')+' '+store.waitlist.length+' customers on the waitlist.</p></section>';
|
||
return h;
|
||
}
|
||
function adminBookings(){
|
||
var f=S.bf; var today=E.localParts(now(),TZ).date;
|
||
var rows=allBookings().filter(function(b){
|
||
if(f.status!=="all"&&b.status!==f.status) return false;
|
||
if(f.method!=="all"&&b.method!==f.method) return false;
|
||
if(f.loc!=="all"&&b.locationId!==f.loc) return false;
|
||
if(f.from&&b.date&&b.date<f.from) return false;
|
||
if(!f.from&&f.status==="all"&&b.date&&b.date<today) return false;
|
||
return true;
|
||
}).sort(function(a,b){ if(a.status!==b.status){ if(a.status==="needs_review") return -1; if(b.status==="needs_review") return 1; } return (a.date+(a.slotStart||"")).localeCompare(b.date+(b.slotStart||"")); });
|
||
var h='<div class="sectionhead"><h2>Bookings</h2><button class="btn sm" data-act="csv">Copy filtered CSV</button></div>';
|
||
h+='<div class="filters"><div class="field"><label class="label" for="fStatus">Status</label><select id="fStatus">'+opt(["all","confirmed","needs_review","cancelled"],f.status)+'</select></div><div class="field"><label class="label" for="fMethod">Method</label><select id="fMethod">'+opt(["all","delivery","pickup","shipping"],f.method)+'</select></div><div class="field"><label class="label" for="fLoc">Location</label><select id="fLoc"><option value="all">all</option>'+LOCS.map(function(l){return '<option value="'+l.id+'"'+(l.id===f.loc?' selected':'')+'>'+esc(l.name)+'</option>';}).join("")+'</select></div><div class="field"><label class="label" for="fFrom">From date</label><input type="date" id="fFrom" value="'+f.from+'"></div><span class="small muted">'+rows.length+' bookings</span></div>';
|
||
h+='<div class="tablewrap"><table><thead><tr><th>Order</th><th>Date</th><th>Time</th><th>Method</th><th>Customer</th><th>Status</th><th>Action</th></tr></thead><tbody>';
|
||
rows.slice(0,80).forEach(function(b){
|
||
h+='<tr><td class="mono">'+esc(b.orderName)+'</td><td>'+(b.date?dLabel(b.date):'<span class="muted">missing</span>')+'</td><td class="mono">'+(b.slotStart?slotLabel(b.slotStart,b.slotEnd):'—')+'</td><td>'+b.method+(b.peakDayId?' <span class="pill peak">Diwali</span>':'')+'</td><td>'+esc(b.customer&&b.customer.name)+'</td><td><span class="pill '+b.status+'">'+b.status.replace("_"," ")+'</span>'+(b.note?'<div class="small muted" style="white-space:normal;max-width:240px">'+esc(b.note)+'</div>':'')+'</td><td>'+(b.status==="confirmed"?'<button class="btn sm" data-act="admin-move" data-v="'+b.id+'">Move</button>':b.status==="needs_review"?'<button class="btn sm" data-act="admin-move" data-v="'+b.id+'">Pick a time</button>':'')+'</td></tr>';
|
||
if(S.moving===b.id) h+='<tr><td colspan="7" class="wrap">'+adminMoveForm(b)+'</td></tr>';
|
||
});
|
||
h+='</tbody></table></div><textarea id="csvOut" aria-label="CSV export" hidden></textarea>';
|
||
return h;
|
||
}
|
||
function adminMoveForm(b){
|
||
var loc=locById(b.locationId||"kit"); var method=b.method||"pickup";
|
||
var av=E.computeAvailability({location:loc,method:method,cart:{items:[]},rules:[],peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
|
||
var o=''; var cnt=0;
|
||
av.dates.forEach(function(d){ d.slots.forEach(function(s){ if(s.available&&cnt<60){ cnt++; o+='<option value="'+d.date+'|'+s.id+'">'+dLabel(d.date)+' · '+(av.dateOnly?'dispatch':slotLabel(s.start,s.end))+' · '+(s.remaining==null?'open':s.remaining+' left')+'</option>'; } }); });
|
||
return '<div class="row"><span class="small">Move '+esc(b.orderName)+' to</span><select id="mvSel">'+o+'</select><button class="btn primary sm" data-act="admin-move-save" data-v="'+b.id+'">Save</button><button class="btn sm" data-act="admin-move-cancel">Cancel</button><span class="small muted">The old place is released only if the new one saves.</span></div>';
|
||
}
|
||
function opt(list,v){ return list.map(function(x){return '<option'+(x===v?' selected':'')+'>'+x+'</option>';}).join(""); }
|
||
function adminPeak(){
|
||
var p=PEAKS[0];
|
||
var h='<div class="sectionhead"><h2>Rush control</h2><span class="small muted">Changes apply to the customer view immediately</span></div>';
|
||
h+='<section class="panel stack"><div class="sectionhead"><h3>'+esc(p.name)+'</h3><span class="mono small">'+p.dates.map(function(d){return dLabel(d);}).join(", ")+'</span></div><div class="form">'+
|
||
num("pkDay","Daily order limit",p.dailyCapacity)+num("pkSlot","Per-slot limit",p.slotCapacity)+num("pkPrep","Extra prep (minutes)",p.extraPrepMinutes)+num("pkDays","Close orders N days before",p.cutoff.daysBefore)+
|
||
'<div class="field"><label class="label" for="pkTime">Cutoff time</label><input type="time" id="pkTime" value="'+p.cutoff.time+'"></div>'+num("pkSur","Peak surcharge (cents)",p.surcharge)+
|
||
'<div class="field"><span class="label">When full</span><label class="switch"><input type="checkbox" id="pkWait"'+(p.waitlist?' checked':'')+'> Offer a waitlist</label></div></div>';
|
||
h+='<div class="field"><label class="label" for="pkMsg">Customer banner</label><input type="text" id="pkMsg" value="'+esc(p.message)+'"></div><div><button class="btn primary" data-act="peak-save">Save rush settings</button></div></section>';
|
||
h+='<section class="panel"><h3>Waitlist</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>Joined</th><th>Customer</th><th>Wants</th><th>Status</th></tr></thead><tbody>';
|
||
store.waitlist.forEach(function(w){ h+='<tr><td class="mono small">'+nowLabel(w.createdAt)+'</td><td>'+esc(w.contact.name||w.contact.email||w.contact.phone)+'</td><td>'+dLabel(w.date)+' · '+w.method+' · '+(w.slot==="*"?"any time":slotLabel(w.slot.split("-")[0],w.slot.split("-")[1]))+'</td><td>'+(w.notifiedAt?'<span class="pill confirmed">Offered a place</span>':'<span class="pill peak">Waiting</span>')+'</td></tr>'; });
|
||
h+='</tbody></table></div><p class="small muted">When an order on a full day is cancelled or moved, the first person waiting is offered the place automatically.</p></section>';
|
||
return h;
|
||
}
|
||
function num(id,label,v){ return '<div class="field"><label class="label" for="'+id+'">'+label+'</label><input type="number" id="'+id+'" value="'+(v==null?"":v)+'"></div>'; }
|
||
function adminFresh(){
|
||
var fr=RULES.filter(function(r){return r.id==="r_fresh";})[0];
|
||
var today=E.localParts(now(),TZ).date;
|
||
var h='<div class="sectionhead"><h2>Products & freshness</h2></div><section class="panel"><h3>Rules</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>On</th><th>Rule</th><th>Applies to</th><th>Effect</th></tr></thead><tbody>';
|
||
RULES.forEach(function(r){ h+='<tr><td><input type="checkbox" aria-label="Enable '+esc(r.name)+'" data-act="rule-toggle" data-v="'+r.id+'"'+(r.enabled?' checked':'')+'></td><td>'+esc(r.name)+'</td><td><code>'+r.target.type+': '+esc(r.target.values.join(", "))+'</code></td><td class="wrap">'+esc(describeAction(r.action))+'</td></tr>'; });
|
||
h+='</tbody></table></div><p class="small muted">If rules disagree, a prohibition beats an allowance, and a mixed cart uses its longest preparation time. Customers see which item caused a restriction.</p></section>';
|
||
var a=fr.action;
|
||
h+='<section class="panel stack"><div class="sectionhead"><h3>Fresh Marigold Garland: stock arrivals</h3><span class="small muted">Sellable from arrival + '+a.minDaysAfterArrival+' day to arrival + '+a.shelfLifeDays+' days</span></div><div class="chips">';
|
||
a.arrivals.forEach(function(x,i){ h+='<span class="chip">'+dLabel(x.date)+'<button aria-label="Remove arrival '+x.date+'" data-act="arr-del" data-v="'+i+'">×</button></span>'; });
|
||
h+='</div><div class="row"><input type="date" id="arrNew"><button class="btn sm" data-act="arr-add">Add arrival</button>'+'<label class="small">Ready after <input type="number" id="frMin" value="'+a.minDaysAfterArrival+'" style="width:60px"> day(s)</label><label class="small">Shelf life <input type="number" id="frShelf" value="'+a.shelfLifeDays+'" style="width:60px"> days</label><button class="btn sm" data-act="fr-save">Update</button></div>';
|
||
h+='<div class="label">Dates a garland can be delivered, next 28 days</div><div class="strip">';
|
||
var cons=E.evaluateRules({items:[Object.assign({},PRODUCTS[0],{quantity:1})]},RULES);
|
||
for(var i=0;i<28;i++){ var d=E.addDays(today,i); var ok=!E.checkFreshness(cons.freshness,d,"kit"); h+='<div class="'+(ok?'y':'n')+'" title="'+dLabel(d)+'">'+d.slice(8)+'</div>'; }
|
||
h+='</div></section>';
|
||
return h;
|
||
}
|
||
function describeAction(a){
|
||
switch(a.type){ case "prohibitMethods": return "Blocks "+a.methods.join(", "); case "allowOnlyMethods": return "Only "+a.methods.join(", ");
|
||
case "addPrep": return "Adds "+fmtMins(a.minutes)+" preparation"; case "freshness": return a.arrivals.length+" stock arrivals; sellable for "+(a.shelfLifeDays-a.minDaysAfterArrival+1)+" days each";
|
||
case "restrictLocations": return "Only from "+a.locationIds.join(", "); case "blockWeekdays": return "Blocks weekdays "+a.weekdays.join(","); default: return a.type; }
|
||
}
|
||
function adminRun(){
|
||
var today=E.localParts(now(),TZ).date;
|
||
var dates=[]; allBookings().forEach(function(b){ if(b.method==="delivery"&&b.status==="confirmed"&&b.date>=today&&dates.indexOf(b.date)<0) dates.push(b.date); }); dates.sort();
|
||
if(!S.run.date||dates.indexOf(S.run.date)<0) S.run.date=dates[0];
|
||
var sheet=E.buildRunSheet(allBookings(),LOCS[0],S.run.date);
|
||
var h='<div class="sectionhead"><h2>Driver run sheet</h2><div class="row"><select id="runDate" aria-label="Run date">'+dates.slice(0,20).map(function(d){return '<option value="'+d+'"'+(d===S.run.date?' selected':'')+'>'+dLabel(d)+'</option>';}).join("")+'</select><button class="btn sm" data-act="run-csv">Copy CSV</button></div></div>';
|
||
h+='<div class="kpis"><div class="kpi"><div class="label">Stops</div><div class="v num">'+sheet.stops.length+'</div></div><div class="kpi"><div class="label">Est. distance</div><div class="v num">'+(sheet.totalKm||0)+' km</div></div><div class="kpi"><div class="label">Windows</div><div class="v num">'+new Set(sheet.stops.map(function(s){return s.window;})).size+'</div></div><div class="kpi"><div class="label">Missing address</div><div class="v num">'+sheet.unlocated.length+'</div></div></div>';
|
||
h+='<section class="panel"><div class="sectionhead"><h3>Route links</h3><span class="small muted">Promised windows first, nearest stop next. Straight-line estimate.</span></div><div class="row" style="margin-top:8px">';
|
||
sheet.mapsUrls.forEach(function(u,i){ h+='<a class="btn sm" href="'+esc(u)+'" target="_blank" rel="noopener">Open leg '+(i+1)+' in Google Maps</a>'; });
|
||
h+='</div></section><div class="tablewrap"><table><thead><tr><th>#</th><th>Window</th><th>Order</th><th>Customer</th><th>Address</th><th>Leg</th><th>Items</th></tr></thead><tbody>';
|
||
sheet.stops.forEach(function(s){ var b=s.booking; h+='<tr><td class="num">'+s.seq+'</td><td class="mono">'+slotLabel(b.slotStart,b.slotEnd)+'</td><td class="mono">'+esc(b.orderName)+'</td><td>'+esc(b.customer&&b.customer.name)+'</td><td>'+esc(b.address&&b.address.line1)+'</td><td class="num">'+(s.legKm!=null?s.legKm.toFixed(1)+' km':'—')+'</td><td>'+esc((b.items||[]).map(function(i){return i.quantity+"× "+i.title;}).join(", "))+'</td></tr>'; });
|
||
h+='</tbody></table></div><textarea id="csvOut" aria-label="CSV export" hidden></textarea>';
|
||
return h;
|
||
}
|
||
function adminDiag(){
|
||
var today=E.localParts(now(),TZ).date; var D=S.diag; if(!D.date) D.date=E.addDays(today,5);
|
||
var loc=locById(D.loc); var c=cart();
|
||
var av=E.computeAvailability({location:loc,method:D.method,cart:c,rules:RULES,peakDays:PEAKS,usage:store.usage(now()),settings:SETTINGS,now:now()});
|
||
var cons=E.evaluateRules(c,RULES,SETTINGS.mixedPrepPolicy);
|
||
var h='<div class="sectionhead"><h2>Why is a date unavailable?</h2><span class="small muted">Uses the cart from the customer view</span></div>';
|
||
h+='<div class="filters"><div class="field"><label class="label" for="dgM">Method</label><select id="dgM">'+opt(["delivery","pickup","shipping"],D.method)+'</select></div><div class="field"><label class="label" for="dgL">Location</label><select id="dgL">'+LOCS.map(function(l){return '<option value="'+l.id+'"'+(l.id===D.loc?' selected':'')+'>'+esc(l.name)+'</option>';}).join("")+'</select></div><div class="field"><label class="label" for="dgD">Date</label><input type="date" id="dgD" value="'+D.date+'"></div></div>';
|
||
h+='<section class="panel stack"><div><span class="label">Cart</span><p style="margin:4px 0">'+esc(c.items.map(function(i){return i.quantity+"× "+i.title;}).join(", ")||"Empty")+'</p><p class="small muted" style="margin:0">Matched rules: '+(cons.matched.length?esc(cons.matched.map(function(m){return m.ruleName+" ("+m.itemTitle+")";}).join("; ")):"none")+'. Preparation used: '+fmtMins(av.prepMinutes)+'.</p></div><div class="reasons">';
|
||
if(av.blockers.length) av.blockers.forEach(function(r){ h+='<div class="reason"><code>'+r.code+'</code><span>'+esc(r.message)+(r.ruleId?' · rule <code>'+r.ruleId+'</code>':'')+'</span></div>'; });
|
||
else { var d=av.dates.filter(function(x){return x.date===D.date;})[0];
|
||
if(!d) h+='<div class="reason"><code>BEYOND_HORIZON</code><span>Outside the '+loc.methods[D.method].horizonDays+'-day booking window, or before today.</span></div>';
|
||
else { d.reasons.forEach(function(r){ h+='<div class="reason"><code>'+r.code+'</code><span>'+esc(r.message)+(r.ruleId?' · rule <code>'+r.ruleId+'</code>':'')+'</span></div>'; });
|
||
d.slots.forEach(function(s){ h+='<div class="reason'+(s.available?' ok':'')+'"><span class="mono">'+(av.dateOnly?'Dispatch':slotLabel(s.start,s.end))+'</span><span>'+(s.available?'Available'+(s.remaining!=null?', '+s.remaining+' left':''):s.reasons.map(function(r){return r.code+": "+r.message;}).join("; "))+'</span></div>'; }); } }
|
||
h+='</div></section>';
|
||
return h;
|
||
}
|
||
function adminSettings(){
|
||
var R=SETTINGS.reschedule;
|
||
var h='<div class="sectionhead"><h2>Reminders & policy</h2></div><section class="panel stack"><h3>Customer self-reschedule</h3><div class="form"><div class="field"><span class="label">Allowed</span><label class="switch"><input type="checkbox" id="rsOn"'+(R.enabled?' checked':'')+'> Customers can change their time</label></div>'+num("rsHrs","Up to N hours before",R.minHoursBefore)+num("rsMax","Max changes per order",R.maxChanges)+num("holdMin","Hold a picked slot (minutes)",SETTINGS.holdMinutes)+'</div><div><button class="btn primary" data-act="pol-save">Save policy</button></div></section>';
|
||
h+='<section class="panel"><h3>Reminders</h3><div class="tablewrap" style="margin-top:8px"><table><thead><tr><th>On</th><th>Channel</th><th>When</th><th>Methods</th><th>Message</th></tr></thead><tbody>';
|
||
SETTINGS.reminders.forEach(function(r){ h+='<tr><td><input type="checkbox" data-act="rem-toggle" data-v="'+r.id+'" aria-label="Enable reminder"'+(r.enabled?' checked':'')+'></td><td>'+r.channel+'</td><td>'+fmtMins(r.minutesBefore)+' before</td><td>'+(r.methods?r.methods.join(", "):"all")+'</td><td class="wrap small">'+esc(r.template)+'</td></tr>'; });
|
||
h+='</tbody></table></div><p class="small muted">WhatsApp uses the Meta Cloud API with approved templates; SMS uses Twilio; email uses your sending domain. Customers who opt out are skipped.</p></section>';
|
||
return h;
|
||
}
|
||
|
||
/* ---------- render + events ---------- */
|
||
var app=document.getElementById("app");
|
||
function render(){
|
||
document.getElementById("v-shop").setAttribute("aria-pressed",S.view==="shop");
|
||
document.getElementById("v-admin").setAttribute("aria-pressed",S.view==="admin");
|
||
document.getElementById("nowLabel").textContent=nowLabel(now());
|
||
app.innerHTML=S.view==="shop"?renderShop():renderAdmin();
|
||
tick();
|
||
}
|
||
function tick(){
|
||
document.getElementById("nowLabel").textContent=nowLabel(now());
|
||
var el=document.getElementById("holdLeft"); var h=myHold();
|
||
if(el&&h){ var s=Math.max(0,Math.round((h.expiresAt-now())/1000)); el.textContent=Math.floor(s/60)+":"+String(s%60).padStart(2,"0"); }
|
||
if(el&&!h&&S.view==="shop") render();
|
||
}
|
||
setInterval(tick,1000);
|
||
|
||
function holdSlot(date,slot){
|
||
var sh=computeShop(); if(!sh.chosen) return;
|
||
if(!S.token) S.token="tok_"+Math.random().toString(36).slice(2);
|
||
var sel={locationId:sh.chosen.location.id,method:S.method,date:date,slot:slot};
|
||
var v=E.validateSelection(ctx({location:sh.chosen.location}),date,slot);
|
||
if(!v.ok){ S.notice={kind:"bad",text:v.reasons[0].message}; S.sel=null; return; }
|
||
var r=store.hold(S.token,sel,E.capsFor(sh.chosen.location,S.method,date,slot,PEAKS),now(),SETTINGS.holdMinutes);
|
||
if(r.ok){ S.sel=sel; S.notice=null; } else { S.notice={kind:"bad",text:r.message+". Please choose another time."}; S.sel=null; }
|
||
}
|
||
function val(id){ var e=document.getElementById(id); return e?e.value:null; }
|
||
|
||
document.addEventListener("click",function(e){
|
||
var t=e.target.closest("[data-act]"); if(!t||t.tagName==="SELECT"||t.type==="checkbox") return;
|
||
var a=t.dataset.act, v=t.dataset.v;
|
||
if(a==="view"){ S.view=v; }
|
||
else if(a==="tab"){ S.view="admin"; S.tab=v; S.moving=null; }
|
||
else if(a==="skip"){ setClock(now()+11*60000); }
|
||
else if(a==="qty"){ S.cart[v]=Math.max(0,(S.cart[v]||0)+(+t.dataset.d)); }
|
||
else if(a==="method"){ S.method=v; S.date=null; }
|
||
else if(a==="loc"){ S.locId=v; S.date=null; }
|
||
else if(a==="postal"){ S.postal=val("postal"); S.date=null; }
|
||
else if(a==="month"){ var p=S.month.split("-"); var m=+p[1]+(+t.dataset.d); var y=+p[0]; if(m<1){m=12;y--;} if(m>12){m=1;y++;} S.month=y+"-"+String(m).padStart(2,"0"); }
|
||
else if(a==="date"){ S.date=v; S.notice=null; }
|
||
else if(a==="slot"){ holdSlot(S.date,v); }
|
||
else if(a==="rush"){ if(S.sel){ var sh=computeShop(); var caps=E.capsFor(sh.chosen.location,S.sel.method,S.sel.date,S.sel.slot,PEAKS); for(var i=0;i<40;i++){ var r=store.hold("rush_"+Date.now()+"_"+i,S.sel,caps,now(),SETTINGS.holdMinutes); if(!r.ok) break; } S.notice={kind:"info",text:"Other shoppers took every remaining place in this slot. "+(myHold()?"Yours is still held.":"")}; } }
|
||
else if(a==="waitlist"){ var em=val("waitEmail"); var sh2=computeShop(); if(em&&sh2.chosen){ store.joinWaitlist({locationId:sh2.chosen.location.id,method:S.method,date:S.date,slot:"*"},{email:em,name:em},now()); S.notice={kind:"ok",text:"You're on the waitlist for "+dLabel(S.date)+". We'll message "+em+" if a place opens."}; S.waitEmail=""; } }
|
||
else if(a==="checkout"){
|
||
S.customer={name:val("cName"),email:val("cEmail"),phone:val("cPhone")};
|
||
var sh3=computeShop(); var c=cart(); var loc=sh3.chosen.location; var caps2=E.capsFor(loc,S.sel.method,S.sel.date,S.sel.slot,PEAKS);
|
||
var g=geocode(S.postal); var fee=feeQuote(sh3);
|
||
var res=store.confirm("o"+(++orderNo),S.token,S.sel,caps2,now(),{orderName:"#"+orderNo,timezone:TZ,customer:S.customer,address:S.method==="delivery"?{line1:"Customer address",postal:S.postal,lat:g.lat,lng:g.lng}:undefined,items:c.items.map(function(i){return {title:i.title,quantity:i.quantity};}),fee:fee?fee.fee:0,peakDayId:(E.peakFor(PEAKS,loc.id,S.sel.method,S.sel.date)||{}).id||null});
|
||
res.booking.cart=c;
|
||
S.lastOrder=res.booking.id; S.sel=null; S.token=null; S.date=null;
|
||
S.notice=res.ok?(res.reReserved?{kind:"info",text:"Your hold had expired, but the place was still free, so the booking went through."}:null):{kind:"bad",text:res.message+". The order is in the merchant's review queue."};
|
||
}
|
||
else if(a==="neworder"){ S.lastOrder=null; S.notice=null; }
|
||
else if(a==="resched-open"){ S.resched={date:null}; }
|
||
else if(a==="resched-cancel"){ S.resched=null; }
|
||
else if(a==="resched-save"){ var b=store.bookings.get(S.lastOrder); var d=val("rsDate"), s=val("rsSlot"); if(d&&s){ var r2=store.reschedule(b.id,{locationId:b.locationId,method:b.method,date:d,slot:s},E.capsFor(locById(b.locationId),b.method,d,s,PEAKS),now(),"customer"); S.notice=r2.ok?{kind:"ok",text:"Changed to "+dLabel(d)+", "+slotLabel(s.split("-")[0],s.split("-")[1])+". Reminders were re-planned."}:{kind:"bad",text:r2.message}; offerWaitlist(r2); S.resched=null; } }
|
||
else if(a==="admin-move"){ S.moving=v; }
|
||
else if(a==="admin-move-cancel"){ S.moving=null; }
|
||
else if(a==="admin-move-save"){ var bk=store.bookings.get(v); var pv=val("mvSel"); if(pv){ var p2=pv.split("|"); var loc2=locById(bk.locationId||"kit");
|
||
if(bk.status==="needs_review"){ bk.status="confirmed"; bk.locationId=loc2.id; bk.method=bk.method||"pickup"; var caps3=E.capsFor(loc2,bk.method,p2[0],p2[1],PEAKS); var u=store.usage(now())[loc2.id+"|"+bk.method+"|"+p2[0]]||{day:0,slots:{}}; if((caps3.dayCap!=null&&u.day>=caps3.dayCap)||(caps3.slotCap!=null&&(u.slots[p2[1]]||0)>=caps3.slotCap)){ bk.status="needs_review"; S.flash="That slot just filled."; } else { bk.date=p2[0]; bk.slotStart=p2[1].split("-")[0]; bk.slotEnd=p2[1].split("-")[1]; bk.note="Resolved by merchant"; store.audit.push({at:now(),actor:"merchant",bookingId:bk.id,action:"resolved"}); } }
|
||
else { var r3=store.reschedule(bk.id,{locationId:bk.locationId,method:bk.method,date:p2[0],slot:p2[1]},E.capsFor(loc2,bk.method,p2[0],p2[1],PEAKS),now(),"merchant"); offerWaitlist(r3); }
|
||
S.moving=null; } }
|
||
else if(a==="csv"||a==="run-csv"){ var ta=document.getElementById("csvOut"); var csv=a==="csv"?bookingsCsv():runCsv(); ta.hidden=false; ta.value=csv; try{ navigator.clipboard.writeText(csv).then(function(){t.textContent="Copied";},function(){ta.select();}); }catch(_){ ta.select(); } return; }
|
||
else if(a==="peak-save"){ var p=PEAKS[0]; p.dailyCapacity=+val("pkDay")||null; p.slotCapacity=+val("pkSlot")||null; p.extraPrepMinutes=+val("pkPrep")||0; p.cutoff={daysBefore:+val("pkDays")||0,time:val("pkTime")||"18:00"}; p.surcharge=+val("pkSur")||0; p.waitlist=document.getElementById("pkWait").checked; p.message=val("pkMsg"); t.textContent="Saved"; return; }
|
||
else if(a==="arr-add"){ var nd=val("arrNew"); if(nd){ RULES[3].action.arrivals.push({date:nd}); RULES[3].action.arrivals.sort(function(x,y){return x.date<y.date?-1:1;}); } }
|
||
else if(a==="arr-del"){ RULES[3].action.arrivals.splice(+v,1); }
|
||
else if(a==="fr-save"){ RULES[3].action.minDaysAfterArrival=+val("frMin")||0; RULES[3].action.shelfLifeDays=+val("frShelf")||1; }
|
||
else if(a==="pol-save"){ SETTINGS.reschedule.enabled=document.getElementById("rsOn").checked; SETTINGS.reschedule.minHoursBefore=+val("rsHrs")||0; SETTINGS.reschedule.maxChanges=+val("rsMax")||0; SETTINGS.holdMinutes=+val("holdMin")||10; t.textContent="Saved"; return; }
|
||
else return;
|
||
render();
|
||
});
|
||
function offerWaitlist(r){
|
||
if(!r||!r.ok||!r.freed) return;
|
||
var w=store.nextWaitlisted(r.freed)||store.nextWaitlisted(Object.assign({},r.freed,{slot:"*"}));
|
||
if(w){ w.notifiedAt=now(); }
|
||
}
|
||
document.addEventListener("change",function(e){
|
||
var t=e.target, id=t.id;
|
||
if(t.dataset.act==="rule-toggle"){ RULES.forEach(function(r){ if(r.id===t.dataset.v) r.enabled=t.checked; }); render(); return; }
|
||
if(t.dataset.act==="rem-toggle"){ SETTINGS.reminders.forEach(function(r){ if(r.id===t.dataset.v) r.enabled=t.checked; }); return; }
|
||
if(id==="preset"){ if(t.value==="live") setClock(Date.now()); else { var p=t.value.split("T"); setClock(E.zonedToUtc(p[0],p[1],TZ)); } S.month=null; S.date=null; if(S.token) store.release(S.token); S.sel=null; S.notice=null; render(); return; }
|
||
if(id==="fStatus"){ S.bf.status=t.value; } else if(id==="fMethod"){ S.bf.method=t.value; } else if(id==="fLoc"){ S.bf.loc=t.value; } else if(id==="fFrom"){ S.bf.from=t.value; }
|
||
else if(id==="runDate"){ S.run.date=t.value; }
|
||
else if(id==="dgM"){ S.diag.method=t.value; } else if(id==="dgL"){ S.diag.loc=t.value; } else if(id==="dgD"){ S.diag.date=t.value; }
|
||
else if(id==="rsDate"){ S.resched.date=t.value; }
|
||
else if(id==="postal"){ S.postal=t.value; S.date=null; }
|
||
else return;
|
||
render();
|
||
});
|
||
document.addEventListener("keydown",function(e){ if(e.key==="Enter"&&e.target.id==="postal"){ S.postal=e.target.value; S.date=null; render(); } });
|
||
|
||
function bookingsCsv(){
|
||
var rows=allBookings().map(function(b){ var loc=locById(b.locationId); return {order:b.orderName,status:b.status,method:b.method,location:loc?loc.name:"",date:b.date,slot_start:b.slotStart||"",slot_end:b.slotEnd||"",timezone:b.timezone,start_utc:b.date&&b.slotStart?new Date(E.zonedToUtc(b.date,b.slotStart,b.timezone)).toISOString():"",customer:b.customer&&b.customer.name,email:b.customer&&b.customer.email,items:(b.items||[]).map(function(i){return i.quantity+"x "+i.title;}).join("; ")}; });
|
||
return E.toCsv(rows);
|
||
}
|
||
function runCsv(){
|
||
var sheet=E.buildRunSheet(allBookings(),LOCS[0],S.run.date);
|
||
return E.toCsv(sheet.stops.map(function(s){ var b=s.booking; return {stop:s.seq,window:b.slotStart+"-"+b.slotEnd,order:b.orderName,customer:b.customer&&b.customer.name,phone:b.customer&&b.customer.phone,address:b.address&&b.address.line1,lat:b.address.lat.toFixed(5),lng:b.address.lng.toFixed(5),leg_km:s.legKm!=null?s.legKm.toFixed(1):""}; }));
|
||
}
|
||
|
||
setClock(E.zonedToUtc("2026-11-05","10:00",TZ));
|
||
document.getElementById("preset").value="2026-11-05T10:00";
|
||
render();
|
||
})();
|
||
</script>
|