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 { billingIsTest } from "../lib/billing.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: await billingIsTest(admin) }); 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); const isTest = await billingIsTest(admin); if (plan === "free") { const check = await billing.check({ plans: [PLAN_GROWTH, PLAN_PRO], isTest }); for (const sub of check.appSubscriptions) await billing.cancel({ subscriptionId: sub.id, isTest, 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, 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(); 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}`}
)}
))}
); } function CheckoutToggle({ intent, state, title, body }: { intent: string; state: { deployed: boolean; enabled: boolean }; title: string; body: string }) { return ( {title} {state.enabled ? "On" : state.deployed ? "Off" : "Not deployed"} {body}
{state.enabled ? "Turn off" : "Turn on"}
); }