/** * 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>; } 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; 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; export const SELECTION_ATTR = "_datewise";