Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md). Core (code + unit tests, 156 green): - Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget now sends variantIds so inventory-based location exclusion actually runs. - Live slot re-validation at checkout: new checkout-snapshot.server.ts writes a shop-metafield capacity snapshot; validation-slot's evaluateCheckout rejects a complete selection that has since filled / blacked out / closed / hit the daily cap / left the schedule. Refreshed on order webhooks and slot/blackout/location/enforcement edits. - Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) + enforcementTag, new app.settings.tsx admin page, honoured via the snapshot. - Per-day order cap: Location.dailyOrderCap threaded through getAvailability (dailyCap + consumedPerDate); admin field on the location screen. - Product-rule slot blocking: ProductRule.blockedStartMins, unioned in resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest; admin field on the product rules screen. - Product-page placement: product-availability.liquid block + widget data-mode="preview" (read-only earliest-date line). - Second locale: datetime-widget fr.json / fr.schema.json. - Migration 20260904120000_review_gaps (apply with prisma migrate deploy). New Functions (source + unit tests; need `shopify app deploy` to ship): - extensions/payment-customization: cart.payment-methods.transform.run — hides cash-on-delivery / pay-in-store gateways on SHIPPING orders. - extensions/checkout-datetime/src: restored from a gitignored dist-only state — Plus native picker + Thank you / Order status confirmation blocks, all calling the existing checkout.scheduling.* routes (one capacity pool). tsconfig ships checkJs:false pending reconciliation with live checkout types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
711 lines
26 KiB
TypeScript
711 lines
26 KiB
TypeScript
// Storefront widget for the Theme App Extension. Vanilla TS, no framework
|
||
// (IMPLEMENTATION_PLAN.md §1 sanctions "Preact or vanilla TS" — vanilla
|
||
// keeps the bundle tiny and avoids a runtime dependency for something this
|
||
// small). Bundled to ../assets/datetime-widget.js via esbuild
|
||
// (`npm run build:widget` at the repo root) — Theme App Extensions ship
|
||
// static assets as-is, there's no CLI build step for this extension type.
|
||
//
|
||
// The widget only *collects* a selection and writes it to cart attributes.
|
||
// It never enforces anything — that's the Validation Function's job
|
||
// (Phase 4), per CLAUDE.md's non-negotiable that enforcement is
|
||
// server-side. Losing network, JS, or an ad-blocker here should degrade to
|
||
// "no slot picked" (which the Function then rejects at checkout), not to a
|
||
// bypass.
|
||
|
||
type Method = "SHIPPING" | "LOCAL_DELIVERY" | "PICKUP";
|
||
|
||
interface SlotDto {
|
||
date: string;
|
||
startMin: number;
|
||
endMin: number;
|
||
capacity: number;
|
||
remainingCapacity: number;
|
||
// SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): present
|
||
// only when the merchant configured transit days on this slot's template.
|
||
// ISO date-time strings (Luxon's DateTime.toJSON()), same shape as
|
||
// start/end elsewhere in this response.
|
||
arrivalRangeStart?: string;
|
||
arrivalRangeEnd?: string;
|
||
}
|
||
|
||
interface RateDto {
|
||
name: string;
|
||
priceCents: number;
|
||
label: string;
|
||
}
|
||
|
||
interface AvailabilityResponse {
|
||
locationId: string | null;
|
||
locationName?: string;
|
||
locationLat?: number | null;
|
||
locationLng?: number | null;
|
||
timezone?: string;
|
||
method: Method;
|
||
dates: Record<string, SlotDto[]>;
|
||
zoneId?: string | null;
|
||
distanceKm?: number | null;
|
||
rate?: RateDto | null;
|
||
error?: string;
|
||
}
|
||
|
||
interface WidgetConfig {
|
||
root: HTMLElement;
|
||
heading: string;
|
||
locationId: string | null;
|
||
googleMapsApiKey: string | null;
|
||
/**
|
||
* "full" (default): the interactive picker that reserves a hold and writes
|
||
* cart attributes. "preview": a read-only "earliest available date" line
|
||
* for the product page (study §3.7 "Delivery/availability information can
|
||
* surface at the product level") — it collects nothing.
|
||
*/
|
||
mode: "full" | "preview";
|
||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||
labels: {
|
||
chooseDate: string;
|
||
chooseTime: string;
|
||
noDates: string;
|
||
confirmed: string;
|
||
change: string;
|
||
loading: string;
|
||
error: string;
|
||
postalCodeLabel: string;
|
||
postalCodeSubmit: string;
|
||
outOfArea: string;
|
||
earliestPrefix: string;
|
||
deliveryAtCheckout: string;
|
||
};
|
||
}
|
||
|
||
const PROXY_BASE = "/apps/scheduling";
|
||
|
||
function minutesToDisplayTime(minutes: number): string {
|
||
const h24 = Math.floor(minutes / 60);
|
||
const m = minutes % 60;
|
||
const period = h24 < 12 ? "AM" : "PM";
|
||
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
|
||
}
|
||
|
||
/** Formats the date portion of a full ISO date-time string (arrivalRangeStart/End) using the same weekday/month/day style as formatDateLabel. */
|
||
function formatArrivalDateLabel(dateTimeIso: string): string {
|
||
return formatDateLabel(dateTimeIso.slice(0, 10));
|
||
}
|
||
|
||
/**
|
||
* SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): a
|
||
* shipping slot's start/end time is a ship-out window the shopper doesn't
|
||
* care about — when the merchant configured transit days, show the
|
||
* estimated ARRIVAL range instead. Falls back to the normal time-of-day
|
||
* label for every other slot (PICKUP/LOCAL_DELIVERY, or SHIPPING with no
|
||
* transit days set).
|
||
*/
|
||
function slotTimeLabel(slot: SlotDto): string {
|
||
if (slot.arrivalRangeStart && slot.arrivalRangeEnd) {
|
||
return `Arrives ${formatArrivalDateLabel(slot.arrivalRangeStart)}–${formatArrivalDateLabel(slot.arrivalRangeEnd)}`;
|
||
}
|
||
if (slot.arrivalRangeStart) {
|
||
return `Arrives from ${formatArrivalDateLabel(slot.arrivalRangeStart)}`;
|
||
}
|
||
if (slot.arrivalRangeEnd) {
|
||
return `Arrives by ${formatArrivalDateLabel(slot.arrivalRangeEnd)}`;
|
||
}
|
||
return `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
||
}
|
||
|
||
function formatDateLabel(dateIso: string): string {
|
||
// Parsed as a plain calendar date (no timezone conversion) — this string
|
||
// already represents the location-local calendar day from the API.
|
||
const [year, month, day] = dateIso.split("-").map(Number);
|
||
const date = new Date(Date.UTC(year, month - 1, day));
|
||
return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
|
||
}
|
||
|
||
function readConfig(root: HTMLElement): WidgetConfig {
|
||
const d = root.dataset;
|
||
const methods: WidgetConfig["methods"] = [];
|
||
if (d.showShipping === "true") {
|
||
methods.push({ value: "SHIPPING", label: d.labelShipping || "Shipping", attrLabel: d.attrLabelShipping || "Shipping date" });
|
||
}
|
||
if (d.showLocalDelivery === "true") {
|
||
methods.push({
|
||
value: "LOCAL_DELIVERY",
|
||
label: d.labelLocalDelivery || "Local delivery",
|
||
attrLabel: d.attrLabelLocalDelivery || "Delivery date",
|
||
});
|
||
}
|
||
if (d.showPickup === "true") {
|
||
methods.push({ value: "PICKUP", label: d.labelPickup || "Pickup", attrLabel: d.attrLabelPickup || "Pickup date" });
|
||
}
|
||
|
||
return {
|
||
root,
|
||
heading: d.heading || "",
|
||
locationId: d.locationId || null,
|
||
googleMapsApiKey: d.googleMapsApiKey || null,
|
||
mode: d.mode === "preview" ? "preview" : "full",
|
||
methods,
|
||
labels: {
|
||
chooseDate: d.labelChooseDate || "Choose a date",
|
||
chooseTime: d.labelChooseTime || "Choose a time",
|
||
noDates: d.labelNoDates || "No dates are available right now.",
|
||
confirmed: d.labelConfirmed || "Confirmed for",
|
||
change: d.labelChange || "Change",
|
||
loading: d.labelLoading || "Loading available dates…",
|
||
error: d.labelError || "Couldn't load available dates. Please try again.",
|
||
postalCodeLabel: d.labelPostalCode || "Enter your postal/ZIP code",
|
||
postalCodeSubmit: d.labelPostalCodeSubmit || "Check availability",
|
||
outOfArea: d.labelOutOfArea || "Sorry, we don't deliver to this address.",
|
||
earliestPrefix: d.labelEarliestPrefix || "Earliest",
|
||
deliveryAtCheckout: d.labelDeliveryAtCheckout || "Enter your address at checkout to see local delivery dates.",
|
||
},
|
||
};
|
||
}
|
||
|
||
// ProductRule scoping (PRODUCT_STRATEGY.md §2): vendor/productType come
|
||
// straight off /cart.js with no extra round trip; productId lets the backend
|
||
// additionally resolve collection/tag-scoped rules via one Admin API call.
|
||
// Fetched fresh on every availability/hold request so it always reflects the
|
||
// shopper's *current* cart, not a stale snapshot from an earlier step.
|
||
interface CartLineInfo {
|
||
vendor: string;
|
||
productType: string;
|
||
productId: string;
|
||
variantId: string;
|
||
}
|
||
|
||
interface CartInfo {
|
||
token: string;
|
||
lines: CartLineInfo[];
|
||
}
|
||
|
||
async function fetchCartInfo(): Promise<CartInfo> {
|
||
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
|
||
const cart = (await res.json()) as {
|
||
token: string;
|
||
items?: Array<{ vendor?: string; product_type?: string; product_id: number; id: number; variant_id?: number }>;
|
||
};
|
||
return {
|
||
token: cart.token,
|
||
lines: (cart.items ?? []).map((item) => ({
|
||
vendor: item.vendor ?? "",
|
||
productType: item.product_type ?? "",
|
||
productId: `gid://shopify/Product/${item.product_id}`,
|
||
// /cart.js line `id` is the variant id; `variant_id` is present on some
|
||
// theme payloads too — prefer whichever we get.
|
||
variantId: `gid://shopify/ProductVariant/${item.variant_id ?? item.id}`,
|
||
})),
|
||
};
|
||
}
|
||
|
||
async function fetchAvailability(
|
||
method: Method,
|
||
locationId: string | null,
|
||
cart: CartInfo,
|
||
postalCode?: string,
|
||
): Promise<AvailabilityResponse> {
|
||
const params = new URLSearchParams({ method, days: "14" });
|
||
if (locationId) params.set("locationId", locationId);
|
||
if (postalCode) params.set("postalCode", postalCode);
|
||
if (cart.lines.length > 0) {
|
||
params.set("cartLines", JSON.stringify(cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType }))));
|
||
params.set("productIds", cart.lines.map((l) => l.productId).join(","));
|
||
params.set("variantIds", cart.lines.map((l) => l.variantId).join(","));
|
||
}
|
||
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
const body = (await res.json()) as AvailabilityResponse;
|
||
if (!res.ok) throw new Error(body.error || `Request failed (${res.status})`);
|
||
return body;
|
||
}
|
||
|
||
interface HoldResponse {
|
||
success: boolean;
|
||
expiresAt?: number;
|
||
error?: string;
|
||
}
|
||
|
||
async function requestHold(params: {
|
||
intent: "create" | "release";
|
||
locationId: string;
|
||
method: Method;
|
||
date: string;
|
||
startMin: number;
|
||
cartToken: string;
|
||
cartLines?: Array<{ vendor: string; productType: string }>;
|
||
productIds?: string[];
|
||
}): Promise<HoldResponse> {
|
||
const res = await fetch(`${PROXY_BASE}/hold`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const body = (await res.json()) as HoldResponse;
|
||
if (!res.ok && params.intent === "create") return { success: false, error: body.error || "Slot unavailable" };
|
||
return body;
|
||
}
|
||
|
||
async function writeCartAttribute(key: string, machine: Record<string, string>, display: string): Promise<void> {
|
||
await fetch("/cart/update.js", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ attributes: { [key]: display, ...machine } }),
|
||
});
|
||
}
|
||
|
||
// Google Maps JS API loads once per page and calls a global callback — the
|
||
// callback name has to be unique-ish and reachable on `window`.
|
||
let mapsLoadPromise: Promise<void> | null = null;
|
||
function loadGoogleMaps(apiKey: string): Promise<void> {
|
||
if (mapsLoadPromise) return mapsLoadPromise;
|
||
|
||
mapsLoadPromise = new Promise((resolve, reject) => {
|
||
const callbackName = "__ddMapsReady";
|
||
(window as unknown as Record<string, () => void>)[callbackName] = () => resolve();
|
||
|
||
const script = document.createElement("script");
|
||
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&callback=${callbackName}`;
|
||
script.async = true;
|
||
script.onerror = () => reject(new Error("Failed to load Google Maps"));
|
||
document.head.appendChild(script);
|
||
});
|
||
|
||
return mapsLoadPromise;
|
||
}
|
||
|
||
interface GoogleMapsGlobal {
|
||
maps: {
|
||
Map: new (el: HTMLElement, options: { center: { lat: number; lng: number }; zoom: number }) => unknown;
|
||
Marker: new (options: { position: { lat: number; lng: number }; map: unknown; title?: string }) => unknown;
|
||
};
|
||
}
|
||
|
||
async function renderPickupMap(container: HTMLElement, apiKey: string, lat: number, lng: number, title: string) {
|
||
try {
|
||
await loadGoogleMaps(apiKey);
|
||
const google = (window as unknown as { google: GoogleMapsGlobal }).google;
|
||
const map = new google.maps.Map(container, { center: { lat, lng }, zoom: 13 });
|
||
new google.maps.Marker({ position: { lat, lng }, map, title });
|
||
} catch {
|
||
container.hidden = true; // no map, no crash — the date/time picker still works fine without it
|
||
}
|
||
}
|
||
|
||
class DateTimeWidget {
|
||
private config: WidgetConfig;
|
||
private el = {
|
||
heading: document.createElement("h3"),
|
||
methodRow: document.createElement("div"),
|
||
postalRow: document.createElement("div"),
|
||
mapContainer: document.createElement("div"),
|
||
dateRow: document.createElement("div"),
|
||
timeRow: document.createElement("div"),
|
||
status: document.createElement("p"),
|
||
confirmation: document.createElement("div"),
|
||
};
|
||
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||
private selectedDate: string | null = null;
|
||
private availability: AvailabilityResponse | null = null;
|
||
private heldSlot:
|
||
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
|
||
| null = null;
|
||
|
||
constructor(config: WidgetConfig) {
|
||
this.config = config;
|
||
}
|
||
|
||
mount() {
|
||
const { root, heading, methods } = this.config;
|
||
root.classList.add("dd-widget--ready");
|
||
root.innerHTML = "";
|
||
|
||
if (methods.length === 0) return; // merchant disabled every method — render nothing
|
||
|
||
if (this.config.mode === "preview") {
|
||
void this.mountPreview();
|
||
return;
|
||
}
|
||
|
||
if (heading) {
|
||
this.el.heading.className = "dd-widget__heading";
|
||
this.el.heading.textContent = heading;
|
||
root.appendChild(this.el.heading);
|
||
}
|
||
|
||
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
|
||
this.el.postalRow.className = "dd-widget__row dd-widget__postal";
|
||
this.el.postalRow.hidden = true;
|
||
this.el.mapContainer.className = "dd-widget__map";
|
||
this.el.mapContainer.hidden = true;
|
||
this.el.dateRow.className = "dd-widget__row dd-widget__dates";
|
||
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
||
this.el.status.className = "dd-widget__status";
|
||
this.el.confirmation.className = "dd-widget__confirmation";
|
||
this.el.confirmation.hidden = true;
|
||
|
||
root.append(
|
||
this.el.confirmation,
|
||
this.el.methodRow,
|
||
this.el.postalRow,
|
||
this.el.mapContainer,
|
||
this.el.dateRow,
|
||
this.el.timeRow,
|
||
this.el.status,
|
||
);
|
||
|
||
if (methods.length === 1) {
|
||
this.selectMethod(methods[0]);
|
||
} else {
|
||
this.renderMethods();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Product-page preview (study §3.7): a read-only "earliest available
|
||
* <method> date" line per method. Collects nothing, reserves nothing —
|
||
* the real picker on the cart page does that.
|
||
*/
|
||
private async mountPreview() {
|
||
const { root, heading, methods } = this.config;
|
||
|
||
if (heading) {
|
||
this.el.heading.className = "dd-widget__heading";
|
||
this.el.heading.textContent = heading;
|
||
root.appendChild(this.el.heading);
|
||
}
|
||
|
||
const list = document.createElement("ul");
|
||
list.className = "dd-widget__preview";
|
||
root.appendChild(list);
|
||
|
||
const cart = await fetchCartInfo().catch(() => ({ token: "", lines: [] as CartLineInfo[] }));
|
||
|
||
await Promise.all(
|
||
methods.map(async (method) => {
|
||
const li = document.createElement("li");
|
||
li.className = "dd-widget__preview-item";
|
||
|
||
if (method.value === "LOCAL_DELIVERY") {
|
||
li.textContent = this.config.labels.deliveryAtCheckout;
|
||
list.appendChild(li);
|
||
return;
|
||
}
|
||
|
||
li.textContent = this.config.labels.loading;
|
||
list.appendChild(li);
|
||
|
||
try {
|
||
const availability = await fetchAvailability(method.value, this.config.locationId, cart);
|
||
const earliest = Object.keys(availability.dates ?? {}).sort()[0];
|
||
li.textContent = earliest
|
||
? `${this.config.labels.earliestPrefix} ${method.label.toLowerCase()}: ${formatDateLabel(earliest)}`
|
||
: `${method.label}: ${this.config.labels.noDates}`;
|
||
} catch {
|
||
li.textContent = `${method.label}: ${this.config.labels.error}`;
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
private renderMethods() {
|
||
this.el.methodRow.innerHTML = "";
|
||
for (const method of this.config.methods) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "dd-widget__pill";
|
||
button.textContent = method.label;
|
||
button.setAttribute("aria-pressed", String(this.selectedMethod?.value === method.value));
|
||
button.addEventListener("click", () => this.selectMethod(method));
|
||
this.el.methodRow.appendChild(button);
|
||
}
|
||
}
|
||
|
||
private async selectMethod(method: WidgetConfig["methods"][number]) {
|
||
this.selectedMethod = method;
|
||
this.selectedDate = null;
|
||
this.el.timeRow.innerHTML = "";
|
||
this.el.dateRow.innerHTML = "";
|
||
this.el.mapContainer.hidden = true;
|
||
this.el.confirmation.hidden = false;
|
||
this.el.confirmation.hidden = true;
|
||
if (this.config.methods.length > 1) this.renderMethods();
|
||
|
||
// Local delivery needs a postal/ZIP code first — availability depends
|
||
// on which zone (if any) the address falls into, and which location
|
||
// that zone routes to (PRODUCT_STRATEGY.md §2 "Auto location assignment").
|
||
if (method.value === "LOCAL_DELIVERY") {
|
||
this.renderPostalCodeInput(method);
|
||
return;
|
||
}
|
||
|
||
this.el.postalRow.hidden = true;
|
||
await this.loadAvailability(method);
|
||
}
|
||
|
||
private renderPostalCodeInput(method: WidgetConfig["methods"][number]) {
|
||
this.el.postalRow.hidden = false;
|
||
this.el.postalRow.innerHTML = "";
|
||
this.el.status.textContent = "";
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.className = "dd-widget__input";
|
||
input.placeholder = this.config.labels.postalCodeLabel;
|
||
input.setAttribute("aria-label", this.config.labels.postalCodeLabel);
|
||
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "dd-widget__pill";
|
||
button.textContent = this.config.labels.postalCodeSubmit;
|
||
button.addEventListener("click", async () => {
|
||
const postalCode = input.value.trim();
|
||
if (!postalCode) return;
|
||
await this.loadAvailability(method, postalCode);
|
||
});
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") button.click();
|
||
});
|
||
|
||
this.el.postalRow.append(input, button);
|
||
}
|
||
|
||
private async loadAvailability(method: WidgetConfig["methods"][number], postalCode?: string) {
|
||
this.el.status.textContent = this.config.labels.loading;
|
||
this.el.dateRow.innerHTML = "";
|
||
|
||
try {
|
||
const cart = await fetchCartInfo();
|
||
this.availability = await fetchAvailability(method.value, this.config.locationId, cart, postalCode);
|
||
|
||
if (!this.availability.locationId) {
|
||
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
|
||
return;
|
||
}
|
||
|
||
if (method.value === "PICKUP" && this.config.googleMapsApiKey) {
|
||
this.showPickupMap();
|
||
}
|
||
|
||
this.renderDates();
|
||
} catch {
|
||
this.el.status.textContent = this.config.labels.error;
|
||
}
|
||
}
|
||
|
||
private showPickupMap() {
|
||
const a = this.availability;
|
||
if (!a?.locationLat || !a?.locationLng || !this.config.googleMapsApiKey) return;
|
||
this.el.mapContainer.hidden = false;
|
||
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, a.locationLat, a.locationLng, a.locationName ?? "");
|
||
}
|
||
|
||
private renderDates() {
|
||
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
||
this.el.dateRow.innerHTML = "";
|
||
|
||
if (dates.length === 0) {
|
||
this.el.status.textContent = this.config.labels.noDates;
|
||
return;
|
||
}
|
||
|
||
this.el.status.textContent = this.config.labels.chooseDate;
|
||
for (const date of dates) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "dd-widget__pill";
|
||
button.textContent = formatDateLabel(date);
|
||
button.setAttribute("aria-pressed", String(this.selectedDate === date));
|
||
button.addEventListener("click", () => this.selectDate(date));
|
||
this.el.dateRow.appendChild(button);
|
||
}
|
||
}
|
||
|
||
private selectDate(date: string) {
|
||
this.selectedDate = date;
|
||
for (const child of Array.from(this.el.dateRow.children)) {
|
||
child.setAttribute("aria-pressed", String(child.textContent === formatDateLabel(date)));
|
||
}
|
||
|
||
const slots = this.availability?.dates[date] ?? [];
|
||
this.el.timeRow.innerHTML = "";
|
||
this.el.status.textContent = this.config.labels.chooseTime;
|
||
|
||
for (const slot of slots) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "dd-widget__pill";
|
||
button.textContent = slotTimeLabel(slot);
|
||
button.addEventListener("click", () => this.selectSlot(date, slot));
|
||
this.el.timeRow.appendChild(button);
|
||
}
|
||
}
|
||
|
||
private async selectSlot(date: string, slot: SlotDto) {
|
||
const method = this.selectedMethod!;
|
||
const availability = this.availability!;
|
||
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
|
||
const display =
|
||
slot.arrivalRangeStart || slot.arrivalRangeEnd
|
||
? `Ships ${formatDateLabel(date)}, ${slotTimeLabel(slot)}${rateLabel}`
|
||
: `${formatDateLabel(date)}, ${slotTimeLabel(slot)}${rateLabel}`;
|
||
|
||
this.el.status.textContent = this.config.labels.loading;
|
||
|
||
try {
|
||
const cart = await fetchCartInfo();
|
||
|
||
// Reserve capacity FIRST. Writing the cart attribute alone would just
|
||
// be two shoppers racing to write the same free-text field — nothing
|
||
// would stop both checkouts from completing for the last slot. The
|
||
// hold is what the Validation Function (Phase 4) actually enforces
|
||
// against at checkout. It also re-checks ProductRule constraints
|
||
// server-side (hold-request.server.ts) — the real enforcement point,
|
||
// not just what this widget chose to display.
|
||
const hold = await requestHold({
|
||
intent: "create",
|
||
locationId: availability.locationId!,
|
||
method: method.value,
|
||
date,
|
||
startMin: slot.startMin,
|
||
cartToken: cart.token,
|
||
cartLines: cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType })),
|
||
productIds: cart.lines.map((l) => l.productId),
|
||
});
|
||
|
||
if (!hold.success) {
|
||
this.el.status.textContent = hold.error || this.config.labels.error;
|
||
// The slot we just tried is gone (or a ProductRule now excludes it) — refresh so the list reflects reality.
|
||
await this.selectMethod(method);
|
||
return;
|
||
}
|
||
|
||
this.heldSlot = {
|
||
locationId: availability.locationId!,
|
||
method: method.value,
|
||
date,
|
||
startMin: slot.startMin,
|
||
cartToken: cart.token,
|
||
zoneId: availability.zoneId ?? null,
|
||
};
|
||
|
||
const machineAttrs: Record<string, string> = {
|
||
dd_method: method.value,
|
||
dd_date: date,
|
||
dd_start_min: String(slot.startMin),
|
||
dd_end_min: String(slot.endMin),
|
||
dd_location_id: availability.locationId!,
|
||
};
|
||
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
|
||
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
|
||
if (slot.arrivalRangeStart) machineAttrs.dd_arrival_range_start = slot.arrivalRangeStart;
|
||
if (slot.arrivalRangeEnd) machineAttrs.dd_arrival_range_end = slot.arrivalRangeEnd;
|
||
|
||
await writeCartAttribute(method.attrLabel, machineAttrs, display);
|
||
|
||
this.el.status.textContent = "";
|
||
this.el.methodRow.hidden = true;
|
||
this.el.postalRow.hidden = true;
|
||
this.el.mapContainer.hidden = true;
|
||
this.el.dateRow.hidden = true;
|
||
this.el.timeRow.hidden = true;
|
||
this.el.confirmation.hidden = false;
|
||
this.el.confirmation.innerHTML = "";
|
||
|
||
const summary = document.createElement("p");
|
||
summary.textContent = `${this.config.labels.confirmed} ${display}`;
|
||
const changeButton = document.createElement("button");
|
||
changeButton.type = "button";
|
||
changeButton.className = "dd-widget__link";
|
||
changeButton.textContent = this.config.labels.change;
|
||
changeButton.addEventListener("click", () => {
|
||
if (this.heldSlot) {
|
||
void requestHold({ intent: "release", ...this.heldSlot });
|
||
this.heldSlot = null;
|
||
}
|
||
this.el.methodRow.hidden = false;
|
||
this.el.dateRow.hidden = false;
|
||
this.el.timeRow.hidden = false;
|
||
this.el.confirmation.hidden = true;
|
||
});
|
||
|
||
this.el.confirmation.append(summary, changeButton);
|
||
} catch {
|
||
this.el.status.textContent = this.config.labels.error;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Cross-theme cart placement: a manually-placed app BLOCK only ever lands
|
||
// wherever the active theme's own section schema happens to declare an
|
||
// `@app` slot — many themes only expose "Add section" for the cart's
|
||
// checkout area, not "Add block" next to the actual Checkout button, which
|
||
// is what shows up as a disconnected standalone section. There's no
|
||
// Shopify-supported way for an app to inject a block into an arbitrary
|
||
// spot in a theme's own markup, so instead: the app embed (blocks/
|
||
// app-embed.liquid, loaded site-wide once merchants enable it, independent
|
||
// of any block placement) emits an inert <template id="dd-widget-cart-
|
||
// template"> carrying the widget's config as data-* attributes. If we're
|
||
// on the cart page, no block-placed widget already exists (avoids a
|
||
// double render), and a Checkout button can be found by one of these
|
||
// selectors, clone a live widget in immediately before it. Ordered
|
||
// roughly most-to-least specific/reliable across common theme markup
|
||
// patterns; the first match wins.
|
||
const CHECKOUT_BUTTON_SELECTORS = [
|
||
'form[action*="/cart"] button[name="checkout"]',
|
||
'form[action*="/cart"] input[name="checkout"]',
|
||
'[name="checkout"]',
|
||
'#checkout',
|
||
'a[href="/checkout"]',
|
||
];
|
||
|
||
function findCheckoutButton(): HTMLElement | null {
|
||
for (const selector of CHECKOUT_BUTTON_SELECTORS) {
|
||
const el = document.querySelector<HTMLElement>(selector);
|
||
if (el) return el;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isCartPage(): boolean {
|
||
// Matches /cart, /cart/, and locale-prefixed variants like /en/cart —
|
||
// but not /cart/add or similar sub-paths that aren't the cart page itself.
|
||
return window.location.pathname.replace(/\/+$/, "").endsWith("/cart");
|
||
}
|
||
|
||
function maybeAutoPlaceOnCart() {
|
||
if (!isCartPage()) return;
|
||
if (document.querySelector("[data-dd-widget]")) return; // a block is already placed manually — don't double up
|
||
|
||
const template = document.getElementById("dd-widget-cart-template");
|
||
if (!template) return; // merchant turned auto-placement off in the app embed's settings
|
||
|
||
const checkoutButton = findCheckoutButton();
|
||
if (!checkoutButton) return; // couldn't find a safe, theme-agnostic anchor — do nothing rather than guess
|
||
|
||
const widget = document.createElement("div");
|
||
widget.setAttribute("data-dd-widget", "");
|
||
widget.classList.add("dd-widget--cart-injected");
|
||
for (const attr of Array.from(template.attributes)) {
|
||
if (attr.name === "id") continue;
|
||
widget.setAttribute(attr.name, attr.value);
|
||
}
|
||
|
||
checkoutButton.insertAdjacentElement("beforebegin", widget);
|
||
new DateTimeWidget(readConfig(widget)).mount();
|
||
}
|
||
|
||
function init() {
|
||
const roots = document.querySelectorAll<HTMLElement>("[data-dd-widget]");
|
||
roots.forEach((root) => {
|
||
new DateTimeWidget(readConfig(root)).mount();
|
||
});
|
||
maybeAutoPlaceOnCart();
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|