Merge branch 'feature/event-timezone' into main
This commit is contained in:
commit
877ef1a220
13 changed files with 318 additions and 159 deletions
|
|
@ -231,6 +231,10 @@ const props = defineProps({
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
eventTimezone: {
|
||||
type: String,
|
||||
default: "America/Toronto",
|
||||
},
|
||||
userEmail: {
|
||||
type: String,
|
||||
default: null,
|
||||
|
|
@ -415,6 +419,7 @@ const formatEventDate = (date) => {
|
|||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: props.eventTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<div v-if="events?.length" class="em-rows">
|
||||
<div v-for="event in events" :key="event._id" class="em-item">
|
||||
<div class="em-inset em-item-body">
|
||||
<span class="em-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="em-date">{{ formatDate(event) }}</span>
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || event._id}`"
|
||||
class="em-title"
|
||||
|
|
@ -37,10 +37,13 @@ defineProps({
|
|||
events: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,85 +1,98 @@
|
|||
// Utility composable for event date handling with timezone support
|
||||
// Utility composable for event date handling with timezone support.
|
||||
// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ.
|
||||
export const useEventDateUtils = () => {
|
||||
const TIMEZONE = "America/Toronto";
|
||||
const DEFAULT_TIMEZONE = "America/Toronto";
|
||||
|
||||
// Format a date to a specific format
|
||||
const formatDate = (date, options = {}) => {
|
||||
if (!date) return "";
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const { month = "short", day = "numeric", year = "numeric" } = options;
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
const {
|
||||
month = "short",
|
||||
day = "numeric",
|
||||
year = "numeric",
|
||||
weekday,
|
||||
timeZone,
|
||||
} = options;
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
...(weekday && { weekday }),
|
||||
month,
|
||||
day,
|
||||
year,
|
||||
...(timeZone && { timeZone }),
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
// Format event date range
|
||||
const formatDateRange = (startDate, endDate, compact = false) => {
|
||||
const formatDateRange = (startDate, endDate, compact = false, timeZone) => {
|
||||
if (!startDate || !endDate) return "No dates";
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const startMonth = start.toLocaleDateString("en-US", { month: "short" });
|
||||
const endMonth = end.toLocaleDateString("en-US", { month: "short" });
|
||||
const startDay = start.getDate();
|
||||
const endDay = end.getDate();
|
||||
const year = end.getFullYear();
|
||||
const tzOpts = timeZone ? { timeZone } : {};
|
||||
const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||
const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts });
|
||||
const startDay = Number(
|
||||
start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||
);
|
||||
const endDay = Number(
|
||||
end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }),
|
||||
);
|
||||
const year = Number(
|
||||
end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||
);
|
||||
const startMonthIdx = startMonth; // compared as label string
|
||||
const endMonthIdx = endMonth;
|
||||
const startYear = Number(
|
||||
start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }),
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
if (
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getFullYear() === end.getFullYear()
|
||||
) {
|
||||
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||
return `${startMonth} ${startDay}-${endDay}`;
|
||||
}
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}`;
|
||||
}
|
||||
|
||||
if (
|
||||
start.getMonth() === end.getMonth() &&
|
||||
start.getFullYear() === end.getFullYear()
|
||||
) {
|
||||
if (startMonthIdx === endMonthIdx && startYear === year) {
|
||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||
} else if (start.getFullYear() === end.getFullYear()) {
|
||||
} else if (startYear === year) {
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||
} else {
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a date is in the past
|
||||
const isPastDate = (date) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
return dateObj < now;
|
||||
return dateObj < new Date();
|
||||
};
|
||||
|
||||
// Check if a date is today
|
||||
const isToday = (date) => {
|
||||
const isToday = (date, timeZone) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const today = new Date();
|
||||
const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) };
|
||||
return (
|
||||
dateObj.getDate() === today.getDate() &&
|
||||
dateObj.getMonth() === today.getMonth() &&
|
||||
dateObj.getFullYear() === today.getFullYear()
|
||||
dateObj.toLocaleDateString("en-US", opts) ===
|
||||
today.toLocaleDateString("en-US", opts)
|
||||
);
|
||||
};
|
||||
|
||||
// Get a readable time string
|
||||
const formatTime = (date, includeSeconds = false) => {
|
||||
const formatTime = (date, includeSeconds = false, timeZone) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const options = {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(includeSeconds && { second: "2-digit" }),
|
||||
};
|
||||
return new Intl.DateTimeFormat("en-US", options).format(dateObj);
|
||||
...(timeZone && { timeZone }),
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
return {
|
||||
TIMEZONE,
|
||||
DEFAULT_TIMEZONE,
|
||||
// Legacy alias for callers that hard-coded the constant.
|
||||
TIMEZONE: DEFAULT_TIMEZONE,
|
||||
formatDate,
|
||||
formatDateRange,
|
||||
isPastDate,
|
||||
|
|
|
|||
|
|
@ -110,6 +110,23 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label> Event Timezone <span class="required">*</span> </label>
|
||||
<USelectMenu
|
||||
v-model="eventForm.displayTimezone"
|
||||
:items="timezoneItems"
|
||||
value-key="value"
|
||||
searchable
|
||||
searchable-placeholder="Search timezones..."
|
||||
placeholder="Select a timezone"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="help-text">
|
||||
Dates below are interpreted in this timezone. Attendees see the
|
||||
event time in this zone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label> Location <span class="required">*</span> </label>
|
||||
<UInput
|
||||
|
|
@ -601,6 +618,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TIMEZONE_OPTIONS } from "~/config/timezones";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
middleware: "admin",
|
||||
|
|
@ -630,6 +649,7 @@ const eventForm = reactive({
|
|||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
displayTimezone: "America/Toronto",
|
||||
location: "",
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
|
|
@ -672,6 +692,38 @@ const formatForDatetimeLocal = (value) => {
|
|||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
// Render the form's datetime fields in the event's display timezone.
|
||||
const formatForEventTZ = (value) => {
|
||||
if (!value) return "";
|
||||
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
|
||||
};
|
||||
|
||||
const utcOffsetLabel = (tz) => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "longOffset",
|
||||
}).formatToParts(new Date());
|
||||
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
|
||||
if (name === "GMT") return "UTC+00:00";
|
||||
return name.replace("GMT", "UTC");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const timezoneItems = computed(() => {
|
||||
const list = TIMEZONE_OPTIONS.map((t) => {
|
||||
const off = utcOffsetLabel(t.value);
|
||||
return { ...t, label: off ? `${t.label} (${off})` : t.label };
|
||||
});
|
||||
const saved = eventForm.displayTimezone;
|
||||
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
|
||||
list.unshift({ label: saved, value: saved });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
// Agenda management functions
|
||||
const addAgendaItem = () => {
|
||||
eventForm.agenda.push("");
|
||||
|
|
@ -725,14 +777,17 @@ function populateEditForm(payload) {
|
|||
const event = payload?.data;
|
||||
if (!event) return;
|
||||
editingEvent.value = event;
|
||||
// Pin the form's timezone first so subsequent date conversions use it.
|
||||
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
|
||||
Object.assign(eventForm, {
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content: event.content || "",
|
||||
featureImage: event.featureImage || null,
|
||||
startDate: formatForDatetimeLocal(event.startDate),
|
||||
endDate: formatForDatetimeLocal(event.endDate),
|
||||
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
|
||||
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
|
||||
eventType: event.eventType,
|
||||
displayTimezone: eventForm.displayTimezone,
|
||||
location: event.location || "",
|
||||
isOnline: event.isOnline,
|
||||
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
||||
|
|
@ -743,7 +798,7 @@ function populateEditForm(payload) {
|
|||
tags: event.tags || [],
|
||||
maxAttendees: event.maxAttendees || "",
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: formatForDatetimeLocal(event.registrationDeadline),
|
||||
registrationDeadline: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
|
||||
agenda: event.agenda || [],
|
||||
tickets: event.tickets || {
|
||||
enabled: false,
|
||||
|
|
@ -765,8 +820,9 @@ function populateEditForm(payload) {
|
|||
},
|
||||
});
|
||||
if (event.tickets?.public?.earlyBirdDeadline) {
|
||||
eventForm.tickets.public.earlyBirdDeadline = formatForDatetimeLocal(
|
||||
eventForm.tickets.public.earlyBirdDeadline = utcToZonedLocal(
|
||||
event.tickets.public.earlyBirdDeadline,
|
||||
eventForm.displayTimezone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -911,15 +967,37 @@ const saveEvent = async (redirect = true) => {
|
|||
// Individual series creation is handled through the series management page
|
||||
}
|
||||
|
||||
const tz = eventForm.displayTimezone || "America/Toronto";
|
||||
const toUTC = (v) => {
|
||||
const d = zonedLocalToUTC(v, tz);
|
||||
return d ? d.toISOString() : v;
|
||||
};
|
||||
const payload = {
|
||||
...eventForm,
|
||||
startDate: toUTC(eventForm.startDate),
|
||||
endDate: toUTC(eventForm.endDate),
|
||||
registrationDeadline: eventForm.registrationDeadline
|
||||
? toUTC(eventForm.registrationDeadline)
|
||||
: eventForm.registrationDeadline,
|
||||
tickets: {
|
||||
...eventForm.tickets,
|
||||
public: {
|
||||
...eventForm.tickets.public,
|
||||
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
|
||||
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
|
||||
: eventForm.tickets.public.earlyBirdDeadline,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (editingEvent.value) {
|
||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||
method: "PUT",
|
||||
body: eventForm,
|
||||
body: payload,
|
||||
});
|
||||
} else {
|
||||
await $fetch("/api/admin/events", {
|
||||
method: "POST",
|
||||
body: eventForm,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -959,6 +1037,7 @@ const saveAndCreateAnother = async () => {
|
|||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
displayTimezone: "America/Toronto",
|
||||
location: "",
|
||||
isOnline: true,
|
||||
isVisible: true,
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@
|
|||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
|
|
@ -190,8 +190,8 @@
|
|||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
</td>
|
||||
<td class="col-date">
|
||||
<span class="date-main">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="date-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="date-main">{{ formatDate(event) }}</span>
|
||||
<span class="date-time">{{ formatTime(event) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
||||
|
|
@ -349,19 +349,23 @@ watch([searchQuery, typeFilter, seriesFilter], () => {
|
|||
pastPage.value = 1
|
||||
})
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return ''
|
||||
return new Date(event.startDate).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: event.displayTimezone || 'America/Toronto',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@
|
|||
:event-id="event._id || event.id"
|
||||
:event-start-date="event.startDate"
|
||||
:event-title="event.title"
|
||||
:event-timezone="eventTimeZone"
|
||||
:user-email="memberData?.email"
|
||||
:user-name="memberData?.name"
|
||||
@success="handleTicketSuccess"
|
||||
|
|
@ -197,21 +198,29 @@ onMounted(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
const eventTimeZone = computed(
|
||||
() => event.value?.displayTimezone || "America/Toronto",
|
||||
);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: eventTimeZone.value,
|
||||
}).format(d);
|
||||
};
|
||||
|
||||
const formatTime = (start, end) => {
|
||||
if (!start || !end) return "";
|
||||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: eventTimeZone.value,
|
||||
});
|
||||
return `${fmt.format(new Date(start))} – ${fmt.format(new Date(end))}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@
|
|||
:class="{ 'is-cancelled': event.isCancelled }"
|
||||
>
|
||||
<div class="event-date-col">
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="event-time">{{ formatTime(event.startDate) }}</span>
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-time">{{ formatTime(event) }}</span>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div class="event-title">
|
||||
|
|
@ -152,18 +152,24 @@ const activeSeries = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const opts = { month: "short", day: "numeric" };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
const tz = event.displayTimezone || "America/Toronto";
|
||||
const d = new Date(event.startDate);
|
||||
const opts = { month: "short", day: "numeric", timeZone: tz };
|
||||
const dYear = d.toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
const nowYear = new Date().toLocaleDateString("en-US", { year: "numeric", timeZone: tz });
|
||||
if (dYear !== nowYear) opts.year = "numeric";
|
||||
return d.toLocaleDateString("en-US", opts);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const formatTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
|
||||
const formatLocation = (event) => {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<div v-if="events?.length" class="event-list">
|
||||
<div v-for="event in events" :key="event._id" class="event-item">
|
||||
<div class="block-inset event-item-inner">
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="event-date">{{ formatDate(event) }}</span>
|
||||
<span class="event-title">
|
||||
<NuxtLink :to="`/events/${event.slug || event._id}`">{{
|
||||
event.title
|
||||
|
|
@ -168,10 +168,13 @@ const circleData = [
|
|||
},
|
||||
];
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const formatDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Date(event.startDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,7 @@
|
|||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{
|
||||
formatEventDate(evt.startDate)
|
||||
}}</span>
|
||||
<span class="event-date">{{ formatEventDate(evt) }}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
|
@ -365,20 +363,22 @@ const getEventImageUrl = (featureImage) => {
|
|||
return "";
|
||||
};
|
||||
|
||||
const formatEventDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const formatEventDate = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
};
|
||||
|
||||
const formatEventTime = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const formatEventTime = (event) => {
|
||||
if (!event?.startDate) return "";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
timeZone: event.displayTimezone || "America/Toronto",
|
||||
}).format(new Date(event.startDate));
|
||||
};
|
||||
|
||||
const formatMemberSince = (dateString) => {
|
||||
|
|
|
|||
77
app/utils/timezones.js
Normal file
77
app/utils/timezones.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Convert a datetime-local string ("YYYY-MM-DDTHH:MM") to a UTC Date,
|
||||
// interpreting the wall-clock time in the given IANA timezone.
|
||||
export function zonedLocalToUTC(localStr, tz) {
|
||||
if (!localStr || !tz) return null;
|
||||
const [datePart, timePart] = String(localStr).split("T");
|
||||
if (!datePart || !timePart) return null;
|
||||
const [y, mo, d] = datePart.split("-").map(Number);
|
||||
const [h, mi] = timePart.split(":").map(Number);
|
||||
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
||||
|
||||
// Treat the components as if they are already UTC. The result's wall-clock
|
||||
// in the target TZ will differ from what we want by exactly the TZ offset
|
||||
// for that moment, so we measure that offset and subtract it.
|
||||
const asUTC = new Date(Date.UTC(y, mo - 1, d, h, mi));
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(asUTC);
|
||||
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
||||
const observed = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
get("hour") % 24,
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
const offsetMs = observed - asUTC.getTime();
|
||||
return new Date(asUTC.getTime() - offsetMs);
|
||||
}
|
||||
|
||||
// Convert a UTC Date (or ISO string) to a datetime-local string
|
||||
// ("YYYY-MM-DDTHH:MM") rendered in the given IANA timezone.
|
||||
export function utcToZonedLocal(utc, tz) {
|
||||
if (!utc || !tz) return "";
|
||||
const d = utc instanceof Date ? utc : new Date(utc);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
const get = (type) => parts.find((p) => p.type === type)?.value;
|
||||
const year = get("year");
|
||||
const month = get("month");
|
||||
const day = get("day");
|
||||
let hour = get("hour");
|
||||
const minute = get("minute");
|
||||
if (hour === "24") hour = "00";
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
}
|
||||
|
||||
// Short timezone label (e.g., "EDT", "PDT") for a Date in a given IANA TZ.
|
||||
export function shortTimezoneName(date, tz) {
|
||||
if (!date || !tz) return "";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(d);
|
||||
return parts.find((p) => p.type === "timeZoneName")?.value || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue