feat: product rules, driving-distance zones, and shipping date ranges

Closes remaining DS-parity gaps from the feature audit:

- ProductRule model (product/collection/vendor/type/tag scoping) with
  real server-side enforcement in hold-request.server.ts, plus shaped
  availability in availability-request.server.ts. Covers per-product
  prep time, cart-content-based slot blocking, and product-restricted
  locations in one mechanism. New /app/rules admin page (Growth+).
- Driving-distance delivery zones via Google's Distance Matrix API,
  cached like existing geocoding results.
- SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays
  on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a
  meaningless ship-out time slot; carried through to the order
  metafield write-back.

Storefront widget and POS extension now send cart contents (vendor/
type from cart.js, product ids for Admin-API-resolved collection/tag
rules) to both availability and hold endpoints.

checkout-datetime remains excluded from this deploy pending Shopify's
Network Access approval (unrelated to this work) — re-add from
../checkout-datetime-disabled and redeploy once granted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
MOHAN 2026-08-26 00:28:45 +05:30
parent 730a681d46
commit 03574a4914
32 changed files with 1111 additions and 400 deletions

View File

@ -0,0 +1,34 @@
// Shared parsing for the ProductRule-scoping params every scheduling route
// (storefront + POS, availability + hold) accepts identically — kept here so
// the four call sites don't each re-implement the same defensive JSON parsing.
export interface CartLineParam {
vendor?: string;
productType?: string;
}
/** `cartLines` is a JSON-encoded array; malformed/missing input degrades to "no cart info" rather than erroring the request. */
export function parseCartLinesParam(raw: string | null | undefined): CartLineParam[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
.map((entry) => ({
vendor: typeof entry.vendor === "string" ? entry.vendor : undefined,
productType: typeof entry.productType === "string" ? entry.productType : undefined,
}));
} catch {
return [];
}
}
/** `productIds` is a comma-separated list of Shopify Product GIDs. */
export function parseProductIdsParam(raw: string | null | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}

View File

