metatroncubeswdev 6598691372 feat: Phase 5 — multi-location, zones, rates, auto-assignment
- 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>
2026-08-24 03:27:21 -04:00

507 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
}