feat(events): markdown body, registered indicator, drop capacity counters

- Public event detail page renders description/series-description/content
  as markdown via useMarkdown, with prose styles; agenda becomes an
  unordered list with the same custom bullets.
- Events list API returns `isRegistered` per event (derived from the
  requester's registrations, ignoring cancelled rows), and the list page
  shows a "Registered" tag. Stops exposing the full registrations array
  in the list response.
- Removes the seats/sold-out/limited capacity UI from list and detail
  pages.
- EventTicketPurchase: minor template formatting (self-closing inputs,
  prettier wrapping).
This commit is contained in:
Jennie Robinson Faber 2026-05-21 17:50:48 +01:00
parent 2ffaf0ef09
commit 622cc8e53b
4 changed files with 176 additions and 118 deletions

View file

@ -38,14 +38,14 @@
<!-- Already Registered --> <!-- Already Registered -->
<div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel"> <div v-else-if="ticketInfo?.alreadyRegistered" class="ticket-panel">
<div class="box-title">Registration</div>
<p class="ticket-status" style="color: var(--green)"> <p class="ticket-status" style="color: var(--green)">
You're Registered! You're Registered!
</p> </p>
<p class="ticket-detail"> <p class="ticket-detail">
<template v-if="ticketInfo.viaSeriesPass"> <template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong>. <strong>{{ ticketInfo.series?.title }}</strong
>.
</template> </template>
<template v-else> <template v-else>
You're all set for this event. Check your email for confirmation You're all set for this event. Check your email for confirmation
@ -70,13 +70,11 @@
<!-- Registration (logged-in member) --> <!-- Registration (logged-in member) -->
<div <div
v-if="ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn" v-if="
ticketInfo.available && !ticketInfo.alreadyRegistered && isLoggedIn
"
class="ticket-panel" class="ticket-panel"
> >
<div class="box-title">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</div>
<p <p
v-if="ticketInfo.isMember && ticketInfo.isFree" v-if="ticketInfo.isMember && ticketInfo.isFree"
class="ticket-notice" class="ticket-notice"
@ -90,8 +88,7 @@
class="ticket-notice" class="ticket-notice"
style="color: var(--candle)" style="color: var(--candle)"
> >
Payment of {{ ticketInfo.formattedPrice }} will be processed Payment of {{ ticketInfo.formattedPrice }} will be processed securely
securely
</p> </p>
<button <button
@ -129,7 +126,7 @@
autocomplete="name" autocomplete="name"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<div class="field"> <div class="field">
@ -142,7 +139,7 @@
autocomplete="email" autocomplete="email"
required required
:disabled="processing" :disabled="processing"
> />
</div> </div>
<p <p
@ -160,11 +157,15 @@
v-model="form.createAccount" v-model="form.createAccount"
type="checkbox" type="checkbox"
:disabled="processing" :disabled="processing"
/>
<span
>Create a free guest account so I can manage my
registration</span
> >
<span>Create a free guest account so I can manage my registration</span>
</label> </label>
<p class="field-hint consent-hint"> <p class="field-hint consent-hint">
Guest accounts let you view your tickets and register faster next time. We won't add you to member communications. Guest accounts let you view your tickets and register faster next
time. We won't add you to member communications.
</p> </p>
</div> </div>
@ -190,24 +191,18 @@
class="ticket-panel" class="ticket-panel"
> >
<div class="box-title">Waitlist</div> <div class="box-title">Waitlist</div>
<p class="ticket-status" style="color: var(--ember)"> <p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
Event Sold Out
</p>
<p class="ticket-detail"> <p class="ticket-detail">
This event is currently at capacity. Join the waitlist to be notified This event is currently at capacity. Join the waitlist to be notified
if spots become available. if spots become available.
</p> </p>
<button class="btn" @click="handleJoinWaitlist"> <button class="btn" @click="handleJoinWaitlist">Join Waitlist</button>
Join Waitlist
</button>
</div> </div>
<!-- Sold Out (No Waitlist) --> <!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="ticket-panel"> <div v-else-if="!ticketInfo.available" class="ticket-panel">
<div class="box-title">Tickets</div> <div class="box-title">Tickets</div>
<p class="ticket-status" style="color: var(--ember)"> <p class="ticket-status" style="color: var(--ember)">Event Sold Out</p>
Event Sold Out
</p>
<p class="ticket-detail"> <p class="ticket-detail">
Unfortunately, this event is at capacity and no longer accepting Unfortunately, this event is at capacity and no longer accepting
registrations. registrations.
@ -311,7 +306,9 @@ const fetchTicketInfo = async (emailOverride = null) => {
} }
// Regular ticket availability check // Regular ticket availability check
const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : ""; const params = effectiveEmail
? `?email=${encodeURIComponent(effectiveEmail)}`
: "";
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`, `/api/events/${props.eventId}/tickets/available${params}`,
); );

View file

@ -30,10 +30,6 @@
<div v-if="event.circle" class="event-meta-item"> <div v-if="event.circle" class="event-meta-item">
<CircleBadge :circle="event.circle" /> <CircleBadge :circle="event.circle" />
</div> </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>
</div> </div>
@ -85,7 +81,7 @@
<!-- Description --> <!-- Description -->
<div class="section"> <div class="section">
<h2>About This Event</h2> <h2>About This Event</h2>
<p>{{ event.description }}</p> <div class="prose" v-html="renderMarkdown(event.description)" />
</div> </div>
<!-- Series Description --> <!-- Series Description -->
@ -94,17 +90,23 @@
class="section" class="section"
> >
<h2>About the {{ event.series.title }} Series</h2> <h2>About the {{ event.series.title }} Series</h2>
<p>{{ event.series.description }}</p> <div class="prose" v-html="renderMarkdown(event.series.description)" />
</div>
<!-- Additional Information -->
<div v-if="event.content" class="section">
<h2>Additional Information</h2>
<div class="prose" v-html="renderMarkdown(event.content)" />
</div> </div>
<!-- Agenda --> <!-- Agenda -->
<div v-if="event.agenda?.length" class="section"> <div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2> <h2>Agenda</h2>
<ol class="agenda-list"> <ul class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index"> <li v-for="(item, index) in event.agenda" :key="index">
{{ item }} {{ item }}
</li> </li>
</ol> </ul>
</div> </div>
<!-- Speakers --> <!-- Speakers -->
@ -143,7 +145,7 @@
<div class="box-title">Event Details</div> <div class="box-title">Event Details</div>
<div v-if="event.eventType" class="detail-row"> <div v-if="event.eventType" class="detail-row">
<span class="detail-key">Type</span> <span class="detail-key">Type</span>
<span class="detail-val">{{ event.eventType }}</span> <span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
</div> </div>
<div v-if="event.membersOnly" class="detail-row"> <div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span> <span class="detail-key">Members only</span>
@ -169,6 +171,8 @@
</template> </template>
<script setup> <script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute(); const route = useRoute();
const toast = useToast(); const toast = useToast();
@ -190,6 +194,7 @@ if (error.value?.statusCode === 404) {
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding(); const { trackGoal, isComplete } = useOnboarding();
const { render: renderMarkdown } = useMarkdown();
onMounted(async () => { onMounted(async () => {
await checkMemberStatus(); await checkMemberStatus();
@ -232,16 +237,12 @@ const handleTicketError = (err) => {
console.error("Ticket purchase failed:", err); console.error("Ticket purchase failed:", err);
}; };
useHead(() => ({ useSiteMeta(() => ({
title: event.value title: event.value ? `${event.value.title} · Events` : "Event",
? `${event.value.title} - Ghost Guild Events` description:
: "Event - Ghost Guild", event.value?.description || "View event details and register.",
meta: [ type: "article",
{ image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
name: "description",
content: event.value?.description || "View event details and register",
},
],
})); }));
</script> </script>
@ -349,12 +350,79 @@ useHead(() => ({
margin-bottom: 8px; margin-bottom: 8px;
} }
.section p { .section p {
font-size: 12px; font-size: 14px;
color: var(--text-dim); color: var(--text-dim);
line-height: 1.7; line-height: 1.7;
max-width: 560px; max-width: 560px;
} }
.prose {
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.prose :deep(p) {
margin-bottom: 12px;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
.prose :deep(a) {
color: var(--ember);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(strong) {
color: var(--text-bright);
}
.prose :deep(ul),
.prose :deep(ol) {
list-style: none;
padding: 0;
margin: 8px 0 12px;
}
.prose :deep(ul li),
.prose :deep(ol li) {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.prose :deep(ul li::before) {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
}
.prose :deep(ol) {
counter-reset: prose-item;
}
.prose :deep(ol li) {
counter-increment: prose-item;
padding-left: 28px;
}
.prose :deep(ol li::before) {
content: counter(prose-item) ".";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
}
.prose :deep(blockquote) {
border-left: 2px solid var(--candle-faint);
padding-left: 12px;
margin: 12px 0;
color: var(--text-faint);
}
.prose :deep(code) {
font-family: "Commit Mono", monospace;
background: var(--input-bg);
padding: 0 4px;
}
.circle-badges { .circle-badges {
display: flex; display: flex;
gap: 6px; gap: 6px;
@ -367,10 +435,27 @@ useHead(() => ({
} }
.agenda-list { .agenda-list {
padding-left: 20px; list-style: none;
font-size: 12px; padding: 0;
margin: 8px 0 0;
font-size: 14px;
color: var(--text-dim); color: var(--text-dim);
line-height: 2; line-height: 1.7;
max-width: 560px;
}
.agenda-list li {
position: relative;
padding: 2px 0 2px 16px;
margin-bottom: 4px;
}
.agenda-list li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.7;
} }
.speaker { .speaker {

View file

@ -45,34 +45,21 @@
<span v-if="event.isCancelled" class="cancelled-tag" <span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span >cancelled</span
> >
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
</div> </div>
<div v-if="event.tagline" class="event-tagline"> <div v-if="event.tagline" class="event-tagline">
{{ event.tagline }} {{ event.tagline }}
</div> </div>
<div class="event-sub"> <div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{ <span v-if="event.eventType" class="event-type-tag">{{
event.eventType eventTypeLabel(event.eventType)
}}</span> }}</span>
<span v-if="event.eventType" class="sep">·</span> <span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span> <span class="event-location">{{ formatLocation(event) }}</span>
</div> </div>
</div> </div>
<span class="event-capacity">
<template v-if="event.maxAttendees">
<span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span>
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
>Sold out</span
>
<span
v-else-if="isAlmostFull(event)"
class="capacity-badge limited"
>Limited tickets</span
>
</template>
<template v-else>Open</template>
</span>
<div class="event-badges"> <div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span> <span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" /> <CircleBadge v-if="event.circle" :circle="event.circle" />
@ -119,15 +106,20 @@
</template> </template>
<script setup> <script setup>
import { EVENT_TYPES, eventTypeLabel } from "~/config/eventTypes";
useSiteMeta({
title: "Events",
description:
"Workshops, meetups, and gatherings for game developers practicing cooperative models. Some events are open to the public; others are for Ghost Guild members.",
});
const activeFilter = ref("all"); const activeFilter = ref("all");
const includePastEvents = ref(false); const includePastEvents = ref(false);
const filterOptions = [ const filterOptions = [
{ label: "All", value: "all" }, { label: "All", value: "all" },
{ label: "Workshops", value: "workshop" }, ...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
{ label: "Community", value: "community" },
{ label: "Social", value: "social" },
{ label: "Showcase", value: "showcase" },
]; ];
const { data: eventsData } = await useFetch("/api/events"); const { data: eventsData } = await useFetch("/api/events");
@ -168,6 +160,7 @@ const formatTime = (event) => {
return new Date(event.startDate).toLocaleTimeString("en-US", { return new Date(event.startDate).toLocaleTimeString("en-US", {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto", timeZone: event.displayTimezone || "America/Toronto",
}); });
}; };
@ -181,16 +174,6 @@ const formatLocation = (event) => {
return event.location; return event.location;
}; };
const isSoldOut = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) >= event.maxAttendees;
};
const isAlmostFull = (event) => {
if (!event.maxAttendees) return false;
if (isSoldOut(event)) return false;
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
};
</script> </script>
<style scoped> <style scoped>
@ -221,7 +204,7 @@ const isAlmostFull = (event) => {
.event-row { .event-row {
display: grid; display: grid;
grid-template-columns: 90px 1fr auto auto; grid-template-columns: 90px 1fr auto;
gap: 16px; gap: 16px;
align-items: start; align-items: start;
padding: 14px 0; padding: 14px 0;
@ -293,6 +276,16 @@ const isAlmostFull = (event) => {
line-height: 1.5; line-height: 1.5;
flex-shrink: 0; flex-shrink: 0;
} }
.registered-tag {
font-size: 10px;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--candle);
border: 1px solid currentColor;
padding: 1px 5px;
line-height: 1.5;
flex-shrink: 0;
}
.event-tagline { .event-tagline {
font-size: 11px; font-size: 11px;
@ -321,35 +314,6 @@ const isAlmostFull = (event) => {
color: var(--text-faint); color: var(--text-faint);
} }
.event-capacity {
font-size: 11px;
color: var(--text-faint);
white-space: nowrap;
padding-top: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.seats-warn {
color: var(--ember);
}
.capacity-badge {
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 1px 5px;
border: 1px dashed currentColor;
line-height: 1.5;
white-space: nowrap;
}
.capacity-badge.limited {
color: var(--ember);
}
.capacity-badge.sold-out {
color: var(--text-faint);
border-style: solid;
}
.event-badges { .event-badges {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -481,7 +445,6 @@ const isAlmostFull = (event) => {
grid-template-columns: 70px 1fr; grid-template-columns: 70px 1fr;
gap: 8px; gap: 8px;
} }
.event-capacity,
.event-badges { .event-badges {
display: none; display: none;
} }

View file

@ -35,17 +35,30 @@ export default defineEventHandler(async (event) => {
} }
// Fetch events from database // Fetch events from database
const events = await Event.find(filter) const events = await Event.find(filter).sort({ startDate: 1 }).lean();
.sort({ startDate: 1 })
.select("-registrations") // Don't expose registration details in list view
.lean();
// Add computed fields const requesterEmail = requester?.email?.toLowerCase();
const eventsWithMeta = events.map((event) => ({ const requesterId = requester?._id?.toString();
...event,
// Add computed fields; strip registrations from the response.
const eventsWithMeta = events.map((event) => {
const regs = event.registrations || [];
const isRegistered =
!!requester &&
regs.some(
(r) =>
!r.cancelledAt &&
((r.memberId && r.memberId.toString() === requesterId) ||
(r.email && r.email.toLowerCase() === requesterEmail)),
);
const { registrations, ...rest } = event;
return {
...rest,
id: event._id.toString(), id: event._id.toString(),
registeredCount: event.registrations?.length || 0, registeredCount: regs.length,
})); isRegistered,
};
});
return eventsWithMeta; return eventsWithMeta;
} catch (error) { } catch (error) {