@ -0,0 +1,255 @@
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, ChoiceList, IndexTable } 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 { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
import { UpsellState } from "../components/UpsellState";
// Product/collection/vendor/type/tag-scoped delivery rules (PRODUCT_STRATEGY.md
// §2 — previously unimplemented; see product-rules.server.ts). Tier-gated at
// Growth, same as Zones/Rates, since scoping rules is an "advanced operator"
// feature, not core parity every merchant needs on day one.
const SCOPE_TYPES = [
{ label: "Product", value: "product" },
{ label: "Collection", value: "collection" },
{ label: "Vendor", value: "vendor" },
{ label: "Product type", value: "type" },
{ label: "Tag", value: "tag" },
];
const METHOD_OPTIONS: Array<{ label: string; value: Method }> = [
{ label: "Shipping", value: "SHIPPING" },
{ label: "Local delivery", value: "LOCAL_DELIVERY" },
{ label: "Pickup", value: "PICKUP" },
];
const SCOPE_LABELS: Record<string, string> = {
product: "Product ID",
collection: "Collection ID",
vendor: "Vendor",
type: "Product type",
tag: "Tag",
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return { gated: true as const, tier };
}
const [rules, locations] = await Promise.all([
db.productRule.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
]);
return { gated: false as const, rules, locations };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return data({ errors: { scopeValue: "Product rules need the Growth plan or higher." } }, { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.productRule.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const scopeType = String(formData.get("scopeType") || "product");
const scopeValue = String(formData.get("scopeValue") || "").trim();
const allowedMethods = formData.getAll("allowedMethods").map(String) as Method[];
const allowedLocationIds = formData.getAll("allowedLocationIds").map(String);
const leadTimeMinRaw = String(formData.get("leadTimeMin") || "");
const errors: Record<string, string> = {};
if (!scopeValue) errors.scopeValue = "This field is required";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.productRule.create({
data: {
shopDomain: session.shop,
scopeType,
scopeValue,
allowedMethods,
allowedLocationIds,
leadTimeMin: leadTimeMinRaw ? Number(leadTimeMinRaw) : null,
},
});
return data({ ok: true });
};
export default function ProductRulesIndex() {
const loaderData = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Product rules" />
<UpsellState
requiredTier="growth"
currentTier={loaderData.tier}
feature="Product rules"
description="Scope prep time, allowed methods, and allowed locations to specific products, collections, vendors, types, or tags."
/>
</Page>
);
}
const { rules, locations } = loaderData;
const locationNameById = new Map(locations.map((l) => [l.id, l.name]));
return (
<Page>
<TitleBar title="Product rules" />
<BlockStack gap="400">
<Card padding="0">
{rules.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No product rules yet. Without one, every product uses the same lead time and is available via any
method/location your slots allow.
</Text>
</div>
) : (
<IndexTable
itemCount={rules.length}
headings={[
{ title: "Scope" },
{ title: "Extra lead time" },
{ title: "Allowed methods" },
{ title: "Allowed locations" },
{ title: "" },
]}
selectable={false}
>
{rules.map((rule, index) => (
<IndexTable.Row id={rule.id} key={rule.id} position={index}>
<IndexTable.Cell>
{SCOPE_LABELS[rule.scopeType] ?? rule.scopeType}: {rule.scopeValue}
</IndexTable.Cell>
<IndexTable.Cell>{rule.leadTimeMin ? `${rule.leadTimeMin} min` : "—"}</IndexTable.Cell>
<IndexTable.Cell>{rule.allowedMethods.length > 0 ? rule.allowedMethods.join(", ") : "Any"}</IndexTable.Cell>
<IndexTable.Cell>
{rule.allowedLocationIds.length > 0
? rule.allowedLocationIds.map((id) => locationNameById.get(id) ?? id).join(", ")
: "Any"}
</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={rule.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddRuleForm locations={locations} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddRuleForm({
locations,
isSubmitting,
}: {
locations: Array<{ id: string; name: string }>;
isSubmitting: boolean;
}) {
const [scopeType, setScopeType] = useState("vendor");
const [scopeValue, setScopeValue] = useState("");
const [leadTimeMin, setLeadTimeMin] = useState("");
const [allowedMethods, setAllowedMethods] = useState<string[]>([]);
const [allowedLocationIds, setAllowedLocationIds] = useState<string[]>([]);
return (
<Card>
<Form method="post">
{allowedMethods.map((m) => (
<input key={m} type="hidden" name="allowedMethods" value={m} />
))}
{allowedLocationIds.map((id) => (
<input key={id} type="hidden" name="allowedLocationIds" value={id} />
))}
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a product rule
</Text>
<InlineStack gap="300" wrap>
<Select label="Scope" name="scopeType" options={SCOPE_TYPES} value={scopeType} onChange={setScopeType} />
<TextField
label={SCOPE_LABELS[scopeType] ?? "Value"}
name="scopeValue"
autoComplete="off"
value={scopeValue}
onChange={setScopeValue}
helpText={
scopeType === "product" || scopeType === "collection"
? "The Shopify GID, e.g. gid://shopify/Product/123456789"
: undefined
}
/>
<TextField
label="Extra prep time (minutes, optional)"
name="leadTimeMin"
type="number"
autoComplete="off"
value={leadTimeMin}
onChange={setLeadTimeMin}
helpText="Added on top of the slot's own lead time when this rule matches something in the cart."
/>
</InlineStack>
<InlineStack gap="400" wrap>
<ChoiceList
title="Allowed methods (leave blank for any)"
allowMultiple
choices={METHOD_OPTIONS}
selected={allowedMethods}
onChange={setAllowedMethods}
/>
{locations.length > 0 && (
<ChoiceList
title="Allowed locations (leave blank for any)"
allowMultiple
choices={locations.map((l) => ({ label: l.name, value: l.id }))}
selected={allowedLocationIds}
onChange={setAllowedLocationIds}
/>
)}
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add rule
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}

View File

@ -75,11 +75,16 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const capacity = Number(formData.get("capacity"));
const cutoffMin = Number(formData.get("cutoffMin") || 0);
const leadTimeMin = Number(formData.get("leadTimeMin") || 0);
const transitMinDaysRaw = String(formData.get("transitMinDays") || "");
const transitMaxDaysRaw = String(formData.get("transitMaxDays") || "");
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (startMin >= endMin) errors.endTime = "End time must be after start time";
if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1";
if (transitMinDaysRaw && transitMaxDaysRaw && Number(transitMinDaysRaw) > Number(transitMaxDaysRaw)) {
errors.transitMaxDays = "Max transit days must be at least the min";
}
if (Object.keys(errors).length > 0) {
return data({ errors });
}
@ -95,6 +100,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
capacity,
cutoffMin,
leadTimeMin,
// Only meaningful for SHIPPING (PRODUCT_STRATEGY.md §2 "date ranges") —
// stored regardless of method since a merchant might switch a
// template's method later, but only ever read for SHIPPING.
transitMinDays: transitMinDaysRaw ? Number(transitMinDaysRaw) : null,
transitMaxDays: transitMaxDaysRaw ? Number(transitMaxDaysRaw) : null,
},
});
@ -154,6 +164,7 @@ export default function SlotsIndex() {
{ title: "Capacity" },
{ title: "Cutoff (min)" },
{ title: "Lead time (min)" },
{ title: "Transit (days)" },
{ title: "" },
]}
selectable={false}
@ -168,6 +179,11 @@ export default function SlotsIndex() {
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
<IndexTable.Cell>
{slot.transitMinDays != null || slot.transitMaxDays != null
? `${slot.transitMinDays ?? "—"}${slot.transitMaxDays ?? "—"}`
: "—"}
</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
@ -195,6 +211,8 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi
const [capacity, setCapacity] = useState("10");
const [cutoffMin, setCutoffMin] = useState("60");
const [leadTimeMin, setLeadTimeMin] = useState("0");
const [transitMinDays, setTransitMinDays] = useState("");
const [transitMaxDays, setTransitMaxDays] = useState("");
return (
<Card>
@ -255,6 +273,23 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi
onChange={setLeadTimeMin}
autoComplete="off"
/>
<TextField
label="Min transit days (Shipping only)"
name="transitMinDays"
type="number"
value={transitMinDays}
onChange={setTransitMinDays}
autoComplete="off"
helpText="Shown as an estimated arrival range instead of a delivery time slot."
/>
<TextField
label="Max transit days (Shipping only)"
name="transitMaxDays"
type="number"
value={transitMaxDays}
onChange={setTransitMaxDays}
autoComplete="off"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>

View File

@ -29,6 +29,7 @@ export default function App() {
<Link to="/app/blackouts">Blackout dates</Link>
<Link to="/app/zones">Delivery zones</Link>
<Link to="/app/rates">Delivery rates</Link>
<Link to="/app/rules">Product rules</Link>
<Link to="/app/dashboard">Dispatch dashboard</Link>
<Link to="/app/billing">Billing</Link>
<Link to="/app/help">Help</Link>

View File

@ -23,6 +23,7 @@ import { UpsellState } from "../components/UpsellState";
const ZONE_TYPES = [
{ label: "Postal / ZIP codes", value: "postal" },
{ label: "Radius (straight-line distance)", value: "radius" },
{ label: "Driving distance (via Google Maps)", value: "driving" },
];
export const loader = async ({ request }: LoaderFunctionArgs) => {
@ -80,7 +81,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
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 ((type === "radius" || type === "driving") && !radiusKmRaw) errors.radiusKm = "Distance is required for this zone type";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
@ -97,7 +98,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
name,
type,
postalCodes: type === "postal" ? postalCodes : [],
radiusKm: type === "radius" && radiusKmRaw ? Number(radiusKmRaw) : null,
radiusKm: (type === "radius" || type === "driving") && radiusKmRaw ? Number(radiusKmRaw) : null,
minOrders: minOrdersRaw ? Number(minOrdersRaw) : null,
},
});
@ -179,7 +180,9 @@ export default function ZonesIndex() {
{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" ? "Postal/ZIP" : zone.type === "driving" ? "Driving distance" : "Radius"}
</IndexTable.Cell>
<IndexTable.Cell>
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
</IndexTable.Cell>
@ -234,12 +237,13 @@ function AddZoneForm({ locationId, isSubmitting }: { locationId: string; isSubmi
/>
) : (
<TextField
label="Radius (km)"
label={type === "driving" ? "Max driving distance (km)" : "Radius (km)"}
name="radiusKm"
type="number"
autoComplete="off"
value={radiusKm}
onChange={setRadiusKm}
helpText={type === "driving" ? "Actual road distance via Google Maps, not straight-line." : undefined}
/>
)}
<TextField

View File

@ -2,6 +2,8 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
import { resolveProductRefs } from "../services/product-rules.server";
import { parseCartLinesParam, parseProductIdsParam } from "../lib/cart-rule-params";
// Public endpoint, reachable only through Shopify's App Proxy (signature
// verified by authenticate.public.appProxy) — this is what the storefront
@ -15,7 +17,7 @@ import { resolveAvailabilityRequest } from "../services/availability-request.ser
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
const { session, admin } = await authenticate.public.appProxy(request);
if (!session) {
return Response.json({ error: "Shop not found" }, { status: 404 });
}
@ -26,12 +28,22 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return Response.json({ error: "Invalid or missing method" }, { status: 400 });
}
// ProductRule scoping (PRODUCT_STRATEGY.md §2): vendor/type come free from
// the widget's own /cart.js read; product/collection/tag rules additionally
// need one Admin API round trip to resolve productIds — skipped gracefully
// if there's no admin client (shouldn't happen for an installed shop's app
// proxy request, but this endpoint must not 500 over an optional feature).
const productIds = parseProductIdsParam(url.searchParams.get("productIds"));
const productRefs = admin && productIds.length > 0 ? await resolveProductRefs(admin, productIds) : [];
const result = await resolveAvailabilityRequest(session.shop, {
method: methodParam as Method,
locationId: url.searchParams.get("locationId") || undefined,
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
cartLines: parseCartLinesParam(url.searchParams.get("cartLines")),
productRefs,
});
if (!result.locationId) {

View File

@ -2,6 +2,8 @@ import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveHoldRequest } from "../services/hold-request.server";
import { resolveProductRefs } from "../services/product-rules.server";
import { parseCartLinesParam, parseProductIdsParam } from "../lib/cart-rule-params";
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
// path-mirroring rationale). Called by the widget the moment a shopper
@ -15,25 +17,30 @@ import { resolveHoldRequest } from "../services/hold-request.server";
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.public.appProxy(request);
const { session, admin } = await authenticate.public.appProxy(request);
if (!session) {
return Response.json({ error: "Shop not found" }, { status: 404 });
}
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
const { intent, locationId, method, date, startMin, cartToken, cartLines, productIds } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
cartLines?: Array<{ vendor?: string; productType?: string }>;
productIds?: string[];
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
}
const resolvedProductIds = parseProductIdsParam(Array.isArray(productIds) ? productIds.join(",") : undefined);
const productRefs = admin && resolvedProductIds.length > 0 ? await resolveProductRefs(admin, resolvedProductIds) : [];
const result = await resolveHoldRequest(session.shop, {
intent: intent === "release" ? "release" : "create",
locationId,
@ -41,6 +48,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
date,
startMin,
cartToken,
cartLines: parseCartLinesParam(Array.isArray(cartLines) ? JSON.stringify(cartLines) : undefined),
productRefs,
});
return Response.json(result.body, { status: result.status });

View File

@ -2,6 +2,7 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveAvailabilityRequest } from "../services/availability-request.server";
import { parseCartLinesParam } from "../lib/cart-rule-params";
// POS UI Extension endpoint — session-token authenticated (POS extensions
// can't use the storefront's app-proxy signature scheme), CORS-enabled
@ -28,6 +29,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
postalCode: url.searchParams.get("postalCode") || undefined,
address: url.searchParams.get("address") || undefined,
days: Number(url.searchParams.get("days")) || undefined,
// POS session-token auth has no Admin API client (unlike the storefront's
// app-proxy auth), so only vendor/type-scoped ProductRules apply here —
// product/collection/tag rules need the Admin API round trip that's only
// available on the app-proxy path (apps.scheduling.availability.tsx).
cartLines: parseCartLinesParam(url.searchParams.get("cartLines")),
});
return cors(Response.json(result));

View File

@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import { resolveHoldRequest } from "../services/hold-request.server";
import { parseCartLinesParam } from "../lib/cart-rule-params";
// POS UI Extension endpoint — see pos.scheduling.availability.tsx for the
// session-token/CORS rationale. Calls the exact same resolveHoldRequest()
@ -17,13 +18,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
const body = await request.json();
const { intent, locationId, method, date, startMin, cartToken } = body as {
const { intent, locationId, method, date, startMin, cartToken, cartLines } = body as {
intent?: "create" | "release";
locationId?: string;
method?: string;
date?: string;
startMin?: number;
cartToken?: string;
cartLines?: Array<{ vendor?: string; productType?: string }>;
};
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
@ -37,6 +39,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
date,
startMin,
cartToken,
// No Admin API client on POS session-token auth — vendor/type rules only, same as pos.scheduling.availability.tsx.
cartLines: parseCartLinesParam(Array.isArray(cartLines) ? JSON.stringify(cartLines) : undefined),
});
return cors(Response.json(result.body, { status: result.status }));

View File

@ -53,7 +53,19 @@ function summarizeBookingAttributes(order: OrderWebhookPayload) {
const startMin = get("dd_start_min");
const endMin = get("dd_end_min");
const locationId = get("dd_location_id");
// SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2) — present
// only when the matched slot template had transit days configured.
const arrivalRangeStart = get("dd_arrival_range_start");
const arrivalRangeEnd = get("dd_arrival_range_end");
if (!method || !date || !startMin || !endMin || !locationId) return null;
return { method, date, startMin: Number(startMin), endMin: Number(endMin), locationId };
return {
method,
date,
startMin: Number(startMin),
endMin: Number(endMin),
locationId,
...(arrivalRangeStart ? { arrivalRangeStart } : {}),
...(arrivalRangeEnd ? { arrivalRangeEnd } : {}),
};
}

View File

@ -5,6 +5,7 @@ import { getAvailability, type AvailableSlot } from "./scheduling.server";
import { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "./zones.server";
import { resolveRate } from "./rates.server";
import { formatPriceLabel } from "../lib/currency";
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
// The single availability resolver every surface calls — storefront widget
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
@ -23,6 +24,17 @@ export interface AvailabilityRequestParams {
postalCode?: string;
address?: string;
days?: number;
/**
* Cart contents for ProductRule scoping (PRODUCT_STRATEGY.md §2). `cartLines`
* (vendor/productType, straight from the storefront's /cart.js no extra
* round-trip) covers vendor/type-scoped rules for free; `productRefs` (already
* resolved via the Admin API by the caller see product-rules.server.ts's
* resolveProductRefs) additionally covers product/collection/tag-scoped rules.
* Both are optional so existing callers (POS, pre-cart availability checks)
* keep working unchanged.
*/
cartLines?: Array<{ vendor?: string; productType?: string }>;
productRefs?: ProductRef[];
}
export interface AvailabilityResult {
@ -55,6 +67,26 @@ export async function resolveAvailabilityRequest(
const { method, locationId: locationIdParam, postalCode, address } = params;
const days = params.days && params.days > 0 ? Math.min(params.days, MAX_DAYS) : DEFAULT_DAYS;
// ProductRule scoping (PRODUCT_STRATEGY.md §2): resolve before touching
// locations, since an allowedLocationIds restriction narrows which
// locations are even eligible to be picked, not just which one filters
// through afterward.
const productRules = await db.productRule.findMany({ where: { shopDomain, active: true } });
const cartProductRefs = [...(params.productRefs ?? []), ...productRefsFromCartLines(params.cartLines ?? [])];
const ruleConstraints = resolveProductRuleConstraints(productRules, cartProductRefs);
if (ruleConstraints.allowedMethods != null && !ruleConstraints.allowedMethods.includes(method)) {
return {
locationId: null,
method,
dates: {},
zoneId: null,
distanceKm: null,
rate: null,
error: "One or more items in your cart aren't available with this fulfillment method.",
};
}
let location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
let zoneId: string | null = null;
let distanceKm: number | null = null;
@ -73,6 +105,7 @@ export async function resolveAvailabilityRequest(
for (const match of matches) {
if (locationIdParam && match.locationId !== locationIdParam) continue;
if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(match.locationId)) 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
@ -96,9 +129,11 @@ export async function resolveAvailabilityRequest(
};
}
} else {
const locationWhere =
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
location = locationIdParam
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true } })
: await db.location.findFirst({ where: { shopDomain, active: true }, orderBy: { createdAt: "asc" } });
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true, ...locationWhere } })
: await db.location.findFirst({ where: { shopDomain, active: true, ...locationWhere }, orderBy: { createdAt: "asc" } });
}
if (!location) {
@ -109,7 +144,10 @@ export async function resolveAvailabilityRequest(
zoneId: null,
distanceKm: null,
rate: null,
error: "No active location configured",
error:
ruleConstraints.allowedLocationIds != null
? "One or more items in your cart aren't available at this location."
: "No active location configured",
};
}
@ -164,7 +202,14 @@ export async function resolveAvailabilityRequest(
endMin: t.endMin,
capacity: t.capacity,
cutoffMin: t.cutoffMin,
leadTimeMin: t.leadTimeMin,
// ProductRule-driven prep-time floor (per-product/vendor/collection lead
// time and cart-content-based slot blocking, PRODUCT_STRATEGY.md §2)
// stacks with the slot's own leadTimeMin — getAvailability already takes
// the max of leadTimeMin/cutoffMin, so folding it in here keeps that
// function's signature and purity guarantee untouched.
leadTimeMin: Math.max(t.leadTimeMin, ruleConstraints.minLeadTimeMin),
transitMinDays: t.transitMinDays,
transitMaxDays: t.transitMaxDays,
})),
overrides: overrides.map((o) => ({
date: toIsoDate(o.date),

View File

@ -1,8 +1,10 @@
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import db from "../db.server";
import { slotDateTime, weekdayOf } from "../lib/time";
import { minutesUntil, slotDateTime, weekdayOf } from "../lib/time";
import { remainingCapacity } from "./capacity.server";
import { tryCreateHold, releaseHold, countActiveHolds } from "./holds.server";
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
// Shared by every surface that reserves capacity — storefront widget (via
// apps.scheduling.hold.tsx) and POS (via pos.scheduling.hold.tsx). Same
@ -16,6 +18,9 @@ export interface HoldRequestParams {
date: string;
startMin: number;
cartToken: string;
/** Same ProductRule-scoping inputs as AvailabilityRequestParams — see availability-request.server.ts. */
cartLines?: Array<{ vendor?: string; productType?: string }>;
productRefs?: ProductRef[];
}
export interface HoldRequestResult {
@ -46,6 +51,28 @@ export async function resolveHoldRequest(shopDomain: string, params: HoldRequest
return { status: 404, body: { error: "Slot not found" } };
}
// ProductRule enforcement (PRODUCT_STRATEGY.md §2) — the real, server-side
// gate for method/location/lead-time restrictions. Availability filters
// what's *shown*; this is what actually stops a hold (and therefore a
// booking) from being created against a rule, whether or not the shopper
// went through the widget to get here.
const productRules = await db.productRule.findMany({ where: { shopDomain, active: true } });
const cartProductRefs = [...(params.productRefs ?? []), ...productRefsFromCartLines(params.cartLines ?? [])];
const ruleConstraints = resolveProductRuleConstraints(productRules, cartProductRefs);
if (ruleConstraints.allowedMethods != null && !ruleConstraints.allowedMethods.includes(method)) {
return { status: 400, body: { success: false, error: "One or more items in your cart aren't available with this fulfillment method." } };
}
if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(location.id)) {
return { status: 400, body: { success: false, error: "One or more items in your cart aren't available at this location." } };
}
if (ruleConstraints.minLeadTimeMin > 0) {
const now = DateTime.now().setZone(location.timezone);
if (minutesUntil(now, slotStart) < ruleConstraints.minLeadTimeMin) {
return { status: 400, body: { success: false, error: "One or more items in your cart need more preparation time than this slot allows." } };
}
}
const confirmedCount = await db.booking.count({
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
});

View File

@ -0,0 +1,160 @@
import type { Method } from "@prisma/client";
import type { AdminGraphQLClient } from "./zones.server";
// Product/collection/vendor/type/tag-scoped delivery rules (PRODUCT_STRATEGY.md
// §2 parity row — previously unimplemented; see extensions/validation-slot's
// evaluate.js comment acknowledging the gap this closes). Pure matching and
// constraint-combination logic lives here so it's exhaustively unit-testable,
// same as scheduling.server.ts; the one I/O function (resolveProductRefs) is
// kept separate and thin.
export interface ProductRuleLike {
scopeType: string; // product|collection|vendor|type|tag
scopeValue: string;
allowedMethods: Method[]; // empty = unrestricted
leadTimeMin: number | null;
allowedLocationIds: string[]; // empty = unrestricted
active: boolean;
}
export interface ProductRef {
id: string; // Shopify Product GID
vendor: string;
productType: string;
tags: string[];
collectionIds: string[];
}
/** Case-insensitive compare for free-text scopes (vendor/type/tag); exact for id-based scopes (product/collection). */
export function matchesRule(rule: ProductRuleLike, product: ProductRef): boolean {
if (!rule.active) return false;
const value = rule.scopeValue.trim().toLowerCase();
switch (rule.scopeType) {
case "product":
return product.id === rule.scopeValue;
case "collection":
return product.collectionIds.includes(rule.scopeValue);
case "vendor":
return product.vendor.trim().toLowerCase() === value;
case "type":
return product.productType.trim().toLowerCase() === value;
case "tag":
return product.tags.some((tag) => tag.trim().toLowerCase() === value);
default:
return false;
}
}
export interface ProductRuleConstraints {
/** Extra lead-time floor this cart's contents impose, on top of the slot's own leadTimeMin/cutoffMin. */
minLeadTimeMin: number;
/** null = no rule restricted methods for this cart. Empty array = every matching rule's restriction intersected to nothing (block everything). */
allowedMethods: Method[] | null;
/** Same null/empty-array convention as allowedMethods, for locations. */
allowedLocationIds: string[] | null;
}
/**
* Combines every rule matching anything in the cart: lead time is additive-worst-case
* (max across matches the slowest item sets the floor for the whole order), while
* method/location restrictions intersect (an order can only use a method/location every
* matching rule permits). No matching rule at all means "unrestricted": null, not [].
*/
export function resolveProductRuleConstraints(
rules: ProductRuleLike[],
products: ProductRef[],
): ProductRuleConstraints {
const matched = rules.filter((rule) => products.some((product) => matchesRule(rule, product)));
let minLeadTimeMin = 0;
let allowedMethods: Method[] | null = null;
let allowedLocationIds: string[] | null = null;
for (const rule of matched) {
if (rule.leadTimeMin != null) {
minLeadTimeMin = Math.max(minLeadTimeMin, rule.leadTimeMin);
}
if (rule.allowedMethods.length > 0) {
allowedMethods = allowedMethods == null ? rule.allowedMethods : allowedMethods.filter((m) => rule.allowedMethods.includes(m));
}
if (rule.allowedLocationIds.length > 0) {
allowedLocationIds =
allowedLocationIds == null ? rule.allowedLocationIds : allowedLocationIds.filter((id) => rule.allowedLocationIds.includes(id));
}
}
return { minLeadTimeMin, allowedMethods, allowedLocationIds };
}
/**
* Resolves vendor/productType/tags/collection membership for a batch of Shopify
* product GIDs via the Admin API same admin.graphql(...) + typed response.json()
* shape as zones.server.ts's excludeLocationsWithoutStock, for the same reason
* (testable with a mock admin client, no direct SDK import here).
*/
export async function resolveProductRefs(admin: AdminGraphQLClient, productIds: string[]): Promise<ProductRef[]> {
if (productIds.length === 0) return [];
const response = await admin.graphql(
`#graphql
query ProductRuleRefs($ids: [ID!]!) {
nodes(ids: $ids) {
... on Product {
id
vendor
productType
tags
collections(first: 50) {
nodes {
id
}
}
}
}
}`,
{ variables: { ids: productIds } },
);
const body = (await response.json()) as {
data?: {
nodes: Array<{
id: string;
vendor: string;
productType: string;
tags: string[];
collections: { nodes: Array<{ id: string }> };
} | null>;
};
};
return (body.data?.nodes ?? [])
.filter((node): node is NonNullable<typeof node> => node != null)
.map((node) => ({
id: node.id,
vendor: node.vendor,
productType: node.productType,
tags: node.tags,
collectionIds: node.collections.nodes.map((c) => c.id),
}));
}
/**
* Cheap, no-Admin-API-call constraint resolution for vendor/type/tag-scoped rules
* only, from data the storefront widget already has via /cart.js (vendor,
* product_type no extra round-trip). Product/collection-scoped rules need
* resolveProductRefs + the full ProductRef; this is the degraded-but-free path
* used when no productIds were supplied (or the admin client is unavailable).
*/
export function productRefsFromCartLines(lines: Array<{ vendor?: string; productType?: string }>): ProductRef[] {
return lines.map((line) => ({
id: "",
vendor: line.vendor ?? "",
productType: line.productType ?? "",
tags: [],
collectionIds: [],
}));
}

View File

@ -11,6 +11,9 @@ export interface SlotTemplateLike {
capacity: number;
cutoffMin: number | null;
leadTimeMin: number;
/** SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2) — see AvailableSlot.arrivalRangeStart/End. */
transitMinDays?: number | null;
transitMaxDays?: number | null;
}
export interface SlotOverrideLike {
@ -33,6 +36,16 @@ export interface AvailableSlot {
end: DateTime;
capacity: number;
remainingCapacity: number;
/**
* SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): a
* shipping slot's own start/end is a ship-out window, not something the
* shopper cares about what they need is an estimated ARRIVAL range,
* computed from this ship date + the template's transit-day spread. Unset
* for PICKUP/LOCAL_DELIVERY (and for SHIPPING templates with no transit
* days configured), where start/end is already the meaningful window.
*/
arrivalRangeStart?: DateTime;
arrivalRangeEnd?: DateTime;
}
export interface GetAvailabilityInput {
@ -96,8 +109,9 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
const override = overridesByDate.get(date);
if (override?.closed) continue;
const daySlotDefs: Array<Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin">> =
override
const daySlotDefs: Array<
Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin" | "transitMinDays" | "transitMaxDays">
> = override
? [
{
startMin: override.startMin ?? 0,
@ -105,6 +119,10 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
capacity: override.capacity ?? 0,
cutoffMin: null,
leadTimeMin: 0,
// An override day has no template to inherit transit days from —
// the arrival range just isn't shown for that one exceptional day.
transitMinDays: null,
transitMaxDays: null,
},
]
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
@ -137,6 +155,8 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
end,
capacity: def.capacity,
remainingCapacity: remaining,
arrivalRangeStart: def.transitMinDays != null ? start.plus({ days: def.transitMinDays }) : undefined,
arrivalRangeEnd: def.transitMaxDays != null ? start.plus({ days: def.transitMaxDays }) : undefined,
});
}

View File

@ -10,9 +10,9 @@ import { haversineDistanceKm, isPostalCodeListed, type Coordinates } from "../li
export interface ZoneLike {
id: string;
locationId: string;
type: string; // "postal" | "radius"
type: string; // "postal" | "radius" | "driving"
postalCodes: string[];
radiusKm: number | null;
radiusKm: number | null; // for "driving" zones, this is the max driving distance in km
minOrders: number | null;
active: boolean;
}
@ -65,11 +65,17 @@ export async function geocodeAddress(address: string): Promise<Coordinates | nul
* 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.
*
* "driving" zones need a road-network distance, which is an API call (see
* resolveDrivingDistanceKm) this function stays pure/synchronous (no I/O
* in the math, CLAUDE.md), so the caller resolves that distance first and
* passes it in via `customer.drivingDistanceKm`, exactly the way it already
* resolves geocoding before calling this for radius zones.
*/
export function isZoneEligible(
zone: ZoneLike,
locationCoordinates: Coordinates | null,
customer: { coordinates?: Coordinates; postalCode?: string },
customer: { coordinates?: Coordinates; postalCode?: string; drivingDistanceKm?: number },
): boolean {
if (!zone.active) return false;
@ -82,9 +88,64 @@ export function isZoneEligible(
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
}
if (zone.type === "driving") {
if (zone.radiusKm == null || customer.drivingDistanceKm == null) return false;
return customer.drivingDistanceKm <= zone.radiusKm;
}
return false;
}
function roundCoord(n: number): number {
return Math.round(n * 10000) / 10000; // ~11m precision — plenty for a zone-eligibility cache key
}
function routeKey(origin: Coordinates, destination: Coordinates): string {
return `${roundCoord(origin.lat)},${roundCoord(origin.lng)}|${roundCoord(destination.lat)},${roundCoord(destination.lng)}`;
}
/**
* Road distance between two points via Google's Distance Matrix API, using
* the same GOOGLE_MAPS_API_KEY as geocodeAddress. Returns null (not a throw)
* when there's no key or the API can't route between the points callers
* must treat that as "can't determine eligibility" (isZoneEligible's driving
* branch already fails closed on null), the same permissive-to-the-API,
* fail-closed-on-eligibility posture as the rest of this file.
*/
export async function resolveDrivingDistanceKm(origin: Coordinates, destination: Coordinates): Promise<number | null> {
const key = routeKey(origin, destination);
const cached = await db.drivingDistanceCache.findUnique({ where: { routeKey: key } });
if (cached) return cached.distanceKm;
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const url = new URL("https://maps.googleapis.com/maps/api/distancematrix/json");
url.searchParams.set("origins", `${origin.lat},${origin.lng}`);
url.searchParams.set("destinations", `${destination.lat},${destination.lng}`);
url.searchParams.set("key", apiKey);
const res = await fetch(url.toString());
if (!res.ok) return null;
const body = (await res.json()) as {
status: string;
rows: Array<{ elements: Array<{ status: string; distance?: { value: number } }> }>;
};
const element = body.rows?.[0]?.elements?.[0];
if (body.status !== "OK" || !element || element.status !== "OK" || !element.distance) return null;
const distanceKm = element.distance.value / 1000;
await db.drivingDistanceCache.upsert({
where: { routeKey: key },
create: { routeKey: key, distanceKm },
update: { distanceKm },
});
return distanceKm;
}
export interface EligibleLocationMatch {
locationId: string;
zoneId: string;
@ -115,14 +176,25 @@ export async function findEligibleLocationsForDelivery(
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
for (const zone of location.zones) {
// "driving" zones need a road-distance lookup before isZoneEligible can
// even evaluate them — resolved (and cached) here, not inside the pure
// eligibility check itself.
let drivingDistanceKm: number | undefined;
if (zone.type === "driving" && locationCoordinates && customerCoordinates) {
// eslint-disable-next-line no-await-in-loop -- one cached lookup per candidate zone; there's no batch API to move this out of the loop
drivingDistanceKm = (await resolveDrivingDistanceKm(customerCoordinates, locationCoordinates)) ?? undefined;
}
const eligible = isZoneEligible(zone, locationCoordinates, {
coordinates: customerCoordinates ?? undefined,
postalCode: customer.postalCode,
drivingDistanceKm,
});
if (!eligible) continue;
const distanceKm =
locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null;
drivingDistanceKm ??
(locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null);
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
}
@ -153,7 +225,7 @@ export async function meetsDeliveryDensity(shopDomain: string, zone: ZoneLike):
return count >= zone.minOrders;
}
interface AdminGraphQLClient {
export interface AdminGraphQLClient {
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
}

View File

@ -1,20 +0,0 @@
{
"name": "Delivery Date & Time",
"description": "Native pickup/delivery date & time picker in checkout, plus a Thank You page confirmation.",
"checkout": {
"heading": "Choose your delivery date",
"method_label": "Method",
"method_pickup": "Pickup",
"method_local_delivery": "Local delivery",
"method_shipping": "Shipping",
"date_label": "Date",
"time_label": "Time",
"loading": "Loading…",
"no_dates": "No dates available.",
"confirmed_heading": "Delivery date & time",
"confirmed_prefix": "Confirmed for",
"error_load": "Couldn't load available dates.",
"error_hold": "That slot was just taken.",
"error_confirm": "Couldn't reserve that slot."
}
}

View File

@ -1,14 +0,0 @@
{
"name": "checkout-datetime",
"private": true,
"version": "1.0.0",
"license": "UNLICENSED",
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"preact": "^10.10.x",
"@preact/signals": "^2.3.x",
"@shopify/ui-extensions": "2026.7.x"
}
}

View File

@ -1,13 +0,0 @@
import '@shopify/ui-extensions';
//@ts-ignore
declare module './src/Checkout.jsx' {
const shopify: import('@shopify/ui-extensions/purchase.checkout.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
//@ts-ignore
declare module './src/ThankYou.jsx' {
const shopify: import('@shopify/ui-extensions/purchase.thank-you.block.render').Api;
const globalThis: { shopify: typeof shopify };
}

View File

@ -1,49 +0,0 @@
# Learn more about configuring your checkout UI extension:
# https://shopify.dev/docs/api/checkout-ui-extensions/latest/configuration
# The version of APIs your extension will receive. Learn more:
# https://shopify.dev/docs/api/usage/versioning
api_version = "2026-07"
[[extensions]]
name = "Delivery Date & Time (Checkout)"
handle = "checkout-datetime"
type = "ui_extension"
uid = "14165501-9e99-cb7d-e2bf-2e25f3028ca218bc5e8a"
description = "Plus-only native picker in checkout, plus a Thank You page confirmation of the scheduled slot."
# purchase.checkout.block.render: the native picker inside checkout itself
# (Plus only — non-Plus checkout can't host a custom block; those stores
# rely entirely on the storefront widget + Validation Function instead, per
# IMPLEMENTATION_PLAN.md §9's "no checkout.liquid fallback" risk note).
[[extensions.targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"
# purchase.thank-you.block.render: read-only confirmation of the slot
# selected above. NOTE: there is no purchase.order-status.block.render in
# this API version — order-status-page display for ALL plans (not just
# Plus) is handled separately, by a Theme App Extension block reading the
# order's note_attributes (see extensions/datetime-widget/blocks/
# order-confirmation.liquid), since that works on every plan via Liquid,
# not just where checkout extensibility is available.
[[extensions.targeting]]
module = "./src/ThankYou.jsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
# Gives your extension access to directly query Shopify's storefront API.
# https://shopify.dev/docs/api/checkout-ui-extensions/latest/configuration#api-access
api_access = true
# Gives your extension access to make external network calls, using the
# JavaScript `fetch()` API. Required — this extension calls our own
# backend (checkout.scheduling.availability/hold) to share the same
# capacity pool as every other surface. Shopify requires this capability
# to be reviewed/approved before a version using it can be RELEASED (not
# just created) — deploys will show "created, but not released" until
# that approval is granted, which is fine for local dev/testing; it only
# matters before public launch. (The embedded admin app failing to load
# was a red herring here — the real cause was a missing shopify.web.toml,
# see that file's comment.)
network_access = true

View File

@ -1,202 +0,0 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";
export default async () => {
render(<Extension />, document.body);
};
// NOTE: unverified against a live Plus checkout session this environment
// has no way to run checkout itself, only to generate the extension and
// typecheck it against @shopify/ui-extensions' own .d.ts files. Same
// APP_URL build-time-substitution assumption as the POS extension (see
// extensions/pos-datetime/src/Modal.jsx) needs live confirmation.
//
// This is the Plus-only native picker (IMPLEMENTATION_PLAN.md Phase 7):
// non-Plus stores rely entirely on the storefront widget + Validation
// Function, since checkout.liquid/Additional Scripts don't exist anymore
// and non-Plus checkout can't host a custom block. Where this extension
// IS available, it collects the same dd_* attributes the widget does, via
// applyAttributeChange instead of /cart/update.js same contract,
// booking.server.ts needs no changes to handle either source.
const APP_URL = process.env.APP_URL || "";
const METHODS = [
{ value: "PICKUP", label: "Pickup" },
{ value: "LOCAL_DELIVERY", label: "Local delivery" },
{ value: "SHIPPING", label: "Shipping" },
];
// TS infers `event.currentTarget` on an inline JSX onChange as the generic
// DOM `EventTarget` here rather than the choice-list-specific element type
// ChoiceListEvents declares this cast is just working around that
// inference gap, the runtime shape is exactly `{ values: string[] }`.
function selectedValue(event) {
return /** @type {{ values: string[] }} */ (event.currentTarget).values[0];
}
function minutesToDisplayTime(minutes) {
const h24 = Math.floor(minutes / 60);
const m = minutes % 60;
const period = h24 < 12 ? "AM" : "PM";
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
function randomToken() {
return `checkout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function Extension() {
const { sessionToken } = shopify;
const [cartToken] = useState(randomToken);
const [method, setMethod] = useState("PICKUP");
const [availability, setAvailability] = useState(null);
const [date, setDate] = useState("");
const [loading, setLoading] = useState(false);
const [confirmed, setConfirmed] = useState(null);
const [error, setError] = useState(null);
async function authedFetch(path, options = {}) {
const token = await sessionToken.get();
return fetch(`${APP_URL}${path}`, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
});
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setDate("");
setAvailability(null);
setError(null);
authedFetch(`/checkout/scheduling/availability?method=${method}&days=14`)
.then((res) => res.json())
.then((body) => {
if (cancelled) return;
if (body.error && !body.locationId) setError(body.error);
setAvailability(body);
})
.catch(() => {
if (!cancelled) setError("Couldn't load available dates.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [method]);
const availableDates = availability ? Object.keys(availability.dates ?? {}) : [];
const slots = date && availability ? (availability.dates[date] ?? []) : [];
async function setAttribute(key, value) {
await shopify.applyAttributeChange({ type: "updateAttribute", key, value });
}
async function selectSlot(slot) {
if (!availability?.locationId) return;
setLoading(true);
setError(null);
try {
const holdRes = await authedFetch("/checkout/scheduling/hold", {
method: "POST",
body: JSON.stringify({
intent: "create",
locationId: availability.locationId,
method,
date,
startMin: slot.startMin,
cartToken,
}),
});
const hold = await holdRes.json();
if (!hold.success) {
setError(hold.error || "That slot was just taken.");
return;
}
const display = `${date}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
const attrLabel = method === "PICKUP" ? "Pickup date" : method === "LOCAL_DELIVERY" ? "Delivery date" : "Shipping date";
await Promise.all([
setAttribute(attrLabel, display),
setAttribute("dd_method", method),
setAttribute("dd_date", date),
setAttribute("dd_start_min", String(slot.startMin)),
setAttribute("dd_end_min", String(slot.endMin)),
setAttribute("dd_location_id", availability.locationId),
]);
setConfirmed(display);
} catch {
setError("Couldn't reserve that slot.");
} finally {
setLoading(false);
}
}
if (confirmed) {
return (
<s-banner heading="Delivery date & time" tone="success">
<s-text>Confirmed for {confirmed}</s-text>
</s-banner>
);
}
return (
<s-stack gap="base">
<s-heading>Choose your delivery date</s-heading>
{error && (
<s-banner tone="critical">
<s-text>{error}</s-text>
</s-banner>
)}
<s-choice-list
label="Method"
values={[method]}
onChange={(e) => setMethod(selectedValue(e))}
>
{METHODS.map((m) => (
<s-choice key={m.value} value={m.value}>
{m.label}
</s-choice>
))}
</s-choice-list>
{loading && <s-text>Loading</s-text>}
{!loading && availability?.locationId && availableDates.length === 0 && <s-text>No dates available.</s-text>}
{!loading && availableDates.length > 0 && (
<s-choice-list label="Date" values={date ? [date] : []} onChange={(e) => setDate(selectedValue(e))}>
{availableDates.map((d) => (
<s-choice key={d} value={d}>
{d}
</s-choice>
))}
</s-choice-list>
)}
{date && slots.length > 0 && (
<s-choice-list
label="Time"
onChange={(e) => selectSlot(slots.find((s) => String(s.startMin) === selectedValue(e)))}
>
{slots.map((s) => (
<s-choice key={s.startMin} value={String(s.startMin)}>
{minutesToDisplayTime(s.startMin)}{minutesToDisplayTime(s.endMin)}
</s-choice>
))}
</s-choice-list>
)}
</s-stack>
);
}

View File

@ -1,28 +0,0 @@
import "@shopify/ui-extensions/preact";
import { render } from "preact";
export default async () => {
render(<Extension />, document.body);
};
// Read-only the order is already placed by the time this renders, so
// there's nothing to collect, just to confirm. Reads the same dd_* keys
// Checkout.jsx (or the storefront widget, for non-Plus orders) wrote.
function Extension() {
const attributes = shopify.attributes.value ?? [];
const get = (key) => attributes.find((a) => a.key === key)?.value;
const method = get("dd_method");
const date = get("dd_date");
if (!method || !date) return null; // no scheduling selection on this order render nothing
const label = method === "PICKUP" ? "Pickup" : method === "LOCAL_DELIVERY" ? "Local delivery" : "Shipping";
const display = [get("Pickup date"), get("Delivery date"), get("Shipping date")].find(Boolean) ?? date;
return (
<s-banner heading={`${label} scheduled`} tone="success">
<s-text>{display}</s-text>
</s-banner>
);
}

View File

@ -1,14 +0,0 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"target": "ES2020",
"checkJs": true,
"allowJs": true,
"moduleResolution": "node",
"esModuleInterop": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["./src", "./shopify.d.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -58,13 +58,24 @@ function Extension() {
});
}
// ProductRule scoping (PRODUCT_STRATEGY.md §2): POS's LineItem only exposes
// `vendor`, not productType/tags/collections, and POS session-token auth has
// no Admin API client to resolve those server-side (unlike the storefront
// app-proxy path) so only vendor-scoped rules apply here. That's a real,
// documented gap versus the storefront widget, not an oversight.
function currentCartLines() {
const lineItems = cart?.current?.value?.lineItems ?? [];
return lineItems.map((item) => ({ vendor: item.vendor || "" }));
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setDate("");
setAvailability(null);
authedFetch(`/pos/scheduling/availability?method=${method}&days=14`)
const cartLinesParam = encodeURIComponent(JSON.stringify(currentCartLines()));
authedFetch(`/pos/scheduling/availability?method=${method}&days=14&cartLines=${cartLinesParam}`)
.then((res) => res.json())
.then((body) => {
if (!cancelled) setAvailability(body);
@ -97,6 +108,7 @@ function Extension() {
date,
startMin: slot.startMin,
cartToken,
cartLines: currentCartLines(),
}),
});
const hold = await holdRes.json();

12
package-lock.json generated
View File

@ -3764,7 +3764,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3785,7 +3784,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3806,7 +3804,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3827,7 +3824,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3848,7 +3844,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3869,7 +3864,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3890,7 +3884,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3911,7 +3904,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3932,7 +3924,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3953,7 +3944,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3974,7 +3964,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3995,7 +3984,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ProductRule" (
"id" TEXT NOT NULL,
"shopDomain" TEXT NOT NULL,
"scopeType" TEXT NOT NULL,
"scopeValue" TEXT NOT NULL,
"allowedMethods" "Method"[],
"leadTimeMin" INTEGER,
"allowedLocationIds" TEXT[],
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductRule_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProductRule_shopDomain_scopeType_idx" ON "ProductRule"("shopDomain", "scopeType");

View File

@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "SlotTemplate" ADD COLUMN "transitMinDays" INTEGER,
ADD COLUMN "transitMaxDays" INTEGER;
-- CreateTable
CREATE TABLE "DrivingDistanceCache" (
"id" TEXT NOT NULL,
"routeKey" TEXT NOT NULL,
"distanceKm" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DrivingDistanceCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DrivingDistanceCache_routeKey_key" ON "DrivingDistanceCache"("routeKey");

View File

@ -89,6 +89,13 @@ model SlotTemplate {
capacity Int // default order capacity if no resources
cutoffMin Int? // cutoff before slot (minutes)
leadTimeMin Int @default(0)
// Shipping-only "date range" parity item (PRODUCT_STRATEGY.md §2): SHIPPING
// has no meaningful time-of-day slot, so instead of a start/end time the
// shopper is shown an estimated ARRIVAL date range — ship date + this
// transit-time spread. Null for PICKUP/LOCAL_DELIVERY, where the slot's own
// startMin/endMin is already the precise, meaningful window.
transitMinDays Int?
transitMaxDays Int?
createdAt DateTime @default(now())
@@index([shopDomain, locationId, method, weekday])
@ -191,6 +198,26 @@ model Rate {
@@index([shopDomain, method])
}
// Product/collection/vendor/type/tag-scoped delivery rule (PRODUCT_STRATEGY.md
// §2 parity row, previously unimplemented — see product-rules.server.ts).
// A cart is matched against every active rule whose scope matches something
// in it; matches combine (max lead time, intersected allowed methods/
// locations) rather than the first match winning, so e.g. a "fragile
// vendor" rule and a "furniture type" rule on the same cart both apply.
model ProductRule {
id String @id @default(cuid())
shopDomain String
scopeType String // product|collection|vendor|type|tag
scopeValue String
allowedMethods Method[] // empty = unrestricted (any method allowed)
leadTimeMin Int? // extra prep-time buffer this rule imposes, on top of the slot's own leadTimeMin
allowedLocationIds String[] // empty = unrestricted (any location allowed)
active Boolean @default(true)
createdAt DateTime @default(now())
@@index([shopDomain, scopeType])
}
// 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.
@ -201,3 +228,15 @@ model GeocodeCache {
lng Float
createdAt DateTime @default(now())
}
// Same rationale as GeocodeCache, for Google's Distance Matrix API (driving-
// distance zones, PRODUCT_STRATEGY.md §2 "radius- or driving-distance-based
// eligibility"): road distance between two fixed points doesn't change often
// enough to justify calling a paid API on every availability request.
// routeKey is a rounded "lat,lng|lat,lng" pair — see zones.server.ts.
model DrivingDistanceCache {
id String @id @default(cuid())
routeKey String @unique
distanceKm Float
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { matchesRule, resolveProductRuleConstraints, type ProductRuleLike, type ProductRef } from "../../app/services/product-rules.server";
function rule(overrides: Partial<ProductRuleLike> = {}): ProductRuleLike {
return {
scopeType: "vendor",
scopeValue: "Acme",
allowedMethods: [],
leadTimeMin: null,
allowedLocationIds: [],
active: true,
...overrides,
};
}
function product(overrides: Partial<ProductRef> = {}): ProductRef {
return {
id: "gid://shopify/Product/1",
vendor: "Acme",
productType: "Furniture",
tags: ["fragile"],
collectionIds: ["gid://shopify/Collection/1"],
...overrides,
};
}
describe("matchesRule", () => {
it("does not match an inactive rule", () => {
expect(matchesRule(rule({ active: false }), product())).toBe(false);
});
it("matches vendor case-insensitively", () => {
expect(matchesRule(rule({ scopeType: "vendor", scopeValue: "acme" }), product({ vendor: "Acme" }))).toBe(true);
});
it("does not match a different vendor", () => {
expect(matchesRule(rule({ scopeType: "vendor", scopeValue: "Acme" }), product({ vendor: "Other" }))).toBe(false);
});
it("matches product type case-insensitively", () => {
expect(matchesRule(rule({ scopeType: "type", scopeValue: "FURNITURE" }), product({ productType: "Furniture" }))).toBe(true);
});
it("matches a tag case-insensitively", () => {
expect(matchesRule(rule({ scopeType: "tag", scopeValue: "Fragile" }), product({ tags: ["fragile", "new"] }))).toBe(true);
});
it("does not match a missing tag", () => {
expect(matchesRule(rule({ scopeType: "tag", scopeValue: "clearance" }), product({ tags: ["fragile"] }))).toBe(false);
});
it("matches product id exactly", () => {
expect(matchesRule(rule({ scopeType: "product", scopeValue: "gid://shopify/Product/1" }), product())).toBe(true);
});
it("matches collection id membership", () => {
expect(
matchesRule(rule({ scopeType: "collection", scopeValue: "gid://shopify/Collection/1" }), product()),
).toBe(true);
});
it("an unrecognized scope type never matches", () => {
expect(matchesRule(rule({ scopeType: "bogus" }), product())).toBe(false);
});
});
describe("resolveProductRuleConstraints", () => {
it("returns unrestricted defaults when nothing matches", () => {
const result = resolveProductRuleConstraints([rule({ scopeValue: "Other" })], [product()]);
expect(result).toEqual({ minLeadTimeMin: 0, allowedMethods: null, allowedLocationIds: null });
});
it("takes the max lead time across multiple matching rules", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 60 }),
rule({ scopeType: "tag", scopeValue: "fragile", leadTimeMin: 1440 }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result.minLeadTimeMin).toBe(1440);
});
it("intersects allowedMethods across matching rules", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Acme", allowedMethods: ["PICKUP", "LOCAL_DELIVERY"] }),
rule({ scopeType: "tag", scopeValue: "fragile", allowedMethods: ["PICKUP", "SHIPPING"] }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result.allowedMethods).toEqual(["PICKUP"]);
});
it("intersecting to nothing blocks every method (empty array, not null)", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Acme", allowedMethods: ["PICKUP"] }),
rule({ scopeType: "tag", scopeValue: "fragile", allowedMethods: ["SHIPPING"] }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result.allowedMethods).toEqual([]);
});
it("a rule with no allowedMethods restriction doesn't narrow an already-restricted set", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Acme", allowedMethods: ["PICKUP"] }),
rule({ scopeType: "tag", scopeValue: "fragile", allowedMethods: [] }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result.allowedMethods).toEqual(["PICKUP"]);
});
it("intersects allowedLocationIds the same way as allowedMethods", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Acme", allowedLocationIds: ["loc_1", "loc_2"] }),
rule({ scopeType: "tag", scopeValue: "fragile", allowedLocationIds: ["loc_2"] }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result.allowedLocationIds).toEqual(["loc_2"]);
});
it("only rules matching something in the cart are combined — a non-matching rule is ignored entirely", () => {
const rules = [
rule({ scopeType: "vendor", scopeValue: "Other", leadTimeMin: 999, allowedMethods: ["PICKUP"] }),
rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 30 }),
];
const result = resolveProductRuleConstraints(rules, [product()]);
expect(result).toEqual({ minLeadTimeMin: 30, allowedMethods: null, allowedLocationIds: null });
});
it("multiple cart products each contribute their own matching rules", () => {
const rules = [rule({ scopeType: "vendor", scopeValue: "Acme", leadTimeMin: 30 }), rule({ scopeType: "vendor", scopeValue: "Other", leadTimeMin: 90 })];
const products = [product({ vendor: "Acme" }), product({ id: "gid://shopify/Product/2", vendor: "Other" })];
const result = resolveProductRuleConstraints(rules, products);
expect(result.minLeadTimeMin).toBe(90);
});
});

