feat(emails): render event reminders in event displayTimezone

Registration, waitlist, and series-pass confirmation emails formatted
dates with Intl.DateTimeFormat in the server's local TZ. Switch to
event.displayTimezone (fallback America/Toronto) so the email shows
the event's intended wall-clock + zone suffix regardless of where the
Resend worker runs.

Inline formatters in three exports collapsed to two module-level
helpers that take a timeZone argument.
This commit is contained in:
Jennie Robinson Faber 2026-05-19 10:47:54 +01:00
parent 9dd007657a
commit 75b1f84d18

View file

@ -2,6 +2,29 @@ import { Resend } from "resend";
const resend = new Resend(useRuntimeConfig().resendApiKey);
const formatEventDate = (dateString, timeZone = "America/Toronto") => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone,
}).format(date);
};
const formatEventTime = (startDate, endDate, timeZone = "America/Toronto") => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone,
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
/**
* Send event registration confirmation email
* @param {Object} options - { requiresSignIn?: boolean } when true, appends a
@ -9,29 +32,7 @@ const resend = new Resend(useRuntimeConfig().resendApiKey);
* registered via the public form and did not receive an auto-login cookie.
*/
export async function sendEventRegistrationEmail(registration, eventData, options = {}) {
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
const tz = eventData.displayTimezone || "America/Toronto";
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
const signInSection = options.requiresSignIn
@ -66,8 +67,8 @@ Paid: $${registration.amountPaid.toFixed(2)} CAD`;
You're now registered for ${eventData.title}.
Date: ${formatDate(eventData.startDate)}
Time: ${formatTime(eventData.startDate, eventData.endDate)}
Date: ${formatEventDate(eventData.startDate, tz)}
Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)}
Location: ${eventData.location}
${eventData.description ? `\n${eventData.description}\n` : ""}${ticketSection}${signInSection}
View event: ${eventUrl}
@ -124,29 +125,7 @@ ${baseUrl}/events`,
* Send waitlist notification email when a spot opens up
*/
export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
const tz = eventData.displayTimezone || "America/Toronto";
const baseUrl = process.env.BASE_URL || "https://ghostguild.org";
const eventUrl = `${baseUrl}/events/${eventData.slug || eventData._id}`;
@ -159,8 +138,8 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
A spot opened up for ${eventData.title}.
Date: ${formatDate(eventData.startDate)}
Time: ${formatTime(eventData.startDate, eventData.endDate)}
Date: ${formatEventDate(eventData.startDate, tz)}
Time: ${formatEventTime(eventData.startDate, eventData.endDate, tz)}
Location: ${eventData.location}
Register now: ${eventUrl}
@ -186,29 +165,6 @@ If you can no longer attend, ignore this email and the spot goes to the next per
export async function sendSeriesPassConfirmation(options) {
const { to, name, series, ticket, events, paymentId } = options;
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
@ -223,13 +179,13 @@ export async function sendSeriesPassConfirmation(options) {
: "";
const eventList = events
.map(
(evt, index) =>
` ${index + 1}. ${evt.title}
${formatDate(evt.startDate)}
${formatTime(evt.startDate, evt.endDate)}
${evt.location}`,
)
.map((evt, index) => {
const tz = evt.displayTimezone || "America/Toronto";
return ` ${index + 1}. ${evt.title}
${formatEventDate(evt.startDate, tz)}
${formatEventTime(evt.startDate, evt.endDate, tz)}
${evt.location}`;
})
.join("\n\n");
try {