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
|
## Status
|
||||||
|
|
||||||
Phase 0 (scaffold & CI) through Phase 4 (enforcement Functions +
|
Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are
|
||||||
slot-holds) are complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased
|
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
|
||||||
build order and acceptance criteria — next up is Phase 5 (multi-location,
|
acceptance criteria — next up is Phase 6 (ops/dispatch dashboard).
|
||||||
zones, rates, auto-assignment).
|
|
||||||
|
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/`,
|
The storefront widget's TypeScript source lives in `widget-src/datetime-widget/`,
|
||||||
**not** inside `extensions/datetime-widget/` — a Theme App Extension's
|
**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 { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
|
import { geocodeAddress } from "../services/zones.server";
|
||||||
|
|
||||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.admin(request);
|
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 address = String(formData.get("address") || "").trim();
|
||||||
const timezone = String(formData.get("timezone") || "").trim();
|
const timezone = String(formData.get("timezone") || "").trim();
|
||||||
const active = formData.get("active") === "true";
|
const active = formData.get("active") === "true";
|
||||||
|
const shopifyLocationId = String(formData.get("shopifyLocationId") || "").trim() || null;
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!name) errors.name = "Name is required";
|
if (!name) errors.name = "Name is required";
|
||||||
@ -51,9 +53,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||||||
return { errors };
|
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({
|
await db.location.updateMany({
|
||||||
where: { id: params.id, shopDomain: session.shop },
|
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 };
|
return { errors };
|
||||||
@ -78,7 +92,16 @@ function LocationForm({
|
|||||||
errors,
|
errors,
|
||||||
isSubmitting,
|
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>;
|
errors?: Record<string, string>;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}) {
|
}) {
|
||||||
@ -86,6 +109,7 @@ function LocationForm({
|
|||||||
const [address, setAddress] = useState(location.address);
|
const [address, setAddress] = useState(location.address);
|
||||||
const [timezone, setTimezone] = useState(location.timezone);
|
const [timezone, setTimezone] = useState(location.timezone);
|
||||||
const [active, setActive] = useState(location.active);
|
const [active, setActive] = useState(location.active);
|
||||||
|
const [shopifyLocationId, setShopifyLocationId] = useState(location.shopifyLocationId ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlockStack gap="400">
|
<BlockStack gap="400">
|
||||||
@ -101,7 +125,19 @@ function LocationForm({
|
|||||||
error={errors?.name}
|
error={errors?.name}
|
||||||
requiredIndicator
|
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
|
<TextField
|
||||||
label="Timezone (IANA)"
|
label="Timezone (IANA)"
|
||||||
name="timezone"
|
name="timezone"
|
||||||
@ -111,6 +147,14 @@ function LocationForm({
|
|||||||
error={errors?.timezone}
|
error={errors?.timezone}
|
||||||
requiredIndicator
|
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} />
|
<Checkbox label="Active" name="active" value="true" checked={active} onChange={setActive} />
|
||||||
<InlineStack gap="200">
|
<InlineStack gap="200">
|
||||||
<Button submit variant="primary" loading={isSubmitting}>
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
@ -118,6 +162,7 @@ function LocationForm({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button url={`/app/slots?locationId=${location.id}`}>Manage weekly slots</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/blackouts?locationId=${location.id}`}>Manage blackout dates</Button>
|
||||||
|
<Button url={`/app/zones?locationId=${location.id}`}>Manage delivery zones</Button>
|
||||||
</InlineStack>
|
</InlineStack>
|
||||||
</FormLayout>
|
</FormLayout>
|
||||||
</Form>
|
</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/locations">Locations</Link>
|
||||||
<Link to="/app/slots">Weekly slots</Link>
|
<Link to="/app/slots">Weekly slots</Link>
|
||||||
<Link to="/app/blackouts">Blackout dates</Link>
|
<Link to="/app/blackouts">Blackout dates</Link>
|
||||||
|
<Link to="/app/zones">Delivery zones</Link>
|
||||||
|
<Link to="/app/rates">Delivery rates</Link>
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppProvider>
|
</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 { authenticate } from "../shopify.server";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { getAvailability } from "../services/scheduling.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
|
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
||||||
// verified by authenticate.public.appProxy) — this is what the storefront
|
// 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
|
// forward here because shopify.app.toml's [app_proxy].url already includes
|
||||||
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
// the /apps/scheduling prefix, so this file's path (apps.scheduling.*)
|
||||||
// mirrors the shop-facing URL exactly.
|
// 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 VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
const MAX_DAYS = 60;
|
const MAX_DAYS = 60;
|
||||||
@ -24,6 +23,11 @@ function toIsoDate(date: Date): string {
|
|||||||
return date.toISOString().slice(0, 10);
|
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) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.public.appProxy(request);
|
const { session } = await authenticate.public.appProxy(request);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@ -33,6 +37,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const methodParam = url.searchParams.get("method");
|
const methodParam = url.searchParams.get("method");
|
||||||
const locationIdParam = url.searchParams.get("locationId");
|
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 daysParam = Number(url.searchParams.get("days") ?? DEFAULT_DAYS);
|
||||||
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
|
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.min(daysParam, MAX_DAYS) : DEFAULT_DAYS;
|
||||||
|
|
||||||
@ -41,14 +47,55 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
const method = methodParam as Method;
|
const method = methodParam as Method;
|
||||||
|
|
||||||
const location = locationIdParam
|
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
|
||||||
? await db.location.findFirst({
|
let zoneId: string | null = null;
|
||||||
where: { id: locationIdParam, shopDomain: session.shop, active: true },
|
let distanceKm: number | null = null;
|
||||||
})
|
|
||||||
: await db.location.findFirst({
|
// Delivery-zone auto-assignment (IMPLEMENTATION_PLAN.md Phase 5): route to
|
||||||
where: { shopDomain: session.shop, active: true },
|
// the nearest eligible, density-qualified zone for the shopper's address.
|
||||||
orderBy: { createdAt: "asc" },
|
// 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 },
|
||||||
|
})
|
||||||
|
: await db.location.findFirst({
|
||||||
|
where: { shopDomain: session.shop, active: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return Response.json({ error: "No active location configured" }, { status: 404 });
|
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 endDate = now.plus({ days }).toISODate()!;
|
||||||
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
|
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
|
||||||
const rangeEnd = DateTime.fromISO(endDate, { 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({
|
db.slotTemplate.findMany({
|
||||||
where: { shopDomain: session.shop, locationId: location.id, method },
|
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 }] }],
|
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({
|
const availability = getAvailability({
|
||||||
timezone: location.timezone,
|
timezone: location.timezone,
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
@ -101,13 +170,21 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
})),
|
})),
|
||||||
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
|
blackoutDates: blackouts.map((b) => ({ date: toIsoDate(b.date) })),
|
||||||
now,
|
now,
|
||||||
|
consumed,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const matchedRate = resolveRate(rates, { method, zoneId: zoneId ?? undefined, distanceKm: distanceKm ?? undefined });
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
locationName: location.name,
|
locationName: location.name,
|
||||||
|
locationLat: location.lat,
|
||||||
|
locationLng: location.lng,
|
||||||
timezone: location.timezone,
|
timezone: location.timezone,
|
||||||
method,
|
method,
|
||||||
dates: availability,
|
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 startMinRaw = readAttr(attrs, "dd_start_min");
|
||||||
const endMinRaw = readAttr(attrs, "dd_end_min");
|
const endMinRaw = readAttr(attrs, "dd_end_min");
|
||||||
const locationId = readAttr(attrs, "dd_location_id");
|
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) {
|
if (!method || !VALID_METHODS.has(method as Method) || !date || !startMinRaw || !endMinRaw || !locationId) {
|
||||||
return; // no scheduling selection on this order — nothing to book
|
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,
|
orderId: order.admin_graphql_api_id,
|
||||||
orderName: order.name,
|
orderName: order.name,
|
||||||
locationId: location.id,
|
locationId: location.id,
|
||||||
|
zoneId,
|
||||||
method: method as Method,
|
method: method as Method,
|
||||||
slotStart: slotStart.toJSDate(),
|
slotStart: slotStart.toJSDate(),
|
||||||
slotEnd: slotEnd.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;
|
color: inherit;
|
||||||
font: 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-change="{{ 'widget.change' | t | escape }}"
|
||||||
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
data-label-loading="{{ 'widget.loading' | t | escape }}"
|
||||||
data-label-error="{{ 'widget.error' | 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.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 }}
|
{{ block.shopify_attributes }}
|
||||||
>
|
>
|
||||||
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
<noscript>{{ 'widget.enable_js' | t | escape }}</noscript>
|
||||||
@ -58,6 +62,12 @@
|
|||||||
"id": "location_id",
|
"id": "location_id",
|
||||||
"label": "t:datetime_picker.location_id_label",
|
"label": "t:datetime_picker.location_id_label",
|
||||||
"info": "t:datetime_picker.location_id_info"
|
"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",
|
"change": "Change",
|
||||||
"loading": "Loading available dates…",
|
"loading": "Loading available dates…",
|
||||||
"error": "Couldn't load available dates. Please try again.",
|
"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_local_delivery_label": "Show Local Delivery",
|
||||||
"show_pickup_label": "Show Pickup",
|
"show_pickup_label": "Show Pickup",
|
||||||
"location_id_label": "Location ID (advanced)",
|
"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") {
|
ddDate: attribute(key: "dd_date") {
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
ddRateLabel: attribute(key: "dd_rate_label") {
|
||||||
|
value
|
||||||
|
}
|
||||||
deliveryGroups {
|
deliveryGroups {
|
||||||
deliveryOptions {
|
deliveryOptions {
|
||||||
handle
|
handle
|
||||||
|
|||||||
@ -15,13 +15,13 @@ const NO_CHANGES = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Relabels every presented delivery option to the shopper's actual chosen
|
* 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
|
* ("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
|
* what's actually a pickup or local-delivery order. There's still no
|
||||||
* have a per-option method mapping (that needs Phase 5's zones/rates
|
* per-option method mapping, so every option in the cart gets the same
|
||||||
* work), so every option in the cart gets the same clarified label —
|
* clarified label — reasonable since a single order only has one chosen
|
||||||
* reasonable since a single order only has one chosen fulfillment method
|
* fulfillment method today.
|
||||||
* today.
|
|
||||||
* @param {CartDeliveryOptionsTransformRunInput} input
|
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||||
* @returns {CartDeliveryOptionsTransformRunResult}
|
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||||
*/
|
*/
|
||||||
@ -29,6 +29,7 @@ export function cartDeliveryOptionsTransformRun(input) {
|
|||||||
const decision = renameLabelFor({
|
const decision = renameLabelFor({
|
||||||
dd_method: input.cart.ddMethod?.value,
|
dd_method: input.cart.ddMethod?.value,
|
||||||
dd_date: input.cart.ddDate?.value,
|
dd_date: input.cart.ddDate?.value,
|
||||||
|
dd_rate_label: input.cart.ddRateLabel?.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!decision.rename) {
|
if (!decision.rename) {
|
||||||
|
|||||||
@ -29,5 +29,9 @@ export function renameLabelFor(attributes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = METHOD_LABEL[method];
|
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": {
|
"cart": {
|
||||||
"ddMethod": null,
|
"ddMethod": null,
|
||||||
"ddDate": null,
|
"ddDate": null,
|
||||||
|
"ddRateLabel": null,
|
||||||
"deliveryGroups": [
|
"deliveryGroups": [
|
||||||
{
|
{
|
||||||
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
"cart": {
|
"cart": {
|
||||||
"ddMethod": { "value": "PICKUP" },
|
"ddMethod": { "value": "PICKUP" },
|
||||||
"ddDate": { "value": "2026-08-25" },
|
"ddDate": { "value": "2026-08-25" },
|
||||||
|
"ddRateLabel": null,
|
||||||
"deliveryGroups": [
|
"deliveryGroups": [
|
||||||
{
|
{
|
||||||
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
"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");
|
||||||
@ -55,19 +55,24 @@ model Shop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Location {
|
model Location {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
shopDomain String
|
shopDomain String
|
||||||
name String
|
name String
|
||||||
address String
|
address String
|
||||||
lat Float?
|
lat Float?
|
||||||
lng Float?
|
lng Float?
|
||||||
timezone String
|
timezone String
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
slotTemplates SlotTemplate[]
|
// Maps this row to Shopify's own Location resource (gid://shopify/Location/…)
|
||||||
overrides SlotOverride[]
|
// so inventory-based exclusion (Phase 5) can query stock at the right
|
||||||
blackouts BlackoutDate[]
|
// Shopify location — optional since not every merchant needs it wired up.
|
||||||
bookings Booking[]
|
shopifyLocationId String?
|
||||||
createdAt DateTime @default(now())
|
slotTemplates SlotTemplate[]
|
||||||
|
overrides SlotOverride[]
|
||||||
|
blackouts BlackoutDate[]
|
||||||
|
bookings Booking[]
|
||||||
|
zones Zone[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain])
|
@@index([shopDomain])
|
||||||
}
|
}
|
||||||
@ -126,6 +131,13 @@ model Booking {
|
|||||||
orderName String? // e.g. "#1001", for display only
|
orderName String? // e.g. "#1001", for display only
|
||||||
locationId String
|
locationId String
|
||||||
location Location @relation(fields: [locationId], references: [id])
|
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
|
method Method
|
||||||
slotStart DateTime
|
slotStart DateTime
|
||||||
slotEnd DateTime
|
slotEnd DateTime
|
||||||
@ -136,6 +148,7 @@ model Booking {
|
|||||||
|
|
||||||
@@index([shopDomain, locationId, method, slotStart])
|
@@index([shopDomain, locationId, method, slotStart])
|
||||||
@@index([shopDomain, status])
|
@@index([shopDomain, status])
|
||||||
|
@@index([shopDomain, zoneId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlotHold is intentionally NOT a Prisma model — per IMPLEMENTATION_PLAN.md
|
// 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
|
// 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
|
// of truth for holds (app/services/holds.server.ts). Mirroring it into
|
||||||
// Postgres too would only add a sync-consistency burden with no benefit.
|
// 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", () => {
|
it("does nothing when the date is missing even if method is present", () => {
|
||||||
expect(renameLabelFor({ dd_method: "PICKUP" })).toEqual({ rename: false });
|
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;
|
remainingCapacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RateDto {
|
||||||
|
name: string;
|
||||||
|
priceCents: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AvailabilityResponse {
|
interface AvailabilityResponse {
|
||||||
locationId: string;
|
locationId: string | null;
|
||||||
locationName: string;
|
locationName?: string;
|
||||||
timezone: string;
|
locationLat?: number | null;
|
||||||
|
locationLng?: number | null;
|
||||||
|
timezone?: string;
|
||||||
method: Method;
|
method: Method;
|
||||||
dates: Record<string, SlotDto[]>;
|
dates: Record<string, SlotDto[]>;
|
||||||
|
zoneId?: string | null;
|
||||||
|
distanceKm?: number | null;
|
||||||
|
rate?: RateDto | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +46,7 @@ interface WidgetConfig {
|
|||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
heading: string;
|
heading: string;
|
||||||
locationId: string | null;
|
locationId: string | null;
|
||||||
|
googleMapsApiKey: string | null;
|
||||||
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
methods: Array<{ value: Method; label: string; attrLabel: string }>;
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: string;
|
chooseDate: string;
|
||||||
@ -44,6 +56,9 @@ interface WidgetConfig {
|
|||||||
change: string;
|
change: string;
|
||||||
loading: string;
|
loading: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
postalCodeLabel: string;
|
||||||
|
postalCodeSubmit: string;
|
||||||
|
outOfArea: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +101,7 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
root,
|
root,
|
||||||
heading: d.heading || "",
|
heading: d.heading || "",
|
||||||
locationId: d.locationId || null,
|
locationId: d.locationId || null,
|
||||||
|
googleMapsApiKey: d.googleMapsApiKey || null,
|
||||||
methods,
|
methods,
|
||||||
labels: {
|
labels: {
|
||||||
chooseDate: d.labelChooseDate || "Choose a date",
|
chooseDate: d.labelChooseDate || "Choose a date",
|
||||||
@ -95,13 +111,21 @@ function readConfig(root: HTMLElement): WidgetConfig {
|
|||||||
change: d.labelChange || "Change",
|
change: d.labelChange || "Change",
|
||||||
loading: d.labelLoading || "Loading available dates…",
|
loading: d.labelLoading || "Loading available dates…",
|
||||||
error: d.labelError || "Couldn't load available dates. Please try again.",
|
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" });
|
const params = new URLSearchParams({ method, days: "14" });
|
||||||
if (locationId) params.set("locationId", locationId);
|
if (locationId) params.set("locationId", locationId);
|
||||||
|
if (postalCode) params.set("postalCode", postalCode);
|
||||||
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||||
headers: { Accept: "application/json" },
|
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 {
|
class DateTimeWidget {
|
||||||
private config: WidgetConfig;
|
private config: WidgetConfig;
|
||||||
private el = {
|
private el = {
|
||||||
heading: document.createElement("h3"),
|
heading: document.createElement("h3"),
|
||||||
methodRow: document.createElement("div"),
|
methodRow: document.createElement("div"),
|
||||||
|
postalRow: document.createElement("div"),
|
||||||
|
mapContainer: document.createElement("div"),
|
||||||
dateRow: document.createElement("div"),
|
dateRow: document.createElement("div"),
|
||||||
timeRow: document.createElement("div"),
|
timeRow: document.createElement("div"),
|
||||||
status: document.createElement("p"),
|
status: document.createElement("p"),
|
||||||
@ -161,8 +225,9 @@ class DateTimeWidget {
|
|||||||
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
private selectedMethod: WidgetConfig["methods"][number] | null = null;
|
||||||
private selectedDate: string | null = null;
|
private selectedDate: string | null = null;
|
||||||
private availability: AvailabilityResponse | null = null;
|
private availability: AvailabilityResponse | null = null;
|
||||||
private heldSlot: { locationId: string; method: Method; date: string; startMin: number; cartToken: string } | null =
|
private heldSlot:
|
||||||
null;
|
| { locationId: string; method: Method; date: string; startMin: number; cartToken: string; zoneId: string | null }
|
||||||
|
| null = null;
|
||||||
|
|
||||||
constructor(config: WidgetConfig) {
|
constructor(config: WidgetConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@ -182,13 +247,25 @@ class DateTimeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.el.methodRow.className = "dd-widget__row dd-widget__methods";
|
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.dateRow.className = "dd-widget__row dd-widget__dates";
|
||||||
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
this.el.timeRow.className = "dd-widget__row dd-widget__times";
|
||||||
this.el.status.className = "dd-widget__status";
|
this.el.status.className = "dd-widget__status";
|
||||||
this.el.confirmation.className = "dd-widget__confirmation";
|
this.el.confirmation.className = "dd-widget__confirmation";
|
||||||
this.el.confirmation.hidden = true;
|
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) {
|
if (methods.length === 1) {
|
||||||
this.selectMethod(methods[0]);
|
this.selectMethod(methods[0]);
|
||||||
@ -214,20 +291,80 @@ class DateTimeWidget {
|
|||||||
this.selectedMethod = method;
|
this.selectedMethod = method;
|
||||||
this.selectedDate = null;
|
this.selectedDate = null;
|
||||||
this.el.timeRow.innerHTML = "";
|
this.el.timeRow.innerHTML = "";
|
||||||
|
this.el.dateRow.innerHTML = "";
|
||||||
|
this.el.mapContainer.hidden = true;
|
||||||
|
this.el.confirmation.hidden = false;
|
||||||
this.el.confirmation.hidden = true;
|
this.el.confirmation.hidden = true;
|
||||||
if (this.config.methods.length > 1) this.renderMethods();
|
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.status.textContent = this.config.labels.loading;
|
||||||
this.el.dateRow.innerHTML = "";
|
this.el.dateRow.innerHTML = "";
|
||||||
|
|
||||||
try {
|
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();
|
this.renderDates();
|
||||||
} catch {
|
} catch {
|
||||||
this.el.status.textContent = this.config.labels.error;
|
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() {
|
private renderDates() {
|
||||||
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
const dates = Object.keys(this.availability?.dates ?? {}).sort();
|
||||||
this.el.dateRow.innerHTML = "";
|
this.el.dateRow.innerHTML = "";
|
||||||
@ -272,7 +409,8 @@ class DateTimeWidget {
|
|||||||
private async selectSlot(date: string, slot: SlotDto) {
|
private async selectSlot(date: string, slot: SlotDto) {
|
||||||
const method = this.selectedMethod!;
|
const method = this.selectedMethod!;
|
||||||
const availability = this.availability!;
|
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;
|
this.el.status.textContent = this.config.labels.loading;
|
||||||
|
|
||||||
@ -286,7 +424,7 @@ class DateTimeWidget {
|
|||||||
// against at checkout.
|
// against at checkout.
|
||||||
const hold = await requestHold({
|
const hold = await requestHold({
|
||||||
intent: "create",
|
intent: "create",
|
||||||
locationId: availability.locationId,
|
locationId: availability.locationId!,
|
||||||
method: method.value,
|
method: method.value,
|
||||||
date,
|
date,
|
||||||
startMin: slot.startMin,
|
startMin: slot.startMin,
|
||||||
@ -300,22 +438,31 @@ class DateTimeWidget {
|
|||||||
return;
|
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(
|
const machineAttrs: Record<string, string> = {
|
||||||
method.attrLabel,
|
dd_method: method.value,
|
||||||
{
|
dd_date: date,
|
||||||
dd_method: method.value,
|
dd_start_min: String(slot.startMin),
|
||||||
dd_date: date,
|
dd_end_min: String(slot.endMin),
|
||||||
dd_start_min: String(slot.startMin),
|
dd_location_id: availability.locationId!,
|
||||||
dd_end_min: String(slot.endMin),
|
};
|
||||||
dd_location_id: availability.locationId,
|
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
|
||||||
},
|
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
|
||||||
display,
|
|
||||||
);
|
await writeCartAttribute(method.attrLabel, machineAttrs, display);
|
||||||
|
|
||||||
this.el.status.textContent = "";
|
this.el.status.textContent = "";
|
||||||
this.el.methodRow.hidden = true;
|
this.el.methodRow.hidden = true;
|
||||||
|
this.el.postalRow.hidden = true;
|
||||||
|
this.el.mapContainer.hidden = true;
|
||||||
this.el.dateRow.hidden = true;
|
this.el.dateRow.hidden = true;
|
||||||
this.el.timeRow.hidden = true;
|
this.el.timeRow.hidden = true;
|
||||||
this.el.confirmation.hidden = false;
|
this.el.confirmation.hidden = false;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user