import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { Form, useActionData, useLoaderData } from "react-router"; import prisma from "../db.server"; import { checkFreshness, evaluateRules } from "../engine/rules"; import { addDays, localParts } from "../engine/time"; import type { RuleAction } from "../engine/types"; import { adminContext, formInt, formList, formString } from "../lib/admin.server"; import { niceDate } from "../lib/format"; import { bumpConfig } from "../services/config.server"; import { RuleActionSchema, RuleTargetSchema } from "../services/settings"; type Fresh = Extract; export const loader = async ({ request }: LoaderFunctionArgs) => { const { cfg, features } = await adminContext(request); const today = localParts(Date.now(), cfg.shop.timezone).date; const rules = cfg.rules .filter((r) => r.action.type === "freshness") .map((r) => { const a = r.action as Fresh; // Preview which of the next 28 days this product could be fulfilled. const cons = evaluateRules({ items: [{ productId: "x", variantId: "x", title: r.name, quantity: 1, price: 0, tags: ["__preview"] }] }, [ { ...r, enabled: true, target: { type: "tag", values: ["__preview"] } }, ]); const strip = Array.from({ length: 28 }, (_, i) => { const d = addDays(today, i); return { d, ok: !checkFreshness(cons.freshness, d, cfg.locations[0]?.id ?? "") }; }); return { ...r, a, strip }; }); return { rules, allowed: features.freshness, locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })) }; }; export const action = async ({ request }: ActionFunctionArgs) => { const { cfg, features } = await adminContext(request); if (!features.freshness) return { ok: false, message: "Fresh-stock windows are on the Growth plan" }; const fd = await request.formData(); const intent = formString(fd, "intent"); const find = async (id: string) => prisma.productRule.findFirst({ where: { id, shopId: cfg.shop.id } }); if (intent === "create") { const target = RuleTargetSchema.safeParse({ type: formString(fd, "targetType"), values: formList(fd, "targetValues") }); const act = RuleActionSchema.safeParse({ type: "freshness", arrivals: formList(fd, "arrivals").map((date) => ({ date })), minDaysAfterArrival: formInt(fd, "minDays") ?? 1, shelfLifeDays: formInt(fd, "shelfLife") ?? 4, }); if (!target.success || !act.success) return { ok: false, message: "Check the product match and the dates (YYYY-MM-DD)" }; await prisma.productRule.create({ data: { shopId: cfg.shop.id, name: formString(fd, "name") || "Fresh stock", priority: 5, target: target.data, action: act.data, message: formString(fd, "message") || null }, }); } else { const [op, id, extra] = intent.split(":"); const r = await find(id); if (!r) return { ok: false, message: "Not found" }; const a = r.action as unknown as Fresh; if (op === "add") { const date = formString(fd, `arrival_${id}`); const locs = formString(fd, `arrivalLoc_${id}`); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return { ok: false, message: "Pick an arrival date" }; a.arrivals = [...a.arrivals, { date, ...(locs ? { locationIds: [locs] } : {}) }].sort((x, y) => (x.date < y.date ? -1 : 1)); } if (op === "remove") a.arrivals = a.arrivals.filter((_, i) => i !== Number(extra)); if (op === "window") { a.minDaysAfterArrival = formInt(fd, `min_${id}`) ?? a.minDaysAfterArrival; a.shelfLifeDays = formInt(fd, `shelf_${id}`) ?? a.shelfLifeDays; } if (op === "delete") { await prisma.productRule.delete({ where: { id } }); await bumpConfig(cfg.shop.id, cfg.shop.domain); return { ok: true, message: "Removed" }; } await prisma.productRule.update({ where: { id }, data: { action: a as object } }); } await bumpConfig(cfg.shop.id, cfg.shop.domain); return { ok: true, message: "Saved" }; }; export default function Freshness() { const { rules, allowed, locations } = useLoaderData(); const result = useActionData(); return ( {result && {result.message}} {!allowed && Tie bookable dates to when perishable stock arrives. See plans} For perishables like flowers, produce and sweets: customers can only pick dates from arrival + "ready after" days up to arrival + shelf life. If no batch covers a date, the calendar tells the customer when the next fresh batch is ready. {rules.map((r) => ( Applies to {r.target.type}: {r.target.values.join(", ")} {r.a.arrivals.map((x, i) => (
{niceDate(x.date)}{x.locationIds?.length ? ` · ${locations.find((l) => l.id === x.locationIds![0])?.name ?? ""}` : ""} ×
))}
All locations {locations.map((l) => {l.name})} Add arrival
Update window
Bookable in the next 28 days:
{r.strip.map((s) => (
{s.d.slice(8)}
))}
Delete this rule
))}
Product tag Product type Collection ID Product ID Add
); }