- delivery-label renames only options matching the chosen method (en/fr dates) - payment-rules hides COD / pay-in-store for shipped orders - functions matched by handle (fixes guard lookup matching any 'datewise' title) - scopes: write_delivery_customizations, write_payment_customizations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
21 KiB
TypeScript
366 lines
21 KiB
TypeScript
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, GUARD_HANDLE, findCustomizations, findGuard, gql, setCustomization } 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(() => ({ fn: undefined, validation: undefined }));
|
||
const off = { deployed: false, enabled: false };
|
||
const custom = await findCustomizations(admin).catch(() => ({ delivery: off, payment: off }));
|
||
const s = cfg.settings;
|
||
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: { deployed: custom.delivery.deployed, enabled: custom.delivery.enabled }, payment: { deployed: custom.payment.deployed, enabled: custom.payment.enabled } },
|
||
};
|
||
};
|
||
|
||
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 { fn, validation } = await findGuard(admin);
|
||
if (!fn) return { ok: false, message: "The checkout guard isn't deployed yet. Run `shopify app deploy`." };
|
||
const metafields = [{ namespace: "$app:datewise", key: "config", type: "json", value: JSON.stringify({ requireScheduling: s.requireScheduling }) }];
|
||
const turnOn = formBool(fd, "on");
|
||
if (validation) {
|
||
await gql(admin, `#graphql
|
||
mutation($id: ID!, $v: ValidationUpdateInput!) { validationUpdate(id: $id, validation: $v) { userErrors { message } } }`, {
|
||
id: validation.id,
|
||
v: { enable: turnOn, blockOnFailure: false, metafields },
|
||
});
|
||
} else if (turnOn) {
|
||
await gql(admin, `#graphql
|
||
mutation($v: ValidationCreateInput!) { validationCreate(validation: $v) { userErrors { message } } }`, {
|
||
v: { functionHandle: GUARD_HANDLE, title: "Datewise checkout guard", enable: true, blockOnFailure: false, metafields },
|
||
});
|
||
}
|
||
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 };
|
||
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>
|
||
);
|
||
}
|