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", }; /** "09:00, 13:30" -> [540, 810]; ignores blank/garbage entries. */ function parseBlockedStartMins(raw: string): number[] { return [ ...new Set( raw .split(",") .map((s) => s.trim()) .filter(Boolean) .map((s) => { const [h, m] = s.split(":").map(Number); return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : NaN; }) .filter((n) => Number.isFinite(n) && n >= 0 && n < 24 * 60), ), ].sort((a, b) => a - b); } function formatBlockedStartMins(mins: number[]): string { return mins .map((n) => `${String(Math.floor(n / 60)).padStart(2, "0")}:${String(n % 60).padStart(2, "0")}`) .join(", "); } 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 blockedStartMins = parseBlockedStartMins(String(formData.get("blockedStartMins") || "")); 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, blockedStartMins, }, }); 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"} {rule.blockedStartMins.length > 0 ? formatBlockedStartMins(rule.blockedStartMins) : "—"}
))}
)}
); } 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 [blockedStartMins, setBlockedStartMins] = useState(""); const [allowedMethods, setAllowedMethods] = useState([]); const [allowedLocationIds, setAllowedLocationIds] = useState([]); return (
{allowedMethods.map((m) => ( ))} {allowedLocationIds.map((id) => ( ))} Add a product rule