Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience.

This commit is contained in:
Jennie Robinson Faber 2025-08-27 20:40:54 +01:00
parent c3a29fa47c
commit a88aa62198
24 changed files with 2897 additions and 44 deletions

234
app/pages/series/index.vue Normal file
View file

@ -0,0 +1,234 @@
<template>
<div>
<!-- Hero Section -->
<div class="bg-gradient-to-br from-purple-600 via-blue-600 to-emerald-500 py-16">
<UContainer>
<div class="text-center">
<h1 class="text-4xl md:text-6xl font-bold text-white mb-6">
Event Series
</h1>
<p class="text-xl md:text-2xl text-purple-100 max-w-3xl mx-auto">
Discover our multi-event series designed to take you on a journey of learning and growth
</p>
</div>
</UContainer>
</div>
<!-- Series Grid -->
<div class="py-16 bg-gray-50">
<UContainer>
<div v-if="pending" class="text-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
<p class="text-gray-600">Loading series...</p>
</div>
<div v-else-if="filteredSeries.length > 0" class="space-y-8">
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-white rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-300"
>
<!-- Series Header -->
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium',
getSeriesTypeBadgeClass(series.type)
]">
{{ formatSeriesType(series.type) }}
</div>
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
]">
{{ series.status }}
</span>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-2">{{ series.title }}</h2>
<p class="text-gray-600 leading-relaxed">{{ series.description }}</p>
</div>
<div class="text-center md:text-right">
<div class="text-3xl font-bold text-purple-600 mb-1">{{ series.eventCount }}</div>
<div class="text-sm text-gray-500">Events</div>
<div v-if="series.totalEvents" class="text-xs text-gray-400 mt-1">
of {{ series.totalEvents }} planned
</div>
</div>
</div>
</div>
<!-- Events List -->
<div class="divide-y divide-gray-100">
<div
v-for="event in series.events"
:key="event.id"
class="p-4 hover:bg-gray-50 transition-colors duration-200"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4 flex-1">
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
</div>
<div class="flex-1">
<h3 class="font-medium text-gray-900 mb-1">{{ event.title }}</h3>
<div class="flex items-center gap-4 text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
{{ formatEventDate(event.startDate) }}
</div>
<div class="flex items-center gap-1">
<Icon name="heroicons:clock" class="w-4 h-4" />
{{ formatEventTime(event.startDate) }}
</div>
<div v-if="event.registrations?.length" class="flex items-center gap-1">
<Icon name="heroicons:users" class="w-4 h-4" />
{{ event.registrations.length }} registered
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
{{ getEventStatus(event) }}
</span>
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="inline-flex items-center px-3 py-1 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
>
View Event
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Series Footer -->
<div v-if="series.startDate && series.endDate" class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<div class="flex items-center justify-between text-sm text-gray-500">
<div class="flex items-center gap-1">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
Series runs from {{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div v-if="series.totalRegistrations" class="flex items-center gap-1">
<Icon name="heroicons:users" class="w-4 h-4" />
{{ series.totalRegistrations }} total registrations
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-16">
<Icon name="heroicons:squares-2x2" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-900 mb-2">No Event Series Available</h3>
<p class="text-gray-600 max-w-md mx-auto">
We're currently planning exciting event series. Check back soon for multi-event learning journeys!
</p>
</div>
</UContainer>
</div>
</div>
</template>
<script setup>
// SEO
useHead({
title: 'Event Series - Ghost Guild',
meta: [
{ name: 'description', content: 'Discover our multi-event series designed to take you on a journey of learning and growth in cooperative game development and community building.' }
]
})
// Fetch series data
const { data: seriesData, pending } = await useFetch('/api/series', {
query: { includeHidden: false }
})
// Filter for active and upcoming series only
const filteredSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value.filter(series =>
series.status === 'active' || series.status === 'upcoming'
)
})
// Helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700',
'recurring_meetup': 'bg-blue-100 text-blue-700',
'multi_day': 'bg-purple-100 text-purple-700',
'course': 'bg-amber-100 text-amber-700',
'tournament': 'bg-red-100 text-red-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})
}
const formatEventTime = (date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
const formatDateRange = (startDate, endDate) => {
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
return `${formatter.format(start)} to ${formatter.format(end)}`
}
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Completed'
}
const getEventStatusClass = (event) => {
const status = getEventStatus(event)
const classes = {
'Upcoming': 'bg-blue-100 text-blue-700',
'Ongoing': 'bg-green-100 text-green-700',
'Completed': 'bg-gray-100 text-gray-700'
}
return classes[status] || 'bg-gray-100 text-gray-700'
}
</script>