775 lines
93 KiB
HTML
775 lines
93 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>"use strict";var DatewiseEngine=(()=>{var K=Object.defineProperty;var Ue=Object.getOwnPropertyDescriptor;var $e=Object.getOwnPropertyNames;var Be=Object.prototype.hasOwnProperty;var Fe=(e,t,n)=>t in e?K(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Ne=(e,t)=>{for(var n in t)K(e,n,{get:t[n],enumerable:!0})},We=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of $e(t))!Be.call(e,r)&&r!==n&&K(e,r,{get:()=>t[r],enumerable:!(o=Ue(t,r))||o.enumerable});return e};var Ke=e=>We(K({},"__esModule",{value:!0}),e);var O=(e,t,n)=>Fe(e,typeof t!="symbol"?t+"":t,n);var Je={};Ne(Je,{METHODS:()=>H,MemoryReservationStore:()=>se,addDays:()=>I,addOperatingMinutes:()=>Oe,buildRunSheet:()=>Xe,canCustomerReschedule:()=>Ve,capsFor:()=>ze,checkFreshness:()=>G,computeAvailability:()=>V,diffDays:()=>Ye,evaluateRules:()=>F,firstAvailable:()=>ne,fromMinutes:()=>Y,haversineKm:()=>Z,hoursFor:()=>te,iso:()=>B,itemMatches:()=>Te,localParts:()=>L,mapsUrls:()=>Ee,matchZone:()=>J,normalizePostal:()=>q,peakFor:()=>j,planReminders:()=>Qe,postalMatches:()=>Me,quoteFee:()=>ee,rankLocations:()=>je,renderTemplate:()=>Le,slotId:()=>we,slotStartMs:()=>re,stockedAt:()=>oe,toCsv:()=>Ge,toMinutes:()=>D,tzOffsetMinutes:()=>X,usageKey:()=>z,validateSelection:()=>Ze,weekdayOf:()=>$,zonedToUtc:()=>v});var H=["pickup","delivery","shipping"];var De=new Map;function Ie(e){let t=De.get(e);return t||(t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",weekday:"short"}),De.set(e,t)),t}var He={Sun:0,Mon:1,Tue:2,Wed:3,Thu:4,Fri:5,Sat:6};function L(e,t){let n={};for(let r of Ie(t).formatToParts(new Date(e)))n[r.type]=r.value;let o=n.hour==="24"?"00":n.hour;return{date:`${n.year}-${n.month}-${n.day}`,time:`${o}:${n.minute}`,minutes:Number(o)*60+Number(n.minute),weekday:He[n.weekday]}}function X(e,t){let n={};for(let i of Ie(t).formatToParts(new Date(e)))n[i.type]=i.value;let o=n.hour==="24"?0:Number(n.hour),r=Date.UTC(+n.year,+n.month-1,+n.day,o,+n.minute,+n.second);return Math.round((r-Math.floor(e/1e3)*1e3)/6e4)}function v(e,t,n){let[o,r,i]=e.split("-").map(Number),[s,a]=t.split(":").map(Number),u=Date.UTC(o,r-1,i,s,a),d=X(u,n),l=u-d*6e4,c=X(l,n);if(c!==d){let g=u-c*6e4;L(g,n).time===t?l=Math.min(l,g):L(l,n).time!==t&&(l=Math.max(l,g))}return l}function D(e){let[t,n]=e.split(":").map(Number);return t*60+n}function Y(e){let t=Math.floor(e/60),n=e%60;return`${String(t).padStart(2,"0")}:${String(n).padStart(2,"0")}`}function I(e,t){let[n,o,r]=e.split("-").map(Number);return new Date(Date.UTC(n,o-1,r+t)).toISOString().slice(0,10)}function Ye(e,t){let[n,o,r]=e.split("-").map(Number),[i,s,a]=t.split("-").map(Number);return Math.round((Date.UTC(n,o-1,r)-Date.UTC(i,s-1,a))/864e5)}function $(e){let[t,n,o]=e.split("-").map(Number);return new Date(Date.UTC(t,n-1,o)).getUTCDay()}function B(e){return new Date(e).toISOString()}function Te(e,t){var r,i;let n=t.target,o=n.values.map(s=>String(s).toLowerCase());switch(n.type){case"product":return o.includes(String(e.productId).toLowerCase());case"variant":return o.includes(String(e.variantId).toLowerCase());case"collection":return((r=e.collectionIds)!=null?r:[]).some(s=>o.includes(String(s).toLowerCase()));case"vendor":return!!e.vendor&&o.includes(e.vendor.toLowerCase());case"productType":return!!e.productType&&o.includes(e.productType.toLowerCase());case"tag":return((i=e.tags)!=null?i:[]).some(s=>o.includes(s.toLowerCase()))}}function E(e,t,n){return{code:"BLOCKED_BY_PRODUCT",message:e.message||n.replace("{item}",t.title),ruleId:e.id}}function F(e,t,n="longest"){var a,u,d,l;let o={allowedMethods:new Set(H),methodReasons:new Map,allowedLocations:null,locationReasons:[],extraPrepMinutes:0,blockedWeekdays:new Map,blockedDates:new Map,windows:[],freshness:[],requiresScheduling:!1,matched:[]},r=t.filter(c=>c.enabled).sort((c,g)=>c.priority-g.priority),i=[],s=0;for(let c of e.items){let g=0,y;for(let m of r){if(!Te(c,m))continue;o.matched.push({ruleId:m.id,ruleName:m.name,itemTitle:c.title});let f=m.action;switch(f.type){case"allowOnlyMethods":for(let h of H)!f.methods.includes(h)&&o.allowedMethods.has(h)&&(o.allowedMethods.delete(h),ve(o.methodReasons,h,{...E(m,c,`{item} is only available for ${f.methods.join(" or ")}`),code:"METHOD_PROHIBITED_BY_PRODUCT"}));break;case"prohibitMethods":for(let h of f.methods)i.push({m:h,reason:{...E(m,c,`{item} can't be sent by ${h}`),code:"METHOD_PROHIBITED_BY_PRODUCT"}});break;case"restrictLocations":{let h=new Set(f.locationIds);o.allowedLocations=o.allowedLocations?new Set([...o.allowedLocations].filter(C=>h.has(C))):h,o.locationReasons.push({...E(m,c,"{item} is only available from selected locations"),code:"LOCATION_PROHIBITED_BY_PRODUCT"});break}case"addPrep":g+=f.minutes,y=m.id;break;case"blockWeekdays":for(let h of f.weekdays)o.blockedWeekdays.has(h)||o.blockedWeekdays.set(h,E(m,c,"{item} isn't available on this day"));break;case"blockDates":for(let h of f.dates)o.blockedDates.has(h)||o.blockedDates.set(h,E(m,c,"{item} isn't available on this date"));break;case"dateWindow":o.windows.push({from:f.from,to:f.to,reason:E(m,c,"{item} is only available in a limited window")});break;case"freshness":o.freshness.push({ruleId:m.id,itemTitle:c.title,arrivals:f.arrivals,minDaysAfterArrival:f.minDaysAfterArrival,shelfLifeDays:f.shelfLifeDays,message:m.message});break;case"requireScheduling":o.requiresScheduling=!0;break}}n==="sum"&&(s+=g),g>((u=(a=o.prepDriver)==null?void 0:a.minutes)!=null?u:0)&&y&&(o.prepDriver={itemTitle:c.title,ruleId:y,minutes:g})}for(let{m:c,reason:g}of i)o.allowedMethods.delete(c),ve(o.methodReasons,c,g);return o.extraPrepMinutes=n==="sum"?s:(l=(d=o.prepDriver)==null?void 0:d.minutes)!=null?l:0,o}function ve(e,t,n){var r;let o=(r=e.get(t))!=null?r:[];o.some(i=>i.ruleId===n.ruleId&&i.message===n.message)||o.push(n),e.set(t,o)}function G(e,t,n){for(let o of e){let r=o.arrivals.filter(s=>{var a;return!((a=s.locationIds)!=null&&a.length)||s.locationIds.includes(n)});if(!r.length)return{code:"NO_STOCK_ARRIVAL",message:o.message||`${o.itemTitle}: no fresh stock is scheduled for this location yet`,ruleId:o.ruleId};if(!r.some(s=>t>=I(s.date,o.minDaysAfterArrival)&&t<=I(s.date,o.shelfLifeDays))){let s=r.map(a=>I(a.date,o.minDaysAfterArrival)).filter(a=>a>t).sort()[0];return s?{code:"NOT_FRESH_YET",message:o.message||`${o.itemTitle}: next fresh batch is ready from ${s}`,ruleId:o.ruleId}:{code:"PAST_FRESHNESS",message:o.message||`${o.itemTitle}: outside its freshness window on this date`,ruleId:o.ruleId}}}return null}function q(e){return(e||"").toUpperCase().replace(/[\s-]/g,"")}function Me(e,t){let n=q(e),o=t.trim().toUpperCase();if(!n||!o)return!1;if(o.includes("..")){let[i,s]=o.split("..").map(q);if(!i||i.length!==s.length)return!1;let a=n.slice(0,i.length);return a>=i&&a<=s}let r=q(o);return r.endsWith("*")?n.startsWith(r.slice(0,-1)):n===r||r.length===3&&n.startsWith(r)}function Z(e,t,n,o){let i=d=>d*Math.PI/180,s=i(n-e),a=i(o-t),u=Math.sin(s/2)**2+Math.cos(i(e))*Math.cos(i(n))*Math.sin(a/2)**2;return 2*6371*Math.asin(Math.sqrt(u))}function J(e,t){var r,i;let n=e.lat!=null&&e.lng!=null&&t.lat!=null&&t.lng!=null?Z(e.lat,e.lng,t.lat,t.lng):null,o=!1;for(let s of e.zones){if((r=s.postalPatterns)!=null&&r.length&&t.postal&&s.postalPatterns.some(a=>Me(t.postal,a)))return{zone:s,distanceKm:n};if(s.radiusKm!=null&&n!=null&&n<=s.radiusKm)return{zone:s,distanceKm:n};if(s.drivingKm!=null){let a=(i=t.drivingKm)==null?void 0:i[e.id];if(a==null)o=!0;else if(a<=s.drivingKm)return{zone:s,distanceKm:a}}}return{zone:null,distanceKm:n,unverified:o,reason:{code:"OUTSIDE_DELIVERY_AREA",message:o?"We couldn't verify your address right now. Please try pickup or check again shortly.":`${e.name} doesn't deliver to this address yet`}}}function ee(e,t,n=[]){var a,u;let o=(a=e==null?void 0:e.fee)!=null?a:0,r=(e==null?void 0:e.freeOver)!=null&&t>=e.freeOver,i=n.filter(d=>d.amount>0),s=(u=e==null?void 0:e.minOrder)!=null?u:null;return{baseFee:o,waived:r,surcharges:i,fee:(r?0:o)+i.reduce((d,l)=>d+l.amount,0),belowMinimum:s!=null&&t<s,minOrder:s}}var z=(e,t,n)=>`${e}|${t}|${n}`,we=(e,t)=>`${e}-${t}`;function te(e,t,n){var i,s;let o=e.methods[t],r=e.exceptions.find(a=>{var u;return a.date===n&&(!((u=a.methods)!=null&&u.length)||a.methods.includes(t))});return r!=null&&r.closed?{hours:[],exception:{code:"CLOSED_EXCEPTION",message:r.note||"Closed on this date"}}:(i=r==null?void 0:r.hours)!=null&&i.length?{hours:r.hours,special:!0}:{hours:(s=o==null?void 0:o.weeklyHours[$(n)])!=null?s:[]}}function j(e,t,n,o){var r;return(r=e.find(i=>{var s,a;return i.dates.includes(o)&&(!((s=i.locationIds)!=null&&s.length)||i.locationIds.includes(t))&&(!((a=i.methods)!=null&&a.length)||i.methods.includes(n))}))!=null?r:null}function qe(e,t,n,o){var s,a;if(!n.length)return[];if(e.slotMode==="dateOnly")return[{start:n[0].start,end:n[n.length-1].end}];if(e.slotMode==="explicit"&&!o)return((a=(s=e.slots)==null?void 0:s[$(t)])!=null?a:[]).filter(d=>n.some(l=>D(d.start)>=D(l.start)&&D(d.end)<=D(l.end)));let r=e.slotMinutes||60,i=[];for(let u of n)for(let d=D(u.start);d+r<=D(u.end);d+=r)i.push({start:Y(d),end:Y(d+r)});return i}function Oe(e,t,n,o){if(t<=0)return e;let r=t*6e4,i=L(e,n.timezone).date;for(let s=0;s<400;s++,i=I(i,1))for(let a of te(n,o,i).hours){let u=v(i,a.start,n.timezone),d=v(i,a.end,n.timezone);if(d<=e)continue;let l=Math.max(u,e),c=d-l;if(r<=c)return l+r;r-=c}return Number.POSITIVE_INFINITY}function V(e){var f,h,C,M,w,P,N,_,ie,ae,le,de,ue,ce;let{location:t,method:n,now:o,usage:r}=e,i=t.timezone,s=t.methods[n],a=(f=e.constraints)!=null?f:F(e.cart,e.rules,e.settings.mixedPrepPolicy),u=[];t.enabled||u.push({code:"LOCATION_DISABLED",message:`${t.name} isn't taking bookings right now`}),s!=null&&s.enabled||u.push({code:"METHOD_DISABLED",message:`${t.name} doesn't offer ${n}`}),a.allowedMethods.has(n)||u.push(...(h=a.methodReasons.get(n))!=null?h:[]),a.allowedLocations&&!a.allowedLocations.has(t.id)&&u.push(...a.locationReasons);let d=((C=s==null?void 0:s.prepMinutes)!=null?C:0)+a.extraPrepMinutes,l=new Map,c=x=>(l.has(x)||l.set(x,(s==null?void 0:s.prepMode)==="operating"?Oe(o,x,t,n):o+x*6e4),l.get(x)),g={locationId:t.id,method:n,timezone:i,dateOnly:(s==null?void 0:s.slotMode)==="dateOnly",prepMinutes:d,earliestReadyUtc:B(c(d)),blockers:u,dates:[]};if(u.length||!s)return g;let y=L(o,i),m=Math.min(s.horizonDays,(M=e.maxDays)!=null?M:s.horizonDays);for(let x=0;x<m;x++){let R=I(y.date,x),me=$(R),p=j(e.peakDays,t.id,n,R),k={date:R,weekday:me,available:!1,remaining:null,peak:p?{id:p.id,name:p.name,message:p.message}:null,surcharge:(w=p==null?void 0:p.surcharge)!=null?w:0,waitlist:!1,reasons:[],slots:[]};g.dates.push(k);let ge=a.blockedWeekdays.get(me);ge&&k.reasons.push(ge);let fe=a.blockedDates.get(R);fe&&k.reasons.push(fe);for(let b of a.windows)(b.from&&R<b.from||b.to&&R>b.to)&&k.reasons.push(b.reason);let he=G(a.freshness,R,t.id);if(he&&k.reasons.push(he),k.reasons.length)continue;let{hours:pe,exception:ye,special:Pe}=te(t,n,R);if(ye){k.reasons.push(ye);continue}if(!pe.length){k.reasons.push({code:"CLOSED_WEEKLY",message:"Closed on this day"});continue}if(x===0&&s.sameDayCutoff&&y.minutes>=D(s.sameDayCutoff)){k.reasons.push({code:"PAST_CUTOFF",message:`Same-day orders close at ${s.sameDayCutoff}`});continue}if(x===1&&s.nextDayCutoff&&y.minutes>=D(s.nextDayCutoff)){k.reasons.push({code:"PAST_CUTOFF",message:`Next-day orders close at ${s.nextDayCutoff}`});continue}if(p!=null&&p.cutoff){let b=v(I(R,-p.cutoff.daysBefore),p.cutoff.time,i);if(o>=b){k.reasons.push({code:"PEAK_CUTOFF",message:`${p.name} orders closed on ${I(R,-p.cutoff.daysBefore)} at ${p.cutoff.time}`});continue}}let be=c(d+((P=p==null?void 0:p.extraPrepMinutes)!=null?P:0)),_e=z(t.id,n,R),ke=(N=r[_e])!=null?N:{day:0,slots:{}},Re=(ie=(_=p==null?void 0:p.dailyCapacity)!=null?_:s.dailyCapacity)!=null?ie:null,A=Re==null?null:Math.max(0,Re-ke.day);k.remaining=A;let Ce=qe(s,R,pe,!!Pe);if(!Ce.length){k.reasons.push({code:"CLOSED_WEEKLY",message:"No time slots on this day"});continue}for(let b of Ce){let U=v(R,b.start,i),T=v(R,b.end,i),Se=we(b.start,b.end),S={id:Se,start:b.start,end:b.end,startUtc:B(U),endUtc:B(T),available:!1,remaining:null,surcharge:(ae=b.surcharge)!=null?ae:0,waitlist:!1,reasons:[]};if(k.slots.push(S),g.dateOnly?T<=o?S.reasons.push({code:"SLOT_IN_PAST",message:"Closed for today"}):be>T&&S.reasons.push({code:"PREP_TIME",message:"Not enough preparation time"}):U<=o?S.reasons.push({code:"SLOT_IN_PAST",message:"This time has passed"}):be>U&&S.reasons.push({code:"PREP_TIME",message:"Not enough preparation time"}),S.reasons.length)continue;let xe=(ue=(de=(le=p==null?void 0:p.slotCapacity)!=null?le:b.capacity)!=null?de:s.slotCapacity)!=null?ue:null,Ae=(ce=ke.slots[Se])!=null?ce:0,W=xe==null?null:Math.max(0,xe-Ae);if(S.remaining=W==null?A:A==null?W:Math.min(W,A),A===0?S.reasons.push({code:"DAY_FULL",message:"Fully booked for this day"}):W===0&&S.reasons.push({code:"SLOT_FULL",message:"This time slot is full"}),S.reasons.length){S.waitlist=!!(p!=null&&p.waitlist);continue}S.available=!0}if(k.available=k.slots.some(b=>b.available),k.waitlist=!k.available&&k.slots.some(b=>b.waitlist),!k.available){let b=new Set;for(let U of k.slots)for(let T of U.reasons)b.has(T.code)||(b.add(T.code),k.reasons.push(T))}}return g}function ne(e){for(let t of e.dates){let n=t.slots.find(o=>o.available);if(n)return{date:t.date,slot:n}}return null}function Ze(e,t,n){let o=V(e);if(o.blockers.length)return{ok:!1,reasons:o.blockers};let r=o.dates.find(s=>s.date===t);if(!r)return{ok:!1,reasons:[{code:"BEYOND_HORIZON",message:"This date can't be booked"}]};let i=r.slots.find(s=>s.id===n);return i?i.available?{ok:!0,slot:i,date:r}:{ok:!1,reasons:i.reasons.length?i.reasons:r.reasons}:{ok:!1,reasons:r.reasons.length?r.reasons:[{code:"CLOSED_WEEKLY",message:"Slot not offered"}]}}function ze(e,t,n,o,r){var u,d,l,c,g,y;let i=e.methods[t],s=j(r,e.id,t,n),a;if((i==null?void 0:i.slotMode)==="explicit"){let[m,f]=o.split("-");for(let h of Object.values((u=i.slots)!=null?u:{}))for(let C of h!=null?h:[])C.start===m&&C.end===f&&C.capacity!=null&&(a=C.capacity)}return{dayCap:(l=(d=s==null?void 0:s.dailyCapacity)!=null?d:i==null?void 0:i.dailyCapacity)!=null?l:null,slotCap:(y=(g=(c=s==null?void 0:s.slotCapacity)!=null?c:a)!=null?g:i==null?void 0:i.slotCapacity)!=null?y:null}}var se=class{constructor(){O(this,"holds",new Map);O(this,"bookings",new Map);O(this,"waitlist",[]);O(this,"audit",[]);O(this,"seq",0)}counts(t,n,o,r){let i=0,s=0,a=u=>u.locationId===t.locationId&&u.method===t.method&&u.date===t.date;for(let u of this.bookings.values()){if(u.id===r||u.status!=="confirmed")continue;let d={locationId:u.locationId,method:u.method,date:u.date,slot:`${u.slotStart}-${u.slotEnd}`};a(d)&&(i++,d.slot===t.slot&&s++)}for(let u of this.holds.values())u.token===o||u.expiresAt<=n||a(u)&&(i++,u.slot===t.slot&&s++);return{day:i,slot:s}}usage(t){let n={},o=r=>{var s,a;let i=z(r.locationId,r.method,r.date);(s=n[i])!=null||(n[i]={day:0,slots:{}}),n[i].day++,n[i].slots[r.slot]=((a=n[i].slots[r.slot])!=null?a:0)+1};for(let r of this.bookings.values())r.status==="confirmed"&&o({locationId:r.locationId,method:r.method,date:r.date,slot:`${r.slotStart}-${r.slotEnd}`});for(let r of this.holds.values())r.expiresAt>t&&o(r);return n}fits(t,n,o,r,i){let s=this.counts(t,o,r,i);return n.dayCap!=null&&s.day>=n.dayCap?"DAY_FULL":n.slotCap!=null&&s.slot>=n.slotCap?"SLOT_FULL":null}hold(t,n,o,r,i){let s=this.fits(n,o,r,t);if(s)return{ok:!1,code:s,message:s==="DAY_FULL"?"Fully booked for this day":"This time slot was just taken"};let a={...n,token:t,expiresAt:r+i*6e4};return this.holds.set(t,a),{ok:!0,hold:a}}release(t){this.holds.delete(t)}sweep(t){let n=0;for(let[o,r]of this.holds)r.expiresAt<=t&&(this.holds.delete(o),n++);return n}confirm(t,n,o,r,i,s){let a=[...this.bookings.values()].find(m=>m.orderId===t);if(a)return{ok:!0,booking:a,duplicate:!0};let u=n?this.holds.get(n):void 0,d=u!=null?u:o,l=`bk_${++this.seq}`,c=m=>({...s,id:l,orderId:t,status:m,method:d.method,locationId:d.locationId,date:d.date,slotStart:d.slot.split("-")[0],slotEnd:d.slot.split("-")[1],rescheduleCount:0});if(!d){let m={...s,id:l,orderId:t,status:"needs_review",method:"pickup",locationId:"",date:""};return this.bookings.set(l,m),{ok:!1,booking:m,code:"NEEDS_REVIEW",message:"Order has no scheduling data"}}let g=u&&u.expiresAt>i;if(!g){let m=this.fits(d,r,i,n!=null?n:void 0);if(m){let f=c("needs_review");return this.bookings.set(l,f),n&&this.holds.delete(n),this.log(i,"system",l,"flagged",void 0,{reason:m}),{ok:!1,booking:f,code:"NEEDS_REVIEW",message:"Hold expired and the slot filled up"}}}let y=c("confirmed");return this.bookings.set(l,y),n&&this.holds.delete(n),this.log(i,"system",l,"confirmed"),{ok:!0,booking:y,duplicate:!1,reReserved:!g}}reschedule(t,n,o,r,i){var g;let s=this.bookings.get(t);if(!s||s.status!=="confirmed")return{ok:!1,message:"Booking can't be rescheduled"};let a=this.fits(n,o,r,void 0,t);if(a)return{ok:!1,message:a==="DAY_FULL"?"That day is fully booked":"That slot is full"};let u={...s},d={locationId:s.locationId,method:s.method,date:s.date,slot:`${s.slotStart}-${s.slotEnd}`},[l,c]=n.slot.split("-");return Object.assign(s,{locationId:n.locationId,method:n.method,date:n.date,slotStart:l,slotEnd:c,rescheduleCount:((g=s.rescheduleCount)!=null?g:0)+1}),this.log(r,i,t,"rescheduled",Q(u),Q(s)),{ok:!0,booking:s,freed:d}}cancel(t,n,o=!0){let r=[...this.bookings.values()].find(s=>s.orderId===t);if(!r||r.status==="cancelled")return r!=null?r:null;let i={...r};return r.status=o?"cancelled":r.status,this.log(n,"system",r.id,"cancelled",Q(i),Q(r)),r}joinWaitlist(t,n,o){let r={...t,id:`wl_${++this.seq}`,contact:n,createdAt:o};return this.waitlist.push(r),r}nextWaitlisted(t){var n;return(n=this.waitlist.find(o=>!o.notifiedAt&&o.locationId===t.locationId&&o.method===t.method&&o.date===t.date&&(o.slot===t.slot||o.slot==="*")))!=null?n:null}log(t,n,o,r,i,s){this.audit.push({at:t,actor:n,bookingId:o,action:r,before:i,after:s})}};function Q(e){return{locationId:e.locationId,method:e.method,date:e.date,slotStart:e.slotStart,slotEnd:e.slotEnd,status:e.status}}function oe(e,t,n){if(!e||!e[t])return!0;let o=e[t];return n.items.every(r=>{var i;return((i=o[r.variantId])!=null?i:0)>=r.quantity})}function je(e){var s,a,u;let t=F(e.cart,e.rules,e.settings.mixedPrepPolicy),n=(s=e.cart.subtotal)!=null?s:e.cart.items.reduce((d,l)=>d+l.price*l.quantity,0),o=[];for(let d of e.locations){let l={location:d,eligible:!0,reasons:[],distanceKm:null,first:null};if(o.push(l),e.method==="delivery"){let y=J(d,(a=e.address)!=null?a:{});l.zone=y,l.distanceKm=y.distanceKm,y.zone?(l.fee=ee(y.zone,n),l.fee.belowMinimum&&(l.eligible=!1,l.reasons.push({code:"BELOW_MINIMUM_ORDER",message:`Delivery needs a minimum order of $${(l.fee.minOrder/100).toFixed(2)}`}))):(l.eligible=!1,l.reasons.push(y.reason))}e.method!=="shipping"&&!oe(e.inventory,d.id,e.cart)&&(l.eligible=!1,l.reasons.push({code:"OUT_OF_STOCK_AT_LOCATION",message:`Not all items are in stock at ${d.name}`}));let c=V({...e,location:d,constraints:t});l.availability=c,c.blockers.length&&(l.eligible=!1,l.reasons.push(...c.blockers));let g=ne(c);l.first=g?{date:g.date,slot:g.slot.id}:null,l.eligible&&!g&&(l.eligible=!1,l.reasons.push({code:"DAY_FULL",message:"No available dates in the booking window"}))}let r=(u=e.ranking)!=null?u:"priority";o.sort((d,l)=>{if(d.eligible!==l.eligible)return d.eligible?-1:1;if(r==="distance"&&d.distanceKm!=null&&l.distanceKm!=null&&d.distanceKm!==l.distanceKm)return d.distanceKm-l.distanceKm;if(r==="earliest"&&d.first&&l.first){let c=`${d.first.date} ${d.first.slot}`,g=`${l.first.date} ${l.first.slot}`;if(c!==g)return c<g?-1:1}return d.location.priority-l.location.priority});let i=null;if(e.inventory&&e.method!=="shipping"&&!o.some(d=>d.eligible)){let d=e.cart.items.every(c=>e.locations.reduce((g,y)=>{var m,f;return g+((f=(m=e.inventory[y.id])==null?void 0:m[c.variantId])!=null?f:0)},0)>=c.quantity),l=e.locations.some(c=>oe(e.inventory,c.id,e.cart));d&&!l&&(i={code:"OUT_OF_STOCK_AT_LOCATION",message:"Your items are stocked at different locations. Choose shipping, or remove some items to schedule a single pickup or delivery."})}return{ranked:o,splitRequired:i}}function re(e){var t;return v(e.date,(t=e.slotStart)!=null?t:"00:00",e.timezone)}function Ve(e,t,n){var r;if(!t.enabled)return{ok:!1,reason:"Rescheduling isn't available for this store. Please contact us."};if(e.status!=="confirmed")return{ok:!1,reason:"This booking can't be changed."};if(((r=e.rescheduleCount)!=null?r:0)>=t.maxChanges)return{ok:!1,reason:`You've already changed this booking ${t.maxChanges} time(s). Please contact us.`};let o=re(e)-t.minHoursBefore*36e5;return n>=o?{ok:!1,reason:`Changes are allowed up to ${t.minHoursBefore} hours before your time slot.`}:{ok:!0,deadline:o}}function Qe(e,t,n,o={}){let r=re(e);return t.filter(i=>{var s;return i.enabled&&(!((s=i.methods)!=null&&s.length)||i.methods.includes(e.method))}).map(i=>{var s,a,u;return{policyId:i.id,channel:i.channel,sendAt:r-i.minutesBefore*6e4,body:Le(i.template,{name:(a=(s=e.customer)==null?void 0:s.name)!=null?a:"there",order:(u=e.orderName)!=null?u:"",date:e.date,time:e.slotStart&&e.slotEnd?`${e.slotStart}\u2013${e.slotEnd}`:"",method:e.method,...o})}}).filter(i=>i.sendAt>n).filter(i=>{var s,a;return i.channel==="email"?!!((s=e.customer)!=null&&s.email):!!((a=e.customer)!=null&&a.phone)})}function Le(e,t){return e.replace(/\{\{\s*(\w+)\s*\}\}/g,(n,o)=>{var r;return(r=t[o])!=null?r:""})}function Xe(e,t,n){var c,g,y;let o=e.filter(m=>m.method==="delivery"&&m.locationId===t.id&&m.date===n&&m.status==="confirmed"),r=o.filter(m=>{var f,h;return((f=m.address)==null?void 0:f.lat)!=null&&((h=m.address)==null?void 0:h.lng)!=null}),i=o.filter(m=>!r.includes(m)),s=new Map;for(let m of r){let f=`${m.slotStart}-${m.slotEnd}`;s.set(f,[...(c=s.get(f))!=null?c:[],m])}let a=[],u={lat:(g=t.lat)!=null?g:0,lng:(y=t.lng)!=null?y:0},d=0,l=t.lat!=null&&t.lng!=null;for(let m of[...s.keys()].sort()){let f=[...s.get(m)];for(;f.length;){let h=0,C=1/0;f.forEach((P,N)=>{let _=Z(u.lat,u.lng,P.address.lat,P.address.lng);_<C&&(C=_,h=N)});let[M]=f.splice(h,1),w=l||a.length?C:null;w!=null&&(d+=w),a.push({seq:a.length+1,booking:M,window:m,legKm:w}),u={lat:M.address.lat,lng:M.address.lng}}}return{locationId:t.id,date:n,stops:a,totalKm:a.length?Math.round(d*10)/10:null,unlocated:i,mapsUrls:Ee(t,a)}}function Ee(e,t){if(!t.length)return[];let n=s=>`${s.address.lat},${s.address.lng}`,o=e.lat!=null?`${e.lat},${e.lng}`:encodeURIComponent(e.address),r=[],i=o;for(let s=0;s<t.length;s+=10){let a=t.slice(s,s+10),u=n(a[a.length-1].booking),d=a.slice(0,-1).map(l=>n(l.booking)).join("|");r.push(`https://www.google.com/maps/dir/?api=1&origin=${i}&destination=${u}`+(d?`&waypoints=${encodeURIComponent(d)}`:"")+"&travelmode=driving"),i=u}return r}function Ge(e,t){var r;let n=t!=null?t:Object.keys((r=e[0])!=null?r:{}),o=i=>{let s=i==null?"":String(i);return/[",\r\n]/.test(s)?`"${s.replace(/"/g,'""')}"`:s};return"\uFEFF"+[n.join(","),...e.map(i=>n.map(s=>o(i[s])).join(","))].join(`\r
|
||
`)}return Ke(Je);})();
|
||
</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>
|