Merge branch 'feature/event-timezone' into main

This commit is contained in:
Jennie Robinson Faber 2026-05-19 13:54:48 +01:00
commit 877ef1a220
13 changed files with 318 additions and 159 deletions

View file

@ -18,6 +18,8 @@ const eventSchema = new mongoose.Schema({
enum: ["community", "workshop", "social", "showcase"],
default: "community",
},
// IANA timezone for interpreting datetime input and rendering the event time.
displayTimezone: { type: String, default: "America/Toronto" },
// Online-first location handling
location: {
type: String,

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 {

View file

@ -173,6 +173,7 @@ export const adminEventCreateSchema = z.object({
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(),
@ -204,6 +205,7 @@ export const adminEventUpdateSchema = z.object({
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
displayTimezone: z.string().max(100).optional(),
location: z.string().max(500).optional(),
maxAttendees: z.preprocess(emptyStringToUndefined, z.number().int().positive().optional().nullable()),
membersOnly: z.boolean().optional(),