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

464 lines
No EOL
18 KiB
Vue

<template>
<div v-if="pending" class="min-h-screen flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading event details...</p>
</div>
</div>
<div v-else-if="error" class="min-h-screen flex items-center justify-center">
<div class="text-center">
<Icon name="heroicons:exclamation-triangle" class="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Event Not Found</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">The event you're looking for doesn't exist.</p>
<NuxtLink to="/events" class="text-blue-600 dark:text-blue-400 hover:underline">
Back to Events
</NuxtLink>
</div>
</div>
<div v-else>
<!-- Feature Image Header -->
<div v-if="event.featureImage && (event.featureImage.publicId || event.featureImage.url)" class="relative h-96 overflow-hidden">
<img
:src="getImageUrl(event.featureImage)"
:alt="event.featureImage.alt || event.title"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<div class="absolute inset-0" style="background-color: rgba(0, 0, 0, 0.4);"></div>
<div class="absolute inset-0 flex items-center">
<UContainer>
<div class="max-w-4xl">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
{{ event.title }}
</h1>
<p v-if="event.tagline" class="text-xl text-gray-200">
{{ event.tagline }}
</p>
</div>
</UContainer>
</div>
</div>
<!-- Page Header (fallback when no image) -->
<PageHeader
v-else
:title="event.title"
:subtitle="event.tagline"
theme="blue"
size="medium"
/>
<!-- Event Details Section -->
<section class="py-16 bg-white dark:bg-gray-900">
<UContainer>
<div class="max-w-4xl mx-auto">
<!-- Event Meta Info -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 mb-8 border border-blue-200 dark:border-blue-800">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Date</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatDate(event.startDate) }}</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Time</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ formatTime(event.startDate, event.endDate) }}</p>
</div>
</div>
<div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-600 dark:text-blue-400" />
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Location</p>
<p class="font-semibold text-gray-900 dark:text-white">{{ event.location }}</p>
</div>
</div>
</div>
</div>
<!-- Event Cancelled Notice -->
<div v-if="event.isCancelled" class="mb-8">
<div class="p-6 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-200 dark:border-red-800">
<div class="flex items-start">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-red-600 dark:text-red-400 mr-3 mt-0.5" />
<div>
<h3 class="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">Event Cancelled</h3>
<p class="text-red-600 dark:text-red-400" v-if="event.cancellationMessage">
{{ event.cancellationMessage }}
</p>
<p class="text-red-600 dark:text-red-400" v-else>
This event has been cancelled. We apologize for any inconvenience.
</p>
</div>
</div>
</div>
</div>
<!-- Member-Only Badge -->
<div v-if="event.membersOnly" class="mb-8">
<div class="inline-flex items-center px-4 py-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Icon name="heroicons:lock-closed" class="w-5 h-5 text-purple-600 dark:text-purple-400 mr-2" />
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
Members Only Event - Open to all circles and contribution levels
</span>
</div>
</div>
<!-- Target Circles -->
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="mb-8">
<div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-600 dark:text-blue-400" />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Recommended for:</span>
<div class="flex flex-wrap gap-2">
<span
v-for="circle in event.targetCircles"
:key="circle"
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ formatCircleName(circle) }}
</span>
</div>
</div>
</div>
<!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">About This Event</h2>
<p class="text-gray-700 dark:text-gray-300">{{ event.description }}</p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Event Agenda</h3>
<ul class="space-y-3">
<li v-for="(item, index) in event.agenda" :key="index" class="flex items-start">
<span class="inline-block w-6 h-6 bg-blue-500 text-white text-sm rounded-full flex items-center justify-center mr-3 mt-0.5">
{{ index + 1 }}
</span>
<span class="text-gray-700 dark:text-gray-300">{{ item }}</span>
</li>
</ul>
</div>
<div v-if="event.speakers && event.speakers.length > 0" class="mt-8">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Speakers</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="speaker in event.speakers" :key="speaker.name" class="flex items-start space-x-4">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon name="heroicons:user" class="w-8 h-8 text-gray-400 dark:text-gray-500" />
</div>
<div>
<p class="font-semibold text-gray-900 dark:text-white">{{ speaker.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ speaker.role }}</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">{{ speaker.bio }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Registration Section -->
<div v-if="!event.isCancelled" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-8 border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">Register for This Event</h3>
<!-- Registration Status -->
<div v-if="registrationStatus === 'registered'" class="mb-6">
<div class="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<Icon name="heroicons:check-circle" class="w-6 h-6 text-green-600 dark:text-green-400 mr-3" />
<div>
<p class="font-semibold text-green-700 dark:text-green-300">You're registered!</p>
<p class="text-sm text-green-600 dark:text-green-400">We've sent a confirmation to your email</p>
</div>
</div>
</div>
<!-- Member Gate Warning -->
<div v-if="event.membersOnly && !isMember && registrationStatus !== 'registered'" class="mb-6">
<div class="flex items-start p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6 text-amber-600 dark:text-amber-400 mr-3 mt-0.5" />
<div>
<p class="font-semibold text-amber-700 dark:text-amber-300">Membership Required</p>
<p class="text-sm text-amber-600 dark:text-amber-400 mt-1">
This event is exclusive to Ghost Guild members. Join any circle to gain access.
</p>
<NuxtLink to="/join" class="inline-flex items-center text-sm font-medium text-amber-700 dark:text-amber-300 hover:underline mt-2">
Become a member
<Icon name="heroicons:arrow-right" class="w-4 h-4 ml-1" />
</NuxtLink>
</div>
</div>
</div>
<!-- Registration Form -->
<form v-if="registrationStatus !== 'registered'" @submit.prevent="handleRegistration" class="space-y-4">
<!-- Show form fields only for public events OR for logged-in members -->
<template v-if="!event.membersOnly || isMember">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label for="membershipLevel" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
</template>
<div class="pt-4">
<UButton
v-if="!event.membersOnly || isMember"
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
</UButton>
<NuxtLink
v-else
to="/join"
class="block"
>
<UButton
color="primary"
size="lg"
block
>
Become a Member to Register
</UButton>
</NuxtLink>
</div>
</form>
<!-- Event Capacity -->
<div v-if="event.maxAttendees" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div class="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">Questions?</h4>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
If you have any questions about this event, please reach out to our events team.
</p>
<a href="mailto:events@ghostguild.org" class="inline-flex items-center text-blue-600 dark:text-blue-400 hover:underline">
<Icon name="heroicons:envelope" class="w-4 h-4 mr-2" />
events@ghostguild.org
</a>
</div>
</div>
</UContainer>
</section>
</div>
</template>
<script setup>
const route = useRoute()
const toast = useToast()
// Fetch event data from API
const { data: event, pending, error } = await useFetch(`/api/events/${route.params.id}`)
// Handle event not found
if (error.value?.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Authentication
const { isMember, memberData, checkMemberStatus } = useAuth()
// Check member status on mount
onMounted(async () => {
await checkMemberStatus()
// Pre-fill form if member is logged in
if (memberData.value) {
registrationForm.value.name = memberData.value.name
registrationForm.value.email = memberData.value.email
registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member'
}
})
// Registration form state
const registrationForm = ref({
name: '',
email: '',
membershipLevel: 'non-member'
})
const membershipOptions = [
{ label: 'Non-member', value: 'non-member' },
{ label: 'Circle of Community', value: 'community' },
{ label: 'Circle of Founders', value: 'founder' },
{ label: 'Circle of Practitioners', value: 'practitioner' }
]
const isRegistering = ref(false)
const registrationStatus = ref('not-registered') // 'not-registered', 'registered'
// Format date for display
const formatDate = (dateString) => {
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date)
}
// Format time range for display
const formatTime = (startDate, endDate) => {
const start = new Date(startDate)
const end = new Date(endDate)
const timeFormat = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
})
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`
}
// Format circle name for display
const formatCircleName = (circleValue) => {
const circleNames = {
'community': 'Community Circle',
'founder': 'Founder Circle',
'practitioner': 'Practitioner Circle'
}
return circleNames[circleValue] || circleValue
}
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return ''
const config = useRuntimeConfig()
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
}
// Get image URL with fallback logic
const getImageUrl = (featureImage) => {
if (!featureImage) return ''
// If we have a direct URL, use it as primary (since seed data uses external URLs)
if (featureImage.url) {
return featureImage.url
}
// Fallback to Cloudinary if we have a publicId
if (featureImage.publicId) {
return getOptimizedImageUrl(featureImage.publicId, 'w_1200,h_400,c_fill')
}
return ''
}
// Handle image loading errors
const handleImageError = (event) => {
console.warn('Image failed to load:', event.target.src)
// Optionally hide the image container or show a placeholder
}
// Handle registration submission
const handleRegistration = async () => {
isRegistering.value = true
try {
// Submit registration to API using slug or ID
const response = await $fetch(`/api/events/${route.params.id}/register`, {
method: 'POST',
body: registrationForm.value
})
// Update registration status
registrationStatus.value = 'registered'
// Show success toast
toast.add({
title: 'Registration Successful!',
description: `You're registered for ${event.value.title}. Check your email for confirmation.`,
color: 'green'
})
// Update registered count
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++
}
} catch (error) {
console.error('Registration failed:', error)
// Handle specific error messages
const errorMessage = error.data?.statusMessage || 'Something went wrong. Please try again.'
toast.add({
title: 'Registration Failed',
description: errorMessage,
color: 'red'
})
} finally {
isRegistering.value = false
}
}
// SEO Meta
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>