From 03574a491440d1752425e6ee8b467d664ccf3fcc Mon Sep 17 00:00:00 2001 From: MOHAN Date: Wed, 26 Aug 2026 00:28:45 +0530 Subject: [PATCH] feat: product rules, driving-distance zones, and shipping date ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/lib/cart-rule-params.ts | 34 +++ app/routes/app.rules._index.tsx | 255 ++++++++++++++++++ app/routes/app.slots._index.tsx | 35 +++ app/routes/app.tsx | 1 + app/routes/app.zones._index.tsx | 12 +- app/routes/apps.scheduling.availability.tsx | 14 +- app/routes/apps.scheduling.hold.tsx | 13 +- app/routes/pos.scheduling.availability.tsx | 6 + app/routes/pos.scheduling.hold.tsx | 6 +- app/routes/webhooks.orders.create.tsx | 14 +- app/services/availability-request.server.ts | 53 +++- app/services/hold-request.server.ts | 29 +- app/services/product-rules.server.ts | 160 +++++++++++ app/services/scheduling.server.ts | 44 ++- app/services/zones.server.ts | 82 +++++- .../checkout-datetime/locales/en.default.json | 20 -- extensions/checkout-datetime/package.json | 14 - extensions/checkout-datetime/shopify.d.ts | 13 - .../checkout-datetime/shopify.extension.toml | 49 ---- extensions/checkout-datetime/src/Checkout.jsx | 202 -------------- extensions/checkout-datetime/src/ThankYou.jsx | 28 -- extensions/checkout-datetime/tsconfig.json | 14 - .../datetime-widget/assets/datetime-widget.js | 2 +- extensions/pos-datetime/src/Modal.jsx | 14 +- package-lock.json | 12 - .../migration.sql | 17 ++ .../migration.sql | 16 ++ prisma/schema.prisma | 39 +++ tests/unit/product-rules.test.ts | 133 +++++++++ tests/unit/scheduling.test.ts | 44 +++ tests/unit/zones.test.ts | 31 +++ widget-src/datetime-widget/datetime-widget.ts | 105 ++++++-- 32 files changed, 1111 insertions(+), 400 deletions(-) create mode 100644 app/lib/cart-rule-params.ts create mode 100644 app/routes/app.rules._index.tsx create mode 100644 app/services/product-rules.server.ts delete mode 100644 extensions/checkout-datetime/locales/en.default.json delete mode 100644 extensions/checkout-datetime/package.json delete mode 100644 extensions/checkout-datetime/shopify.d.ts delete mode 100644 extensions/checkout-datetime/shopify.extension.toml delete mode 100644 extensions/checkout-datetime/src/Checkout.jsx delete mode 100644 extensions/checkout-datetime/src/ThankYou.jsx delete mode 100644 extensions/checkout-datetime/tsconfig.json create mode 100644 prisma/migrations/20260825190000_add_product_rule/migration.sql create mode 100644 prisma/migrations/20260826120000_add_driving_distance_and_transit_days/migration.sql create mode 100644 tests/unit/product-rules.test.ts diff --git a/app/lib/cart-rule-params.ts b/app/lib/cart-rule-params.ts new file mode 100644 index 0000000..7d2d142 --- /dev/null +++ b/app/lib/cart-rule-params.ts @@ -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 => 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); +} diff --git a/app/routes/app.rules._index.tsx b/app/routes/app.rules._index.tsx new file mode 100644 index 0000000..3ed3f89 --- /dev/null +++ b/app/routes/app.rules._index.tsx @@ -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 = { + 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 = {}; + 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(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + if (loaderData.gated) { + return ( + + + + + ); + } + + const { rules, locations } = loaderData; + const locationNameById = new Map(locations.map((l) => [l.id, l.name])); + + return ( + + + + + {rules.length === 0 ? ( +
+ + No product rules yet. Without one, every product uses the same lead time and is available via any + method/location your slots allow. + +
+ ) : ( + + {rules.map((rule, index) => ( + + + {SCOPE_LABELS[rule.scopeType] ?? rule.scopeType}: {rule.scopeValue} + + {rule.leadTimeMin ? `${rule.leadTimeMin} min` : "—"} + {rule.allowedMethods.length > 0 ? rule.allowedMethods.join(", ") : "Any"} + + {rule.allowedLocationIds.length > 0 + ? rule.allowedLocationIds.map((id) => locationNameById.get(id) ?? id).join(", ") + : "Any"} + + +
+ + + +
+
+
+ ))} +
+ )} +
+ + +
+
+ ); +} + +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([]); + const [allowedLocationIds, setAllowedLocationIds] = useState([]); + + return ( + +
+ {allowedMethods.map((m) => ( + + ))} + {allowedLocationIds.map((id) => ( + + ))} + + + Add a product rule + + + @@ -195,6 +211,8 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi const [capacity, setCapacity] = useState("10"); const [cutoffMin, setCutoffMin] = useState("60"); const [leadTimeMin, setLeadTimeMin] = useState("0"); + const [transitMinDays, setTransitMinDays] = useState(""); + const [transitMaxDays, setTransitMaxDays] = useState(""); return ( @@ -255,6 +273,23 @@ function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmi onChange={setLeadTimeMin} autoComplete="off" /> + +