/* Datewise storefront widget. Vanilla JS, no dependencies, safe to load twice. * Talks to the app through the shop's app proxy (/apps/datewise/*), so it works on any theme. * * Lifecycle * cart change ─▶ refresh(): read /cart.js ─▶ POST availability ─▶ revalidate selection ─▶ render * slot click ─▶ POST hold (capacity reserved for N minutes) ─▶ write cart attributes + fee line * checkout ─▶ (optional) block click until a time is chosen; the checkout function enforces it server-side */ (function () { "use strict"; if (window.__datewiseLoaded) return; window.__datewiseLoaded = true; // ---------- config ---------- var cfgEl = document.querySelector("[data-datewise-config]"); var CFG = {}; try { CFG = cfgEl ? JSON.parse(cfgEl.textContent) : {}; } catch (e) { CFG = {}; } var T = Object.assign({ choose: "Pick a highlighted date", needTime: "Please choose a delivery or pickup time before checking out.", held: "Held for you for", expired: "Your hold expired. Tap the time again to check it's still free.", left: "left", full: "Full", postal: "Postal / ZIP code", check: "Check", waitlist: "Join the waitlist", joined: "You're on the waitlist. We'll message you if a place opens.", dispatch: "Ships this day (not an arrival date)", change: "Change", prep: "Earliest times include preparation for", free: "Free", fee: "fee", nextAvailable: "Next available", offer: "A place opened up and we're holding it for you", error: "Something went wrong. Please try again." }, CFG.text || {}); var ROOT = ((window.Shopify && Shopify.routes && Shopify.routes.root) || "/").replace(/\/?$/, "/"); var API = ROOT + "apps/datewise"; var LOCALE = CFG.locale || document.documentElement.lang || "en"; var CURRENCY = CFG.currency || (window.Shopify && Shopify.currency && Shopify.currency.active) || "CAD"; var ATTR = "_datewise"; // ---------- storage (best effort) ---------- function lsGet(k) { try { return localStorage.getItem("datewise_" + k); } catch (e) { return null; } } function lsSet(k, v) { try { v == null ? localStorage.removeItem("datewise_" + k) : localStorage.setItem("datewise_" + k, v); } catch (e) {} } // ---------- state ---------- var S = { data: null, cart: null, lines: [], loading: false, message: null, messageTone: "info", method: lsGet("method"), locationId: lsGet("location"), postal: lsGet("postal") || "", date: null, sel: null, token: lsGet("token"), expiresAt: null, monthOffset: 0, waitlistDone: false, mutating: 0 }; // ---------- helpers ---------- function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } function money(c) { try { return new Intl.NumberFormat(LOCALE, { style: "currency", currency: CURRENCY }).format(c / 100); } catch (e) { return "$" + (c / 100).toFixed(2); } } function dateLabel(d, long) { return new Intl.DateTimeFormat(LOCALE, { weekday: long ? "long" : "short", month: long ? "long" : "short", day: "numeric", timeZone: "UTC" }).format(new Date(d + "T12:00:00Z")); } function timeLabel(hhmm) { var p = hhmm.split(":"); return new Intl.DateTimeFormat(LOCALE, { hour: "numeric", minute: p[1] === "00" ? undefined : "2-digit", timeZone: "UTC" }).format(new Date(Date.UTC(2000, 0, 1, +p[0], +p[1]))); } function slotText(s) { return timeLabel(s.start) + " – " + timeLabel(s.end); } function addMonths(ym, n) { var y = +ym.slice(0, 4), m = +ym.slice(5, 7) - 1 + n; y += Math.floor(m / 12); m = ((m % 12) + 12) % 12; return y + "-" + String(m + 1).padStart(2, "0"); } function daysIn(ym) { return new Date(Date.UTC(+ym.slice(0, 4), +ym.slice(5, 7), 0)).getUTCDate(); } function weekday(d) { return new Date(d + "T12:00:00Z").getUTCDay(); } function api(path, body, method) { var url = API + path; var init = { method: method || "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin" }; if (body && init.method !== "GET") init.body = JSON.stringify(body); return realFetch(url, init).then(function (r) { return r.json().catch(function () { return { ok: false, message: T.error }; }); }); } function cartReq(path, body) { S.mutating++; return realFetch(ROOT + path, { method: body ? "POST" : "GET", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: body ? JSON.stringify(body) : undefined, credentials: "same-origin" }) .then(function (r) { return r.json(); }) .finally(function () { setTimeout(function () { S.mutating--; }, 50); }); } // ---------- cart ---------- function readCart() { return cartReq("cart.js").then(function (cart) { S.cart = cart; S.lines = cart.items.filter(function (i) { return !(i.properties && i.properties._datewise_fee); }) .map(function (i) { return { variantId: i.variant_id, quantity: i.quantity, price: i.final_price, title: i.product_title }; }); var raw = cart.attributes && cart.attributes[ATTR]; if (raw) { try { var a = JSON.parse(raw); S.sel = { method: a.m, locationId: a.l, date: a.d, slot: a.s, fee: a.f }; S.token = a.t; lsSet("token", a.t); S.method = S.method || a.m; S.locationId = S.locationId || a.l; S.date = S.date || a.d; } catch (e) { S.sel = null; } } else S.sel = null; return cart; }); } function feeLines() { return (S.cart ? S.cart.items : []).filter(function (i) { return i.properties && i.properties._datewise_fee; }); } function syncFee(variantId) { var current = feeLines(); var chain = Promise.resolve(); var keep = null; current.forEach(function (line) { if (!keep && variantId && String(line.variant_id) === String(variantId) && line.quantity === 1) keep = line; else chain = chain.then(function () { return cartReq("cart/change.js", { id: line.key, quantity: 0 }); }); }); if (variantId && !keep) chain = chain.then(function () { return cartReq("cart/add.js", { items: [{ id: Number(variantId), quantity: 1, properties: { _datewise_fee: "1" } }] }); }); var changed = current.length !== (keep ? 1 : 0) || (variantId && !keep); return chain.then(function () { return changed; }); } function writeAttributes(attrs) { var prev = []; try { prev = JSON.parse(lsGet("keys") || "[]"); } catch (e) {} var out = {}; prev.forEach(function (k) { out[k] = ""; }); Object.keys(attrs).forEach(function (k) { out[k] = attrs[k]; }); lsSet("keys", JSON.stringify(Object.keys(attrs))); return cartReq("cart/update.js", { attributes: out }); } function notifyTheme(feeChanged) { var detail = { source: "datewise" }; ["cart:refresh", "cart:updated", "cart:change", "theme:cart:change"].forEach(function (n) { document.dispatchEvent(new CustomEvent(n, { detail: detail, bubbles: true })); }); if (typeof window.publish === "function" && window.PUB_SUB_EVENTS) { readCart().then(function (cart) { window.publish(window.PUB_SUB_EVENTS.cartUpdate, { source: "datewise", cartData: cart }); }); } else if (feeChanged && /\/cart\/?$/.test(location.pathname)) { setTimeout(function () { location.reload(); }, 300); // totals must show the fee line } } function clearSelection(reason) { var token = S.token; S.sel = null; S.expiresAt = null; if (reason) setMessage(reason, "critical"); if (token) api("/release", { token: token }).catch(function () {}); return writeAttributes({ [ATTR]: "" }).then(function () { return syncFee(null); }).then(function (changed) { lsSet("keys", "[]"); if (changed) notifyTheme(true); }); } // ---------- data ---------- var timer = null; function refresh(soon) { clearTimeout(timer); timer = setTimeout(load, soon ? 0 : 300); } function load() { if (!roots().length && !CFG.checkoutGuard) return Promise.resolve(); S.loading = true; renderAll(); return readCart().then(function () { if (!S.lines.length) { S.data = null; S.loading = false; if (S.sel) clearSelection(null); renderAll(); return; } return api("/availability", { lines: S.lines, method: S.method || undefined, locationId: S.locationId || undefined, postal: S.postal || undefined, token: S.token || undefined, locale: LOCALE }) .then(function (data) { S.loading = false; if (!data || data.ok === false) { setMessage((data && data.message) || T.error, "critical"); renderAll(); return; } S.data = data; S.method = data.method; S.locationId = data.locationId; revalidate(); renderAll(); }); }).catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); }); } function findSlot(date, slot) { var d = S.data && S.data.dates.filter(function (x) { return x.date === date; })[0]; return { d: d, s: d && d.slots.filter(function (x) { return x.id === slot; })[0] }; } /** The cart, method or location changed: drop a selection the rules no longer allow, with a reason. */ function revalidate() { if (!S.sel) return; var d = S.data; if (!d || d.method !== S.sel.method || d.locationId !== S.sel.locationId) return clearSelection(null); var f = findSlot(S.sel.date, S.sel.slot); if (!f.s || !f.s.available) { var why = (f.s && f.s.reason) || (f.d && f.d.reasons[0]) || ""; clearSelection(why ? why + ". " + T.choose + "." : T.choose); } } function hold(date, slot) { if (!S.data) return; setMessage(null); S.loading = true; renderAll(); return api("/hold", { lines: S.lines, method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, token: S.token || undefined, postal: S.postal || undefined, locale: LOCALE }) .then(function (res) { if (!res.ok) { S.loading = false; setMessage(res.message || T.error, "critical"); refresh(true); return; } S.token = res.token; lsSet("token", res.token); S.expiresAt = Date.parse(res.expiresAt); S.sel = { method: S.data.method, locationId: S.data.locationId, date: date, slot: slot, fee: res.fee ? res.fee.fee : 0 }; return writeAttributes(res.attributes) .then(function () { return syncFee(res.feeVariantId); }) .then(function (changed) { S.loading = false; renderAll(); notifyTheme(changed); }); }) .catch(function () { S.loading = false; setMessage(T.error, "critical"); renderAll(); }); } function setMessage(text, tone) { S.message = text; S.messageTone = tone || "info"; } // ---------- rendering ---------- function roots() { return Array.prototype.slice.call(document.querySelectorAll("[data-datewise-root]")); } function renderAll() { roots().forEach(render); } function render(root) { if (!root.classList.contains("datewise")) root.classList.add("datewise"); // custom mounts get styles too var d = S.data; var heading = root.getAttribute("data-heading") || (d && d.text && d.text.heading) || ""; if (d && d.text && d.text.accent) root.style.setProperty("--ss-accent", d.text.accent); var h = ""; if (heading) h += '

' + esc(heading) + "

"; h += '
' + (S.message ? '

' + esc(S.message) + "

" : "") + "
"; if (!d) { root.innerHTML = h + (S.loading ? '' : ""); root.setAttribute("aria-busy", S.loading); return; } // methods h += '
'; d.methods.forEach(function (m) { var on = m.method === d.method; h += '"; }); h += "
"; if (!d.method) { root.innerHTML = h; return; } if (d.method === "delivery") { h += '
"; } if (d.split) h += '

' + esc(d.split) + "

"; if (d.method !== "shipping" && d.locations.length > 1) { h += '
'; d.locations.forEach(function (l) { var on = l.id === d.locationId; var right = l.fee ? (l.fee.waived ? T.free : money(l.fee.fee)) : l.distanceKm != null ? l.distanceKm + " km" : ""; h += '"; }); h += "
"; } else if (d.locationId) { var only = d.locations.filter(function (l) { return l.id === d.locationId; })[0]; if (only && d.method !== "shipping") h += '

' + esc(only.name) + (only.fee ? " · " + (only.fee.waived ? T.free : money(only.fee.fee) + " " + T.fee) : "") + "

"; } if (!d.locationId) { if (d.needsAddress) { root.innerHTML = h; return; } var why = d.locations[0] && d.locations[0].reason; root.innerHTML = h + (why ? '

' + esc(why) + "

" : ""); return; } if (d.prepNote && d.prepNote.minutes >= 60) h += '

' + esc(T.prep + " " + d.prepNote.item) + " (" + Math.round(d.prepNote.minutes / 6) / 10 + " h)

"; var peak = d.dates.filter(function (x) { return x.peak && x.peak.message; })[0]; if (peak) h += '

' + esc(peak.peak.message) + "

"; h += calendar(d); h += slots(d, root); root.innerHTML = h; root.setAttribute("aria-busy", S.loading); tickTimer(); } var ridSeq = 0; function rid(root) { if (!root.__ssid) root.__ssid = ++ridSeq; return root.__ssid; } function calendar(d) { var map = {}; d.dates.forEach(function (x) { map[x.date] = x; }); var firstAvail = (d.dates.filter(function (x) { return x.available; })[0] || d.dates[0] || {}).date; if (!firstAvail) return ""; var ym = addMonths(firstAvail.slice(0, 7), S.monthOffset); var start = d.text.weekStartsOn || 0; var first = ym + "-01"; var lead = (weekday(first) - start + 7) % 7; var h = '
' + esc(new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(first + "T12:00:00Z"))) + '
'; for (var w = 0; w < 7; w++) { var wd = (start + w) % 7; h += '"; } for (var i = 0; i < lead; i++) h += ""; for (var day = 1; day <= daysIn(ym); day++) { var date = ym + "-" + String(day).padStart(2, "0"); var x = map[date]; var cls = "ss-day", label = dateLabel(date, true), extra = ""; if (!x) cls += " ss-out"; else if (x.available) { cls += " ss-avail"; if (d.text.showRemaining && x.remaining != null && x.remaining <= 5) extra = '' + x.remaining + ""; } else if (x.waitlist || /full|booked/i.test(x.reasons[0] || "")) { cls += " ss-full"; label += ", " + T.full; } else { cls += " ss-closed"; label += ", " + (x.reasons[0] || ""); } if (S.date === date) cls += " ss-sel"; if (x && x.peak) { cls += " ss-peakday"; label += ", " + x.peak.name; } h += '"; } return h + "
"; } function slots(d, root) { if (!S.date) return '

' + esc(T.choose) + "

"; var x = d.dates.filter(function (y) { return y.date === S.date; })[0]; if (!x) return ""; var h = '

' + esc(dateLabel(x.date, true)) + (x.peak ? ' ' + esc(x.peak.name) + "" : "") + "

"; if (d.dateOnly) h += '

' + esc(T.dispatch) + "

"; if (!x.available) { h += '

' + esc(x.reasons.join(". ")) + "

"; if (x.waitlist && !S.waitlistDone) h += '
"; if (S.waitlistDone) h += '

' + esc(T.joined) + "

"; return h + "
"; } h += '
'; x.slots.forEach(function (s) { var on = S.sel && S.sel.date === x.date && S.sel.slot === s.id; var sub = s.available ? (d.text.showRemaining && s.remaining != null ? s.remaining + " " + T.left : "") + (s.surcharge ? (s.remaining != null && d.text.showRemaining ? " · " : "") + "+" + money(s.surcharge) : "") : s.reason || ""; h += '"; }); h += "
"; if (S.sel && S.sel.date === x.date) { var live = S.expiresAt && S.expiresAt > Date.now(); h += '

' + (live ? esc(T.held) + ' ' : S.expiresAt ? esc(T.expired) : "✓") + "

"; var loc = d.locations.filter(function (l) { return l.id === d.locationId; })[0]; if (loc && loc.pickupInstructions) h += '

' + esc(loc.pickupInstructions) + "

"; } return h + ""; } function tickTimer() { var left = S.expiresAt ? S.expiresAt - Date.now() : 0; var els = document.querySelectorAll("[data-ss-timer]"); if (left <= 0 && els.length) return renderAll(); var s = Math.max(0, Math.round(left / 1000)); for (var i = 0; i < els.length; i++) els[i].textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); } setInterval(tickTimer, 1000); // ---------- events ---------- document.addEventListener("click", function (e) { var t = e.target.closest && e.target.closest("[data-datewise-root] [data-ss]"); if (!t || t.tagName === "FORM") return; if (t.getAttribute("aria-disabled") === "true" && t.getAttribute("data-ss") !== "date") return; var a = t.getAttribute("data-ss"), v = t.getAttribute("data-v"); if (a === "method" && v !== S.method) { S.method = v; lsSet("method", v); S.date = null; S.monthOffset = 0; refresh(true); } else if (a === "loc" && v !== S.locationId) { S.locationId = v; lsSet("location", v); S.date = null; refresh(true); } else if (a === "month") { S.monthOffset += +v; renderAll(); } else if (a === "date") { S.date = v; S.waitlistDone = false; setMessage(null); renderAll(); focusFirst(".ss-slot:not([aria-disabled])"); } else if (a === "slot") hold(S.date, v); }); document.addEventListener("submit", function (e) { var f = e.target; if (!f.closest || !f.closest("[data-datewise-root]")) return; var a = f.getAttribute("data-ss"); if (!a) return; e.preventDefault(); if (a === "postal") { S.postal = f.postal.value.trim(); lsSet("postal", S.postal); S.date = null; refresh(true); } if (a === "waitlist" && S.data) { api("/waitlist", { method: S.data.method, locationId: S.data.locationId, date: S.date, slot: "*", email: f.email.value.trim() }).then(function (r) { if (r.ok) S.waitlistDone = true; else setMessage(r.message || T.error, "critical"); renderAll(); }); } }); // Arrow-key navigation in the calendar grid. document.addEventListener("keydown", function (e) { var t = e.target; if (!t.classList || !t.classList.contains("ss-day")) return; var step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key]; if (!step) return; var days = Array.prototype.slice.call(t.parentNode.querySelectorAll(".ss-day")); var next = days[days.indexOf(t) + step]; if (next) { e.preventDefault(); t.tabIndex = -1; next.tabIndex = 0; next.focus(); } }); function focusFirst(sel) { setTimeout(function () { var el = document.querySelector("[data-datewise-root] " + sel); if (el) el.focus(); }, 0); } // Checkout guard (convenience; the checkout function is the real enforcement). function needsTime() { return S.data && S.data.requireScheduling && S.lines.length > 0 && !S.sel; } function guard(e) { if (!CFG.checkoutGuard || !needsTime()) return; var t = e.target.closest && e.target.closest('[name="checkout"], a[href$="/checkout"], form[action$="/checkout"] [type="submit"]'); if (!t) return; e.preventDefault(); e.stopPropagation(); setMessage(T.needTime, "critical"); renderAll(); var r = roots()[0]; if (r) { r.scrollIntoView({ behavior: "smooth", block: "center" }); } } document.addEventListener("click", guard, true); // React to cart changes made by the theme (add, quantity, remove) — revalidate the selection. var realFetch = window.fetch.bind(window); window.fetch = function (input, init) { var url = typeof input === "string" ? input : input && input.url; var p = realFetch(input, init); if (url && /\/cart\/(add|change|update|clear)(\.js)?/.test(url) && !S.mutating) p.then(function () { refresh(); }); return p; }; ["cart:updated", "cart:change", "cart-drawer:open", "ajaxCart:updated"].forEach(function (n) { document.addEventListener(n, function (e) { if (!(e.detail && e.detail.source === "datewise")) refresh(); }); }); if (typeof window.subscribe === "function" && window.PUB_SUB_EVENTS) window.subscribe(window.PUB_SUB_EVENTS.cartUpdate, function (ev) { if (!ev || ev.source !== "datewise") refresh(); }); // ---------- cart drawer mounting ---------- var DRAWER_SELECTORS = [CFG.drawerSelector, "cart-drawer .drawer__footer", "#CartDrawer .drawer__footer", "#CartDrawer-Footer", ".cart-drawer__footer", "#cart-drawer .cart__footer", ".js-cart-drawer .cart-drawer__footer", "[data-cart-drawer] footer"].filter(Boolean); function mountDrawer() { for (var i = 0; i < DRAWER_SELECTORS.length; i++) { var foot = document.querySelector(DRAWER_SELECTORS[i]); if (foot && !foot.querySelector("[data-datewise-root]")) { var el = document.createElement("div"); el.className = "datewise datewise--drawer"; el.setAttribute("data-datewise-root", ""); el.setAttribute("data-drawer", ""); foot.insertBefore(el, foot.firstChild); if (S.data) render(el); else refresh(true); return; } } } if (cfgEl) { mountDrawer(); new MutationObserver(function () { mountDrawer(); }).observe(document.body, { childList: true, subtree: true }); } // ---------- waitlist offer links (?datewise_offer=…) ---------- function adoptOffer() { var m = location.search.match(/[?&]datewise_offer=([^&]+)/); if (!m) return Promise.resolve(); return api("/offer?token=" + encodeURIComponent(m[1]), null, "GET").then(function (o) { if (!o.ok) { setMessage(o.message, "critical"); return; } S.token = o.token; lsSet("token", o.token); S.method = o.method; S.locationId = o.locationId; S.date = o.date; setMessage(T.offer + ": " + dateLabel(o.date, true) + ".", "success"); return load().then(function () { if (S.data && S.data.locationId === o.locationId) return hold(o.date, o.slot); }); }); } adoptOffer().then(function () { if (!S.data) refresh(true); }); })();