datewise/app/routes/app.rules.tsx
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

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