ghostguild-org/app/pages/events/[id].vue

511 lines
18 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>
<!-- BACK LINK -->
<div class="back-link">
<NuxtLink to="/events">&larr; Back to Events</NuxtLink>
</div>
<!-- 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>
{{ event.location }}
</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>
<!-- 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
v-if="event.tickets?.enabled"
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Legacy Registration -->
<template v-else>
<!-- Already Registered -->
<div v-if="registrationStatus === 'registered'" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--green);">You're registered!</p>
<p class="reg-price">Confirmation sent to your email</p>
<button class="btn btn-danger" @click="handleCancelRegistration" :disabled="isCancelling">
{{ isCancelling ? 'Cancelling...' : 'Cancel Registration' }}
</button>
</div>
<!-- Member Status Issues -->
<div v-else-if="memberData && !canRSVP" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember);">{{ statusConfig.label }}</p>
<p class="reg-price">{{ getRSVPMessage }}</p>
<NuxtLink v-if="isPendingPayment" to="#" @click.prevent="completePayment">
<button class="btn btn-primary" :disabled="isProcessingPayment">
{{ isProcessingPayment ? 'Processing...' : 'Complete Payment' }}
</button>
</NuxtLink>
</div>
<!-- Members-Only Gate -->
<div v-else-if="event.membersOnly && memberData && !isMember" class="dashed-box">
<div class="box-title">Registration</div>
<p class="reg-status" style="color: var(--ember);">Membership Required</p>
<p class="reg-price">This event is exclusive to members.</p>
<NuxtLink to="/join"><button class="btn btn-primary">Become a Member</button></NuxtLink>
</div>
<!-- Can Register (logged in) -->
<div v-else-if="memberData && (!event.membersOnly || isMember)" class="dashed-box">
<div class="box-title">Registration</div>
<div v-if="event.maxAttendees" class="reg-status">
{{ event.maxAttendees - (event.registeredCount || 0) }} spots remaining
</div>
<div class="reg-price">Free for members</div>
<button class="btn btn-primary" @click="handleRegistration" :disabled="isRegistering">
{{ isRegistering ? 'Registering...' : 'Register for this event' }}
</button>
<a :href="`/api/events/${route.params.id}/calendar`" download class="cal-link">Add to calendar</a>
</div>
<!-- Not Logged In -->
<div v-else class="dashed-box">
<div class="box-title">Registration</div>
<form @submit.prevent="handleRegistration">
<div class="field">
<label>Name</label>
<input v-model="registrationForm.name" type="text" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="registrationForm.email" type="email" required />
</div>
<button type="submit" class="btn btn-primary" :disabled="isRegistering">
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
</button>
</form>
</div>
<!-- Waitlist -->
<div v-if="event.tickets?.waitlist?.enabled && isEventFull" class="dashed-box">
<div class="box-title">Waitlist</div>
<div v-if="isOnWaitlist">
<p class="reg-status">You're on the waitlist (#{{ waitlistPosition }})</p>
<button class="btn" @click="handleLeaveWaitlist" :disabled="isJoiningWaitlist">Leave Waitlist</button>
</div>
<div v-else>
<p class="reg-status" style="color: var(--ember);">This event is full</p>
<form @submit.prevent="handleJoinWaitlist">
<div v-if="!memberData" class="field">
<label>Email</label>
<input v-model="waitlistForm.email" type="email" required />
</div>
<button type="submit" class="btn" :disabled="isJoiningWaitlist">
{{ isJoiningWaitlist ? 'Joining...' : 'Join Waitlist' }}
</button>
</form>
</div>
</div>
</template>
<!-- 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.id}`)
if (error.value?.statusCode === 404) {
throw createError({ statusCode: 404, statusMessage: 'Event not found' })
}
const { isMember, memberData, checkMemberStatus } = useAuth()
const { isPendingPayment, isSuspended, isCancelled, canRSVP, statusConfig, getRSVPMessage } = useMemberStatus()
const { completePayment, isProcessingPayment } = useMemberPayment()
onMounted(async () => {
await checkMemberStatus()
if (memberData.value) {
registrationForm.value.name = memberData.value.name
registrationForm.value.email = memberData.value.email
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
await checkRegistrationStatus()
checkWaitlistStatus()
}
})
const checkRegistrationStatus = async () => {
if (!memberData.value?.email) return
try {
const response = await $fetch(`/api/events/${route.params.id}/check-registration`, {
method: 'POST',
body: { email: memberData.value.email },
})
if (response.isRegistered) registrationStatus.value = 'registered'
} catch (err) {
console.error('Failed to check registration status:', err)
}
}
const registrationForm = ref({ name: '', email: '', membershipLevel: 'non-member' })
const isRegistering = ref(false)
const isCancelling = ref(false)
const registrationStatus = ref('not-registered')
const isJoiningWaitlist = ref(false)
const isOnWaitlist = ref(false)
const waitlistPosition = ref(0)
const waitlistForm = ref({ email: '' })
const isEventFull = computed(() => {
if (!event.value?.maxAttendees) return false
return (event.value.registeredCount || 0) >= event.value.maxAttendees
})
const checkWaitlistStatus = () => {
const email = memberData.value?.email || waitlistForm.value.email
if (!email || !event.value?.tickets?.waitlist?.enabled) return
const entries = event.value.tickets.waitlist.entries || []
const idx = entries.findIndex((e) => e.email.toLowerCase() === email.toLowerCase())
if (idx !== -1) { isOnWaitlist.value = true; waitlistPosition.value = idx + 1 }
}
const handleJoinWaitlist = async () => {
isJoiningWaitlist.value = true
try {
const email = memberData.value?.email || waitlistForm.value.email
const name = memberData.value?.name || 'Guest'
const response = await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'POST', body: { email, name } })
isOnWaitlist.value = true
waitlistPosition.value = response.position
toast.add({ title: 'Added to Waitlist', description: `You're #${response.position} on the waitlist.`, color: 'orange' })
} catch (err) {
toast.add({ title: "Couldn't Join Waitlist", description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isJoiningWaitlist.value = false }
}
const handleLeaveWaitlist = async () => {
isJoiningWaitlist.value = true
try {
const email = memberData.value?.email || waitlistForm.value.email
await $fetch(`/api/events/${route.params.id}/waitlist`, { method: 'DELETE', body: { email } })
isOnWaitlist.value = false
waitlistPosition.value = 0
toast.add({ title: 'Removed from Waitlist', color: 'blue' })
} catch (err) {
toast.add({ title: 'Error', description: 'Failed to leave waitlist.', color: 'red' })
} finally { isJoiningWaitlist.value = false }
}
const formatDate = (dateStr) => {
const d = new Date(dateStr)
return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(d)
}
const formatTime = (start, end) => {
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
return `${fmt.format(new Date(start))} ${fmt.format(new Date(end))}`
}
const handleRegistration = async () => {
isRegistering.value = true
try {
await $fetch(`/api/events/${route.params.id}/register`, { method: 'POST', body: registrationForm.value })
registrationStatus.value = 'registered'
toast.add({ title: 'Registered!', description: `You're registered for ${event.value.title}.`, color: 'green' })
if (event.value.registeredCount !== undefined) event.value.registeredCount++
} catch (err) {
toast.add({ title: 'Registration Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isRegistering.value = false }
}
const handleCancelRegistration = async () => {
isCancelling.value = true
try {
await $fetch(`/api/events/${route.params.id}/cancel-registration`, {
method: 'POST',
body: { email: registrationForm.value.email || memberData.value?.email },
})
registrationStatus.value = 'not-registered'
toast.add({ title: 'Registration Cancelled', color: 'blue' })
if (event.value.registeredCount !== undefined) event.value.registeredCount--
} catch (err) {
toast.add({ title: 'Cancellation Failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
} finally { isCancelling.value = false }
}
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;
}
.back-link {
padding: 12px 32px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
.back-link a { color: var(--candle); text-decoration: none; }
.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;
}
/* ---- TWO-COLUMN BODY ---- */
.event-body {
display: grid;
grid-template-columns: 1fr 280px;
}
.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;
}
.reg-status { font-size: 13px; color: var(--text); margin-bottom: 4px; }
.reg-price { font-size: 11px; color: var(--text-faint); margin-bottom: 10px; }
.cal-link {
display: block;
margin-top: 8px;
font-size: 11px;
color: var(--candle);
}
.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>