ghostguild-org/app/pages/events/[slug].vue
Jennie Robinson Faber acbd3c0737 feat(events): render public detail page in event timezone
Event detail page formatted dates in viewer-local time, so a Lisbon
viewer of a Toronto ET event saw the time in WEST. Format with
event.displayTimezone instead so attendees see the event's intended
wall-clock + zone suffix ("6:00 AM EDT") regardless of where they sit.

useEventDateUtils.formatDate / formatTime / formatDateRange / isToday
now accept a { timeZone } option and pass it to Intl.DateTimeFormat.
Existing call sites that don't pass timeZone fall through to viewer-
local, matching prior behaviour.
2026-05-19 10:45:11 +01:00

434 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="pending" class="loading">Loading event details...</div>
<div v-else-if="error" class="loading">
<h2>Event Not Found</h2>
<p>The event you're looking for doesn't exist.</p>
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<div v-else class="page-fill">
<!-- EVENT HEADER -->
<div class="event-header">
<h1>{{ event.title }}</h1>
<div class="event-meta-row">
<div class="event-meta-item">
<span class="meta-label">Date</span>
{{ formatDate(event.startDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Time</span>
{{ formatTime(event.startDate, event.endDate) }}
</div>
<div class="event-meta-item">
<span class="meta-label">Location</span>
<span v-if="event.location?.trim().toUpperCase() === 'TBD'">
Platform TBD
</span>
<template v-else>{{ event.location }}</template>
</div>
<div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" />
</div>
<div v-if="event.maxAttendees" class="event-meta-item">
<span class="meta-label">Capacity</span>
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</div>
</div>
</div>
<!-- CANCELLED NOTICE -->
<div v-if="event.isCancelled" class="cancelled-notice">
<strong>Event Cancelled</strong>
<p v-if="event.cancellationMessage">{{ event.cancellationMessage }}</p>
<p v-else>
This event has been cancelled. We apologize for any inconvenience.
</p>
</div>
<!-- FEATURE IMAGE -->
<div v-if="event.featureImage?.url" class="event-feature-image">
<img
:src="event.featureImage.url"
:alt="event.featureImage.alt || event.title"
>
</div>
<!-- TWO-COLUMN BODY -->
<div class="event-body">
<!-- LEFT: MAIN CONTENT -->
<div class="event-main">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="section">
<div class="series-note">
<span class="section-label">Part of Series</span>
<NuxtLink :to="`/series/${event.series.id}`">{{
event.series.title
}}</NuxtLink>
&mdash; Event {{ event.series.position }} of
{{ event.series.totalEvents }}
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles?.length" class="section">
<span class="section-label">Recommended for</span>
<div class="circle-badges">
<CircleBadge
v-for="circle in event.targetCircles"
:key="circle"
:circle="circle"
/>
</div>
</div>
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<p>{{ event.description }}</p>
</div>
<!-- Series Description -->
<div
v-if="event.series?.isSeriesEvent && event.series.description"
class="section"
>
<h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p>
</div>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ol class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ol>
</div>
<!-- Speakers -->
<div v-if="event.speakers?.length" class="section">
<h2>Speakers</h2>
<div
v-for="speaker in event.speakers"
:key="speaker.name"
class="speaker"
>
<div class="speaker-name">{{ speaker.name }}</div>
<div v-if="speaker.role" class="speaker-role">
{{ speaker.role }}
</div>
<div v-if="speaker.bio" class="speaker-bio">{{ speaker.bio }}</div>
</div>
</div>
</div>
<!-- RIGHT: SIDEBAR PANELS -->
<div v-if="!event.isCancelled" class="event-aside">
<!-- Ticket System -->
<EventTicketPurchase
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
:user-name="memberData?.name"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Event Details Box -->
<div class="dashed-box">
<div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span>
<span class="detail-val">{{ event.eventType }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
<span class="detail-val">Yes</span>
</div>
</div>
<!-- Questions -->
<div class="dashed-box">
<div class="box-title">Questions?</div>
<p
style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px"
>
Drop us a line.
</p>
<a href="mailto:events@ghostguild.org" style="font-size: 12px"
>events@ghostguild.org</a
>
</div>
</div>
</div>
</div>
</template>
<script setup>
const route = useRoute();
const toast = useToast();
const {
data: event,
pending,
error,
} = await useFetch(`/api/events/${route.params.slug}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = event.value?.title || "";
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: "Event not found" });
}
const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding();
onMounted(async () => {
await checkMemberStatus();
if (memberData.value && !isComplete.value) {
trackGoal('eventPageVisited');
}
});
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))}`;
};
const handleTicketSuccess = () => {
if (event.value.registeredCount !== undefined) event.value.registeredCount++;
};
const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err);
};
useHead(() => ({
title: event.value
? `${event.value.title} - Ghost Guild Events`
: "Event - Ghost Guild",
meta: [
{
name: "description",
content: event.value?.description || "View event details and register",
},
],
}));
</script>
<style scoped>
.loading {
padding: 48px 32px;
color: var(--text-dim);
}
.loading h2 {
font-family: "Brygada 1918", serif;
font-size: 22px;
color: var(--text-bright);
margin-bottom: 8px;
}
.event-feature-image {
border-bottom: 1px dashed var(--border);
}
.event-feature-image img {
display: block;
width: 100%;
max-height: 400px;
object-fit: cover;
}
.event-header {
padding: 28px 32px;
border-bottom: 1px dashed var(--border);
}
.event-header h1 {
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
margin-bottom: 16px;
}
.event-meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
font-size: 12px;
color: var(--text-dim);
}
.meta-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 2px;
}
.cancelled-notice {
padding: 20px 32px;
border-bottom: 1px dashed var(--border);
color: var(--ember);
font-size: 12px;
}
.cancelled-notice strong {
display: block;
margin-bottom: 4px;
}
/* ---- PAGE FILL (aside border reaches viewport bottom) ---- */
.page-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
flex: 1;
}
.event-main {
min-width: 0;
}
.event-aside {
border-left: 1px dashed var(--border);
padding: 0;
}
.event-aside .dashed-box {
margin: 0;
border: none;
border-bottom: 1px dashed var(--border);
padding: 20px 24px;
}
.event-aside .dashed-box:hover {
border-color: var(--border);
}
.section {
padding: 24px 32px;
border-bottom: 1px dashed var(--border);
}
.section h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.circle-badges {
display: flex;
gap: 6px;
margin-top: 4px;
}
.series-note {
font-size: 12px;
color: var(--text-dim);
}
.agenda-list {
padding-left: 20px;
font-size: 12px;
color: var(--text-dim);
line-height: 2;
}
.speaker {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.speaker:last-child {
border-bottom: none;
}
.speaker-name {
font-size: 13px;
color: var(--text-bright);
font-weight: 500;
}
.speaker-role {
font-size: 11px;
color: var(--text-dim);
}
.speaker-bio {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.box-title {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
margin-bottom: 8px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-key {
color: var(--text-faint);
}
.detail-val {
color: var(--text);
}
@media (max-width: 768px) {
.event-body {
grid-template-columns: 1fr;
}
.event-aside {
border-left: none;
border-top: 1px dashed var(--border);
}
.event-meta-row {
flex-direction: column;
gap: 8px;
}
}
</style>