`shopify app dev` failed dev preview with "Only assets, blocks, snippets, locales directories are allowed" — the widget's TypeScript source lived in extensions/datetime-widget/src/, which isn't one of the four directories a Theme App Extension may contain. Moved it to widget-src/datetime-widget/ (a plain, non-extension folder outside extensions/) and updated build:widget's esbuild input path accordingly; the bundled output still lands in the same place (extensions/datetime-widget/assets/). Also fixed a theme-check warning surfaced during the same run: `script_tag` renders a parser-blocking <script> with no way to defer it — switched to a manual <script defer> tag for the widget's JS asset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
360 lines
12 KiB
TypeScript
360 lines
12 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;
|
||
}
|
||
|
||
interface AvailabilityResponse {
|
||
locationId: string;
|
||
locationName: string;
|
||
timezone: string;
|
||
method: Method;
|
||
dates: Record<string, SlotDto[]>;
|
||
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<AvailabilityResponse> {
|
||
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<string> {
|
||
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<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 } }),
|
||
});
|
||
}
|
||
|
||
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<HTMLElement>("[data-dd-widget]");
|
||
roots.forEach((root) => {
|
||
new DateTimeWidget(readConfig(root)).mount();
|
||
});
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|