- Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band
keyed), GeocodeCache (permanent address->lat/lng cache per
IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to
Shopify's own Location resource for inventory checks), Booking.zoneId
(needed for per-zone delivery-density counts, not just per-location).
- app/lib/geo.ts: pure haversine distance + postal-code matching, unit
tested against known city-to-city distances.
- app/services/zones.server.ts: geocoding (Google Maps Geocoding API,
cached — never re-geocodes the same address twice), zone eligibility,
nearest-location auto-assignment ranked by distance, delivery-density
threshold checks (a sparse zone doesn't unlock until minOrders bookings
have already routed through it), and inventory-based location exclusion
via Shopify's InventoryLevel API (locations without a mapped
shopifyLocationId are left in rather than false-negative excluded).
- app/services/rates.server.ts: pure rate resolution by zone or distance
band, cheapest-match-wins when bands overlap.
- apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a
postalCode/address now auto-assign to the nearest eligible,
density-qualified zone/location instead of the shop's default location;
response includes the matched rate. Also fixed a real gap left over from
Phase 4: this route never actually read Booking counts into
getAvailability's `consumed` map, so capacity always showed as fully
available regardless of existing bookings — now it does.
- extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code
before showing dates; PICKUP shows a Google Maps pin for the location
(both gated on an optional Maps API key — a block setting in the theme
editor, since it needs to be public/client-side, not an app secret);
confirmation display and cart attributes (dd_zone_id, dd_rate_label)
carry the resolved zone/rate through to checkout.
- extensions/delivery-customization: now appends the resolved rate to the
relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's
configured — real Cart Transform-based fee *charging* stays deferred to
v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only.
- Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's
patterns), plus shopifyLocationId and auto-geocode-on-save added to the
location edit form.
Fixed one real bug caught only by `npm run build` (not tsc/vitest, which
both passed clean): app.rates._index.tsx's component called
formatPriceLabel from rates.server.ts, and Remix correctly refuses to
bundle anything imported from a .server.ts path for the client. Moved the
pure (no I/O, no Prisma) formatter to app/lib/currency.ts.
Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates,
delivery-customization's rate-label case with a real WASM fixture run),
16 integration tests against live Postgres (+8 new: geocode caching,
postal/radius zone matching, nearest-first ranking, density thresholds),
both builds, and a live script exercising the full
zone-match -> density-check -> rate-resolve -> availability pipeline
together against the Postgres container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
507 lines
18 KiB
TypeScript
507 lines
18 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 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<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;
|
||
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}`;
|
||
}
|
||
|
||
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.",
|
||
},
|
||
};
|
||
}
|
||
|
||
async function fetchAvailability(
|
||
method: Method,
|
||
locationId: string | null,
|
||
postalCode?: string,
|
||
): Promise<AvailabilityResponse> {
|
||
const params = new URLSearchParams({ method, days: "14" });
|
||
if (locationId) params.set("locationId", locationId);
|
||
if (postalCode) params.set("postalCode", postalCode);
|
||
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 } }),
|
||
});
|
||
}
|
||
|
||
// 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, 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 {
|
||
this.availability = await fetchAvailability(method.value, this.config.locationId, 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 = `${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 rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
|
||
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}${rateLabel}`;
|
||
|
||
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,
|
||
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;
|
||
|
||
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<HTMLElement>("[data-dd-widget]");
|
||
roots.forEach((root) => {
|
||
new DateTimeWidget(readConfig(root)).mount();
|
||
});
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|