metatrondelivery/app/routes/app.rules._index.tsx
MOHAN 03574a4914 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>
2026-08-26 00:28:45 +05:30

256 lines
9.1 KiB
TypeScript

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>
);
}