// 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; 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 `'` (and `&` into `&`, // 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 { 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 { 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 { 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, display: string): Promise { 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 | null = null; function loadGoogleMaps(apiKey: string): Promise { if (mapsLoadPromise) return mapsLoadPromise; mapsLoadPromise = new Promise((resolve, reject) => { const callbackName = "__ddMapsReady"; (window as unknown as Record 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 * 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 = { 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