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

View file

@ -30,10 +30,6 @@
<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>
@ -85,7 +81,7 @@
<!-- Description -->
<div class="section">
<h2>About This Event</h2>
<p>{{ event.description }}</p>
<div class="prose" v-html="renderMarkdown(event.description)" />
</div>
<!-- Series Description -->
@ -94,17 +90,23 @@
class="section"
>
<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>
<!-- Agenda -->
<div v-if="event.agenda?.length" class="section">
<h2>Agenda</h2>
<ol class="agenda-list">
<ul class="agenda-list">
<li v-for="(item, index) in event.agenda" :key="index">
{{ item }}
</li>
</ol>
</ul>
</div>
<!-- Speakers -->
@ -143,7 +145,7 @@
<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>
<span class="detail-val">{{ eventTypeLabel(event.eventType) }}</span>
</div>
<div v-if="event.membersOnly" class="detail-row">
<span class="detail-key">Members only</span>
@ -169,6 +171,8 @@
</template>
<script setup>
import { eventTypeLabel } from "~/config/eventTypes";
const route = useRoute();
const toast = useToast();
@ -190,6 +194,7 @@ if (error.value?.statusCode === 404) {
const { memberData, checkMemberStatus } = useAuth();
const { trackGoal, isComplete } = useOnboarding();
const { render: renderMarkdown } = useMarkdown();
onMounted(async () => {
await checkMemberStatus();
@ -232,16 +237,12 @@ 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",
},
],
useSiteMeta(() => ({
title: event.value ? `${event.value.title} · Events` : "Event",
description:
event.value?.description || "View event details and register.",
type: "article",
image: event.value?.slug ? `/og/events/${event.value.slug}.png` : undefined,
}));
</script>
@ -349,12 +350,79 @@ useHead(() => ({
margin-bottom: 8px;
}
.section p {
font-size: 12px;
font-size: 14px;
color: var(--text-dim);
line-height: 1.7;
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 {
display: flex;
gap: 6px;
@ -367,10 +435,27 @@ useHead(() => ({
}
.agenda-list {
padding-left: 20px;
font-size: 12px;
list-style: none;
padding: 0;
margin: 8px 0 0;
font-size: 14px;
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 {

View file

@ -45,34 +45,21 @@
<span v-if="event.isCancelled" class="cancelled-tag"
>cancelled</span
>
<span v-if="event.isRegistered" class="registered-tag"
>Registered</span
>
</div>
<div v-if="event.tagline" class="event-tagline">
{{ event.tagline }}
</div>
<div class="event-sub">
<span v-if="event.eventType" class="event-type-tag">{{
event.eventType
eventTypeLabel(event.eventType)
}}</span>
<span v-if="event.eventType" class="sep">·</span>
<span class="event-location">{{ formatLocation(event) }}</span>
</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">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" />
@ -119,15 +106,20 @@
</template>
<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 includePastEvents = ref(false);
const filterOptions = [
{ label: "All", value: "all" },
{ label: "Workshops", value: "workshop" },
{ label: "Community", value: "community" },
{ label: "Social", value: "social" },
{ label: "Showcase", value: "showcase" },
...EVENT_TYPES.map((t) => ({ label: t.label, value: t.value })),
];
const { data: eventsData } = await useFetch("/api/events");
@ -168,6 +160,7 @@ const formatTime = (event) => {
return new Date(event.startDate).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
timeZone: event.displayTimezone || "America/Toronto",
});
};
@ -181,16 +174,6 @@ const formatLocation = (event) => {
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>
<style scoped>
@ -221,7 +204,7 @@ const isAlmostFull = (event) => {
.event-row {
display: grid;
grid-template-columns: 90px 1fr auto auto;
grid-template-columns: 90px 1fr auto;
gap: 16px;
align-items: start;
padding: 14px 0;
@ -293,6 +276,16 @@ const isAlmostFull = (event) => {
line-height: 1.5;
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 {
font-size: 11px;
@ -321,35 +314,6 @@ const isAlmostFull = (event) => {
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 {
display: flex;
flex-direction: column;
@ -481,7 +445,6 @@ const isAlmostFull = (event) => {
grid-template-columns: 70px 1fr;
gap: 8px;
}
.event-capacity,
.event-badges {
display: none;
}