metatroncubeswdev 123ca71d42 feat(widget): restyle storefront scheduler; booking stub with hold countdown
- segmented method control with line icons
- round calendar days with availability dots and scarcity badges
- pill time chips; confirmation stub shows method, place, date, time, fee
- colours derive from theme text colour + one merchant accent (light/dark safe)
- rename leftover ss- internals to dw-

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:17:09 -04:00

447 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* 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, heldAt: null, monthOffset: 0, waitlistDone: false, mutating: 0
};
// ---------- helpers ----------
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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(); }
var ICON = {
pickup: '<path d="M4 10v9h16v-9M3 10l1.5-5h15L21 10M3 10h18M3 10c0 1.4 1.3 2.5 3 2.5S9 11.4 9 10c0 1.4 1.3 2.5 3 2.5s3-1.1 3-2.5c0 1.4 1.3 2.5 3 2.5s3-1.1 3-2.5M10 19v-4h4v4"/>',
delivery: '<path d="M2 6h11v10H2zM13 9h4.5l3.5 3.5V16h-8M6.5 18.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM17 18.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/>',
shipping: '<path d="M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5zM3.5 7.5 12 12l8.5-4.5M12 12v9M7.8 5.3l8.5 4.5"/>'
};
function icon(m) { return '<svg class="dw-ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">' + (ICON[m] || "") + "</svg>"; }
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.heldAt = Date.now();
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("--dw-accent", d.text.accent);
var h = "";
if (heading) h += '<h2 class="dw-h">' + esc(heading) + "</h2>";
h += '<div class="dw-live" role="status" aria-live="polite">' + (S.message ? '<p class="dw-msg dw-' + S.messageTone + '">' + esc(S.message) + "</p>" : "") + "</div>";
if (!d) { root.innerHTML = h + (S.loading ? '<div class="dw-skeleton" aria-hidden="true"></div>' : ""); root.setAttribute("aria-busy", S.loading); return; }
// methods
h += '<div class="dw-methods" role="group" aria-label="Delivery method">';
d.methods.forEach(function (m) {
var on = m.method === d.method;
h += '<button type="button" class="dw-method" data-dw="method" data-v="' + m.method + '" aria-pressed="' + on + '"' + (m.available || on ? "" : ' aria-disabled="true"') + ">" + icon(m.method) +
"<b>" + esc(d.text.labels[m.method]) + "</b><small>" + (m.needsAddress ? esc(T.postal) : m.available ? (m.firstDate ? esc(T.nextAvailable + ": " + dateLabel(m.firstDate)) : "") : esc(m.reason || "")) + "</small></button>";
});
h += "</div>";
if (!d.method) { root.innerHTML = h; return; }
if (d.method === "delivery") {
h += '<form class="dw-postal" data-dw="postal"><label for="dw-postal-' + rid(root) + '">' + esc(T.postal) + '</label><div class="dw-row"><input id="dw-postal-' + rid(root) + '" name="postal" autocomplete="postal-code" value="' + esc(S.postal) + '"><button type="submit">' + esc(T.check) + "</button></div></form>";
}
if (d.split) h += '<p class="dw-msg dw-critical">' + esc(d.split) + "</p>";
if (d.method !== "shipping" && d.locations.length > 1) {
h += '<div class="dw-locs" role="group" aria-label="Location">';
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 += '<button type="button" class="dw-loc" data-dw="loc" data-v="' + esc(l.id) + '" aria-pressed="' + on + '"' + (l.eligible ? "" : ' aria-disabled="true"') + "><span><b>" + esc(l.name) + "</b><small>" +
esc(l.eligible ? (l.first ? T.nextAvailable + ": " + dateLabel(l.first.date) : "") : l.reason || "") + "</small></span><span>" + esc(right) + "</span></button>";
});
h += "</div>";
} else if (d.locationId) {
var only = d.locations.filter(function (l) { return l.id === d.locationId; })[0];
if (only && d.method !== "shipping") h += '<p class="dw-note">' + esc(only.name) + (only.fee ? " · " + (only.fee.waived ? T.free : money(only.fee.fee) + " " + T.fee) : "") + "</p>";
}
if (!d.locationId) {
if (d.needsAddress) { root.innerHTML = h; return; }
var why = d.locations[0] && d.locations[0].reason;
root.innerHTML = h + (why ? '<p class="dw-msg dw-critical">' + esc(why) + "</p>" : "");
return;
}
if (d.prepNote && d.prepNote.minutes >= 60) h += '<p class="dw-note">' + esc(T.prep + " " + d.prepNote.item) + " (" + Math.round(d.prepNote.minutes / 6) / 10 + " h)</p>";
var peak = d.dates.filter(function (x) { return x.peak && x.peak.message; })[0];
if (peak) h += '<p class="dw-msg dw-peak">' + esc(peak.peak.message) + "</p>";
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.__dwid) root.__dwid = ++ridSeq; return root.__dwid; }
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 = '<div class="dw-cal"><div class="dw-calhead"><button type="button" data-dw="month" data-v="-1" aria-label="Previous month">‹</button><b>' +
esc(new Intl.DateTimeFormat(LOCALE, { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(first + "T12:00:00Z"))) +
'</b><button type="button" data-dw="month" data-v="1" aria-label="Next month">›</button></div><div class="dw-grid" role="grid">';
for (var w = 0; w < 7; w++) {
var wd = (start + w) % 7;
h += '<span class="dw-dow" aria-hidden="true">' + esc(new Intl.DateTimeFormat(LOCALE, { weekday: "narrow", timeZone: "UTC" }).format(new Date(Date.UTC(2023, 0, 1 + wd)))) + "</span>";
}
for (var i = 0; i < lead; i++) h += "<span></span>";
for (var day = 1; day <= daysIn(ym); day++) {
var date = ym + "-" + String(day).padStart(2, "0");
var x = map[date];
var cls = "dw-day", label = dateLabel(date, true), extra = "";
if (!x) cls += " dw-out";
else if (x.available) { cls += " dw-avail"; if (d.text.showRemaining && x.remaining != null && x.remaining <= 5) extra = '<i class="dw-left">' + x.remaining + "</i>"; }
else if (x.waitlist || /full|booked/i.test(x.reasons[0] || "")) { cls += " dw-full"; label += ", " + T.full; }
else { cls += " dw-closed"; label += ", " + (x.reasons[0] || ""); }
if (S.date === date) cls += " dw-sel";
if (x && x.peak) { cls += " dw-peakday"; label += ", " + x.peak.name; }
h += '<button type="button" class="' + cls + '" data-dw="date" data-v="' + date + '" aria-label="' + esc(label) + '"' + (x && (x.available || x.waitlist || x.reasons.length) ? "" : ' aria-disabled="true"') + ' tabindex="' + (S.date === date || (!S.date && date === firstAvail) ? "0" : "-1") + '">' + day + extra + "</button>";
}
return h + "</div></div>";
}
function slots(d, root) {
if (!S.date) return '<p class="dw-note">' + esc(T.choose) + "</p>";
var x = d.dates.filter(function (y) { return y.date === S.date; })[0];
if (!x) return "";
var h = '<div class="dw-day-detail"><h3>' + esc(dateLabel(x.date, true)) + (x.peak ? ' <span class="dw-badge">' + esc(x.peak.name) + "</span>" : "") + "</h3>";
if (d.dateOnly) h += '<p class="dw-note">' + esc(T.dispatch) + "</p>";
if (!x.available) {
h += '<p class="dw-msg ' + (x.waitlist ? "dw-peak" : "dw-critical") + '">' + esc(x.reasons.join(". ")) + "</p>";
if (x.waitlist && !S.waitlistDone)
h += '<form class="dw-row" data-dw="waitlist"><input type="email" name="email" required placeholder="Email" aria-label="Email"><button type="submit">' + esc(T.waitlist) + "</button></form>";
if (S.waitlistDone) h += '<p class="dw-msg dw-success">' + esc(T.joined) + "</p>";
return h + "</div>";
}
h += '<div class="dw-slots" role="group" aria-label="Times">';
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 += '<button type="button" class="dw-slot" data-dw="slot" data-v="' + s.id + '" aria-pressed="' + !!on + '"' + (s.available || on ? "" : ' aria-disabled="true"') + "><b>" + esc(d.dateOnly ? dateLabel(x.date) : slotText(s)) + "</b><small>" + esc(sub) + "</small></button>";
});
h += "</div>";
if (S.sel && S.sel.date === x.date) h += stub(d, x);
return h + "</div>";
}
/** The "ticket" confirming what the customer chose, with the hold countdown. */
function stub(d, x) {
var loc = d.locations.filter(function (l) { return l.id === d.locationId; })[0];
var s = x.slots.filter(function (y) { return y.id === S.sel.slot; })[0];
var live = S.expiresAt && S.expiresAt > Date.now();
var expired = S.expiresAt && !live;
var h = '<div class="dw-stub' + (expired ? " dw-stub--expired" : "") + '"><div class="dw-stub__main">' +
'<span class="dw-stub__method">' + icon(d.method) + esc(d.text.labels[d.method]) + (loc && d.method !== "shipping" ? ", " + esc(loc.name) : "") + "</span>" +
'<b class="dw-stub__date">' + esc(dateLabel(x.date, true)) + "</b>" +
(s && !d.dateOnly ? '<span class="dw-stub__time">' + esc(slotText(s)) + "</span>" : "") +
(S.sel.fee ? '<span class="dw-stub__fee">+' + esc(money(S.sel.fee)) + " " + esc(T.fee) + "</span>" : "") +
'</div><div class="dw-stub__side">';
if (live) h += '<svg class="dw-ring" viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="18" r="15.5"/><circle class="dw-ring__arc" data-dw-ring cx="18" cy="18" r="15.5" pathLength="100"/></svg><span class="dw-stub__hold">' + esc(T.held) + ' <b data-dw-timer></b></span>';
else if (expired) h += '<span class="dw-stub__hold">' + esc(T.expired) + "</span>";
else h += '<svg class="dw-check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></svg>';
h += "</div></div>";
if (loc && loc.pickupInstructions && d.method === "pickup") h += '<p class="dw-note">' + esc(loc.pickupInstructions) + "</p>";
return h;
}
function tickTimer() {
var left = S.expiresAt ? S.expiresAt - Date.now() : 0;
var els = document.querySelectorAll("[data-dw-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");
var total = S.expiresAt && S.heldAt ? S.expiresAt - S.heldAt : 0;
var rings = document.querySelectorAll("[data-dw-ring]");
for (var j = 0; j < rings.length; j++) rings[j].style.strokeDashoffset = total > 0 ? String(100 - Math.max(0, Math.min(100, (left / total) * 100))) : "0";
}
setInterval(tickTimer, 1000);
// ---------- events ----------
document.addEventListener("click", function (e) {
var t = e.target.closest && e.target.closest("[data-datewise-root] [data-dw]");
if (!t || t.tagName === "FORM") return;
if (t.getAttribute("aria-disabled") === "true" && t.getAttribute("data-dw") !== "date") return;
var a = t.getAttribute("data-dw"), 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(".dw-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-dw");
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("dw-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(".dw-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); });
})();