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>
This commit is contained in:
metatroncubeswdev 2026-08-24 03:27:21 -04:00
parent 4eebcc5c78
commit 6598691372
31 changed files with 1757 additions and 64 deletions

View File

@ -53,10 +53,17 @@ public launch or Built-for-Shopify submission** — don't ship without it.
## Status
Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
build order and acceptance criteria — next up is Phase 5 (multi-location,
zones, rates, auto-assignment).
Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
acceptance criteria — next up is Phase 6 (ops/dispatch dashboard).
Phase 5's Google Maps / geocoding features (pickup-location map in the
widget, radius-zone eligibility, address auto-geocoding on Save Location)
are only live if `GOOGLE_MAPS_API_KEY` is set — either as an env var for
server-side geocoding, or as the "Google Maps API key" block setting in the
theme editor for the storefront map. Without a key, everything else in
Phase 5 (postal-code zones, distance-band rates, delivery-density
thresholds) still works — those don't need Maps at all.
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
**not** inside `extensions/datetime-widget/` — a Theme App Extension's

9
app/lib/currency.ts Normal file
View File

@ -0,0 +1,9 @@
// Pure formatting, no I/O, no server-only dependencies — safe to import
// from both client components and server code (unlike rates.server.ts,
// which pulls in @prisma/client and can't be bundled for the browser).
export function formatPriceLabel(priceCents: number, currencyCode = "USD"): string {
const amount = (priceCents / 100).toFixed(2);
const symbol = currencyCode === "USD" || currencyCode === "CAD" ? "$" : `${currencyCode} `;
return `${symbol}${amount}`;
}

49
app/lib/geo.ts Normal file
View File

@ -0,0 +1,49 @@
// Pure geographic math — no network, no DB (CLAUDE.md: pure functions,
// inject data, no I/O in the math). Geocoding itself (address -> lat/lng)
// is I/O and lives in services/zones.server.ts; this file is just the
// distance/eligibility arithmetic once coordinates are known.
export interface Coordinates {
lat: number;
lng: number;
}
const EARTH_RADIUS_KM = 6371;
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Great-circle (straight-line) distance between two points, in kilometers. */
export function haversineDistanceKm(a: Coordinates, b: Coordinates): number {
const dLat = toRadians(b.lat - a.lat);
const dLng = toRadians(b.lng - a.lng);
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
return EARTH_RADIUS_KM * c;
}
export function isWithinRadiusKm(point: Coordinates, center: Coordinates, radiusKm: number): boolean {
return haversineDistanceKm(point, center) <= radiusKm;
}
/** Loose normalization for postal/ZIP comparison: uppercase, strip spaces. Matches "V6B 1A1" against "v6b1a1". */
export function normalizePostalCode(code: string): string {
return code.toUpperCase().replace(/\s+/g, "");
}
export function isPostalCodeListed(postalCode: string, listed: string[]): boolean {
const normalized = normalizePostalCode(postalCode);
return listed.some((entry) => normalizePostalCode(entry) === normalized);
}
/** Sorts locations by distance from a point, nearest first. */
export function sortByDistance<T extends { coordinates: Coordinates }>(point: Coordinates, items: T[]): T[] {
return [...items].sort(
(a, b) => haversineDistanceKm(point, a.coordinates) - haversineDistanceKm(point, b.coordinates),
);
}

View File

