Enhance application structure: Add runtime configuration for environment variables, integrate new dependencies for Cloudinary and UI components, and refactor member management features including improved forms and member dashboard. Update styles and layout for better user experience.
This commit is contained in:
parent
6e7e27ac4e
commit
e4a0a9ab0f
61 changed files with 7902 additions and 950 deletions
417
app/pages/events/[id].vue
Normal file
417
app/pages/events/[id].vue
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
<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="event.featureImage.publicId
|
||||
? getOptimizedImageUrl(event.featureImage.publicId, 'w_1200,h_400,c_fill')
|
||||
: event.featureImage.url"
|
||||
:alt="event.featureImage.alt || event.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-40"></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">
|
||||
<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
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
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
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
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"
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:disabled="event.membersOnly && !isMember"
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{ isRegistering ? 'Registering...' : 'Register for Event' }}
|
||||
</UButton>
|
||||
</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'
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is a member (this would normally come from auth/store)
|
||||
const isMember = ref(false) // Set to true if user is logged in and is a 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}`
|
||||
}
|
||||
|
||||
// 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue