// 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; } interface AvailabilityResponse { locationId: string; locationName: string; timezone: string; method: Method; dates: Record; error?: string; } interface WidgetConfig { root: HTMLElement; heading: string; locationId: 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; }; } 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}`; } 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, 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.", }, }; } async function fetchAvailability(method: Method, locationId: string | null): Promise { const params = new URLSearchParams({ method, days: "14" }); if (locationId) params.set("locationId", locationId); 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 getCartToken(): Promise { const res = await fetch("/cart.js", { headers: { Accept: "application/json" } }); const cart = (await res.json()) as { token: string }; return cart.token; } async function requestHold(params: { intent: "create" | "release"; locationId: string; method: Method; date: string; startMin: number; cartToken: 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 } }), }); } class DateTimeWidget { private config: WidgetConfig; private el = { heading: document.createElement("h3"), methodRow: 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 } | 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.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.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.confirmation.hidden = true; if (this.config.methods.length > 1) this.renderMethods(); this.el.status.textContent = this.config.labels.loading; this.el.dateRow.innerHTML = ""; try { this.availability = await fetchAvailability(method.value, this.config.locationId); this.renderDates(); } catch { this.el.status.textContent = this.config.labels.error; } } 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 = `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`; 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 display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`; this.el.status.textContent = this.config.labels.loading; try { const cartToken = await getCartToken(); // 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. const hold = await requestHold({ intent: "create", locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken, }); if (!hold.success) { this.el.status.textContent = this.config.labels.error; // The slot we just tried is gone — refresh so the list reflects reality. await this.selectMethod(method); return; } this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken }; await writeCartAttribute( method.attrLabel, { dd_method: method.value, dd_date: date, dd_start_min: String(slot.startMin), dd_end_min: String(slot.endMin), dd_location_id: availability.locationId, }, display, ); this.el.status.textContent = ""; this.el.methodRow.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(); }