fix(build): make every extension build for shopify app deploy

- functions: add codegen config and the downloaded schema.graphql (generated/ ignored)
- order-reschedule: add the @preact/signals peer dependency
- widget: source moved to widget-src/, minified build in assets (npm run build:widget,
  also run by predeploy); e2e passes on the minified file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-15 00:47:08 -04:00
parent 3ca60ef174
commit db85f3263d
14 changed files with 15912 additions and 449 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ extensions/*/dist
extensions/*/node_modules
*.log
.DS_Store
extensions/*/generated

View File

@ -12,5 +12,20 @@
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
},
"codegen": {
"schema": "schema.graphql",
"documents": "src/*.graphql",
"generates": {
"./generated/api.ts": {
"plugins": [
"typescript",
"typescript-operations"
]
}
},
"config": {
"omitOperationSuffix": true
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -12,5 +12,20 @@
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
},
"codegen": {
"schema": "schema.graphql",
"documents": "src/*.graphql",
"generates": {
"./generated/api.ts": {
"plugins": [
"typescript",
"typescript-operations"
]
}
},
"config": {
"omitOperationSuffix": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
{
"customer-account.order-status.block.render": {}
}

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.11.3",
"@shopify/ui-extensions": "2026.1.x",
"preact": "^10.26.0"
}
@ -48,6 +49,32 @@
"node": ">= 8"
}
},
"node_modules/@preact/signals": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.11.3.tgz",
"integrity": "sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw==",
"license": "MIT",
"dependencies": {
"@preact/signals-core": "^1.14.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact": ">= 10.25.0 || >=11.0.0-0"
}
},
"node_modules/@preact/signals-core": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz",
"integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@shopify/ui-extensions": {
"version": "2026.1.5",
"resolved": "https://registry.npmjs.org/@shopify/ui-extensions/-/ui-extensions-2026.1.5.tgz",

View File

@ -4,6 +4,7 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@preact/signals": "^2.11.3",
"@shopify/ui-extensions": "2026.1.x",
"preact": "^10.26.0"
}

View File

@ -12,5 +12,20 @@
},
"dependencies": {
"@shopify/shopify_function": "^2.0.1"
},
"codegen": {
"schema": "schema.graphql",
"documents": "src/*.graphql",
"generates": {
"./generated/api.ts": {
"plugins": [
"typescript",
"typescript-operations"
]
}
},
"config": {
"omitOperationSuffix": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Datewise \u2014 delivery & pickup date/time scheduling for Shopify, with capacity holds, peak-day rush control, perishable-aware dates, customer self-reschedule, reminders and driver run sheets.",
"description": "Datewise — delivery & pickup date/time scheduling for Shopify, with capacity holds, peak-day rush control, perishable-aware dates, customer self-reschedule, reminders and driver run sheets.",
"scripts": {
"build": "react-router build",
"dev": "shopify app dev",
@ -18,7 +18,9 @@
"prototype": "esbuild app/engine/index.ts --bundle --format=iife --global-name=DatewiseEngine --target=es2019 --minify --outfile=prototype/engine.js && node prototype/build.mjs",
"test:db": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres@localhost:5432/datewise_test} vitest run",
"e2e:server": "tsx tests/e2e/server.ts",
"e2e": "node tests/e2e/widget.e2e.mjs"
"e2e": "node tests/e2e/widget.e2e.mjs",
"build:widget": "esbuild widget-src/datewise.js --minify --target=es2019 --legal-comments=none --outfile=extensions/datewise-widget/assets/datewise.js",
"predeploy": "npm run build:widget"
},
"engines": {
"node": ">=20.10"

View File

@ -9,7 +9,6 @@ embedded = true
[build]
automatically_update_urls_on_dev = false
include_config_on_deploy = true
[access_scopes]
# Each scope is used by a shipped feature — see README "Permissions".

447
widget-src/datewise.js Normal file
View File

@ -0,0 +1,447 @@
/* Datewise storefront widget SOURCE. Edit this file, then `npm run build:widget` (minified copy → extensions/datewise-widget/assets/datewise.js). */
/* 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); });
})();