167 lines
6.4 KiB
TypeScript
167 lines
6.4 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 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";
|
|
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",
|
|
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 ?? {}) },
|
|
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";
|