import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { Form, useActionData, useLoaderData, useNavigation } from "react-router"; import prisma from "../db.server"; import type { RuleAction } from "../engine/types"; import { METHODS } from "../engine/types"; import { adminContext, formBool, formInt, formList, formString } from "../lib/admin.server"; import { METHOD_LABEL } from "../lib/format"; import { bumpConfig } from "../services/config.server"; import { RuleActionSchema, RuleTargetSchema } from "../services/settings"; const TARGETS = [ ["productType", "Product type"], ["tag", "Product tag"], ["collection", "Collection ID"], ["vendor", "Vendor"], ["product", "Product ID"], ["variant", "Variant ID"], ] as const; const ACTIONS = [ ["prohibitMethods", "Block methods"], ["allowOnlyMethods", "Only allow methods"], ["addPrep", "Add preparation time"], ["restrictLocations", "Only from locations"], ["blockWeekdays", "Block weekdays"], ["blockDates", "Block dates"], ["dateWindow", "Only between dates"], ["requireScheduling", "Always require a time"], ] as const; export const loader = async ({ request }: LoaderFunctionArgs) => { const { cfg } = await adminContext(request); return { rules: cfg.rules.filter((r) => r.action.type !== "freshness").sort((a, b) => a.priority - b.priority), locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })), }; }; export const action = async ({ request }: ActionFunctionArgs) => { const { cfg } = await adminContext(request); const fd = await request.formData(); const intent = formString(fd, "intent"); const own = async (id: string) => !!(await prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } })); if (intent.startsWith("toggle:") || intent.startsWith("delete:")) { const id = intent.split(":")[1]; if (!(await own(id))) return { ok: false, message: "Rule not found" }; if (intent.startsWith("delete:")) await prisma.productRule.delete({ where: { id } }); else { const r = await prisma.productRule.findUniqueOrThrow({ where: { id } }); await prisma.productRule.update({ where: { id }, data: { enabled: !r.enabled } }); } await bumpConfig(cfg.shop.id, cfg.shop.domain); return { ok: true, message: "Rule updated" }; } const target = { type: formString(fd, "targetType"), values: formList(fd, "targetValues") }; const type = formString(fd, "actionType"); const methods = METHODS.filter((m) => formBool(fd, `m_${m}`)); let action: unknown = { type }; if (type === "prohibitMethods" || type === "allowOnlyMethods") action = { type, methods }; if (type === "addPrep") action = { type, minutes: Math.round((Number(formString(fd, "prepHours")) || 0) * 60) }; if (type === "restrictLocations") action = { type, locationIds: cfg.locations.filter((l) => formBool(fd, `loc_${l.id}`)).map((l) => l.id) }; if (type === "blockWeekdays") action = { type, weekdays: [0, 1, 2, 3, 4, 5, 6].filter((d) => formBool(fd, `wd_${d}`)) }; if (type === "blockDates") action = { type, dates: formList(fd, "dates") }; if (type === "dateWindow") action = { type, from: formString(fd, "from") || undefined, to: formString(fd, "to") || undefined }; const t = RuleTargetSchema.safeParse(target); const a = RuleActionSchema.safeParse(action); if (!t.success) return { ok: false, message: "Add at least one value the rule applies to" }; if (!a.success) return { ok: false, message: `Rule settings: ${a.error.issues.map((i) => i.message).join("; ")}` }; await prisma.productRule.create({ data: { shopId: cfg.shop.id, name: formString(fd, "name") || describe(a.data as RuleAction), priority: formInt(fd, "priority") ?? 10, target: t.data, action: a.data, message: formString(fd, "message") || null, }, }); await bumpConfig(cfg.shop.id, cfg.shop.domain); return { ok: true, message: "Rule added" }; }; function describe(a: RuleAction): string { switch (a.type) { case "prohibitMethods": return `No ${a.methods.join(" or ")}`; case "allowOnlyMethods": return `Only ${a.methods.join(" or ")}`; case "addPrep": return `+${(a.minutes / 60).toFixed(a.minutes % 60 ? 1 : 0)} h preparation`; case "restrictLocations": return `Only ${a.locationIds.length} location(s)`; case "blockWeekdays": return `Not on ${a.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(", ")}`; case "blockDates": return `Blocked on ${a.dates.length} date(s)`; case "dateWindow": return `Only ${a.from ?? "…"} to ${a.to ?? "…"}`; case "requireScheduling": return "Time required"; case "freshness": return "Fresh stock window"; } } export default function Rules() { const { rules, locations } = useLoaderData(); const result = useActionData(); const busy = useNavigation().state !== "idle"; return ( {result && {result.message}} Rules apply to items in the cart. When rules disagree, a block beats an allowance, and a mixed cart uses its longest preparation time. Customers see the message you write, so they know which item caused it. Rule Applies to Customer message Status Actions {rules.map((r) => ( {r.name} {TARGETS.find((t) => t[0] === r.target.type)?.[1]}: {r.target.values.join(", ")} {r.message ?? "—"} {r.enabled ? "On" : "Off"}
{r.enabled ? "Turn off" : "Turn on"}
Delete
))}
{rules.length === 0 && No rules yet.}
{TARGETS.map(([v, l]) => {l})} {ACTIONS.map(([v, l]) => {l})} Fill in only what your chosen action needs. Methods: {METHODS.map((m) => )} Locations: {locations.map((l) => )} Weekdays: {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d, i) => )} Add rule
); }