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:
parent
2ffaf0ef09
commit
622cc8e53b
4 changed files with 176 additions and 118 deletions
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue