metatroncubeswdev b29962bf9c
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: finish multi-pin pickup end-to-end; fix checkout-datetime followups
Multi-pin pickup selection (study §3.4) — previously a standalone unused
endpoint + TODO comments:
- app/services/pickup-locations.server.ts: pure filterPickupLocationIds +
  I/O resolvePickupLocations (active + has a PICKUP slot template +
  ProductRule allowedLocationIds + inventory exclusion).
- resolveAvailabilityRequest returns pickupLocations[] on method=PICKUP and
  defaults the active location to the first eligible pickup point.
- apps.scheduling.locations.tsx refactored onto the shared resolver
  (was a second copy of the logic).
- Storefront widget: pickup-location chooser (name + address, optional
  multi-pin Google map) when >1 eligible point and none block-configured;
  picking one re-requests availability for that location. New styles +
  widget.choose_pickup_location locale (en + fr).
- Checkout extension Checkout.jsx: same chooser before the date list.
- tests/unit/pickup-locations.test.ts (5 cases); suite 161 green.

checkout-datetime follow-up fixes from the 808a3b7 review:
- typescript devDep ^7.0.2 -> ^5.6.3 (there is no typescript@7 on npm).
- Deleted dead shopify.d.ts (Preact-global shim, unused after the React
  rewrite) and dropped it from tsconfig include.

Also stages the CLI-written `uid` lines in the checkout-datetime and
payment-customization extension tomls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:47:59 -04:00

830 lines
30 KiB
TypeScript
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.

// 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 PickupLocationDto {
id: string;
name: string;
address: string;
lat: number | null;
lng: number | null;
}
interface AvailabilityResponse {
locationId: string | null;
locationName?: string;
// Every eligible pickup point (study §3.4 multi-pin pickup). Present only
// for method === "PICKUP"; `dates` is for whichever one is currently
// active — re-request with `locationId` to switch.
pickupLocations?: PickupLocationDto[];
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;
choosePickupLocation: 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" });
}
// Liquid's `| escape` filter turns `'` into `&#39;` (and `&` into `&amp;`,
// etc.) before the string lands in a data-* attribute. Depending on how the
// theme/section double-processes translations, that can survive into
// `dataset` still entity-encoded and then render literally when we assign it
// to `textContent`. Decode once here so labels always show as plain text.
function decodeEntities(value: string): string {
if (!value || value.indexOf("&") === -1) return value;
const el = document.createElement("textarea");
el.innerHTML = value;
return el.value;
}
function readConfig(root: HTMLElement): WidgetConfig {
const d = root.dataset;
const get = (key: string, fallback: string): string => {
const raw = d[key];
return raw ? decodeEntities(raw) : fallback;
};
const methods: WidgetConfig["methods"] = [];
if (d.showShipping === "true") {
methods.push({ value: "SHIPPING", label: get("labelShipping", "Shipping"), attrLabel: get("attrLabelShipping", "Shipping date") });
}
if (d.showLocalDelivery === "true") {
methods.push({
value: "LOCAL_DELIVERY",
label: get("labelLocalDelivery", "Local delivery"),
attrLabel: get("attrLabelLocalDelivery", "Delivery date"),
});
}
if (d.showPickup === "true") {
methods.push({ value: "PICKUP", label: get("labelPickup", "Pickup"), attrLabel: get("attrLabelPickup", "Pickup date") });
}
return {
root,
heading: get("heading", ""),
locationId: d.locationId || null,
googleMapsApiKey: d.googleMapsApiKey || null,
mode: d.mode === "preview" ? "preview" : "full",
methods,
labels: {
chooseDate: get("labelChooseDate", "Choose a date"),
chooseTime: get("labelChooseTime", "Choose a time"),
noDates: get("labelNoDates", "No dates are available right now."),
confirmed: get("labelConfirmed", "Confirmed for"),
change: get("labelChange", "Change"),
loading: get("labelLoading", "Loading available dates…"),
error: get("labelError", "Couldn't load available dates. Please try again."),
postalCodeLabel: get("labelPostalCode", "Enter your postal/ZIP code"),
postalCodeSubmit: get("labelPostalCodeSubmit", "Check availability"),
outOfArea: get("labelOutOfArea", "Sorry, we don't deliver to this address."),
earliestPrefix: get("labelEarliestPrefix", "Earliest"),
deliveryAtCheckout: get("labelDeliveryAtCheckout", "Enter your address at checkout to see local delivery dates."),
choosePickupLocation: get("labelChoosePickupLocation", "Choose a pickup location"),
},
};
}
// 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,
points: Array<{ lat: number; lng: number; title: string }>,
) {
if (points.length === 0) {
container.hidden = true;
return;
}
try {
await loadGoogleMaps(apiKey);
const google = (window as unknown as { google: GoogleMapsGlobal }).google;
const map = new google.maps.Map(container, { center: { lat: points[0].lat, lng: points[0].lng }, zoom: 13 });
for (const p of points) {
new google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, title: p.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"),
pickupRow: 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 selectedPickupLocationId: 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.pickupRow.className = "dd-widget__row dd-widget__pickup-locations";
this.el.pickupRow.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.pickupRow,
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.selectedPickupLocationId = null;
this.el.timeRow.innerHTML = "";
this.el.dateRow.innerHTML = "";
this.el.pickupRow.hidden = true;
this.el.pickupRow.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,
pickupLocationId?: string,
) {
this.el.status.textContent = this.config.labels.loading;
this.el.dateRow.innerHTML = "";
try {
const cart = await fetchCartInfo();
// A block-configured fixed locationId always wins; otherwise use the
// shopper's pin choice (multi-pin pickup), else let the backend default.
const locationId = this.config.locationId ?? pickupLocationId ?? null;
this.availability = await fetchAvailability(method.value, locationId, cart, postalCode);
// Multi-pin pickup (study §3.4): when the shop has more than one
// eligible pickup point and the shopper hasn't picked one yet (and the
// block didn't hard-code one), show the chooser before the calendar.
const pickupLocations = this.availability.pickupLocations ?? [];
if (
method.value === "PICKUP" &&
!this.config.locationId &&
!this.selectedPickupLocationId &&
pickupLocations.length > 1
) {
this.renderPickupLocations(method, pickupLocations);
return;
}
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 renderPickupLocations(
method: WidgetConfig["methods"][number],
locations: PickupLocationDto[],
) {
this.el.pickupRow.hidden = false;
this.el.pickupRow.innerHTML = "";
this.el.status.textContent = this.config.labels.choosePickupLocation;
if (this.config.googleMapsApiKey) {
const points = locations
.filter((l): l is PickupLocationDto & { lat: number; lng: number } => l.lat != null && l.lng != null)
.map((l) => ({ lat: l.lat, lng: l.lng, title: l.name }));
if (points.length > 0) {
this.el.mapContainer.hidden = false;
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, points);
}
}
for (const loc of locations) {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pickup-location";
button.setAttribute("aria-pressed", String(this.selectedPickupLocationId === loc.id));
const name = document.createElement("span");
name.className = "dd-widget__pickup-location-name";
name.textContent = loc.name;
const addr = document.createElement("span");
addr.className = "dd-widget__pickup-location-address";
addr.textContent = loc.address;
button.append(name, addr);
button.addEventListener("click", async () => {
this.selectedPickupLocationId = loc.id;
this.el.pickupRow.hidden = true;
await this.loadAvailability(method, undefined, loc.id);
});
this.el.pickupRow.appendChild(button);
}
}
private showPickupMap() {
const a = this.availability;
if (!this.config.googleMapsApiKey) return;
// Prefer plotting every pin; fall back to the single active location.
const points = (a?.pickupLocations ?? [])
.filter((l): l is PickupLocationDto & { lat: number; lng: number } => l.lat != null && l.lng != null)
.map((l) => ({ lat: l.lat, lng: l.lng, title: l.name }));
if (points.length === 0 && a?.locationLat != null && a?.locationLng != null) {
points.push({ lat: a.locationLat, lng: a.locationLng, title: a.locationName ?? "" });
}
if (points.length === 0) return;
this.el.mapContainer.hidden = false;
void renderPickupMap(this.el.mapContainer, this.config.googleMapsApiKey, points);
}
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();
}