View File

@ -159,4 +159,48 @@ describe("getAvailability", () => {
});
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
});
describe("SHIPPING arrival range (transitMinDays/transitMaxDays)", () => {
it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => {
const templates: SlotTemplateLike[] = [
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 2, transitMaxDays: 4 },
];
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: templates,
now: now("2024-03-01", 0),
});
const slot = result["2024-03-04"][0];
expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-06");
expect(slot.arrivalRangeEnd?.toISODate()).toBe("2024-03-08");
});
it("leaves arrivalRangeStart/End unset when no transit days are configured (PICKUP/LOCAL_DELIVERY, or SHIPPING without them)", () => {
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: WEEKDAY_TEMPLATE,
now: now("2024-03-01", 0),
});
const slot = result["2024-03-04"][0];
expect(slot.arrivalRangeStart).toBeUndefined();
expect(slot.arrivalRangeEnd).toBeUndefined();
});
it("supports only a min or only a max being configured independently", () => {
const templates: SlotTemplateLike[] = [
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 3, transitMaxDays: null },
];
const result = getAvailability({
timezone: ZONE,
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
slotTemplates: templates,
now: now("2024-03-01", 0),
});
const slot = result["2024-03-04"][0];
expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-07");
expect(slot.arrivalRangeEnd).toBeUndefined();
});
});
});

View File

@ -76,6 +76,37 @@ describe("isZoneEligible", () => {
});
});
describe("driving-distance zones", () => {
function drivingZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
return {
id: "zone_3",
locationId: "loc_1",
type: "driving",
postalCodes: [],
radiusKm: 30,
minOrders: null,
active: true,
...overrides,
};
}
it("is eligible when the pre-resolved driving distance is within the max", () => {
expect(isZoneEligible(drivingZone({ radiusKm: 30 }), TORONTO, { drivingDistanceKm: 25 })).toBe(true);
});
it("is ineligible when the pre-resolved driving distance exceeds the max", () => {
expect(isZoneEligible(drivingZone({ radiusKm: 30 }), TORONTO, { drivingDistanceKm: 45 })).toBe(false);
});
it("is ineligible when no driving distance could be resolved (no API key, routing failure)", () => {
expect(isZoneEligible(drivingZone(), TORONTO, {})).toBe(false);
});
it("is ineligible when radiusKm isn't configured", () => {
expect(isZoneEligible(drivingZone({ radiusKm: null }), TORONTO, { drivingDistanceKm: 5 })).toBe(false);
});
});
it("an unrecognized zone type is never eligible", () => {
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
});

View File

@ -20,6 +20,12 @@ interface SlotDto {
endMin: number;
capacity: number;
remainingCapacity: number;
// SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): present
// only when the merchant configured transit days on this slot's template.
// ISO date-time strings (Luxon's DateTime.toJSON()), same shape as
// start/end elsewhere in this response.
arrivalRangeStart?: string;
arrivalRangeEnd?: string;
}
interface RateDto {
@ -72,6 +78,32 @@ function minutesToDisplayTime(minutes: number): string {
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
}
/** Formats the date portion of a full ISO date-time string (arrivalRangeStart/End) using the same weekday/month/day style as formatDateLabel. */
function formatArrivalDateLabel(dateTimeIso: string): string {
return formatDateLabel(dateTimeIso.slice(0, 10));
}
/**
* SHIPPING-only "date range" parity item (PRODUCT_STRATEGY.md §2): a
* shipping slot's start/end time is a ship-out window the shopper doesn't
* care about when the merchant configured transit days, show the
* estimated ARRIVAL range instead. Falls back to the normal time-of-day
* label for every other slot (PICKUP/LOCAL_DELIVERY, or SHIPPING with no
* transit days set).
*/
function slotTimeLabel(slot: SlotDto): string {
if (slot.arrivalRangeStart && slot.arrivalRangeEnd) {
return `Arrives ${formatArrivalDateLabel(slot.arrivalRangeStart)}${formatArrivalDateLabel(slot.arrivalRangeEnd)}`;
}
if (slot.arrivalRangeStart) {
return `Arrives from ${formatArrivalDateLabel(slot.arrivalRangeStart)}`;
}
if (slot.arrivalRangeEnd) {
return `Arrives by ${formatArrivalDateLabel(slot.arrivalRangeEnd)}`;
}
return `${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
}
function formatDateLabel(dateIso: string): string {
// Parsed as a plain calendar date (no timezone conversion) — this string
// already represents the location-local calendar day from the API.
@ -118,14 +150,51 @@ function readConfig(root: HTMLElement): WidgetConfig {
};
}
// ProductRule scoping (PRODUCT_STRATEGY.md §2): vendor/productType come
// straight off /cart.js with no extra round trip; productId lets the backend
// additionally resolve collection/tag-scoped rules via one Admin API call.
// Fetched fresh on every availability/hold request so it always reflects the
// shopper's *current* cart, not a stale snapshot from an earlier step.
interface CartLineInfo {
vendor: string;
productType: string;
productId: string;
}
interface CartInfo {
token: string;
lines: CartLineInfo[];
}
async function fetchCartInfo(): Promise<CartInfo> {
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
const cart = (await res.json()) as {
token: string;
items?: Array<{ vendor?: string; product_type?: string; product_id: number }>;
};
return {
token: cart.token,
lines: (cart.items ?? []).map((item) => ({
vendor: item.vendor ?? "",
productType: item.product_type ?? "",
productId: `gid://shopify/Product/${item.product_id}`,
})),
};
}
async function fetchAvailability(
method: Method,
locationId: string | null,
cart: CartInfo,
postalCode?: string,
): Promise<AvailabilityResponse> {
const params = new URLSearchParams({ method, days: "14" });
if (locationId) params.set("locationId", locationId);
if (postalCode) params.set("postalCode", postalCode);
if (cart.lines.length > 0) {
params.set("cartLines", JSON.stringify(cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType }))));
params.set("productIds", cart.lines.map((l) => l.productId).join(","));
}
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
headers: { Accept: "application/json" },
});
@ -140,12 +209,6 @@ interface HoldResponse {
error?: string;
}
async function getCartToken(): Promise<string> {
const res = await fetch("/cart.js", { headers: { Accept: "application/json" } });
const cart = (await res.json()) as { token: string };
return cart.token;
}
async function requestHold(params: {
intent: "create" | "release";
locationId: string;
@ -153,6 +216,8 @@ async function requestHold(params: {
date: string;
startMin: number;
cartToken: string;
cartLines?: Array<{ vendor: string; productType: string }>;
productIds?: string[];
}): Promise<HoldResponse> {
const res = await fetch(`${PROXY_BASE}/hold`, {
method: "POST",
@ -341,7 +406,8 @@ class DateTimeWidget {
this.el.dateRow.innerHTML = "";
try {
this.availability = await fetchAvailability(method.value, this.config.locationId, postalCode);
const cart = await fetchCartInfo();
this.availability = await fetchAvailability(method.value, this.config.locationId, cart, postalCode);
if (!this.availability.locationId) {
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
@ -400,7 +466,7 @@ class DateTimeWidget {
const button = document.createElement("button");
button.type = "button";
button.className = "dd-widget__pill";
button.textContent = `${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}`;
button.textContent = slotTimeLabel(slot);
button.addEventListener("click", () => this.selectSlot(date, slot));
this.el.timeRow.appendChild(button);
}
@ -410,30 +476,37 @@ class DateTimeWidget {
const method = this.selectedMethod!;
const availability = this.availability!;
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
const display = `${formatDateLabel(date)}, ${minutesToDisplayTime(slot.startMin)}${minutesToDisplayTime(slot.endMin)}${rateLabel}`;
const display =
slot.arrivalRangeStart || slot.arrivalRangeEnd
? `Ships ${formatDateLabel(date)}, ${slotTimeLabel(slot)}${rateLabel}`
: `${formatDateLabel(date)}, ${slotTimeLabel(slot)}${rateLabel}`;
this.el.status.textContent = this.config.labels.loading;
try {
const cartToken = await getCartToken();
const cart = await fetchCartInfo();
// Reserve capacity FIRST. Writing the cart attribute alone would just
// be two shoppers racing to write the same free-text field — nothing
// would stop both checkouts from completing for the last slot. The
// hold is what the Validation Function (Phase 4) actually enforces
// against at checkout.
// against at checkout. It also re-checks ProductRule constraints
// server-side (hold-request.server.ts) — the real enforcement point,
// not just what this widget chose to display.
const hold = await requestHold({
intent: "create",
locationId: availability.locationId!,
method: method.value,
date,
startMin: slot.startMin,
cartToken,
cartToken: cart.token,
cartLines: cart.lines.map((l) => ({ vendor: l.vendor, productType: l.productType })),
productIds: cart.lines.map((l) => l.productId),
});
if (!hold.success) {
this.el.status.textContent = this.config.labels.error;
// The slot we just tried is gone — refresh so the list reflects reality.
this.el.status.textContent = hold.error || this.config.labels.error;
// The slot we just tried is gone (or a ProductRule now excludes it) — refresh so the list reflects reality.
await this.selectMethod(method);
return;
}
@ -443,7 +516,7 @@ class DateTimeWidget {
method: method.value,
date,
startMin: slot.startMin,
cartToken,
cartToken: cart.token,
zoneId: availability.zoneId ?? null,
};
@ -456,6 +529,8 @@ class DateTimeWidget {
};
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
if (slot.arrivalRangeStart) machineAttrs.dd_arrival_range_start = slot.arrivalRangeStart;
if (slot.arrivalRangeEnd) machineAttrs.dd_arrival_range_end = slot.arrivalRangeEnd;
await writeCartAttribute(method.attrLabel, machineAttrs, display);