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 { GUARD_HANDLE, findGuard, gql } 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 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 }, }; }; 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 === "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 } = useLoaderData(); const result = useActionData(); const busy = useNavigation().state !== "idle"; return ( {result && {result.message}} 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. {guard.enabled ? "On" : guard.deployed ? "Off" : "Not deployed"}
{guard.enabled ? "Turn off" : "Turn on"}
The longest preparation time All preparation times added My priority order Closest to the customer Earliest available time Sunday Monday Customer self-reschedule Save policies
Save widget
Placeholders: {"{{name}} {{order}} {{date}} {{time}} {{method}} {{location}}"} {s.reminders.map((r) => ( ))} Email SMS (Pro) WhatsApp (Pro) Save reminders
Twilio (SMS) {s.messaging.twilio && } WhatsApp (Meta Cloud API) {s.messaging.whatsapp && } Save messaging
{PLAN_COPY.map((p) => ( {p.name} {p.price} {p.points.map((x) => {x})} {plan === p.id ? ( Current plan ) : (
{p.id === "free" ? "Switch to Starter" : `Choose ${p.name}`}
)}
))}
); }