187 lines
9.5 KiB
TypeScript
187 lines
9.5 KiB
TypeScript
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<typeof loader>();
|
|
const result = useActionData<typeof action>();
|
|
const busy = useNavigation().state !== "idle";
|
|
return (
|
|
<s-page heading="Product rules">
|
|
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
|
|
<s-section>
|
|
<s-paragraph>
|
|
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.
|
|
</s-paragraph>
|
|
</s-section>
|
|
<s-section padding="none">
|
|
<s-table>
|
|
<s-table-header-row>
|
|
<s-table-header listSlot="primary">Rule</s-table-header>
|
|
<s-table-header>Applies to</s-table-header>
|
|
<s-table-header>Customer message</s-table-header>
|
|
<s-table-header>Status</s-table-header>
|
|
<s-table-header>Actions</s-table-header>
|
|
</s-table-header-row>
|
|
<s-table-body>
|
|
{rules.map((r) => (
|
|
<s-table-row key={r.id}>
|
|
<s-table-cell>{r.name}</s-table-cell>
|
|
<s-table-cell>{TARGETS.find((t) => t[0] === r.target.type)?.[1]}: {r.target.values.join(", ")}</s-table-cell>
|
|
<s-table-cell>{r.message ?? "—"}</s-table-cell>
|
|
<s-table-cell><s-badge tone={r.enabled ? "success" : "neutral"}>{r.enabled ? "On" : "Off"}</s-badge></s-table-cell>
|
|
<s-table-cell>
|
|
<s-stack direction="inline" gap="small-200">
|
|
<Form method="post"><input type="hidden" name="intent" value={`toggle:${r.id}`} /><s-button type="submit" variant="tertiary">{r.enabled ? "Turn off" : "Turn on"}</s-button></Form>
|
|
<Form method="post"><input type="hidden" name="intent" value={`delete:${r.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Delete</s-button></Form>
|
|
</s-stack>
|
|
</s-table-cell>
|
|
</s-table-row>
|
|
))}
|
|
</s-table-body>
|
|
</s-table>
|
|
{rules.length === 0 && <s-box padding="base"><s-paragraph>No rules yet.</s-paragraph></s-box>}
|
|
</s-section>
|
|
<s-section heading="Add a rule">
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="create" />
|
|
<s-stack gap="base">
|
|
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap="base">
|
|
<s-select name="targetType" label="When the cart has an item with">
|
|
{TARGETS.map(([v, l]) => <s-option key={v} value={v}>{l}</s-option>)}
|
|
</s-select>
|
|
<s-text-field name="targetValues" label="Matching any of" placeholder="Fresh Flowers, perishable" required />
|
|
<s-select name="actionType" label="Then">
|
|
{ACTIONS.map(([v, l]) => <s-option key={v} value={v}>{l}</s-option>)}
|
|
</s-select>
|
|
<s-text-field name="name" label="Rule name (optional)" />
|
|
</s-grid>
|
|
<s-box padding="base" background="subdued" borderRadius="base">
|
|
<s-stack gap="base">
|
|
<s-text color="subdued">Fill in only what your chosen action needs.</s-text>
|
|
<s-stack direction="inline" gap="base">
|
|
<s-text>Methods:</s-text>
|
|
{METHODS.map((m) => <s-checkbox key={m} name={`m_${m}`} label={METHOD_LABEL[m]} />)}
|
|
</s-stack>
|
|
<s-stack direction="inline" gap="base">
|
|
<s-text>Locations:</s-text>
|
|
{locations.map((l) => <s-checkbox key={l.id} name={`loc_${l.id}`} label={l.name} />)}
|
|
</s-stack>
|
|
<s-stack direction="inline" gap="base">
|
|
<s-text>Weekdays:</s-text>
|
|
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d, i) => <s-checkbox key={d} name={`wd_${i}`} label={d} />)}
|
|
</s-stack>
|
|
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(170px, 1fr))" gap="base">
|
|
<s-number-field name="prepHours" label="Extra preparation (hours)" min={0} step={0.5} />
|
|
<s-text-field name="dates" label="Dates" placeholder="2026-12-25, 2026-12-26" />
|
|
<s-date-field name="from" label="From" />
|
|
<s-date-field name="to" label="To" />
|
|
</s-grid>
|
|
</s-stack>
|
|
</s-box>
|
|
<s-grid gridTemplateColumns="3fr 1fr" gap="base">
|
|
<s-text-field name="message" label="Message customers see" placeholder="Fresh flowers are pickup or local delivery only" />
|
|
<s-number-field name="priority" label="Order" defaultValue="10" details="Lower shows first" />
|
|
</s-grid>
|
|
<s-button type="submit" variant="primary" loading={busy}>Add rule</s-button>
|
|
</s-stack>
|
|
</Form>
|
|
</s-section>
|
|
</s-page>
|
|
);
|
|
}
|