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:
parent
4eebcc5c78
commit
6598691372
15
README.md
15
README.md
@ -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
9
app/lib/currency.ts
Normal 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
49
app/lib/geo.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
229
app/routes/app.rates._index.tsx
Normal file
229
app/routes/app.rates._index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
233
app/routes/app.zones._index.tsx
Normal file
233
app/routes/app.zones._index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
58
app/services/rates.server.ts
Normal file
58
app/services/rates.server.ts
Normal 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.
|
||||
224
app/services/zones.server.ts
Normal file
224
app/services/zones.server.ts
Normal 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;
|
||||
}
|
||||
@ -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
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ query CartDeliveryOptionsTransformRunInput {
|
||||
ddDate: attribute(key: "dd_date") {
|
||||
value
|
||||
}
|
||||
ddRateLabel: attribute(key: "dd_rate_label") {
|
||||
value
|
||||
}
|
||||
deliveryGroups {
|
||||
deliveryOptions {
|
||||
handle
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}` };
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"cart": {
|
||||
"ddMethod": null,
|
||||
"ddDate": null,
|
||||
"ddRateLabel": null,
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"cart": {
|
||||
"ddMethod": { "value": "PICKUP" },
|
||||
"ddDate": { "value": "2026-08-25" },
|
||||
"ddRateLabel": null,
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
||||
|
||||
28
extensions/delivery-customization/tests/fixtures/renames-with-rate-label.json
vendored
Normal file
28
extensions/delivery-customization/tests/fixtures/renames-with-rate-label.json
vendored
Normal 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)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "zoneId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_shopDomain_zoneId_idx" ON "Booking"("shopDomain", "zoneId");
|
||||
@ -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())
|
||||
}
|
||||
|
||||
166
tests/integration/zones.test.ts
Normal file
166
tests/integration/zones.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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
83
tests/unit/geo.test.ts
Normal 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
77
tests/unit/rates.test.ts
Normal 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
82
tests/unit/zones.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user