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

328 lines
19 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, useNavigation } from "react-router";
import prisma from "../db.server";
import type { ReminderPolicy } from "../engine/types";
import { adminContext, formBool, formInt, formString } from "../lib/admin.server";
import { bumpConfig } from "../services/config.server";
import { encrypt } from "../services/crypto.server";
import { type AppSettings, withDefaults } from "../services/settings";
import { type CustomizationKind, findCustomizations, findGuard, gql, setCustomization, setGuard } from "../lib/guard.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, admin } = await adminContext(request);
const guard = await findGuard(admin).catch(() => null);
const custom = await findCustomizations(admin).catch(() => null);
const s = cfg.settings;
// Remember what is really on in Shopify (also catches changes made outside Datewise), so a reinstall can restore it.
if (guard && custom) {
const live = { guard: !!guard.validation?.enabled, delivery: custom.delivery.enabled, payment: custom.payment.enabled };
const stored = s.checkoutFunctions;
if (live.guard !== stored.guard || live.delivery !== stored.delivery || live.payment !== stored.payment) {
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { settings: { ...s, checkoutFunctions: live } as object } });
await bumpConfig(cfg.shop.id, cfg.shop.domain);
}
}
const off = { deployed: false, enabled: false };
return {
s: { ...s, messaging: { ...s.messaging, twilio: s.messaging.twilio ? { accountSid: s.messaging.twilio.accountSid, from: s.messaging.twilio.from } : null, whatsapp: s.messaging.whatsapp ? { phoneNumberId: s.messaging.whatsapp.phoneNumberId, templateName: s.messaging.whatsapp.templateName, templateLang: s.messaging.whatsapp.templateLang } : null } },
guard: { deployed: !!guard?.fn, enabled: !!guard?.validation?.enabled },
custom: {
delivery: custom ? { deployed: custom.delivery.deployed, enabled: custom.delivery.enabled } : off,
payment: custom ? { deployed: custom.payment.deployed, enabled: custom.payment.enabled } : off,
},
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { cfg, admin, features } = await adminContext(request);
const fd = await request.formData();
const intent = formString(fd, "intent");
const s: AppSettings = withDefaults(cfg.shop.settings);
const persist = async (next: AppSettings) => {
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { settings: next as object } });
await bumpConfig(cfg.shop.id, cfg.shop.domain);
};
if (intent === "guard") {
const turnOn = formBool(fd, "on");
const error = await setGuard(admin, turnOn, s.requireScheduling).catch((e: Error) => e.message);
if (error) return { ok: false, message: error };
await persist({ ...s, checkoutFunctions: { ...s.checkoutFunctions, guard: turnOn } });
return { ok: true, message: turnOn ? "Checkout guard is on" : "Checkout guard is off" };
}
if (intent === "custom:delivery" || intent === "custom:payment") {
const kind = intent.slice(7) as CustomizationKind;
const on = formBool(fd, "on");
const error = await setCustomization(admin, kind, on).catch((e: Error) => e.message);
if (error) return { ok: false, message: error };
await persist({ ...s, checkoutFunctions: { ...s.checkoutFunctions, [kind]: on } });
const name = kind === "delivery" ? "Date and time at checkout" : "Payment rules";
return { ok: true, message: `${name} ${on ? "on" : "off"}` };
}
if (intent === "policy") {
s.holdMinutes = Math.min(60, Math.max(3, formInt(fd, "holdMinutes") ?? 10));
s.mixedPrepPolicy = formString(fd, "mixedPrepPolicy") === "sum" ? "sum" : "longest";
s.requireScheduling = formBool(fd, "requireScheduling");
s.releaseOnCancel = formBool(fd, "releaseOnCancel");
s.waitlistOfferMinutes = formInt(fd, "waitlistOfferMinutes") ?? 30;
s.locationRanking = (formString(fd, "locationRanking") as AppSettings["locationRanking"]) || "priority";
s.weekStartsOn = (formInt(fd, "weekStartsOn") ?? 0) as AppSettings["weekStartsOn"];
s.reschedule = {
enabled: formBool(fd, "rsEnabled"),
minHoursBefore: formInt(fd, "rsHours") ?? 24,
maxChanges: formInt(fd, "rsMax") ?? 2,
allowMethodChange: false,
};
await persist(s);
// Keep the checkout function's copy of the setting in sync.
const { validation } = await findGuard(admin).catch(() => ({ validation: undefined }));
if (validation)
await gql(admin, `#graphql
mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, {
id: validation.id,
v: { metafields: [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }] },
});
return { ok: true, message: "Policies saved" };
}
if (intent === "widget") {
let translations = s.widget.translations;
const raw = formString(fd, "translations");
try {
translations = raw ? JSON.parse(raw) : {};
} catch {
return { ok: false, message: "Translations must be valid JSON, e.g. {\"fr\": {\"heading\": \"Quand ?\"}}" };
}
s.widget = {
...s.widget,
heading: formString(fd, "heading") || s.widget.heading,
labels: { pickup: formString(fd, "lp") || "Store pickup", delivery: formString(fd, "ld") || "Local delivery", shipping: formString(fd, "ls") || "Shipping" },
accent: /^#[0-9a-f]{6}$/i.test(formString(fd, "accent")) ? formString(fd, "accent") : s.widget.accent,
showRemaining: formBool(fd, "showRemaining"),
drawer: formBool(fd, "drawer"),
translations,
};
await persist(s);
return { ok: true, message: "Widget saved" };
}
if (intent === "reminders") {
const list: ReminderPolicy[] = s.reminders.map((r) => ({
...r,
enabled: formBool(fd, `en_${r.id}`),
minutesBefore: Math.round((Number(formString(fd, `hrs_${r.id}`)) || r.minutesBefore / 60) * 60),
template: formString(fd, `tpl_${r.id}`) || r.template,
}));
const newTpl = formString(fd, "new_tpl");
if (newTpl) {
const channel = formString(fd, "new_channel") as ReminderPolicy["channel"];
if (channel !== "email" && !features.smsWhatsapp) return { ok: false, message: "SMS and WhatsApp reminders are on the Pro plan" };
list.push({ id: `r${Date.now()}`, channel, minutesBefore: Math.round((Number(formString(fd, "new_hrs")) || 24) * 60), template: newTpl, enabled: true });
}
s.reminders = list.filter((r) => !formBool(fd, `del_${r.id}`));
await persist(s);
return { ok: true, message: "Reminders saved. New bookings use them; existing bookings keep their plan until changed." };
}
if (intent === "messaging") {
s.messaging.emailFromName = formString(fd, "fromName");
s.messaging.replyTo = formString(fd, "replyTo") || undefined;
const sid = formString(fd, "twSid");
const tok = formString(fd, "twToken");
if (formBool(fd, "twClear")) s.messaging.twilio = null;
else if (sid && formString(fd, "twFrom")) s.messaging.twilio = { accountSid: sid, from: formString(fd, "twFrom"), authTokenEnc: tok ? encrypt(tok) : s.messaging.twilio?.authTokenEnc ?? "" };
const pid = formString(fd, "waPhone");
const wtok = formString(fd, "waToken");
if (formBool(fd, "waClear")) s.messaging.whatsapp = null;
else if (pid && formString(fd, "waTemplate"))
s.messaging.whatsapp = { phoneNumberId: pid, templateName: formString(fd, "waTemplate"), templateLang: formString(fd, "waLang") || "en", tokenEnc: wtok ? encrypt(wtok) : s.messaging.whatsapp?.tokenEnc ?? "" };
await persist(s);
return { ok: true, message: "Messaging saved. Tokens are stored encrypted." };
}
return { ok: false, message: "Unknown action" };
};
export default function Settings() {
const { s, guard, custom } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
return (
<s-page heading="Settings">
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
<s-section heading="Checkout guard">
<s-stack gap="base">
<s-paragraph>
Some shoppers skip the cart (Buy now, Shop Pay, Apple Pay). The checkout guard stops checkout when a time is required but missing, when the chosen date has passed, or when the delivery fee in the cart doesn't match the quote. It works on every Shopify plan.
</s-paragraph>
<s-stack direction="inline" gap="base" alignItems="center">
<s-badge tone={guard.enabled ? "success" : "warning"}>{guard.enabled ? "On" : guard.deployed ? "Off" : "Not deployed"}</s-badge>
<Form method="post">
<input type="hidden" name="intent" value="guard" />
<input type="hidden" name="on" value={guard.enabled ? "false" : "true"} />
<s-button type="submit" disabled={!guard.deployed}>{guard.enabled ? "Turn off" : "Turn on"}</s-button>
</Form>
</s-stack>
</s-stack>
</s-section>
<s-section heading="Checkout display">
<s-stack gap="base">
<CheckoutToggle
intent="custom:delivery"
state={custom.delivery}
title="Show the chosen date and time at checkout"
body="Adds the customer's date and time to the order's delivery method, for example “Local Delivery — Sat, Nov 7, 10 AM–1 PM”, so staff see it on the order. Shoppers always see their date and time on the thank-you page, and on Shopify Plus also next to the pickup and shipping options in checkout."
/>
<s-divider />
<CheckoutToggle
intent="custom:payment"
state={custom.payment}
title="Hide pay-in-person methods for shipped orders"
body="Hides manual methods such as Cash on Delivery or Pay in store when the customer chose shipping. Pickup and local delivery keep every method."
/>
</s-stack>
</s-section>
<s-section heading="Booking policies">
<Form method="post">
<input type="hidden" name="intent" value="policy" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-number-field name="holdMinutes" label="Hold a chosen time for (minutes)" defaultValue={String(s.holdMinutes)} min={3} max={60} details="Other shoppers see the place as taken during the hold" />
<s-select name="mixedPrepPolicy" label="Mixed carts use" value={s.mixedPrepPolicy}>
<s-option value="longest">The longest preparation time</s-option>
<s-option value="sum">All preparation times added</s-option>
</s-select>
<s-select name="locationRanking" label="Suggest locations by" value={s.locationRanking}>
<s-option value="priority">My priority order</s-option>
<s-option value="distance">Closest to the customer</s-option>
<s-option value="earliest">Earliest available time</s-option>
</s-select>
<s-select name="weekStartsOn" label="Calendar weeks start on" value={String(s.weekStartsOn)}>
<s-option value="0">Sunday</s-option>
<s-option value="1">Monday</s-option>
</s-select>
<s-number-field name="waitlistOfferMinutes" label="Hold a freed place for the waitlist (minutes)" defaultValue={String(s.waitlistOfferMinutes)} min={5} />
</s-grid>
<s-checkbox name="requireScheduling" label="Every order needs a date and time" defaultChecked={s.requireScheduling} details="Orders that arrive without one go to the review queue" />
<s-checkbox name="releaseOnCancel" label="Free the place when an order is cancelled" defaultChecked={s.releaseOnCancel} />
<s-divider />
<s-heading>Customer self-reschedule</s-heading>
<s-checkbox name="rsEnabled" label="Let customers change their time from the order status page" defaultChecked={s.reschedule.enabled} />
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-number-field name="rsHours" label="Up to this many hours before" defaultValue={String(s.reschedule.minHoursBefore)} min={0} />
<s-number-field name="rsMax" label="Changes allowed per order" defaultValue={String(s.reschedule.maxChanges)} min={0} />
</s-grid>
<s-button type="submit" variant="primary" loading={busy}>Save policies</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Storefront widget">
<Form method="post">
<input type="hidden" name="intent" value="widget" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap="base">
<s-text-field name="heading" label="Heading" defaultValue={s.widget.heading} />
<s-text-field name="ld" label="Delivery label" defaultValue={s.widget.labels.delivery} />
<s-text-field name="lp" label="Pickup label" defaultValue={s.widget.labels.pickup} />
<s-text-field name="ls" label="Shipping label" defaultValue={s.widget.labels.shipping} />
<s-color-field name="accent" label="Accent colour" defaultValue={s.widget.accent} />
</s-grid>
<s-checkbox name="showRemaining" label="Show places left" defaultChecked={s.widget.showRemaining} />
<s-checkbox name="drawer" label="Show in the cart drawer too" defaultChecked={s.widget.drawer} />
<s-text-area name="translations" label="Translations (JSON by language)" rows={3} defaultValue={JSON.stringify(s.widget.translations)} details='Example: {"fr": {"heading": "Quand le voulez-vous ?", "delivery": "Livraison locale"}}' />
<s-button type="submit" variant="primary" loading={busy}>Save widget</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Reminders">
<Form method="post">
<input type="hidden" name="intent" value="reminders" />
<s-stack gap="base">
<s-text color="subdued">Placeholders: {"{{name}} {{order}} {{date}} {{time}} {{method}} {{location}}"}</s-text>
{s.reminders.map((r) => (
<s-box key={r.id} padding="base" border="base" borderRadius="base">
<s-stack gap="small">
<s-stack direction="inline" gap="base" alignItems="center">
<s-checkbox name={`en_${r.id}`} label={`${r.channel.toUpperCase()}${r.methods ? ` · ${r.methods.join(", ")}` : ""}`} defaultChecked={r.enabled} />
<s-number-field name={`hrs_${r.id}`} label="Hours before" defaultValue={String(r.minutesBefore / 60)} step={0.5} min={0} />
<s-checkbox name={`del_${r.id}`} label="Delete" />
</s-stack>
<s-text-area name={`tpl_${r.id}`} label="Message" rows={2} defaultValue={r.template} />
</s-stack>
</s-box>
))}
<s-grid gridTemplateColumns="1fr 1fr 3fr" gap="base" alignItems="end">
<s-select name="new_channel" label="New reminder">
<s-option value="email">Email</s-option>
<s-option value="sms">SMS (Pro)</s-option>
<s-option value="whatsapp">WhatsApp (Pro)</s-option>
</s-select>
<s-number-field name="new_hrs" label="Hours before" defaultValue="24" />
<s-text-field name="new_tpl" label="Message" placeholder="Hi {{name}}, see you {{date}} {{time}}" />
</s-grid>
<s-button type="submit" variant="primary" loading={busy}>Save reminders</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Messaging accounts">
<Form method="post">
<input type="hidden" name="intent" value="messaging" />
<s-stack gap="base">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="fromName" label="Email sender name" defaultValue={s.messaging.emailFromName} />
<s-email-field name="replyTo" label="Replies go to" defaultValue={s.messaging.replyTo ?? ""} />
</s-grid>
<s-heading>Twilio (SMS)</s-heading>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="twSid" label="Account SID" defaultValue={s.messaging.twilio?.accountSid ?? ""} />
<s-password-field name="twToken" label="Auth token" placeholder={s.messaging.twilio ? "Saved. Leave blank to keep" : ""} />
<s-text-field name="twFrom" label="From number" defaultValue={s.messaging.twilio?.from ?? ""} />
</s-grid>
{s.messaging.twilio && <s-checkbox name="twClear" label="Disconnect Twilio" />}
<s-heading>WhatsApp (Meta Cloud API)</s-heading>
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
<s-text-field name="waPhone" label="Phone number ID" defaultValue={s.messaging.whatsapp?.phoneNumberId ?? ""} />
<s-password-field name="waToken" label="Access token" placeholder={s.messaging.whatsapp ? "Saved. Leave blank to keep" : ""} />
<s-text-field name="waTemplate" label="Approved template name" defaultValue={s.messaging.whatsapp?.templateName ?? ""} details="Template body must contain {{1}}" />
<s-text-field name="waLang" label="Template language" defaultValue={s.messaging.whatsapp?.templateLang ?? "en"} />
</s-grid>
{s.messaging.whatsapp && <s-checkbox name="waClear" label="Disconnect WhatsApp" />}
<s-button type="submit" variant="primary" loading={busy}>Save messaging</s-button>
</s-stack>
</Form>
</s-section>
<s-section heading="Plan" id="plan">
<s-button href="/app/plans">See plans</s-button>
</s-section>
</s-page>
);
}
function CheckoutToggle({ intent, state, title, body }: { intent: string; state: { deployed: boolean; enabled: boolean }; title: string; body: string }) {
return (
<s-grid gridTemplateColumns="1fr auto" gap="base" alignItems="center">
<s-stack gap="small-200">
<s-stack direction="inline" gap="small-200" alignItems="center">
<s-text type="strong">{title}</s-text>
<s-badge tone={state.enabled ? "success" : undefined}>{state.enabled ? "On" : state.deployed ? "Off" : "Not deployed"}</s-badge>
</s-stack>
<s-paragraph color="subdued">{body}</s-paragraph>
</s-stack>
<Form method="post">
<input type="hidden" name="intent" value={intent} />
<input type="hidden" name="on" value={state.enabled ? "false" : "true"} />
<s-button type="submit" disabled={!state.deployed}>{state.enabled ? "Turn off" : "Turn on"}</s-button>
</Form>
</s-grid>
);
}