datewise/app/routes/app.freshness.tsx
metatroncubeswdev 54734e8938 feat(billing): switch to Shopify managed pricing; show chosen time on thank-you page
App Store review 1.2.2 ("fails to register the plan"): follow Offerlyst's approved
managed-pricing setup. The plan is read from the Partner API on every app load,
shops without a plan (new install or reinstall) go to Shopify's plan selection
page, and app_subscriptions/update keeps it in sync. Billing API code removed.

Reviewer's checkout video: Shopify's checkout shows pickup by location name, so
the delivery label rename never reached the shopper. New checkout-time extension
shows the chosen date and time on the thank-you page (all plans) and next to
pickup/shipping options (Plus). Checkout display copy now matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:52:00 -05:00

163 lines
9.0 KiB
TypeScript
Raw 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/plans">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>
);
}