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" /> + +