datewise/app/routes/app.freshness.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

163 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<RuleAction, { type: "freshness" }>;
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<typeof loader>();
const result = useActionData<typeof action>();
return (
<s-page heading="Fresh stock">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{!allowed && <s-banner tone="info" heading="Growth plan feature">Tie bookable dates to when perishable stock arrives. <s-link href="/app/settings#plan">See plans</s-link></s-banner>}
<s-section>
<s-paragraph>
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.
</s-paragraph>
</s-section>
{rules.map((r) => (
<s-section key={r.id} heading={r.name}>
<s-stack gap="base">
<s-text color="subdued">Applies to {r.target.type}: {r.target.values.join(", ")}</s-text>
<s-stack direction="inline" gap="small-200">
{r.a.arrivals.map((x, i) => (
<Form method="post" key={`${x.date}-${i}`}>
<input type="hidden" name="intent" value={`remove:${r.id}:${i}`} />
<s-box padding="small-200" border="base" borderRadius="large">
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-text>{niceDate(x.date)}{x.locationIds?.length ? ` · ${locations.find((l) => l.id === x.locationIds![0])?.name ?? ""}` : ""}</s-text>
<s-button type="submit" variant="tertiary" accessibilityLabel={`Remove arrival ${x.date}`}>×</s-button>
</s-stack>
</s-box>
</Form>
))}
</s-stack>
<Form method="post">
<input type="hidden" name="intent" value={`add:${r.id}`} />
<s-stack direction="inline" gap="base" alignItems="end">
<s-date-field name={`arrival_${r.id}`} label="Stock arrives" />
<s-select name={`arrivalLoc_${r.id}`} label="At">
<s-option value="">All locations</s-option>
{locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
</s-select>
<s-button type="submit">Add arrival</s-button>
</s-stack>
</Form>
<Form method="post">
<input type="hidden" name="intent" value={`window:${r.id}`} />
<s-stack direction="inline" gap="base" alignItems="end">
<s-number-field name={`min_${r.id}`} label="Ready after (days)" defaultValue={String(r.a.minDaysAfterArrival)} min={0} />
<s-number-field name={`shelf_${r.id}`} label="Shelf life (days)" defaultValue={String(r.a.shelfLifeDays)} min={1} />
<s-button type="submit">Update window</s-button>
</s-stack>
</Form>
<s-text color="subdued">Bookable in the next 28 days:</s-text>
<div style={{ display: "grid", gridTemplateColumns: "repeat(28, minmax(22px, 1fr))", gap: 3, overflowX: "auto" }}>
{r.strip.map((s) => (
<div key={s.d} title={niceDate(s.d)} style={{ textAlign: "center", fontSize: 11, padding: "4px 0", borderRadius: 5, background: s.ok ? "#cdfee1" : "#f1f1f1", color: s.ok ? "#0c5132" : "#8a8a8a" }}>
{s.d.slice(8)}
</div>
))}
</div>
<Form method="post"><input type="hidden" name="intent" value={`delete:${r.id}`} /><s-button type="submit" variant="tertiary" tone="critical">Delete this rule</s-button></Form>
</s-stack>
</s-section>
))}
<s-section heading="Track a perishable product">
<Form method="post">
<input type="hidden" name="intent" value="create" />
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(190px, 1fr))" gap="base" alignItems="end">
<s-text-field name="name" label="Name" placeholder="Marigold air shipments" />
<s-select name="targetType" label="Match by">
<s-option value="tag">Product tag</s-option>
<s-option value="productType">Product type</s-option>
<s-option value="collection">Collection ID</s-option>
<s-option value="product">Product ID</s-option>
</s-select>
<s-text-field name="targetValues" label="Values" placeholder="fresh-flowers" required />
<s-text-field name="arrivals" label="Arrival dates" placeholder="2026-11-03, 2026-11-05" />
<s-number-field name="minDays" label="Ready after (days)" defaultValue="1" min={0} />
<s-number-field name="shelfLife" label="Shelf life (days)" defaultValue="4" min={1} />
<s-text-field name="message" label="Customer message (optional)" />
<s-button type="submit" variant="primary" disabled={!allowed}>Add</s-button>
</s-grid>
</Form>
</s-section>
</s-page>
);
}