// 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; zoneId?: string | null; distanceKm?: number | null; rate?: RateDto | null; error?: string; } interface WidgetConfig { root: HTMLElement; heading: string; locationId: string | null; googleMapsApiKey: string | null; 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; }; } 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, 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.", }, }; } // 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; } 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 }>; }; return { token: cart.token, lines: (cart.items ?? []).map((item) => ({ vendor: item.vendor ?? "", productType: item.product_type ?? "", productId: `gid://shopify/Product/${item.product_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(",")); } 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, 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 (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(); } } 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 = { 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; } } } function init() { const roots = document.querySelectorAll("[data-dd-widget]"); roots.forEach((root) => { new DateTimeWidget(readConfig(root)).mount(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); }