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