datewise/app/routes/app.settings.tsx
metatroncubeswdev 0a979becc3 feat: restore checkout functions after reinstall; sync listing copy
Shopify deletes an app's validation and delivery/payment customizations on
uninstall. Datewise now remembers which ones were on (settings.checkoutFunctions,
kept in sync with Shopify whenever Settings loads) and switches them back on
when a shop reinstalls.

LISTING.md and alt text updated to what the listing form accepted (5 features,
20-char search terms, 64-char alt text, no "plan" outside pricing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:50:55 -04:00

367 lines
21 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 { PLAN_GROWTH, PLAN_PRO } from "../shopify.server";
import { bumpConfig } from "../services/config.server";
import { encrypt } from "../services/crypto.server";
import { PLAN_COPY } from "../services/plans";
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, billing } = await adminContext(request);
// Keep the stored plan in sync with Shopify billing.
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
const plan = check.appSubscriptions.find((s) => s.status === "ACTIVE")?.name ?? "free";
if (plan !== cfg.shop.plan) await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan } });
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 {
plan,
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, billing, 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.startsWith("plan:")) {
const plan = intent.slice(5);
if (plan === "free") {
const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest: process.env.NODE_ENV !== "production" });
for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest: process.env.NODE_ENV !== "production", prorate: true });
await prisma.shop.update({ where: { id: cfg.shop.id }, data: { plan: "free" } });
return { ok: true, message: "Switched to Starter" };
}
// Redirects the merchant to Shopify's approval page.
await billing.request({ plan: plan as typeof PLAN_GROWTH, isTest: process.env.NODE_ENV !== "production", returnUrl: `https://${cfg.shop.domain}/admin/apps/${process.env.SHOPIFY_API_KEY}/app/settings` });
}
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 { plan, 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 matching delivery option, for example “Local Delivery — Sat, Nov 7, 10 AM–1 PM”, so it appears in the order summary and confirmation."
/>
<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-grid gridTemplateColumns="repeat(auto-fit, minmax(220px, 1fr))" gap="base">
{PLAN_COPY.map((p) => (
<s-box key={p.id} padding="base" border="base" borderRadius="base" background={plan === p.id ? "subdued" : "base"}>
<s-stack gap="small">
<s-heading>{p.name}</s-heading>
<s-text type="strong">{p.price}</s-text>
<s-unordered-list>
{p.points.map((x) => <s-list-item key={x}>{x}</s-list-item>)}
</s-unordered-list>
{plan === p.id ? (
<s-badge tone="success">Current plan</s-badge>
) : (
<Form method="post">
<input type="hidden" name="intent" value={`plan:${p.id}`} />
<s-button type="submit">{p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`}</s-button>
</Form>
)}
</s-stack>
</s-box>
))}
</s-grid>
</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>
);
}