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

177 lines
6.8 KiB
TypeScript

/**
* App-level settings stored in Shop.settings (JSON). Engine settings + widget + messaging.
* Shared by server and admin UI (no server-only imports here).
*/
import { z } from "zod";
import type { ShopSettings } from "../engine/types";
export interface WidgetSettings {
heading: string;
labels: { pickup: string; delivery: string; shipping: string };
accent: string;
dateFormat: "short" | "long";
showRemaining: boolean;
/** Show the calendar inside the cart drawer too (app embed). */
drawer: boolean;
/** Per-locale overrides of customer-facing text. */
translations: Record<string, Partial<{ heading: string; pickup: string; delivery: string; shipping: string }>>;
}
export interface CheckoutFunctions {
guard: boolean;
delivery: boolean;
payment: boolean;
}
export interface AppSettings extends ShopSettings {
/** Block checkout (validation function) until a time is chosen. */
requireScheduling: boolean;
/** Minutes a freed place is held for the first person on a waitlist. */
waitlistOfferMinutes: number;
/** Return capacity when an order is cancelled. */
releaseOnCancel: boolean;
locationRanking: "priority" | "distance" | "earliest";
/** Which checkout functions the merchant turned on. Shopify deletes them on uninstall; a reinstall restores these. */
checkoutFunctions: CheckoutFunctions;
widget: WidgetSettings;
messaging: {
emailFromName: string;
replyTo?: string;
/** Encrypted with APP_ENCRYPTION_KEY (services/crypto.server.ts). */
twilio?: { accountSid: string; authTokenEnc: string; from: string } | null;
whatsapp?: { phoneNumberId: string; tokenEnc: string; templateName: string; templateLang: string } | null;
};
}
export const DEFAULT_SETTINGS: AppSettings = {
mixedPrepPolicy: "longest",
holdMinutes: 10,
weekStartsOn: 0,
reschedule: { enabled: true, minHoursBefore: 24, maxChanges: 2, allowMethodChange: false },
reminders: [
{
id: "day-before",
channel: "email",
minutesBefore: 1440,
enabled: true,
template: "Hi {{name}}, a reminder that your {{method}} for order {{order}} is on {{date}}, {{time}}. Need a different time? Use the link on your order page.",
},
{
id: "out-for-delivery",
channel: "sms",
minutesBefore: 90,
methods: ["delivery"],
enabled: false,
template: "{{name}}, order {{order}} arrives today {{time}}.",
},
],
requireScheduling: true,
waitlistOfferMinutes: 30,
releaseOnCancel: true,
locationRanking: "priority",
checkoutFunctions: { guard: false, delivery: false, payment: false },
widget: {
heading: "When would you like it?",
labels: { pickup: "Store pickup", delivery: "Local delivery", shipping: "Shipping" },
accent: "#0D6B5D",
dateFormat: "short",
showRemaining: true,
drawer: true,
translations: {},
},
messaging: { emailFromName: "", twilio: null, whatsapp: null },
};
/** Deep-merge stored settings over defaults so new keys appear after upgrades. */
export function withDefaults(stored: unknown): AppSettings {
const s = (stored ?? {}) as Partial<AppSettings>;
return {
...DEFAULT_SETTINGS,
...s,
reschedule: { ...DEFAULT_SETTINGS.reschedule, ...(s.reschedule ?? {}) },
checkoutFunctions: { ...DEFAULT_SETTINGS.checkoutFunctions, ...(s.checkoutFunctions ?? {}) },
widget: {
...DEFAULT_SETTINGS.widget,
...(s.widget ?? {}),
labels: { ...DEFAULT_SETTINGS.widget.labels, ...(s.widget?.labels ?? {}) },
},
messaging: { ...DEFAULT_SETTINGS.messaging, ...(s.messaging ?? {}) },
reminders: s.reminders ?? DEFAULT_SETTINGS.reminders,
};
}
const hhmm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour HH:MM");
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
const method = z.enum(["pickup", "delivery", "shipping"]);
const range = z.object({ start: hhmm, end: hhmm }).refine((r) => r.start < r.end, "End must be after start");
export const MethodConfigSchema = z.object({
enabled: z.boolean(),
weeklyHours: z.record(z.string(), z.array(range)),
slotMode: z.enum(["generated", "explicit", "dateOnly"]),
slotMinutes: z.number().int().min(15).max(720).optional(),
slots: z
.record(z.string(), z.array(range.and(z.object({ capacity: z.number().int().min(0).nullable().optional(), surcharge: z.number().int().min(0).optional() }))))
.optional(),
prepMinutes: z.number().int().min(0).max(60 * 24 * 60),
prepMode: z.enum(["elapsed", "operating"]),
sameDayCutoff: hhmm.nullable().optional(),
nextDayCutoff: hhmm.nullable().optional(),
horizonDays: z.number().int().min(1).max(365),
dailyCapacity: z.number().int().min(0).nullable().optional(),
slotCapacity: z.number().int().min(0).nullable().optional(),
});
export const ExceptionSchema = z.object({
date: ymd,
closed: z.boolean().optional(),
hours: z.array(range).optional(),
methods: z.array(method).optional(),
note: z.string().max(200).optional(),
});
export const ZoneSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
postalPatterns: z.array(z.string()).optional(),
radiusKm: z.number().positive().nullable().optional(),
drivingKm: z.number().positive().nullable().optional(),
fee: z.number().int().min(0),
freeOver: z.number().int().min(0).nullable().optional(),
minOrder: z.number().int().min(0).nullable().optional(),
});
export const RuleTargetSchema = z.object({
type: z.enum(["product", "variant", "collection", "vendor", "productType", "tag"]),
values: z.array(z.string().min(1)).min(1),
});
export const RuleActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("prohibitMethods"), methods: z.array(method).min(1) }),
z.object({ type: z.literal("allowOnlyMethods"), methods: z.array(method).min(1) }),
z.object({ type: z.literal("restrictLocations"), locationIds: z.array(z.string()).min(1) }),
z.object({ type: z.literal("addPrep"), minutes: z.number().int().min(1) }),
z.object({ type: z.literal("blockWeekdays"), weekdays: z.array(z.number().int().min(0).max(6)).min(1) }),
z.object({ type: z.literal("blockDates"), dates: z.array(ymd).min(1) }),
z.object({ type: z.literal("dateWindow"), from: ymd.optional(), to: ymd.optional() }),
z.object({
type: z.literal("freshness"),
arrivals: z.array(z.object({ date: ymd, locationIds: z.array(z.string()).optional() })),
minDaysAfterArrival: z.number().int().min(0),
shelfLifeDays: z.number().int().min(1),
}),
z.object({ type: z.literal("requireScheduling") }),
]);
export const SelectionAttrSchema = z.object({
t: z.string().min(8).max(80), // hold token
m: method,
l: z.string(),
d: ymd,
s: z.string().regex(/^\d\d:\d\d-\d\d:\d\d$/),
f: z.number().int().min(0).default(0), // fee (minor units)
v: z.number().int().optional(), // config version
});
export type SelectionAttr = z.infer<typeof SelectionAttrSchema>;
export const SELECTION_ATTR = "_datewise";