67 lines
2.3 KiB
TypeScript
67 lines
2.3 KiB
TypeScript
/**
|
||
* Customer-facing post-purchase features: self-reschedule eligibility and reminders.
|
||
*/
|
||
import type { Booking, ReminderPolicy, ShopSettings } from "./types";
|
||
import { zonedToUtc } from "./time";
|
||
|
||
export function slotStartMs(b: Booking): number {
|
||
return zonedToUtc(b.date, b.slotStart ?? "00:00", b.timezone);
|
||
}
|
||
|
||
export function canCustomerReschedule(
|
||
b: Booking,
|
||
policy: ShopSettings["reschedule"],
|
||
now: number,
|
||
): { ok: true; deadline: number } | { ok: false; reason: string } {
|
||
if (!policy.enabled) return { ok: false, reason: "Rescheduling isn't available for this store. Please contact us." };
|
||
if (b.status !== "confirmed") return { ok: false, reason: "This booking can't be changed." };
|
||
if ((b.rescheduleCount ?? 0) >= policy.maxChanges)
|
||
return { ok: false, reason: `You've already changed this booking ${policy.maxChanges} time(s). Please contact us.` };
|
||
const deadline = slotStartMs(b) - policy.minHoursBefore * 3600000;
|
||
if (now >= deadline)
|
||
return { ok: false, reason: `Changes are allowed up to ${policy.minHoursBefore} hours before your time slot.` };
|
||
return { ok: true, deadline };
|
||
}
|
||
|
||
export interface PlannedReminder {
|
||
policyId: string;
|
||
channel: ReminderPolicy["channel"];
|
||
sendAt: number;
|
||
body: string;
|
||
}
|
||
|
||
export function planReminders(
|
||
b: Booking,
|
||
policies: ReminderPolicy[],
|
||
now: number,
|
||
vars: Record<string, string> = {},
|
||
): PlannedReminder[] {
|
||
const start = slotStartMs(b);
|
||
return policies
|
||
.filter((p) => p.enabled && (!p.methods?.length || p.methods.includes(b.method)))
|
||
.map((p) => ({
|
||
policyId: p.id,
|
||
channel: p.channel,
|
||
sendAt: start - p.minutesBefore * 60000,
|
||
body: renderTemplate(p.template, {
|
||
name: b.customer?.name ?? "there",
|
||
order: b.orderName ?? "",
|
||
date: b.date,
|
||
time: b.slotStart && b.slotEnd ? `${b.slotStart}–${b.slotEnd}` : "",
|
||
method: b.method,
|
||
...vars,
|
||
}),
|
||
}))
|
||
.filter((r) => r.sendAt > now)
|
||
.filter((r) => {
|
||
// Respect channel requirements.
|
||
if (r.channel === "email") return !!b.customer?.email;
|
||
return !!b.customer?.phone;
|
||
});
|
||
}
|
||
|
||
/** {{var}} substitution; unknown vars render empty. */
|
||
export function renderTemplate(tpl: string, vars: Record<string, string>): string {
|
||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => vars[k] ?? "");
|
||
}
|