@ -14,6 +14,7 @@ import {
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { geocodeAddress } from "../services/zones.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
@ -43,6 +44,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const address = String(formData.get("address") || "").trim();
const timezone = String(formData.get("timezone") || "").trim();
const active = formData.get("active") === "true";
const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null;
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
@ -51,9 +53,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return { errors };
}
// Auto-geocode on save so radius zones and the widget's pickup map have
// coordinates without a separate manual step — no-ops silently if
// GOOGLE_MAPS_API_KEY isn't configured (see zones.server.ts).
const coordinates = address ? await geocodeAddress(address) : null;
await db.location.updateMany({
where: { id: params.id, shopDomain: session.shop },
data: { name, address, timezone, active },
data: {
name,
address,
timezone,
active,
shopifyLocationId,
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {}),
},
});
return { errors };
@ -78,7 +92,16 @@ function LocationForm({
errors,
isSubmitting,
}: {
location: { id: string; name: string; address: string; timezone: string; active: boolean };
location: {
id: string;
name: string;
address: string;
timezone: string;
active: boolean;
shopifyLocationId: string | null;
lat: number | null;
lng: number | null;
};
errors?: Record<string, string>;
isSubmitting: boolean;
}) {
@ -86,6 +109,7 @@ function LocationForm({
const [address, setAddress] = useState(location.address);
const [timezone, setTimezone] = useState(location.timezone);
const [active, setActive] = useState(location.active);
const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? "");
return (
<BlockStack gap="400">
@ -101,7 +125,19 @@ function LocationForm({
error={errors?.name}
requiredIndicator
/>
<TextField label="Address" name="address" autoComplete="off" multiline={2} value={address} onChange={setAddress} />
<TextField
label="Address"
name="address"
autoComplete="off"
multiline={2}
value={address}
onChange={setAddress}
helpText={
location.lat != null
? `Geocoded: ${location.lat.toFixed(4)}, ${location.lng?.toFixed(4)}`
: "Saved without coordinates — set GOOGLE_MAPS_API_KEY to auto-geocode on save."
}
/>
<TextField
label="Timezone (IANA)"
name="timezone"
@ -111,6 +147,14 @@ function LocationForm({
error={errors?.timezone}
requiredIndicator
/>
<TextField
label="Shopify Location ID (advanced)"
name="shopifyLocationId"
autoComplete="off"
value={shopifyLocationId}
onChange={setShopifyLocationId}
helpText="gid://shopify/Location/… — only needed for inventory-based location exclusion."
/>
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
<InlineStack gap="200">
<Button submit variant="primary" loading={isSubmitting}>
@ -118,6 +162,7 @@ function LocationForm({
</Button>
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</Button>
<Button url={`/app/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
<Button url={`/app/zones?locationId=${location.id}`}>Manage delivery zones</Button>
</InlineStack>
</FormLayout>
</Form>

View File

@ -0,0 +1,229 @@
import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { formatPriceLabel } from "../lib/currency";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const KEYED_BY_OPTIONS = [
{ label: "Zone", value: "zone" },
{ label: "Distance band", value: "distance" },
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const [rates, zones] = await Promise.all([
db.rate.findMany({ where: { shopDomain: session.shop }, include: { zone: true }, orderBy: { createdAt: "asc" } }),
db.zone.findMany({ where: { shopDomain: session.shop }, include: { location: true }, orderBy: { name: "asc" } }),
]);
return { rates, zones };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.rate.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const method = formData.get("method") as Method;
const name = String(formData.get("name") || "").trim();
const keyedBy = String(formData.get("keyedBy") || "zone");
const zoneId = String(formData.get("zoneId") || "") || null;
const priceRaw = String(formData.get("price") || "");
const minDistanceRaw = String(formData.get("minDistanceKm") || "");
const maxDistanceRaw = String(formData.get("maxDistanceKm") || "");
const errors: Record<string, string> = {};
if (!name) errors.name = "Name is required";
if (!priceRaw || Number.isNaN(Number(priceRaw))) errors.price = "Price is required";
if (keyedBy === "zone" && !zoneId) errors.zoneId = "Choose a zone";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.rate.create({
data: {
shopDomain: session.shop,
method,
name,
keyedBy,
zoneId: keyedBy === "zone" ? zoneId : null,
priceCents: Math.round(Number(priceRaw) * 100),
minDistanceKm: keyedBy === "distance" && minDistanceRaw ? Number(minDistanceRaw) : null,
maxDistanceKm: keyedBy === "distance" && maxDistanceRaw ? Number(maxDistanceRaw) : null,
},
});
return data({ ok: true });
};
export default function RatesIndex() {
const { rates, zones } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (zones.length === 0) {
return (
<Page>
<TitleBar title="Delivery rates" />
<Card>
<EmptyState
heading="Add a delivery zone first"
action={{ content: "Add a zone", url: "/app/zones" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Zone-keyed rates need at least one zone to attach to.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Delivery rates" />
<BlockStack gap="400">
<Card padding="0">
{rates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No rates yet. Without a rate, checkout uses Shopify's own configured shipping rates.
</Text>
</div>
) : (
<IndexTable
itemCount={rates.length}
headings={[
{ title: "Name" },
{ title: "Method" },
{ title: "Keyed by" },
{ title: "Coverage" },
{ title: "Price" },
{ title: "" },
]}
selectable={false}
>
{rates.map((rate, index) => (
<IndexTable.Row id={rate.id} key={rate.id} position={index}>
<IndexTable.Cell>{rate.name}</IndexTable.Cell>
<IndexTable.Cell>{rate.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{rate.keyedBy}</IndexTable.Cell>
<IndexTable.Cell>
{rate.keyedBy === "zone"
? (rate.zone?.name ?? "—")
: `${rate.minDistanceKm ?? 0}${rate.maxDistanceKm ?? "∞"} km`}
</IndexTable.Cell>
<IndexTable.Cell>{formatPriceLabel(rate.priceCents)}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={rate.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddRateForm zones={zones} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddRateForm({
zones,
isSubmitting,
}: {
zones: Array<{ id: string; name: string; location: { name: string } }>;
isSubmitting: boolean;
}) {
const [keyedBy, setKeyedBy] = useState("zone");
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [minDistanceKm, setMinDistanceKm] = useState("");
const [maxDistanceKm, setMaxDistanceKm] = useState("");
return (
<Card>
<Form method="post">
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a rate
</Text>
<InlineStack gap="300" wrap>
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
<Select label="Method" name="method" options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))} />
<Select label="Keyed by" name="keyedBy" options={KEYED_BY_OPTIONS} value={keyedBy} onChange={setKeyedBy} />
{keyedBy === "zone" ? (
<Select
label="Zone"
name="zoneId"
options={zones.map((z) => ({ label: `${z.name} (${z.location.name})`, value: z.id }))}
/>
) : (
<>
<TextField
label="Min distance (km)"
name="minDistanceKm"
type="number"
autoComplete="off"
value={minDistanceKm}
onChange={setMinDistanceKm}
/>
<TextField
label="Max distance (km)"
name="maxDistanceKm"
type="number"
autoComplete="off"
value={maxDistanceKm}
onChange={setMaxDistanceKm}
/>
</>
)}
<TextField
label="Price"
name="price"
type="number"
autoComplete="off"
value={price}
onChange={setPrice}
prefix="$"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add rate
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}

View File

@ -27,6 +27,8 @@ export default function App() {
<Link to="/app/locations">Locations</Link>
<Link to="/app/slots">Weekly slots</Link>
<Link to="/app/blackouts">Blackout dates</Link>
<Link to="/app/zones">Delivery zones</Link>
<Link to="/app/rates">Delivery rates</Link>
</NavMenu>
<Outlet />
</AppProvider>

View File

@ -0,0 +1,233 @@
import { useState } from "react";
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const ZONE_TYPES = [
{ label: "Postal / ZIP codes", value: "postal" },
{ label: "Radius (straight-line distance)", value: "radius" },
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
orderBy: { createdAt: "asc" },
});
const activeLocationId = locationId || locations[0]?.id || null;
const zones = activeLocationId
? await db.zone.findMany({
where: { shopDomain: session.shop, locationId: activeLocationId },
orderBy: { createdAt: "asc" },
})
: [];
return { locations, activeLocationId, zones };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.zone.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const locationId = String(formData.get("locationId") || "");
const name = String(formData.get("name") || "").trim();
const type = String(formData.get("type") || "postal");
const postalCodesRaw = String(formData.get("postalCodes") || "");
const radiusKmRaw = String(formData.get("radiusKm") || "");
const minOrdersRaw = String(formData.get("minOrders") || "");
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (!name) errors.name = "Name is required";
if (type === "radius" && !radiusKmRaw) errors.radiusKm = "Radius is required for a radius zone";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
const postalCodes = postalCodesRaw
.split(",")
.map((code) => code.trim())
.filter(Boolean);
await db.zone.create({
data: {
shopDomain: session.shop,
locationId,
name,
type,
postalCodes: type === "postal" ? postalCodes : [],
radiusKm: type === "radius" && radiusKmRaw ? Number(radiusKmRaw) : null,
minOrders: minOrdersRaw ? Number(minOrdersRaw) : null,
},
});
return data({ ok: true });
};
export default function ZonesIndex() {
const { locations, activeLocationId, zones } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Delivery zones" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Delivery zones determine which addresses a location's Local Delivery covers.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Delivery zones" />
<BlockStack gap="400">
<Card>
<Select
label="Location"
options={locations.map((l) => ({ label: l.name, value: l.id }))}
value={activeLocationId ?? undefined}
onChange={(value) => setSearchParams({ locationId: value })}
/>
</Card>
<Card padding="0">
{zones.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No delivery zones yet for this location. Without a zone, Local Delivery is offered everywhere.
</Text>
</div>
) : (
<IndexTable
itemCount={zones.length}
headings={[
{ title: "Name" },
{ title: "Type" },
{ title: "Coverage" },
{ title: "Min. orders" },
{ title: "" },
]}
selectable={false}
>
{zones.map((zone, index) => (
<IndexTable.Row id={zone.id} key={zone.id} position={index}>
<IndexTable.Cell>{zone.name}</IndexTable.Cell>
<IndexTable.Cell>{zone.type === "postal" ? "Postal/ZIP" : "Radius"}</IndexTable.Cell>
<IndexTable.Cell>
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
</IndexTable.Cell>
<IndexTable.Cell>{zone.minOrders ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={zone.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddZoneForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddZoneForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
const [type, setType] = useState("postal");
const [name, setName] = useState("");
const [postalCodes, setPostalCodes] = useState("");
const [radiusKm, setRadiusKm] = useState("");
const [minOrders, setMinOrders] = useState("");
return (
<Card>
<Form method="post">
<input type="hidden" name="locationId" value={locationId} />
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a delivery zone
</Text>
<InlineStack gap="300" wrap>
<TextField label="Name" name="name" autoComplete="off" value={name} onChange={setName} />
<Select label="Type" name="type" options={ZONE_TYPES} value={type} onChange={setType} />
{type === "postal" ? (
<TextField
label="Postal/ZIP codes (comma-separated)"
name="postalCodes"
autoComplete="off"
value={postalCodes}
onChange={setPostalCodes}
helpText="e.g. M5V 3A8, M4B 1B3"
/>
) : (
<TextField
label="Radius (km)"
name="radiusKm"
type="number"
autoComplete="off"
value={radiusKm}
onChange={setRadiusKm}
/>
)}
<TextField
label="Min. orders before this zone unlocks (optional)"
name="minOrders"
type="number"
autoComplete="off"
value={minOrders}
onChange={setMinOrders}
helpText="Delivery-density threshold — leave blank for no minimum."
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add zone
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}

View File

@ -4,6 +4,9 @@ import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { getAvailability } from "../services/scheduling.server";
import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "../services/zones.server";
import { resolveRate } from "../services/rates.server";
import { formatPriceLabel } from "../lib/currency";
// Public endpoint, reachable only through Shopify's App Proxy (signature
// verified by authenticate.public.appProxy) — this is what the storefront
@ -11,10 +14,6 @@ import { getAvailability } from "../services/scheduling.server";
// forward here because shopify.app.toml's [app_proxy].url already includes
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
// mirrors the shop-facing URL exactly.
//
// No capacity consumption is wired up yet (Booking/SlotHold don't exist
// until Phase 4), so every slot's `consumed` is implicitly 0 here — that's
// expected for Phase 3, not a bug to fix in this file.
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
const MAX_DAYS = 60;
@ -24,6 +23,11 @@ function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** `getAvailability`'s consumed-capacity map key — must match scheduling.server.ts's slotKey() exactly. */
function slotKey(date: string, startMin: number): string {
return `${date}|${startMin}`;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
if (!session) {
@ -33,6 +37,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const methodParam = url.searchParams.get("method");
const locationIdParam = url.searchParams.get("locationId");
const postalCode = url.searchParams.get("postalCode") || undefined;
const address = url.searchParams.get("address") || undefined;
const daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS);
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
@ -41,7 +47,47 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
}
const method = methodParam as Method;
const location = locationIdParam
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
let zoneId: string | null = null;
let distanceKm: number | null = null;
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
// the nearest eligible, density-qualified zone for the shopper's address.
// Falls through to the plain location lookup below for PICKUP/SHIPPING,
// or when no address was supplied yet (e.g. the widget hasn't asked for
// one), so the picker can still show something before that input exists.
if (method === "LOCAL_DELIVERY" && (postalCode || address)) {
const matches = await findEligibleLocationsForDelivery(session.shop, { postalCode, address });
const zones = matches.length
? await db.zone.findMany({ where: { id: { in: matches.map((m) => m.zoneId) } } })
: [];
const zoneById = new Map(zones.map((z) => [z.id, z]));
for (const match of matches) {
if (locationIdParam && match.locationId !== locationIdParam) continue;
const zone = zoneById.get(match.zoneId);
if (!zone) continue;
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
if (!(await meetsDeliveryDensity(session.shop, zone))) continue;
zoneId = match.zoneId;
distanceKm = match.distanceKm;
location = await db.location.findFirst({ where: { id: match.locationId, shopDomain: session.shop, active: true } });
break;
}
if (!location) {
return Response.json({
locationId: null,
method,
dates: {},
zoneId: null,
rate: null,
error: "This address is outside our delivery area right now.",
});
}
} else {
location = locationIdParam
? await db.location.findFirst({
where: { id: locationIdParam, shopDomain: session.shop, active: true },
})
@ -49,6 +95,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
where: { shopDomain: session.shop, active: true },
orderBy: { createdAt: "asc" },
});
}
if (!location) {
return Response.json({ error: "No active location configured" }, { status: 404 });
@ -59,8 +106,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const endDate = now.plus({ days }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).toJSDate();
// Bookings are keyed by exact instant; widen the window by a day on each
// side so a slot near midnight UTC-offset boundaries isn't miscounted.
const bookingRangeStart = DateTime.fromISO(startDate, { zone: "utc" }).minus({ days: 1 }).toJSDate();
const bookingRangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [slotTemplates, overrides, blackouts] = await Promise.all([
const [slotTemplates, overrides, blackouts, bookings, rates] = await Promise.all([
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: location.id, method },
}),
@ -79,8 +130,26 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
AND: [{ OR: [{ locationId: location.id }, { locationId: null }] }, { OR: [{ method }, { method: null }] }],
},
}),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: location.id,
method,
status: { in: ["confirmed", "fulfilled"] },
slotStart: { gte: bookingRangeStart, lte: bookingRangeEnd },
},
select: { slotStart: true },
}),
db.rate.findMany({ where: { shopDomain: session.shop, method } }),
]);
const consumed = new Map<string, number>();
for (const booking of bookings) {
const local = DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(location.timezone);
const key = slotKey(local.toISODate()!, local.hour * 60 + local.minute);
consumed.set(key, (consumed.get(key) ?? 0) + 1);
}
const availability = getAvailability({
timezone: location.timezone,
dateRange: { startDate, endDate },
@ -101,13 +170,21 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
})),
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
now,
consumed,
});
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
return Response.json({
locationId: location.id,
locationName: location.name,
locationLat: location.lat,
locationLng: location.lng,
timezone: location.timezone,
method,
dates: availability,
zoneId,
distanceKm,
rate: matchedRate ? { name: matchedRate.name, priceCents: matchedRate.priceCents, label: formatPriceLabel(matchedRate.priceCents) } : null,
});
};

View File

@ -44,6 +44,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
const startMinRaw = readAttr(attrs, "dd_start_min");
const endMinRaw = readAttr(attrs, "dd_end_min");
const locationId = readAttr(attrs, "dd_location_id");
const zoneId = readAttr(attrs, "dd_zone_id") || null; // LOCAL_DELIVERY orders matched by zones.server.ts; absent for PICKUP/SHIPPING
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
return; // no scheduling selection on this order — nothing to book
@ -64,6 +65,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
orderId: order.admin_graphql_api_id,
orderName: order.name,
locationId: location.id,
zoneId,
method: method as Method,
slotStart: slotStart.toJSDate(),
slotEnd: slotEnd.toJSDate(),

View File

@ -0,0 +1,58 @@
import type { Method } from "@prisma/client";
// Pure rate resolution — no DB calls (CLAUDE.md: inject data). The caller
// (apps.scheduling.availability.tsx) fetches the shop's Rate rows for the
// method and passes them in here.
export interface RateLike {
id: string;
method: Method;
zoneId: string | null;
name: string;
priceCents: number;
keyedBy: string; // "zone" | "distance"
minDistanceKm: number | null;
maxDistanceKm: number | null;
}
export interface RateContext {
method: Method;
zoneId?: string;
distanceKm?: number;
}
/**
* Picks the rate that applies to a given method/zone/distance. Zone-keyed
* rates are matched by exact zoneId; distance-keyed rates by falling within
* [minDistanceKm, maxDistanceKm) (open-ended bounds are unbounded on that
* side). When more than one rate matches, the cheapest wins a merchant
* with overlapping bands almost certainly wants the shopper quoted the best
* available price, not an arbitrary one.
*/
export function resolveRate(rates: RateLike[], context: RateContext): RateLike | null {
const candidates = rates.filter((rate) => {
if (rate.method !== context.method) return false;
if (rate.keyedBy === "zone") {
return context.zoneId !== undefined && rate.zoneId === context.zoneId;
}
if (rate.keyedBy === "distance") {
if (context.distanceKm === undefined) return false;
const min = rate.minDistanceKm ?? 0;
const max = rate.maxDistanceKm ?? Infinity;
return context.distanceKm >= min && context.distanceKm < max;
}
return false;
});
if (candidates.length === 0) return null;
return candidates.reduce((cheapest, candidate) => (candidate.priceCents < cheapest.priceCents ? candidate : cheapest));
}
// formatPriceLabel moved to app/lib/currency.ts (2026-08-24): it's pure/
// no-I/O, but a route *component* needs it (app.rates._index.tsx), and
// Remix refuses to bundle anything imported from a .server.ts path for the
// client — import it from lib/currency directly, not re-exported here.

View File

@ -0,0 +1,224 @@
import db from "../db.server";
import { haversineDistanceKm, isPostalCodeListed, type Coordinates } from "../lib/geo";
// I/O orchestration around the pure math in lib/geo.ts. Geocoding results
// are cached permanently per IMPLEMENTATION_PLAN.md §9 ("cache geocode
// results per address; don't call the maps API on every availability
// request") — addresses don't move, so there's no cache invalidation to
// worry about, only cache growth, which is fine for this volume.
export interface ZoneLike {
id: string;
locationId: string;
type: string; // "postal" | "radius"
postalCodes: string[];
radiusKm: number | null;
minOrders: number | null;
active: boolean;
}
function normalizeAddressKey(address: string): string {
return address.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Geocodes an address via the Google Maps Geocoding API, using
* GOOGLE_MAPS_API_KEY. Returns null (rather than throwing) when no key is
* configured or the address can't be resolved callers should treat that
* as "can't determine eligibility," not a hard error, since a merchant may
* not have set up Maps yet.
*/
export async function geocodeAddress(address: string): Promise<Coordinates | null> {
const key = normalizeAddressKey(address);
const cached = await db.geocodeCache.findUnique({ where: { normalizedKey: key } });
if (cached) return { lat: cached.lat, lng: cached.lng };
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.set("address", address);
url.searchParams.set("key", apiKey);
const res = await fetch(url.toString());
if (!res.ok) return null;
const body = (await res.json()) as {
status: string;
results: Array<{ geometry: { location: { lat: number; lng: number } } }>;
};
if (body.status !== "OK" || body.results.length === 0) return null;
const { lat, lng } = body.results[0].geometry.location;
await db.geocodeCache.upsert({
where: { normalizedKey: key },
create: { normalizedKey: key, lat, lng },
update: { lat, lng },
});
return { lat, lng };
}
/**
* Radius zones use the location's own lat/lng as the center postal zones
* need no coordinates at all. This is why isZoneEligible takes the
* location's coordinates separately rather than assuming the zone carries
* its own center point.
*/
export function isZoneEligible(
zone: ZoneLike,
locationCoordinates: Coordinates | null,
customer: { coordinates?: Coordinates; postalCode?: string },
): boolean {
if (!zone.active) return false;
if (zone.type === "postal") {
return customer.postalCode ? isPostalCodeListed(customer.postalCode, zone.postalCodes) : false;
}
if (zone.type === "radius") {
if (!locationCoordinates || !customer.coordinates || zone.radiusKm == null) return false;
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
}
return false;
}
export interface EligibleLocationMatch {
locationId: string;
zoneId: string;
distanceKm: number | null;
}
/**
* Nearest-location auto-assign (PRODUCT_STRATEGY.md §2 "Auto location
* assignment"): given a shopper's address, finds every active zone across
* the shop's locations that covers it, ranked nearest-first when distance
* is known (radius zones) postal zones without location coordinates sort
* after distance-ranked ones, in the order returned by the query.
*/
export async function findEligibleLocationsForDelivery(
shopDomain: string,
customer: { address?: string; postalCode?: string },
): Promise<EligibleLocationMatch[]> {
const customerCoordinates = customer.address ? await geocodeAddress(customer.address) : null;
const locations = await db.location.findMany({
where: { shopDomain, active: true },
include: { zones: { where: { active: true } } },
});
const matches: EligibleLocationMatch[] = [];
for (const location of locations) {
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
for (const zone of location.zones) {
const eligible = isZoneEligible(zone, locationCoordinates, {
coordinates: customerCoordinates ?? undefined,
postalCode: customer.postalCode,
});
if (!eligible) continue;
const distanceKm =
locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null;
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
}
}
return matches.sort((a, b) => {
if (a.distanceKm == null && b.distanceKm == null) return 0;
if (a.distanceKm == null) return 1;
if (b.distanceKm == null) return -1;
return a.distanceKm - b.distanceKm;
});
}
/**
* Delivery-density threshold (PRODUCT_STRATEGY.md §3.2): a sparse zone
* shouldn't unlock delivery capacity until `minOrders` bookings have
* already routed through it cuts delivery cost on routes that wouldn't
* be worth a driver trip for just one or two orders. A zone with no
* minOrders set (or 0) always passes.
*/
export async function meetsDeliveryDensity(shopDomain: string, zone: ZoneLike): Promise<boolean> {
if (!zone.minOrders || zone.minOrders <= 0) return true;
const count = await db.booking.count({
where: { shopDomain, zoneId: zone.id, status: { in: ["confirmed", "fulfilled"] } },
});
return count >= zone.minOrders;
}
interface AdminGraphQLClient {
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
}
/**
* Inventory-based location exclusion (PRODUCT_STRATEGY.md §2): drops any
* candidate location that doesn't stock at least one of the cart's
* products, per Shopify's InventoryLevel API. Locations without a
* shopifyLocationId mapping are left in (can't check what we can't query
* excluding them would be a false negative, not a safe default) and this
* whole check is a no-op when productIds is empty (nothing to check stock
* for, e.g. availability requests made before a cart exists).
*/
export async function excludeLocationsWithoutStock(
admin: AdminGraphQLClient,
locationIds: string[],
productVariantGids: string[],
): Promise<Set<string>> {
if (productVariantGids.length === 0) return new Set(locationIds);
const locations = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true, shopifyLocationId: true },
});
const inStock = new Set<string>();
for (const location of locations) {
if (!location.shopifyLocationId) {
inStock.add(location.id); // unmapped — can't verify, don't exclude
continue;
}
const response = await admin.graphql(
`#graphql
query LocationStock($locationId: ID!, $variantIds: [ID!]!) {
location(id: $locationId) {
id
}
nodes(ids: $variantIds) {
... on ProductVariant {
inventoryItem {
inventoryLevel(locationId: $locationId) {
quantities(names: ["available"]) {
quantity
}
}
}
}
}
}`,
{ variables: { locationId: location.shopifyLocationId, variantIds: productVariantGids } },
);
const body = (await response.json()) as {
data?: {
nodes: Array<{ inventoryItem?: { inventoryLevel?: { quantities: Array<{ quantity: number }> } | null } } | null>;
};
};
const hasStock = (body.data?.nodes ?? []).some((node) =>
(node?.inventoryItem?.inventoryLevel?.quantities ?? []).some((q) => q.quantity > 0),
);
if (hasStock) inStock.add(location.id);
}
return inStock;
}

View File

@ -55,3 +55,21 @@
color: inherit;
font: inherit;
}
.dd-widget__input {
border: 1px solid currentColor;
border-radius: 4px;
background: transparent;
padding: 0.4rem 0.75rem;
font-size: 0.875rem;
color: inherit;
font-family: inherit;
min-width: 10rem;
}
.dd-widget__map {
width: 100%;
height: 220px;
border-radius: 8px;
overflow: hidden;
}

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,11 @@
data-label-change="{{ 'widget.change' | t | escape }}"
data-label-loading="{{ 'widget.loading' | t | escape }}"
data-label-error="{{ 'widget.error' | t | escape }}"
data-label-postal-code="{{ 'widget.postal_code' | t | escape }}"
data-label-postal-code-submit="{{ 'widget.postal_code_submit' | t | escape }}"
data-label-out-of-area="{{ 'widget.out_of_area' | t | escape }}"
{% if block.settings.location_id != blank %}data-location-id="{{ block.settings.location_id | escape }}"{% endif %}
{% if block.settings.google_maps_api_key != blank %}data-google-maps-api-key="{{ block.settings.google_maps_api_key | escape }}"{% endif %}
{{ block.shopify_attributes }}
>
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
@ -58,6 +62,12 @@
"id": "location_id",
"label": "t:datetime_picker.location_id_label",
"info": "t:datetime_picker.location_id_info"
},
{
"type": "text",
"id": "google_maps_api_key",
"label": "t:datetime_picker.google_maps_api_key_label",
"info": "t:datetime_picker.google_maps_api_key_info"
}
]
}

View File

@ -13,6 +13,9 @@
"change": "Change",
"loading": "Loading available dates…",
"error": "Couldn't load available dates. Please try again.",
"enable_js": "Please enable JavaScript to choose a delivery date and time."
"enable_js": "Please enable JavaScript to choose a delivery date and time.",
"postal_code": "Enter your postal/ZIP code",
"postal_code_submit": "Check availability",
"out_of_area": "Sorry, we don't deliver to this address."
}
}

View File

@ -9,6 +9,8 @@
"show_local_delivery_label": "Show Local Delivery",
"show_pickup_label": "Show Pickup",
"location_id_label": "Location ID (advanced)",
"location_id_info": "Leave blank to use the shop's default location."
"location_id_info": "Leave blank to use the shop's default location.",
"google_maps_api_key_label": "Google Maps API key",
"google_maps_api_key_info": "Optional — shows a map for Pickup locations. Restrict this key to your store's domain in Google Cloud Console."
}
}

View File

@ -6,6 +6,9 @@ query CartDeliveryOptionsTransformRunInput {
ddDate: attribute(key: "dd_date") {
value
}
ddRateLabel: attribute(key: "dd_rate_label") {
value
}
deliveryGroups {
deliveryOptions {
handle

View File

@ -15,13 +15,13 @@ const NO_CHANGES = {
/**
* Relabels every presented delivery option to the shopper's actual chosen
* method + date, so checkout never shows a generic carrier label
* method + date (+ zone/distance rate, if the shop has Rate rows
* configured Phase 5), so checkout never shows a generic carrier label
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
* have a per-option method mapping (that needs Phase 5's zones/rates
* work), so every option in the cart gets the same clarified label
* reasonable since a single order only has one chosen fulfillment method
* today.
* what's actually a pickup or local-delivery order. There's still no
* per-option method mapping, so every option in the cart gets the same
* clarified label reasonable since a single order only has one chosen
* fulfillment method today.
* @param {CartDeliveryOptionsTransformRunInput} input
* @returns {CartDeliveryOptionsTransformRunResult}
*/
@ -29,6 +29,7 @@ export function cartDeliveryOptionsTransformRun(input) {
const decision = renameLabelFor({
dd_method: input.cart.ddMethod?.value,
dd_date: input.cart.ddDate?.value,
dd_rate_label: input.cart.ddRateLabel?.value,
});
if (!decision.rename) {

View File

@ -29,5 +29,9 @@ export function renameLabelFor(attributes) {
}
const label = METHOD_LABEL[method];
return { rename: true, title: `${label}${date}` };
// dd_rate_label comes from Phase 5's zone/distance rate resolution
// (rates.server.ts, surfaced by apps.scheduling.availability.tsx) —
// absent when the shop hasn't configured any Rate rows yet.
const rateSuffix = attributes.dd_rate_label ? ` (${attributes.dd_rate_label})` : "";
return { rename: true, title: `${label}${date}${rateSuffix}` };
}

View File

@ -6,6 +6,7 @@
"cart": {
"ddMethod": null,
"ddDate": null,
"ddRateLabel": null,
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }]

View File

@ -6,6 +6,7 @@
"cart": {
"ddMethod": { "value": "PICKUP" },
"ddDate": { "value": "2026-08-25" },
"ddRateLabel": null,
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]

View File

@ -0,0 +1,28 @@
{
"payload": {
"export": "cart-delivery-options-transform-run",
"target": "cart.delivery-options.transform.run",
"input": {
"cart": {
"ddMethod": { "value": "LOCAL_DELIVERY" },
"ddDate": { "value": "2026-08-25" },
"ddRateLabel": { "value": "$5.00" },
"deliveryGroups": [
{
"deliveryOptions": [{ "handle": "local-delivery" }]
}
]
}
},
"output": {
"operations": [
{
"deliveryOptionRename": {
"deliveryOptionHandle": "local-delivery",
"title": "Local delivery — 2026-08-25 ($5.00)"
}
}
]
}
}
}

View File

@ -0,0 +1,60 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "shopifyLocationId" TEXT;
-- CreateTable
CREATE TABLE "Zone" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"postalCodes" TEXT[],
"radiusKm" DOUBLE PRECISION,
"minOrders" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Zone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rate" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"method" "Method" NOT NULL,
"zoneId" TEXT,
"name" TEXT NOT NULL,
"priceCents" INTEGER NOT NULL,
"keyedBy" TEXT NOT NULL,
"minDistanceKm" DOUBLE PRECISION,
"maxDistanceKm" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Rate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GeocodeCache" (
"id" TEXT NOT NULL,
"normalizedKey" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GeocodeCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Zone_shopDomain_locationId_idx" ON "Zone"("shopDomain", "locationId");
-- CreateIndex
CREATE INDEX "Rate_shopDomain_method_idx" ON "Rate"("shopDomain", "method");
-- CreateIndex
CREATE UNIQUE INDEX "GeocodeCache_normalizedKey_key" ON "GeocodeCache"("normalizedKey");
-- AddForeignKey
ALTER TABLE "Zone" ADD CONSTRAINT "Zone_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rate" ADD CONSTRAINT "Rate_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "zoneId" TEXT;
-- CreateIndex
CREATE INDEX "Booking_shopDomain_zoneId_idx" ON "Booking"("shopDomain", "zoneId");

View File

@ -63,10 +63,15 @@ model Location {
lng Float?
timezone String
active Boolean @default(true)
// Maps this row to Shopify's own Location resource (gid://shopify/Location/…)
// so inventory-based exclusion (Phase 5) can query stock at the right
// Shopify location — optional since not every merchant needs it wired up.
shopifyLocationId String?
slotTemplates SlotTemplate[]
overrides SlotOverride[]
blackouts BlackoutDate[]
bookings Booking[]
zones Zone[]
createdAt DateTime @default(now())
@@index([shopDomain])
@ -126,6 +131,13 @@ model Booking {
orderName String? // e.g. "#1001", for display only
locationId String
location Location @relation(fields: [locationId], references: [id])
// Which delivery Zone the order was routed through, if any (LOCAL_DELIVERY
// orders matched by zones.server.ts). Nullable: PICKUP/SHIPPING orders and
// any LOCAL_DELIVERY order placed before zones existed have none. Not a
// relation (no onDelete behavior wanted if a zone is later removed) —
// deliberately just an id for the delivery-density threshold check in
// zones.server.ts to count "how many orders have already routed here."
zoneId String?
method Method
slotStart DateTime
slotEnd DateTime
@ -136,6 +148,7 @@ model Booking {
@@index([shopDomain, locationId, method, slotStart])
@@index([shopDomain, status])
@@index([shopDomain, zoneId])
}
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
@ -143,3 +156,47 @@ model Booking {
// semantics a soft, time-limited reservation needs, so it's the sole source
// of truth for holds (app/services/holds.server.ts). Mirroring it into
// Postgres too would only add a sync-consistency burden with no benefit.
model Zone {
id String @id @default(cuid())
shopDomain String
locationId String
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
name String
type String // postal|radius
postalCodes String[] // used when type = postal
radiusKm Float? // used when type = radius (straight-line distance from the location)
minOrders Int? // delivery-density threshold: don't offer this zone's days until N orders already routed there
active Boolean @default(true)
rates Rate[]
createdAt DateTime @default(now())
@@index([shopDomain, locationId])
}
model Rate {
id String @id @default(cuid())
shopDomain String
method Method
zoneId String?
zone Zone? @relation(fields: [zoneId], references: [id], onDelete: Cascade)
name String // shown to the shopper, e.g. "Standard local delivery"
priceCents Int
keyedBy String // zone|distance — see rates.server.ts for resolution order
minDistanceKm Float? // used when keyedBy = distance
maxDistanceKm Float? // used when keyedBy = distance
createdAt DateTime @default(now())
@@index([shopDomain, method])
}
// Permanent cache of address -> lat/lng geocode results (IMPLEMENTATION_PLAN.md
// §9: "cache geocode results per address; don't call the maps API on every
// availability request"). Addresses don't move, so entries never expire.
model GeocodeCache {
id String @id @default(cuid())
normalizedKey String @unique // lowercased, whitespace-collapsed address string
lat Float
lng Float
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,166 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import { findEligibleLocationsForDelivery, geocodeAddress, meetsDeliveryDensity } from "../../app/services/zones.server";
const shopDomain = "zones-integration-test.myshopify.com";
async function cleanup() {
await db.booking.deleteMany({ where: { shopDomain } });
await db.zone.deleteMany({ where: { shopDomain } });
await db.location.deleteMany({ where: { shopDomain } });
await db.geocodeCache.deleteMany({ where: { normalizedKey: { contains: "123 test" } } });
}
describe("zones.server integration", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
});
it("geocodeAddress caches a result and reuses it without a real API key on the second call", async () => {
// No GOOGLE_MAPS_API_KEY is set in this test environment, so the first
// call would normally return null — pre-seed the cache directly to
// prove the cache-read path works without needing live Maps access.
await db.geocodeCache.upsert({
where: { normalizedKey: "123 test street" },
create: { normalizedKey: "123 test street", lat: 43.65, lng: -79.38 },
update: {},
});
const result = await geocodeAddress("123 Test Street");
expect(result).toEqual({ lat: 43.65, lng: -79.38 });
});
it("returns null when there's no cache entry and no API key configured", async () => {
delete process.env.GOOGLE_MAPS_API_KEY;
const result = await geocodeAddress("456 Nowhere Cached Ave");
expect(result).toBeNull();
});
it("findEligibleLocationsForDelivery matches a postal-zone location by postal code alone (no geocoding needed)", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Postal Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: {
shopDomain,
locationId: location.id,
name: "Downtown",
type: "postal",
postalCodes: ["M5V 3A8"],
},
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode: "M5V3A8" });
expect(matches).toHaveLength(1);
expect(matches[0]).toMatchObject({ locationId: location.id, zoneId: zone.id, distanceKm: null });
});
it("excludes an inactive location's zones", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Inactive Loc", address: "", timezone: "America/Toronto", active: false },
});
await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: ["M5V 3A8"] },
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { postalCode: "M5V3A8" });
expect(matches).toHaveLength(0);
});
it("ranks radius-zone matches nearest-first using cached geocode coordinates", async () => {
await db.geocodeCache.upsert({
where: { normalizedKey: "123 test customer address" },
create: { normalizedKey: "123 test customer address", lat: 43.6532, lng: -79.3832 }, // Toronto
update: {},
});
const near = await db.location.create({
data: { shopDomain, name: "Near", address: "", timezone: "America/Toronto", lat: 43.65, lng: -79.4 },
});
const far = await db.location.create({
data: { shopDomain, name: "Far", address: "", timezone: "America/Toronto", lat: 45.4215, lng: -75.6972 }, // Ottawa
});
await db.zone.create({
data: { shopDomain, locationId: near.id, name: "Zone", type: "radius", radiusKm: 500 },
});
await db.zone.create({
data: { shopDomain, locationId: far.id, name: "Zone", type: "radius", radiusKm: 500 },
});
const matches = await findEligibleLocationsForDelivery(shopDomain, { address: "123 Test Customer Address" });
expect(matches).toHaveLength(2);
expect(matches[0].locationId).toBe(near.id);
expect(matches[1].locationId).toBe(far.id);
});
it("meetsDeliveryDensity passes when minOrders is unset", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: [] },
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(true);
});
it("meetsDeliveryDensity fails below threshold and passes once enough bookings exist", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Sparse Zone", type: "postal", postalCodes: [], minOrders: 2 },
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false);
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-1",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false); // still only 1 of 2
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-2",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(true);
});
it("meetsDeliveryDensity does not count a cancelled booking toward the threshold", async () => {
const location = await db.location.create({
data: { shopDomain, name: "Loc", address: "", timezone: "America/Toronto" },
});
const zone = await db.zone.create({
data: { shopDomain, locationId: location.id, name: "Zone", type: "postal", postalCodes: [], minOrders: 1 },
});
await db.booking.create({
data: {
shopDomain,
orderId: "gid://shopify/Order/zone-density-cancelled",
locationId: location.id,
zoneId: zone.id,
method: "LOCAL_DELIVERY",
slotStart: new Date(),
slotEnd: new Date(),
status: "cancelled",
},
});
expect(await meetsDeliveryDensity(shopDomain, zone)).toBe(false);
});
});

View File

@ -31,4 +31,14 @@ describe("renameLabelFor", () => {
it("does nothing when the date is missing even if method is present", () => {
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
});
it("appends the resolved rate when a rate label is present", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25", dd_rate_label: "$5.00" });
expect(result).toEqual({ rename: true, title: "Local delivery — 2026-08-25 ($5.00)" });
});
it("omits the rate suffix entirely when no rate label is present", () => {
const result = renameLabelFor({ dd_method: "LOCAL_DELIVERY", dd_date: "2026-08-25" });
expect(result).toEqual({ rename: true, title: "Local delivery — 2026-08-25" });
});
});

83
tests/unit/geo.test.ts Normal file
View File

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
haversineDistanceKm,
isPostalCodeListed,
isWithinRadiusKm,
normalizePostalCode,
sortByDistance,
} from "../../app/lib/geo";
// Known reference distances (verifiable against any mapping service):
const TORONTO = { lat: 43.6532, lng: -79.3832 };
const OTTAWA = { lat: 45.4215, lng: -75.6972 };
const KITCHENER = { lat: 43.4516, lng: -80.4925 };
describe("haversineDistanceKm", () => {
it("is zero for the same point", () => {
expect(haversineDistanceKm(TORONTO, TORONTO)).toBeCloseTo(0, 5);
});
it("is symmetric", () => {
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeCloseTo(haversineDistanceKm(OTTAWA, TORONTO), 8);
});
it("matches the known Toronto-Ottawa distance within a reasonable tolerance", () => {
// Real-world straight-line distance is ~352 km.
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeGreaterThan(340);
expect(haversineDistanceKm(TORONTO, OTTAWA)).toBeLessThan(365);
});
it("Kitchener is closer to Toronto than Ottawa is", () => {
expect(haversineDistanceKm(TORONTO, KITCHENER)).toBeLessThan(haversineDistanceKm(TORONTO, OTTAWA));
});
});
describe("isWithinRadiusKm", () => {
it("is true when distance is under the radius", () => {
expect(isWithinRadiusKm(TORONTO, KITCHENER, 200)).toBe(true);
});
it("is false when distance exceeds the radius", () => {
expect(isWithinRadiusKm(TORONTO, OTTAWA, 100)).toBe(false);
});
it("is true exactly at the boundary", () => {
const d = haversineDistanceKm(TORONTO, OTTAWA);
expect(isWithinRadiusKm(TORONTO, OTTAWA, d)).toBe(true);
});
});
describe("normalizePostalCode / isPostalCodeListed", () => {
it("normalizes case and whitespace", () => {
expect(normalizePostalCode("v6b 1a1")).toBe("V6B1A1");
expect(normalizePostalCode(" M5V 3A8 ")).toBe("M5V3A8");
});
it("matches regardless of formatting differences", () => {
expect(isPostalCodeListed("m5v3a8", ["M5V 3A8", "N2G 1A1"])).toBe(true);
});
it("does not match a postal code outside the list", () => {
expect(isPostalCodeListed("K1A 0A6", ["M5V 3A8", "N2G 1A1"])).toBe(false);
});
});
describe("sortByDistance", () => {
it("orders items nearest-first", () => {
const items = [
{ name: "Ottawa", coordinates: OTTAWA },
{ name: "Kitchener", coordinates: KITCHENER },
];
expect(sortByDistance(TORONTO, items).map((i) => i.name)).toEqual(["Kitchener", "Ottawa"]);
});
it("does not mutate the input array", () => {
const items = [
{ name: "Ottawa", coordinates: OTTAWA },
{ name: "Kitchener", coordinates: KITCHENER },
];
const original = [...items];
sortByDistance(TORONTO, items);
expect(items).toEqual(original);
});
});

77
tests/unit/rates.test.ts Normal file
View File

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { resolveRate, type RateLike } from "../../app/services/rates.server";
import { formatPriceLabel } from "../../app/lib/currency";
function zoneRate(overrides: Partial<RateLike> = {}): RateLike {
return {
id: "rate_1",
method: "LOCAL_DELIVERY",
zoneId: "zone_1",
name: "Zone A",
priceCents: 500,
keyedBy: "zone",
minDistanceKm: null,
maxDistanceKm: null,
...overrides,
};
}
describe("resolveRate", () => {
it("matches a zone-keyed rate by exact zoneId", () => {
const rates = [zoneRate({ id: "a", zoneId: "zone_1" }), zoneRate({ id: "b", zoneId: "zone_2" })];
const result = resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" });
expect(result?.id).toBe("a");
});
it("returns null when no zone matches", () => {
const rates = [zoneRate({ zoneId: "zone_1" })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_9" })).toBeNull();
});
it("does not match a different method", () => {
const rates = [zoneRate({ method: "PICKUP", zoneId: "zone_1" })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" })).toBeNull();
});
it("matches a distance-keyed rate within its band", () => {
const rates = [
zoneRate({ id: "near", keyedBy: "distance", zoneId: null, minDistanceKm: 0, maxDistanceKm: 5, priceCents: 300 }),
zoneRate({ id: "far", keyedBy: "distance", zoneId: null, minDistanceKm: 5, maxDistanceKm: 15, priceCents: 700 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 3 })?.id).toBe("near");
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 10 })?.id).toBe("far");
});
it("treats an unbounded minDistanceKm as 0 and unbounded maxDistanceKm as infinite", () => {
const rates = [zoneRate({ id: "flat", keyedBy: "distance", zoneId: null, minDistanceKm: null, maxDistanceKm: null })];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 999 })?.id).toBe("flat");
});
it("excludes the upper bound of a distance band (half-open interval)", () => {
const rates = [
zoneRate({ id: "near", keyedBy: "distance", zoneId: null, minDistanceKm: 0, maxDistanceKm: 5 }),
zoneRate({ id: "far", keyedBy: "distance", zoneId: null, minDistanceKm: 5, maxDistanceKm: 15 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", distanceKm: 5 })?.id).toBe("far");
});
it("returns the cheapest match when multiple rates overlap", () => {
const rates = [
zoneRate({ id: "expensive", zoneId: "zone_1", priceCents: 900 }),
zoneRate({ id: "cheap", zoneId: "zone_1", priceCents: 400 }),
];
expect(resolveRate(rates, { method: "LOCAL_DELIVERY", zoneId: "zone_1" })?.id).toBe("cheap");
});
it("returns null for an empty rate list", () => {
expect(resolveRate([], { method: "LOCAL_DELIVERY", zoneId: "zone_1" })).toBeNull();
});
});
describe("formatPriceLabel", () => {
it("formats cents as a dollar amount with 2 decimals", () => {
expect(formatPriceLabel(500)).toBe("$5.00");
expect(formatPriceLabel(1999)).toBe("$19.99");
expect(formatPriceLabel(0)).toBe("$0.00");
});
});

82
tests/unit/zones.test.ts Normal file
View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { isZoneEligible, type ZoneLike } from "../../app/services/zones.server";
const TORONTO = { lat: 43.6532, lng: -79.3832 };
const KITCHENER = { lat: 43.4516, lng: -80.4925 }; // ~100km from Toronto
function postalZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
return {
id: "zone_1",
locationId: "loc_1",
type: "postal",
postalCodes: ["M5V 3A8", "M4B 1B3"],
radiusKm: null,
minOrders: null,
active: true,
...overrides,
};
}
function radiusZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
return {
id: "zone_2",
locationId: "loc_1",
type: "radius",
postalCodes: [],
radiusKm: 25,
minOrders: null,
active: true,
...overrides,
};
}
describe("isZoneEligible", () => {
it("an inactive zone is never eligible, even with a matching postal code", () => {
const zone = postalZone({ active: false });
expect(isZoneEligible(zone, null, { postalCode: "M5V 3A8" })).toBe(false);
});
describe("postal zones", () => {
it("matches a listed postal code", () => {
expect(isZoneEligible(postalZone(), null, { postalCode: "m5v3a8" })).toBe(true);
});
it("rejects an unlisted postal code", () => {
expect(isZoneEligible(postalZone(), null, { postalCode: "K1A 0A6" })).toBe(false);
});
it("is ineligible when no postal code is provided", () => {
expect(isZoneEligible(postalZone(), null, {})).toBe(false);
});
it("ignores location coordinates entirely (postal zones don't need them)", () => {
expect(isZoneEligible(postalZone(), TORONTO, { postalCode: "M5V 3A8" })).toBe(true);
});
});
describe("radius zones", () => {
it("is eligible within the radius", () => {
expect(isZoneEligible(radiusZone({ radiusKm: 200 }), TORONTO, { coordinates: KITCHENER })).toBe(true);
});
it("is ineligible outside the radius", () => {
expect(isZoneEligible(radiusZone({ radiusKm: 10 }), TORONTO, { coordinates: KITCHENER })).toBe(false);
});
it("is ineligible when the location has no coordinates set", () => {
expect(isZoneEligible(radiusZone(), null, { coordinates: KITCHENER })).toBe(false);
});
it("is ineligible when the customer has no coordinates (couldn't geocode)", () => {
expect(isZoneEligible(radiusZone(), TORONTO, {})).toBe(false);
});
it("is ineligible when radiusKm isn't configured", () => {
expect(isZoneEligible(radiusZone({ radiusKm: null }), TORONTO, { coordinates: KITCHENER })).toBe(false);
});
});
it("an unrecognized zone type is never eligible", () => {
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
});
});

View File

@ -22,12 +22,23 @@ interface SlotDto {
remainingCapacity: number;
}
interface RateDto {
name: string;
priceCents: number;
label: string;
}
interface AvailabilityResponse {
locationId: string;
locationName: string;
timezone: string;
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;
}
@ -35,6 +46,7 @@ interface WidgetConfig {
root: HTMLElement;
heading: string;
locationId: string | null;
googleMapsApiKey: string | null;
methods: Array<{ value: Method; label: string; attrLabel: string }>;
labels: {
chooseDate: string;
@ -44,6 +56,9 @@ interface WidgetConfig {
change: string;
loading: string;
error: string;
postalCodeLabel: string;
postalCodeSubmit: string;
outOfArea: string;
};
}
@ -86,6 +101,7 @@ function readConfig(root: HTMLElement): WidgetConfig {
root,
heading: d.heading || "",
locationId: d.locationId || null,
googleMapsApiKey: d.googleMapsApiKey || null,
methods,
labels: {
chooseDate: d.labelChooseDate || "Choose a date",
@ -95,13 +111,21 @@ function readConfig(root: HTMLElement): WidgetConfig {
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): Promise<AvailabilityResponse> {
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" },
});
@ -148,11 +172,51 @@ async function writeCartAttribute(key: string, machine: Record<string, string>,
});
}
// 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"),
@ -161,8 +225,9 @@ class DateTimeWidget {
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;
private heldSlot:
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
| null = null;
constructor(config: WidgetConfig) {
this.config = config;
@ -182,13 +247,25 @@ class DateTimeWidget {
}
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.dateRow, this.el.timeRow, this.el.status);
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]);
@ -214,20 +291,80 @@ class DateTimeWidget {
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);
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 = "";
@ -272,7 +409,8 @@ class DateTimeWidget {
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)}`;
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;
@ -286,7 +424,7 @@ class DateTimeWidget {
// against at checkout.
const hold = await requestHold({
intent: "create",
locationId: availability.locationId,
locationId: availability.locationId!,
method: method.value,
date,
startMin: slot.startMin,
@ -300,22 +438,31 @@ class DateTimeWidget {
return;
}
this.heldSlot = { locationId: availability.locationId, method: method.value, date, startMin: slot.startMin, cartToken };
this.heldSlot = {
locationId: availability.locationId!,
method: method.value,
date,
startMin: slot.startMin,
cartToken,
zoneId: availability.zoneId ?? null,
};
await writeCartAttribute(
method.attrLabel,
{
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,
},
display,
);
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;