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:
parent
730a681d46
commit
03574a4914
34
app/lib/cart-rule-params.ts
Normal file
34
app/lib/cart-rule-params.ts
Normal 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);
|
||||
}
|
||||
255
app/routes/app.rules._index.tsx
Normal file
255
app/routes/app.rules._index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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" },
|
||||
});
|
||||
|
||||
160
app/services/product-rules.server.ts
Normal file
160
app/services/product-rules.server.ts
Normal 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: [],
|
||||
}));
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
13
extensions/checkout-datetime/shopify.d.ts
vendored
13
extensions/checkout-datetime/shopify.d.ts
vendored
@ -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 };
|
||||
}
|
||||
@ -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
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
@ -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
12
package-lock.json
generated
@ -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": [
|
||||
|
||||
@ -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");
|
||||
@ -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");
|
||||
@ -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())
|
||||
}
|
||||
|
||||
133
tests/unit/product-rules.test.ts
Normal file
133
tests/unit/product-rules.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user