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

View file

@ -139,12 +139,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2">
Start Date & Time <span class="text-red-500">*</span>
</label>
<input
<NaturalDateInput
v-model="eventForm.startDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
:required="true"
:input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
/>
<p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p>
</div>
@ -153,12 +152,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2">
End Date & Time <span class="text-red-500">*</span>
</label>
<input
<NaturalDateInput
v-model="eventForm.endDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
:required="true"
:input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
/>
<p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p>
</div>
@ -177,11 +175,9 @@
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label>
<input
<NaturalDateInput
v-model="eventForm.registrationDeadline"
type="datetime-local"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
/>
<p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p>
</div>
@ -236,6 +232,273 @@
</div>
</div>
<!-- Ticketing -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Ticketing</h2>
<div class="space-y-6">
<label class="flex items-start">
<input
v-model="eventForm.tickets.enabled"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Enable Ticketing</span>
<p class="text-xs text-gray-500">Allow ticket sales for this event</p>
</div>
</label>
<div v-if="eventForm.tickets.enabled" class="ml-6 space-y-4 p-4 bg-gray-50 rounded-lg">
<label class="flex items-start">
<input
v-model="eventForm.tickets.public.available"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Public Tickets Available</span>
<p class="text-xs text-gray-500">Allow non-members to purchase tickets</p>
</div>
</label>
<div v-if="eventForm.tickets.public.available" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ticket Name</label>
<input
v-model="eventForm.tickets.public.name"
type="text"
placeholder="e.g., General Admission"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Price (CAD)</label>
<input
v-model="eventForm.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p class="mt-1 text-xs text-gray-500">Set to 0 for free public events</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ticket Description</label>
<textarea
v-model="eventForm.tickets.public.description"
placeholder="What's included with this ticket..."
rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Quantity Available</label>
<input
v-model="eventForm.tickets.public.quantity"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Price (Optional)</label>
<input
v-model="eventForm.tickets.public.earlyBirdPrice"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div v-if="eventForm.tickets.public.earlyBirdPrice > 0">
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Deadline</label>
<div class="md:w-1/2">
<NaturalDateInput
v-model="eventForm.tickets.public.earlyBirdDeadline"
placeholder="e.g., '1 week before event', 'next Monday'"
/>
</div>
<p class="mt-1 text-xs text-gray-500">Price increases to regular price after this date</p>
</div>
</div>
</div>
<div class="p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-700">
<strong>Note:</strong> Members always get free access to all events regardless of ticket settings.
</p>
</div>
</div>
</div>
<!-- Series Management -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Series Management</h2>
<div class="space-y-4">
<label class="flex items-start">
<input
v-model="eventForm.series.isSeriesEvent"
type="checkbox"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-gray-700">Part of Event Series</span>
<p class="text-xs text-gray-500">This event is part of a multi-event series</p>
</div>
</label>
<div v-if="eventForm.series.isSeriesEvent" class="ml-6 space-y-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Select Series <span class="text-red-500">*</span>
</label>
<div class="flex gap-2">
<select
v-model="selectedSeriesId"
@change="onSeriesSelect"
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Choose existing series or create new...</option>
<option v-for="series in availableSeries" :key="series.id" :value="series.id">
{{ series.title }} ({{ series.eventCount || 0 }} events)
</option>
</select>
<NuxtLink
to="/admin/series/create"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium whitespace-nowrap"
>
New Series
</NuxtLink>
</div>
<p class="text-xs text-gray-500 mt-1">
Select an existing series or create a new one
</p>
</div>
<div v-if="selectedSeriesId || eventForm.series.id" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series ID <span class="text-red-500">*</span>
</label>
<input
v-model="eventForm.series.id"
type="text"
placeholder="e.g., coop-dev-fundamentals"
required
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
@input="!selectedSeriesId && checkExistingSeries()"
/>
<p class="text-xs text-gray-500 mt-1">
{{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Position in Series <span class="text-red-500">*</span>
</label>
<input
v-model.number="eventForm.series.position"
type="number"
min="1"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p class="text-xs text-gray-500 mt-1">Order within the series (1, 2, 3, etc.)</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Title <span class="text-red-500">*</span>
</label>
<input
v-model="eventForm.series.title"
type="text"
placeholder="e.g., Cooperative Game Development Fundamentals"
required
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
/>
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Descriptive name for the entire series' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Series Description <span class="text-red-500">*</span>
</label>
<textarea
v-model="eventForm.series.description"
placeholder="Describe what the series covers and its goals"
required
rows="3"
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
></textarea>
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Series Type</label>
<select
v-model="eventForm.series.type"
:disabled="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
>
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
<option value="tournament">Tournament</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Total Events Planned</label>
<input
v-model.number="eventForm.series.totalEvents"
type="number"
min="1"
placeholder="e.g., 4"
:readonly="selectedSeriesId"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'bg-gray-100': selectedSeriesId }"
/>
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}</p>
</div>
</div>
<div v-if="selectedSeriesId" class="p-3 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-700">
<strong>Note:</strong> This event will be added to the existing "{{ eventForm.series.title }}" series.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Event Settings -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2>
@ -354,6 +617,10 @@ const editingEvent = ref(null)
const showSuccessMessage = ref(false)
const formErrors = ref([])
const fieldErrors = ref({})
const seriesExists = ref(false)
const existingSeries = ref(null)
const selectedSeriesId = ref('')
const availableSeries = ref([])
const eventForm = reactive({
title: '',
@ -371,9 +638,63 @@ const eventForm = reactive({
targetCircles: [],
maxAttendees: '',
registrationRequired: false,
registrationDeadline: ''
registrationDeadline: '',
tickets: {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
})
// Load available series
onMounted(async () => {
try {
const response = await $fetch('/api/admin/series')
availableSeries.value = response
} catch (error) {
console.error('Failed to load series:', error)
}
})
// Handle series selection
const onSeriesSelect = () => {
if (selectedSeriesId.value) {
const series = availableSeries.value.find(s => s.id === selectedSeriesId.value)
if (series) {
eventForm.series.id = series.id
eventForm.series.title = series.title
eventForm.series.description = series.description
eventForm.series.type = series.type
eventForm.series.totalEvents = series.totalEvents
eventForm.series.position = (series.eventCount || 0) + 1
}
} else {
// Reset series form when no series is selected
eventForm.series.id = ''
eventForm.series.title = ''
eventForm.series.description = ''
eventForm.series.type = 'workshop_series'
eventForm.series.position = 1
eventForm.series.totalEvents = null
}
}
// Check if we're editing an event
if (route.query.edit) {
try {
@ -398,8 +719,33 @@ if (route.query.edit) {
targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || '',
registrationRequired: event.registrationRequired,
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '',
tickets: event.tickets || {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: event.series || {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
})
// Handle early bird deadline formatting
if (event.tickets?.public?.earlyBirdDeadline) {
eventForm.tickets.public.earlyBirdDeadline = new Date(event.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16)
}
}
} catch (error) {
console.error('Failed to load event for editing:', error)
@ -420,6 +766,20 @@ if (route.query.duplicate && process.client) {
}
}
// Check if we're creating a series event
if (route.query.series && process.client) {
const seriesData = sessionStorage.getItem('seriesEventData')
if (seriesData) {
try {
const data = JSON.parse(seriesData)
Object.assign(eventForm, data)
sessionStorage.removeItem('seriesEventData')
} catch (error) {
console.error('Failed to load series event data:', error)
}
}
}
const validateForm = () => {
formErrors.value = []
fieldErrors.value = {}
@ -491,6 +851,63 @@ const validateForm = () => {
return formErrors.value.length === 0
}
// Check if a series with this ID already exists
const checkExistingSeries = async () => {
if (!eventForm.series.id || selectedSeriesId.value) {
seriesExists.value = false
existingSeries.value = null
return
}
try {
// First check in standalone series
const standaloneResponse = await $fetch(`/api/admin/series`)
const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id)
if (existingStandalone) {
seriesExists.value = true
existingSeries.value = existingStandalone
// Auto-fill series details
if (!eventForm.series.title || eventForm.series.title === '') {
eventForm.series.title = existingStandalone.title
}
if (!eventForm.series.description || eventForm.series.description === '') {
eventForm.series.description = existingStandalone.description
}
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
eventForm.series.type = existingStandalone.type
}
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
eventForm.series.totalEvents = existingStandalone.totalEvents
}
return
}
// Fallback to legacy series check (events with series data)
const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`)
if (legacyResponse) {
seriesExists.value = true
existingSeries.value = legacyResponse
if (!eventForm.series.title || eventForm.series.title === '') {
eventForm.series.title = legacyResponse.title
}
if (!eventForm.series.description || eventForm.series.description === '') {
eventForm.series.description = legacyResponse.description
}
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
eventForm.series.type = legacyResponse.type
}
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
eventForm.series.totalEvents = legacyResponse.totalEvents
}
}
} catch (error) {
// Series doesn't exist yet
seriesExists.value = false
existingSeries.value = null
}
}
const saveEvent = async (redirect = true) => {
if (!validateForm()) {
// Scroll to top to show errors
@ -500,6 +917,27 @@ const saveEvent = async (redirect = true) => {
creating.value = true
try {
// If this is a series event and not using an existing series, create the standalone series first
if (eventForm.series.isSeriesEvent && eventForm.series.id && !selectedSeriesId.value) {
try {
await $fetch('/api/admin/series', {
method: 'POST',
body: {
id: eventForm.series.id,
title: eventForm.series.title,
description: eventForm.series.description,
type: eventForm.series.type,
totalEvents: eventForm.series.totalEvents
}
})
} catch (seriesError) {
// Series might already exist, that's ok
if (!seriesError.data?.statusMessage?.includes('already exists')) {
throw seriesError
}
}
}
if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: 'PUT',
@ -552,7 +990,28 @@ const saveAndCreateAnother = async () => {
targetCircles: [],
maxAttendees: '',
registrationRequired: false,
registrationDeadline: ''
registrationDeadline: '',
tickets: {
enabled: false,
public: {
available: false,
name: 'Public Ticket',
description: '',
price: 0,
quantity: null,
earlyBirdPrice: null,
earlyBirdDeadline: ''
}
},
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
})
// Clear any existing errors

View file

@ -27,6 +27,11 @@
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="">All Events</option>
<option value="series-only">Series Events Only</option>
<option value="standalone-only">Standalone Only</option>
</select>
</div>
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
@ -77,6 +82,14 @@
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
<div class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full">
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<div class="flex items-center space-x-4 mt-2">
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600">
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
@ -193,6 +206,7 @@ const { data: events, pending, error, refresh } = await useFetch("/api/admin/eve
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const seriesFilter = ref('')
const filteredEvents = computed(() => {
if (!events.value) return []
@ -207,7 +221,11 @@ const filteredEvents = computed(() => {
const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
return matchesSearch && matchesType && matchesStatus
const matchesSeries = !seriesFilter.value ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})