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 capacity = Number(formData.get("capacity"));
|
||||||
const cutoffMin = Number(formData.get("cutoffMin") || 0);
|
const cutoffMin = Number(formData.get("cutoffMin") || 0);
|
||||||
const leadTimeMin = Number(formData.get("leadTimeMin") || 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> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!locationId) errors.locationId = "Choose a location";
|
if (!locationId) errors.locationId = "Choose a location";
|
||||||
if (startMin >= endMin) errors.endTime = "End time must be after start time";
|
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 (!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) {
|
if (Object.keys(errors).length > 0) {
|
||||||
return data({ errors });
|
return data({ errors });
|
||||||
}
|
}
|
||||||
@ -95,6 +100,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
capacity,
|
capacity,
|
||||||
cutoffMin,
|
cutoffMin,
|
||||||
leadTimeMin,
|
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: "Capacity" },
|
||||||
{ title: "Cutoff (min)" },
|
{ title: "Cutoff (min)" },
|
||||||
{ title: "Lead time (min)" },
|
{ title: "Lead time (min)" },
|
||||||
|
{ title: "Transit (days)" },
|
||||||
{ title: "" },
|
{ title: "" },
|
||||||
]}
|
]}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
@ -168,6 +179,11 @@ export default function SlotsIndex() {
|
|||||||
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
|
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
|
||||||
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
|
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
|
||||||
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
|
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
|
||||||
|
<IndexTable.Cell>
|
||||||
|
{slot.transitMinDays != null || slot.transitMaxDays != null
|
||||||
|
? `${slot.transitMinDays ?? "—"}–${slot.transitMaxDays ?? "—"}`
|
||||||
|
: "—"}
|
||||||
|
</IndexTable.Cell>
|
||||||
<IndexTable.Cell>
|
<IndexTable.Cell>
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
<input type="hidden" name="intent" value="delete" />
|
<input type="hidden" name="intent" value="delete" />
|
||||||
@ -195,6 +211,8 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi
|
|||||||
const [capacity, setCapacity] = useState("10");
|
const [capacity, setCapacity] = useState("10");
|
||||||
const [cutoffMin, setCutoffMin] = useState("60");
|
const [cutoffMin, setCutoffMin] = useState("60");
|
||||||
const [leadTimeMin, setLeadTimeMin] = useState("0");
|
const [leadTimeMin, setLeadTimeMin] = useState("0");
|
||||||
|
const [transitMinDays, setTransitMinDays] = useState("");
|
||||||
|
const [transitMaxDays, setTransitMaxDays] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@ -255,6 +273,23 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi
|
|||||||
onChange={setLeadTimeMin}
|
onChange={setLeadTimeMin}
|
||||||
autoComplete="off"
|
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>
|
</InlineStack>
|
||||||
<div>
|
<div>
|
||||||
<Button submit variant="primary" loading={isSubmitting}>
|
<Button submit variant="primary" loading={isSubmitting}>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export default function App() {
|
|||||||
<Link to="/app/blackouts">Blackout dates</Link>
|
<Link to="/app/blackouts">Blackout dates</Link>
|
||||||
<Link to="/app/zones">Delivery zones</Link>
|
<Link to="/app/zones">Delivery zones</Link>
|
||||||
<Link to="/app/rates">Delivery rates</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/dashboard">Dispatch dashboard</Link>
|
||||||
<Link to="/app/billing">Billing</Link>
|
<Link to="/app/billing">Billing</Link>
|
||||||
<Link to="/app/help">Help</Link>
|
<Link to="/app/help">Help</Link>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { UpsellState } from "../components/UpsellState";
|
|||||||
const ZONE_TYPES = [
|
const ZONE_TYPES = [
|
||||||
{ label: "Postal / ZIP codes", value: "postal" },
|
{ label: "Postal / ZIP codes", value: "postal" },
|
||||||
{ label: "Radius (straight-line distance)", value: "radius" },
|
{ label: "Radius (straight-line distance)", value: "radius" },
|
||||||
|
{ label: "Driving distance (via Google Maps)", value: "driving" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
@ -80,7 +81,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
if (!locationId) errors.locationId = "Choose a location";
|
if (!locationId) errors.locationId = "Choose a location";
|
||||||
if (!name) errors.name = "Name is required";
|
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) {
|
if (Object.keys(errors).length > 0) {
|
||||||
return data({ errors });
|
return data({ errors });
|
||||||
}
|
}
|
||||||
@ -97,7 +98,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
postalCodes: type === "postal" ? postalCodes : [],
|
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,
|
minOrders: minOrdersRaw ? Number(minOrdersRaw) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -179,7 +180,9 @@ export default function ZonesIndex() {
|
|||||||
{zones.map((zone, index) => (
|
{zones.map((zone, index) => (
|
||||||
<IndexTable.Row id={zone.id} key={zone.id} position={index}>
|
<IndexTable.Row id={zone.id} key={zone.id} position={index}>
|
||||||
<IndexTable.Cell>{zone.name}</IndexTable.Cell>
|
<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>
|
<IndexTable.Cell>
|
||||||
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
|
{zone.type === "postal" ? zone.postalCodes.join(", ") || "—" : `${zone.radiusKm ?? "—"} km`}
|
||||||
</IndexTable.Cell>
|
</IndexTable.Cell>
|
||||||
@ -234,12 +237,13 @@ function AddZoneForm({ locationId, isSubmitting }: { locationId: string; isSubmi
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
label="Radius (km)"
|
label={type === "driving" ? "Max driving distance (km)" : "Radius (km)"}
|
||||||
name="radiusKm"
|
name="radiusKm"
|
||||||
type="number"
|
type="number"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={radiusKm}
|
value={radiusKm}
|
||||||
onChange={setRadiusKm}
|
onChange={setRadiusKm}
|
||||||
|
helpText={type === "driving" ? "Actual road distance via Google Maps, not straight-line." : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
|
|||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { resolveAvailabilityRequest } from "../services/availability-request.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
|
// Public endpoint, reachable only through Shopify's App Proxy (signature
|
||||||
// verified by authenticate.public.appProxy) — this is what the storefront
|
// verified by authenticate.public.appProxy) — this is what the storefront
|
||||||
@ -15,7 +17,7 @@ import { resolveAvailabilityRequest } from "../services/availability-request.ser
|
|||||||
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const { session } = await authenticate.public.appProxy(request);
|
const { session, admin } = await authenticate.public.appProxy(request);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return Response.json({ error: "Shop not found" }, { status: 404 });
|
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 });
|
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, {
|
const result = await resolveAvailabilityRequest(session.shop, {
|
||||||
method: methodParam as Method,
|
method: methodParam as Method,
|
||||||
locationId: url.searchParams.get("locationId") || undefined,
|
locationId: url.searchParams.get("locationId") || undefined,
|
||||||
postalCode: url.searchParams.get("postalCode") || undefined,
|
postalCode: url.searchParams.get("postalCode") || undefined,
|
||||||
address: url.searchParams.get("address") || undefined,
|
address: url.searchParams.get("address") || undefined,
|
||||||
days: Number(url.searchParams.get("days")) || undefined,
|
days: Number(url.searchParams.get("days")) || undefined,
|
||||||
|
cartLines: parseCartLinesParam(url.searchParams.get("cartLines")),
|
||||||
|
productRefs,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.locationId) {
|
if (!result.locationId) {
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type { ActionFunctionArgs } from "@remix-run/node";
|
|||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { resolveHoldRequest } from "../services/hold-request.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
|
// Public app-proxy endpoint (see apps.scheduling.availability.tsx for the
|
||||||
// path-mirroring rationale). Called by the widget the moment a shopper
|
// 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"]);
|
const VALID_METHODS = new Set<Method>(["SHIPPING", "LOCAL_DELIVERY", "PICKUP"]);
|
||||||
|
|
||||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
const { session } = await authenticate.public.appProxy(request);
|
const { session, admin } = await authenticate.public.appProxy(request);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return Response.json({ error: "Shop not found" }, { status: 404 });
|
return Response.json({ error: "Shop not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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";
|
intent?: "create" | "release";
|
||||||
locationId?: string;
|
locationId?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
startMin?: number;
|
startMin?: number;
|
||||||
cartToken?: string;
|
cartToken?: string;
|
||||||
|
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
||||||
|
productIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
|
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
|
||||||
return Response.json({ error: "Missing or invalid parameters" }, { status: 400 });
|
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, {
|
const result = await resolveHoldRequest(session.shop, {
|
||||||
intent: intent === "release" ? "release" : "create",
|
intent: intent === "release" ? "release" : "create",
|
||||||
locationId,
|
locationId,
|
||||||
@ -41,6 +48,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||||||
date,
|
date,
|
||||||
startMin,
|
startMin,
|
||||||
cartToken,
|
cartToken,
|
||||||
|
cartLines: parseCartLinesParam(Array.isArray(cartLines) ? JSON.stringify(cartLines) : undefined),
|
||||||
|
productRefs,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(result.body, { status: result.status });
|
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 type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { resolveAvailabilityRequest } from "../services/availability-request.server";
|
import { resolveAvailabilityRequest } from "../services/availability-request.server";
|
||||||
|
import { parseCartLinesParam } from "../lib/cart-rule-params";
|
||||||
|
|
||||||
// POS UI Extension endpoint — session-token authenticated (POS extensions
|
// POS UI Extension endpoint — session-token authenticated (POS extensions
|
||||||
// can't use the storefront's app-proxy signature scheme), CORS-enabled
|
// 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,
|
postalCode: url.searchParams.get("postalCode") || undefined,
|
||||||
address: url.searchParams.get("address") || undefined,
|
address: url.searchParams.get("address") || undefined,
|
||||||
days: Number(url.searchParams.get("days")) || 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));
|
return cors(Response.json(result));
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node";
|
|||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
import { resolveHoldRequest } from "../services/hold-request.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
|
// POS UI Extension endpoint — see pos.scheduling.availability.tsx for the
|
||||||
// session-token/CORS rationale. Calls the exact same resolveHoldRequest()
|
// 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 shopDomain = sessionToken.dest.replace(/^https?:\/\//, "");
|
||||||
|
|
||||||
const body = await request.json();
|
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";
|
intent?: "create" | "release";
|
||||||
locationId?: string;
|
locationId?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
startMin?: number;
|
startMin?: number;
|
||||||
cartToken?: string;
|
cartToken?: string;
|
||||||
|
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!locationId || !method || !VALID_METHODS.has(method as Method) || !date || typeof startMin !== "number" || !cartToken) {
|
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,
|
date,
|
||||||
startMin,
|
startMin,
|
||||||
cartToken,
|
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 }));
|
return cors(Response.json(result.body, { status: result.status }));
|
||||||
|
|||||||
@ -53,7 +53,19 @@ function summarizeBookingAttributes(order: OrderWebhookPayload) {
|
|||||||
const startMin = get("dd_start_min");
|
const startMin = get("dd_start_min");
|
||||||
const endMin = get("dd_end_min");
|
const endMin = get("dd_end_min");
|
||||||
const locationId = get("dd_location_id");
|
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;
|
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 { findEligibleLocationsForDelivery, meetsDeliveryDensity } from "./zones.server";
|
||||||
import { resolveRate } from "./rates.server";
|
import { resolveRate } from "./rates.server";
|
||||||
import { formatPriceLabel } from "../lib/currency";
|
import { formatPriceLabel } from "../lib/currency";
|
||||||
|
import { resolveProductRuleConstraints, productRefsFromCartLines, type ProductRef } from "./product-rules.server";
|
||||||
|
|
||||||
// The single availability resolver every surface calls — storefront widget
|
// The single availability resolver every surface calls — storefront widget
|
||||||
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
|
// (via apps.scheduling.availability.tsx, app-proxy auth), POS (via
|
||||||
@ -23,6 +24,17 @@ export interface AvailabilityRequestParams {
|
|||||||
postalCode?: string;
|
postalCode?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
days?: number;
|
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 {
|
export interface AvailabilityResult {
|
||||||
@ -55,6 +67,26 @@ export async function resolveAvailabilityRequest(
|
|||||||
const { method, locationId: locationIdParam, postalCode, address } = params;
|
const { method, locationId: locationIdParam, postalCode, address } = params;
|
||||||
const days = params.days && params.days > 0 ? Math.min(params.days, MAX_DAYS) : DEFAULT_DAYS;
|
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 location: Awaited<ReturnType<typeof db.location.findFirst>> = null;
|
||||||
let zoneId: string | null = null;
|
let zoneId: string | null = null;
|
||||||
let distanceKm: number | null = null;
|
let distanceKm: number | null = null;
|
||||||
@ -73,6 +105,7 @@ export async function resolveAvailabilityRequest(
|
|||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (locationIdParam && match.locationId !== locationIdParam) continue;
|
if (locationIdParam && match.locationId !== locationIdParam) continue;
|
||||||
|
if (ruleConstraints.allowedLocationIds != null && !ruleConstraints.allowedLocationIds.includes(match.locationId)) continue;
|
||||||
const zone = zoneById.get(match.zoneId);
|
const zone = zoneById.get(match.zoneId);
|
||||||
if (!zone) continue;
|
if (!zone) continue;
|
||||||
// eslint-disable-next-line no-await-in-loop -- checked in nearest-first order; stop at the first that qualifies
|
// 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 {
|
} else {
|
||||||
|
const locationWhere =
|
||||||
|
ruleConstraints.allowedLocationIds != null ? { id: { in: ruleConstraints.allowedLocationIds } } : {};
|
||||||
location = locationIdParam
|
location = locationIdParam
|
||||||
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true } })
|
? await db.location.findFirst({ where: { id: locationIdParam, shopDomain, active: true, ...locationWhere } })
|
||||||
: await db.location.findFirst({ where: { shopDomain, active: true }, orderBy: { createdAt: "asc" } });
|
: await db.location.findFirst({ where: { shopDomain, active: true, ...locationWhere }, orderBy: { createdAt: "asc" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
@ -109,7 +144,10 @@ export async function resolveAvailabilityRequest(
|
|||||||
zoneId: null,
|
zoneId: null,
|
||||||
distanceKm: null,
|
distanceKm: null,
|
||||||
rate: 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,
|
endMin: t.endMin,
|
||||||
capacity: t.capacity,
|
capacity: t.capacity,
|
||||||
cutoffMin: t.cutoffMin,
|
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) => ({
|
overrides: overrides.map((o) => ({
|
||||||
date: toIsoDate(o.date),
|
date: toIsoDate(o.date),
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
import type { Method } from "@prisma/client";
|
import type { Method } from "@prisma/client";
|
||||||
import db from "../db.server";
|
import db from "../db.server";
|
||||||
import { slotDateTime, weekdayOf } from "../lib/time";
|
import { minutesUntil, slotDateTime, weekdayOf } from "../lib/time";
|
||||||
import { remainingCapacity } from "./capacity.server";
|
import { remainingCapacity } from "./capacity.server";
|
||||||
import { tryCreateHold, releaseHold, countActiveHolds } from "./holds.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
|
// Shared by every surface that reserves capacity — storefront widget (via
|
||||||
// apps.scheduling.hold.tsx) and POS (via pos.scheduling.hold.tsx). Same
|
// apps.scheduling.hold.tsx) and POS (via pos.scheduling.hold.tsx). Same
|
||||||
@ -16,6 +18,9 @@ export interface HoldRequestParams {
|
|||||||
date: string;
|
date: string;
|
||||||
startMin: number;
|
startMin: number;
|
||||||
cartToken: string;
|
cartToken: string;
|
||||||
|
/** Same ProductRule-scoping inputs as AvailabilityRequestParams — see availability-request.server.ts. */
|
||||||
|
cartLines?: Array<{ vendor?: string; productType?: string }>;
|
||||||
|
productRefs?: ProductRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HoldRequestResult {
|
export interface HoldRequestResult {
|
||||||
@ -46,6 +51,28 @@ export async function resolveHoldRequest(shopDomain: string, params: HoldRequest
|
|||||||
return { status: 404, body: { error: "Slot not found" } };
|
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({
|
const confirmedCount = await db.booking.count({
|
||||||
where: { shopDomain, locationId: location.id, method, slotStart: slotStart.toJSDate(), status: "confirmed" },
|
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;
|
capacity: number;
|
||||||
cutoffMin: number | null;
|
cutoffMin: number | null;
|
||||||
leadTimeMin: number;
|
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 {
|
export interface SlotOverrideLike {
|
||||||
@ -33,6 +36,16 @@ export interface AvailableSlot {
|
|||||||
end: DateTime;
|
end: DateTime;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
remainingCapacity: 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 {
|
export interface GetAvailabilityInput {
|
||||||
@ -96,18 +109,23 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
const override = overridesByDate.get(date);
|
const override = overridesByDate.get(date);
|
||||||
if (override?.closed) continue;
|
if (override?.closed) continue;
|
||||||
|
|
||||||
const daySlotDefs: Array<Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin">> =
|
const daySlotDefs: Array<
|
||||||
override
|
Pick<SlotTemplateLike, "startMin" | "endMin" | "capacity" | "cutoffMin" | "leadTimeMin" | "transitMinDays" | "transitMaxDays">
|
||||||
? [
|
> = override
|
||||||
{
|
? [
|
||||||
startMin: override.startMin ?? 0,
|
{
|
||||||
endMin: override.endMin ?? 24 * 60,
|
startMin: override.startMin ?? 0,
|
||||||
capacity: override.capacity ?? 0,
|
endMin: override.endMin ?? 24 * 60,
|
||||||
cutoffMin: null,
|
capacity: override.capacity ?? 0,
|
||||||
leadTimeMin: 0,
|
cutoffMin: null,
|
||||||
},
|
leadTimeMin: 0,
|
||||||
]
|
// An override day has no template to inherit transit days from —
|
||||||
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
// the arrival range just isn't shown for that one exceptional day.
|
||||||
|
transitMinDays: null,
|
||||||
|
transitMaxDays: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: (templatesByWeekday.get(weekdayOf(date, timezone)) ?? []);
|
||||||
|
|
||||||
const daySlots: AvailableSlot[] = [];
|
const daySlots: AvailableSlot[] = [];
|
||||||
|
|
||||||
@ -137,6 +155,8 @@ export function getAvailability(input: GetAvailabilityInput): Record<IsoDate, Av
|
|||||||
end,
|
end,
|
||||||
capacity: def.capacity,
|
capacity: def.capacity,
|
||||||
remainingCapacity: remaining,
|
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 {
|
export interface ZoneLike {
|
||||||
id: string;
|
id: string;
|
||||||
locationId: string;
|
locationId: string;
|
||||||
type: string; // "postal" | "radius"
|
type: string; // "postal" | "radius" | "driving"
|
||||||
postalCodes: string[];
|
postalCodes: string[];
|
||||||
radiusKm: number | null;
|
radiusKm: number | null; // for "driving" zones, this is the max driving distance in km
|
||||||
minOrders: number | null;
|
minOrders: number | null;
|
||||||
active: boolean;
|
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
|
* need no coordinates at all. This is why isZoneEligible takes the
|
||||||
* location's coordinates separately rather than assuming the zone carries
|
* location's coordinates separately rather than assuming the zone carries
|
||||||
* its own center point.
|
* 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(
|
export function isZoneEligible(
|
||||||
zone: ZoneLike,
|
zone: ZoneLike,
|
||||||
locationCoordinates: Coordinates | null,
|
locationCoordinates: Coordinates | null,
|
||||||
customer: { coordinates?: Coordinates; postalCode?: string },
|
customer: { coordinates?: Coordinates; postalCode?: string; drivingDistanceKm?: number },
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!zone.active) return false;
|
if (!zone.active) return false;
|
||||||
|
|
||||||
@ -82,9 +88,64 @@ export function isZoneEligible(
|
|||||||
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
|
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;
|
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 {
|
export interface EligibleLocationMatch {
|
||||||
locationId: string;
|
locationId: string;
|
||||||
zoneId: 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;
|
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
|
||||||
|
|
||||||
for (const zone of location.zones) {
|
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, {
|
const eligible = isZoneEligible(zone, locationCoordinates, {
|
||||||
coordinates: customerCoordinates ?? undefined,
|
coordinates: customerCoordinates ?? undefined,
|
||||||
postalCode: customer.postalCode,
|
postalCode: customer.postalCode,
|
||||||
|
drivingDistanceKm,
|
||||||
});
|
});
|
||||||
if (!eligible) continue;
|
if (!eligible) continue;
|
||||||
|
|
||||||
const distanceKm =
|
const distanceKm =
|
||||||
locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null;
|
drivingDistanceKm ??
|
||||||
|
(locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null);
|
||||||
|
|
||||||
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
|
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;
|
return count >= zone.minOrders;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminGraphQLClient {
|
export interface AdminGraphQLClient {
|
||||||
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDate("");
|
setDate("");
|
||||||
setAvailability(null);
|
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((res) => res.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!cancelled) setAvailability(body);
|
if (!cancelled) setAvailability(body);
|
||||||
@ -97,6 +108,7 @@ function Extension() {
|
|||||||
date,
|
date,
|
||||||
startMin: slot.startMin,
|
startMin: slot.startMin,
|
||||||
cartToken,
|
cartToken,
|
||||||
|
cartLines: currentCartLines(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const hold = await holdRes.json();
|
const hold = await holdRes.json();
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@ -3764,7 +3764,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3785,7 +3784,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3806,7 +3804,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3827,7 +3824,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3848,7 +3844,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3869,7 +3864,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3890,7 +3884,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3911,7 +3904,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3932,7 +3924,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3953,7 +3944,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3974,7 +3964,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3995,7 +3984,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"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
|
capacity Int // default order capacity if no resources
|
||||||
cutoffMin Int? // cutoff before slot (minutes)
|
cutoffMin Int? // cutoff before slot (minutes)
|
||||||
leadTimeMin Int @default(0)
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain, locationId, method, weekday])
|
@@index([shopDomain, locationId, method, weekday])
|
||||||
@ -191,6 +198,26 @@ model Rate {
|
|||||||
@@index([shopDomain, method])
|
@@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
|
// 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
|
// §9: "cache geocode results per address; don't call the maps API on every
|
||||||
// availability request"). Addresses don't move, so entries never expire.
|
// availability request"). Addresses don't move, so entries never expire.
|
||||||
@ -201,3 +228,15 @@ model GeocodeCache {
|
|||||||
lng Float
|
lng Float
|
||||||
createdAt DateTime @default(now())
|
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]);
|
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", () => {
|
it("an unrecognized zone type is never eligible", () => {
|
||||||
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
|
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,6 +20,12 @@ interface SlotDto {
|
|||||||
endMin: number;
|
endMin: number;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
remainingCapacity: 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 {
|
interface RateDto {
|
||||||
@ -72,6 +78,32 @@ function minutesToDisplayTime(minutes: number): string {
|
|||||||
return `${h12}:${m.toString().padStart(2, "0")} ${period}`;
|
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 {
|
function formatDateLabel(dateIso: string): string {
|
||||||
// Parsed as a plain calendar date (no timezone conversion) — this string
|
// Parsed as a plain calendar date (no timezone conversion) — this string
|
||||||
// already represents the location-local calendar day from the API.
|
// 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(
|
async function fetchAvailability(
|
||||||
method: Method,
|
method: Method,
|
||||||
locationId: string | null,
|
locationId: string | null,
|
||||||
|
cart: CartInfo,
|
||||||
postalCode?: string,
|
postalCode?: string,
|
||||||
): Promise<AvailabilityResponse> {
|
): Promise<AvailabilityResponse> {
|
||||||
const params = new URLSearchParams({ method, days: "14" });
|
const params = new URLSearchParams({ method, days: "14" });
|
||||||
if (locationId) params.set("locationId", locationId);
|
if (locationId) params.set("locationId", locationId);
|
||||||
if (postalCode) params.set("postalCode", postalCode);
|
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()}`, {
|
const res = await fetch(`${PROXY_BASE}/availability?${params.toString()}`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
@ -140,12 +209,6 @@ interface HoldResponse {
|
|||||||
error?: string;
|
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: {
|
async function requestHold(params: {
|
||||||
intent: "create" | "release";
|
intent: "create" | "release";
|
||||||
locationId: string;
|
locationId: string;
|
||||||
@ -153,6 +216,8 @@ async function requestHold(params: {
|
|||||||
date: string;
|
date: string;
|
||||||
startMin: number;
|
startMin: number;
|
||||||
cartToken: string;
|
cartToken: string;
|
||||||
|
cartLines?: Array<{ vendor: string; productType: string }>;
|
||||||
|
productIds?: string[];
|
||||||
}): Promise<HoldResponse> {
|
}): Promise<HoldResponse> {
|
||||||
const res = await fetch(`${PROXY_BASE}/hold`, {
|
const res = await fetch(`${PROXY_BASE}/hold`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -341,7 +406,8 @@ class DateTimeWidget {
|
|||||||
this.el.dateRow.innerHTML = "";
|
this.el.dateRow.innerHTML = "";
|
||||||
|
|
||||||
try {
|
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) {
|
if (!this.availability.locationId) {
|
||||||
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
|
this.el.status.textContent = this.availability.error || this.config.labels.outOfArea;
|
||||||
@ -400,7 +466,7 @@ class DateTimeWidget {
|
|||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "dd-widget__pill";
|
button.className = "dd-widget__pill";
|
||||||
button.textContent = `${minutesToDisplayTime(slot.startMin)}–${minutesToDisplayTime(slot.endMin)}`;
|
button.textContent = slotTimeLabel(slot);
|
||||||
button.addEventListener("click", () => this.selectSlot(date, slot));
|
button.addEventListener("click", () => this.selectSlot(date, slot));
|
||||||
this.el.timeRow.appendChild(button);
|
this.el.timeRow.appendChild(button);
|
||||||
}
|
}
|
||||||
@ -410,30 +476,37 @@ class DateTimeWidget {
|
|||||||
const method = this.selectedMethod!;
|
const method = this.selectedMethod!;
|
||||||
const availability = this.availability!;
|
const availability = this.availability!;
|
||||||
const rateLabel = availability.rate ? ` (${availability.rate.label})` : "";
|
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;
|
this.el.status.textContent = this.config.labels.loading;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cartToken = await getCartToken();
|
const cart = await fetchCartInfo();
|
||||||
|
|
||||||
// Reserve capacity FIRST. Writing the cart attribute alone would just
|
// Reserve capacity FIRST. Writing the cart attribute alone would just
|
||||||
// be two shoppers racing to write the same free-text field — nothing
|
// be two shoppers racing to write the same free-text field — nothing
|
||||||
// would stop both checkouts from completing for the last slot. The
|
// would stop both checkouts from completing for the last slot. The
|
||||||
// hold is what the Validation Function (Phase 4) actually enforces
|
// 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({
|
const hold = await requestHold({
|
||||||
intent: "create",
|
intent: "create",
|
||||||
locationId: availability.locationId!,
|
locationId: availability.locationId!,
|
||||||
method: method.value,
|
method: method.value,
|
||||||
date,
|
date,
|
||||||
startMin: slot.startMin,
|
startMin: slot.startMin,
|
||||||
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) {
|
if (!hold.success) {
|
||||||
this.el.status.textContent = this.config.labels.error;
|
this.el.status.textContent = hold.error || this.config.labels.error;
|
||||||
// The slot we just tried is gone — refresh so the list reflects reality.
|
// The slot we just tried is gone (or a ProductRule now excludes it) — refresh so the list reflects reality.
|
||||||
await this.selectMethod(method);
|
await this.selectMethod(method);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -443,7 +516,7 @@ class DateTimeWidget {
|
|||||||
method: method.value,
|
method: method.value,
|
||||||
date,
|
date,
|
||||||
startMin: slot.startMin,
|
startMin: slot.startMin,
|
||||||
cartToken,
|
cartToken: cart.token,
|
||||||
zoneId: availability.zoneId ?? null,
|
zoneId: availability.zoneId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -456,6 +529,8 @@ class DateTimeWidget {
|
|||||||
};
|
};
|
||||||
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
|
if (availability.zoneId) machineAttrs.dd_zone_id = availability.zoneId;
|
||||||
if (availability.rate) machineAttrs.dd_rate_label = availability.rate.label;
|
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);
|
await writeCartAttribute(method.attrLabel, machineAttrs, display);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user