511 lines
18 KiB
Vue
511 lines
18 KiB
Vue
<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">← Back to Events</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else>
|
||
<!-- BACK LINK -->
|
||
<div class="back-link">
|
||
<NuxtLink to="/events">← 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>
|
||
— 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>
|