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

@ -0,0 +1,238 @@
<template>
<div class="space-y-2">
<div class="relative">
<input
v-model="naturalInput"
type="text"
:placeholder="placeholder"
:class="[
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
inputClass,
{
'border-green-300 bg-green-50': isValidParse && naturalInput.trim(),
'border-red-300 bg-red-50': hasError && naturalInput.trim()
}
]"
@input="parseNaturalInput"
@blur="onBlur"
/>
<div v-if="naturalInput.trim()" class="absolute right-3 top-2.5">
<Icon
v-if="isValidParse"
name="heroicons:check-circle"
class="w-5 h-5 text-green-500"
/>
<Icon
v-else-if="hasError"
name="heroicons:exclamation-circle"
class="w-5 h-5 text-red-500"
/>
</div>
</div>
<div v-if="parsedDate && isValidParse" class="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
<div class="flex items-center gap-2">
<Icon name="heroicons:calendar" class="w-4 h-4" />
<span>{{ formatParsedDate(parsedDate) }}</span>
</div>
</div>
<div v-if="hasError && naturalInput.trim()" class="text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
<div class="flex items-center gap-2">
<Icon name="heroicons:exclamation-triangle" class="w-4 h-4" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Fallback datetime-local input -->
<details class="text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-900">
Use traditional date picker
</summary>
<div class="mt-2">
<input
v-model="datetimeValue"
type="datetime-local"
:class="[
'w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent',
inputClass
]"
@change="onDatetimeChange"
/>
</div>
</details>
</div>
</template>
<script setup>
import * as chrono from 'chrono-node'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"'
},
inputClass: {
type: String,
default: ''
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const naturalInput = ref('')
const parsedDate = ref(null)
const isValidParse = ref(false)
const hasError = ref(false)
const errorMessage = ref('')
const datetimeValue = ref('')
// Initialize with current value
onMounted(() => {
if (props.modelValue) {
const date = new Date(props.modelValue)
if (!isNaN(date.getTime())) {
parsedDate.value = date
datetimeValue.value = formatForDatetimeLocal(date)
isValidParse.value = true
}
}
})
// Watch for external changes to modelValue
watch(() => props.modelValue, (newValue) => {
if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) {
const date = new Date(newValue)
if (!isNaN(date.getTime())) {
parsedDate.value = date
datetimeValue.value = formatForDatetimeLocal(date)
isValidParse.value = true
naturalInput.value = '' // Clear natural input when set externally
}
} else if (!newValue) {
reset()
}
})
const parseNaturalInput = () => {
const input = naturalInput.value.trim()
if (!input) {
reset()
return
}
try {
// Parse with chrono-node
const results = chrono.parse(input)
if (results.length > 0) {
const result = results[0]
const date = result.date()
// Validate the parsed date
if (date && !isNaN(date.getTime())) {
parsedDate.value = date
isValidParse.value = true
hasError.value = false
datetimeValue.value = formatForDatetimeLocal(date)
emit('update:modelValue', formatForDatetimeLocal(date))
} else {
setError('Could not parse this date format')
}
} else {
setError('Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"')
}
} catch (error) {
setError('Error parsing date')
}
}
const onBlur = () => {
// If we have a valid parse but the input changed, try to parse again
if (naturalInput.value.trim() && !isValidParse.value) {
parseNaturalInput()
}
}
const onDatetimeChange = () => {
if (datetimeValue.value) {
const date = new Date(datetimeValue.value)
if (!isNaN(date.getTime())) {
parsedDate.value = date
isValidParse.value = true
hasError.value = false
naturalInput.value = '' // Clear natural input when using traditional picker
emit('update:modelValue', datetimeValue.value)
}
} else {
reset()
}
}
const reset = () => {
parsedDate.value = null
isValidParse.value = false
hasError.value = false
errorMessage.value = ''
emit('update:modelValue', '')
}
const setError = (message) => {
isValidParse.value = false
hasError.value = true
errorMessage.value = message
parsedDate.value = null
}
const formatForDatetimeLocal = (date) => {
if (!date) return ''
// Format as YYYY-MM-DDTHH:MM for datetime-local input
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const formatParsedDate = (date) => {
if (!date) return ''
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
const isTomorrow = date.toDateString() === tomorrow.toDateString()
const timeStr = date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
if (isToday) {
return `Today at ${timeStr}`
} else if (isTomorrow) {
return `Tomorrow at ${timeStr}`
} else {
return date.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
}
</script>

View file

@ -57,18 +57,18 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/analytics" to="/admin/series-management"
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/analytics') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
]" ]"
> >
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg> </svg>
Analytics Series
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
@ -159,15 +159,15 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/admin/analytics" to="/admin/series-management"
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/analytics') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700' ? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
]" ]"
> >
Analytics Series
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>

View file

@ -139,12 +139,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 mb-2">
Start Date & Time <span class="text-red-500">*</span> Start Date & Time <span class="text-red-500">*</span>
</label> </label>
<input <NaturalDateInput
v-model="eventForm.startDate" v-model="eventForm.startDate"
type="datetime-local" placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
required :required="true"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" :input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
: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> <p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p>
</div> </div>
@ -153,12 +152,11 @@
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-medium text-gray-700 mb-2">
End Date & Time <span class="text-red-500">*</span> End Date & Time <span class="text-red-500">*</span>
</label> </label>
<input <NaturalDateInput
v-model="eventForm.endDate" v-model="eventForm.endDate"
type="datetime-local" placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
required :required="true"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" :input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
: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> <p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p>
</div> </div>
@ -177,11 +175,9 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label> <label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label>
<input <NaturalDateInput
v-model="eventForm.registrationDeadline" v-model="eventForm.registrationDeadline"
type="datetime-local" placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
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"
/> />
<p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p> <p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p>
</div> </div>
@ -236,6 +232,273 @@
</div> </div>
</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 --> <!-- Event Settings -->
<div class="mb-8"> <div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2> <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 showSuccessMessage = ref(false)
const formErrors = ref([]) const formErrors = ref([])
const fieldErrors = ref({}) const fieldErrors = ref({})
const seriesExists = ref(false)
const existingSeries = ref(null)
const selectedSeriesId = ref('')
const availableSeries = ref([])
const eventForm = reactive({ const eventForm = reactive({
title: '', title: '',
@ -371,9 +638,63 @@ const eventForm = reactive({
targetCircles: [], targetCircles: [],
maxAttendees: '', maxAttendees: '',
registrationRequired: false, 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 // Check if we're editing an event
if (route.query.edit) { if (route.query.edit) {
try { try {
@ -398,8 +719,33 @@ if (route.query.edit) {
targetCircles: event.targetCircles || [], targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || '', maxAttendees: event.maxAttendees || '',
registrationRequired: event.registrationRequired, 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) { } catch (error) {
console.error('Failed to load event for editing:', 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 = () => { const validateForm = () => {
formErrors.value = [] formErrors.value = []
fieldErrors.value = {} fieldErrors.value = {}
@ -491,6 +851,63 @@ const validateForm = () => {
return formErrors.value.length === 0 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) => { const saveEvent = async (redirect = true) => {
if (!validateForm()) { if (!validateForm()) {
// Scroll to top to show errors // Scroll to top to show errors
@ -500,6 +917,27 @@ const saveEvent = async (redirect = true) => {
creating.value = true creating.value = true
try { 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) { if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, { await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: 'PUT', method: 'PUT',
@ -552,7 +990,28 @@ const saveAndCreateAnother = async () => {
targetCircles: [], targetCircles: [],
maxAttendees: '', maxAttendees: '',
registrationRequired: false, 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 // Clear any existing errors

View file

@ -27,6 +27,11 @@
<option value="ongoing">Ongoing</option> <option value="ongoing">Ongoing</option>
<option value="past">Past</option> <option value="past">Past</option>
</select> </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> </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"> <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" /> <Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
@ -77,6 +82,14 @@
<div class="flex-1 min-w-0"> <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 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 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 class="flex items-center space-x-4 mt-2">
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600"> <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" /> <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 searchQuery = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const statusFilter = ref('') const statusFilter = ref('')
const seriesFilter = ref('')
const filteredEvents = computed(() => { const filteredEvents = computed(() => {
if (!events.value) return [] if (!events.value) return []
@ -207,7 +221,11 @@ const filteredEvents = computed(() => {
const eventStatus = getEventStatus(event) const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value 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
}) })
}) })

View file

@ -0,0 +1,504 @@
<template>
<div>
<div class="bg-white border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
<p class="text-gray-600">Manage event series and their relationships</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Series Overview -->
<div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full">
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Active Series</p>
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Series Events</p>
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full">
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Avg Events/Series</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions Bar -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input
v-model="searchQuery"
placeholder="Search series..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="upcoming">Upcoming</option>
<option value="completed">Completed</option>
</select>
</div>
<div class="flex gap-3">
<button
@click="showBulkModal = true"
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center"
>
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 mr-2" />
Bulk Operations
</button>
<NuxtLink
to="/admin/series/create"
class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
Create Series
</NuxtLink>
</div>
</div>
<!-- Series List -->
<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-6">
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-white rounded-lg shadow overflow-hidden"
>
<!-- Series Header -->
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
{{ formatSeriesType(series.type) }}
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
<p class="text-sm text-gray-600">{{ series.description }}</p>
</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',
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>
<span class="text-sm text-gray-500">
{{ series.eventCount }} events
</span>
</div>
</div>
</div>
<!-- Series Events -->
<div class="divide-y divide-gray-200">
<div
v-for="event in series.events"
:key="event.id"
class="px-6 py-4 hover:bg-gray-50"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
</div>
<div>
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
{{ getEventStatus(event) }}
</span>
<div class="flex gap-1">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
@click="editEvent(event)"
class="p-1 text-gray-400 hover:text-purple-600 rounded"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
@click="removeFromSeries(event)"
class="p-1 text-gray-400 hover:text-red-600 rounded"
title="Remove from Series"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Series Actions -->
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200">
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="addEventToSeries(series)"
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Add Event
</button>
<button
@click="duplicateSeries(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Duplicate Series
</button>
<button
@click="deleteSeries(series)"
class="text-sm text-red-600 hover:text-red-700 font-medium"
>
Delete Series
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p class="text-gray-600">No event series found</p>
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
</div>
</div>
<!-- Bulk Operations Modal -->
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
<div class="space-y-3">
<button
@click="reorderAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
</div>
</div>
</button>
<button
@click="validateAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
<p class="text-xs text-gray-500">Check for consistency issues</p>
</div>
</div>
</button>
<button
@click="exportSeriesData"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
<div>
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
<p class="text-xs text-gray-500">Download series information as JSON</p>
</div>
</div>
</button>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
@click="showBulkModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-700"
>
Close
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
const showBulkModal = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
// Fetch series data
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
// Computed properties
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value
})
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
})
const filteredSeries = computed(() => {
if (!activeSeries.value) return []
return activeSeries.value.filter(series => {
const matchesSearch = !searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
return matchesSearch && matchesStatus
})
})
// 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', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric'
})
return `${formatter.format(start)} - ${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'
}
// Actions
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
}
})
await refresh()
} catch (error) {
console.error('Failed to remove event from series:', error)
alert('Failed to remove event from series')
}
}
const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled
const seriesData = {
series: {
isSeriesEvent: true,
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents
}
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
navigateTo('/admin/events/create?series=true')
}
const duplicateSeries = (series) => {
// TODO: Implement series duplication
alert('Series duplication coming soon!')
}
const deleteSeries = async (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
try {
// Update all events to remove series relationship
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
position: 1,
totalEvents: null
}
}
})
}
await refresh()
alert('Series deleted and events converted to standalone events')
} catch (error) {
console.error('Failed to delete series:', error)
alert('Failed to delete series')
}
}
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering
alert('Auto-reorder feature coming soon!')
}
const validateAllSeries = async () => {
// TODO: Implement validation
alert('Validation feature coming soon!')
}
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'event-series-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
</script>

View file

@ -0,0 +1,268 @@
<template>
<div>
<div class="bg-white border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<div class="flex items-center gap-4 mb-2">
<NuxtLink to="/admin/series-management" class="text-gray-500 hover:text-gray-700">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900">Create New Series</h1>
</div>
<p class="text-gray-600">Create a new event series to group related events together</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Error Summary -->
<div v-if="formErrors.length > 0" class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-red-500 mr-3 mt-0.5" />
<div>
<h3 class="text-sm font-medium text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="text-sm text-red-700 space-y-1">
<li v-for="error in formErrors" :key="error"> {{ error }}</li>
</ul>
</div>
</div>
</div>
<!-- Success Message -->
<div v-if="showSuccessMessage" class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-500 mr-3 mt-0.5" />
<div>
<h3 class="text-sm font-medium text-green-800">Series created successfully!</h3>
</div>
</div>
</div>
<form @submit.prevent="createSeries">
<!-- Series Information -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Series Information</h2>
<div class="grid grid-cols-1 gap-6">
<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="seriesForm.title"
type="text"
placeholder="e.g., Cooperative Game Development Fundamentals"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.title }"
@input="generateSlugFromTitle"
/>
<p v-if="fieldErrors.title" class="mt-1 text-sm text-red-600">{{ fieldErrors.title }}</p>
</div>
<div v-if="generatedSlug">
<label class="block text-sm font-medium text-gray-700 mb-2">Generated Series ID</label>
<div class="w-full bg-gray-100 border border-gray-300 rounded-lg px-3 py-2 text-gray-700 font-mono text-sm">
{{ generatedSlug }}
</div>
<p class="mt-1 text-sm text-gray-500">
This unique identifier will be automatically generated from your title
</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="seriesForm.description"
placeholder="Describe what the series covers and its goals"
required
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.description }"
></textarea>
<p v-if="fieldErrors.description" class="mt-1 text-sm text-red-600">{{ fieldErrors.description }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Series Type</label>
<select
v-model="seriesForm.type"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<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="seriesForm.totalEvents"
type="number"
min="1"
placeholder="e.g., 4"
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-sm text-gray-500 mt-1">How many events will be in this series? (optional)</p>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-between items-center pt-6 border-t border-gray-200">
<NuxtLink
to="/admin/series-management"
class="px-4 py-2 text-gray-600 hover:text-gray-900 font-medium"
>
Cancel
</NuxtLink>
<div class="flex gap-3">
<button
type="button"
@click="createAndAddEvent"
:disabled="creating"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{{ creating ? 'Creating...' : 'Create & Add Event' }}
</button>
<button
type="submit"
:disabled="creating"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{{ creating ? 'Creating...' : 'Create Series' }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
const router = useRouter()
const creating = ref(false)
const showSuccessMessage = ref(false)
const formErrors = ref([])
const fieldErrors = ref({})
const generatedSlug = ref('')
const seriesForm = reactive({
title: '',
description: '',
type: 'workshop_series',
totalEvents: null
})
// Generate slug from title
const generateSlug = (text) => {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters except spaces and dashes
.replace(/\s+/g, '-') // Replace spaces with dashes
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
}
const generateSlugFromTitle = () => {
if (seriesForm.title) {
generatedSlug.value = generateSlug(seriesForm.title)
} else {
generatedSlug.value = ''
}
}
const validateForm = () => {
formErrors.value = []
fieldErrors.value = {}
if (!seriesForm.title.trim()) {
formErrors.value.push('Series title is required')
fieldErrors.value.title = 'Please enter a series title'
}
if (!seriesForm.description.trim()) {
formErrors.value.push('Series description is required')
fieldErrors.value.description = 'Please provide a description for the series'
}
if (!generatedSlug.value) {
formErrors.value.push('Series title must generate a valid ID')
fieldErrors.value.title = 'Please enter a title that can generate a valid series ID'
}
return formErrors.value.length === 0
}
const createSeries = async (redirectAfter = true) => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
}
creating.value = true
try {
const response = await $fetch('/api/admin/series', {
method: 'POST',
body: {
...seriesForm,
id: generatedSlug.value
}
})
showSuccessMessage.value = true
setTimeout(() => { showSuccessMessage.value = false }, 5000)
if (redirectAfter) {
setTimeout(() => {
router.push('/admin/series-management')
}, 1500)
}
return response.data
} catch (error) {
console.error('Failed to create series:', error)
formErrors.value = [`Failed to create series: ${error.data?.statusMessage || error.message}`]
window.scrollTo({ top: 0, behavior: 'smooth' })
return false
} finally {
creating.value = false
}
}
const createAndAddEvent = async () => {
const series = await createSeries(false)
if (series) {
// Navigate to event creation with series pre-filled
const seriesData = {
series: {
isSeriesEvent: true,
id: generatedSlug.value,
title: seriesForm.title,
description: seriesForm.description,
type: seriesForm.type,
position: 1,
totalEvents: seriesForm.totalEvents
}
}
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
router.push('/admin/events/create?series=true')
}
}
</script>

View file

@ -66,6 +66,84 @@
</UContainer> </UContainer>
</section> </section>
<!-- Event Series -->
<section v-if="activeSeries.length > 0" class="py-20 bg-purple-50 dark:bg-purple-900/20">
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-purple-600 dark:text-purple-400 mb-8">
Active Event Series
</h2>
<p class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your knowledge and build community connections.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-white dark:bg-gray-900 rounded-xl p-6 shadow-lg border border-gray-200 dark:border-gray-700"
>
<div class="flex items-start justify-between mb-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-gray-500">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="event in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium">
{{ event.series?.position || '?' }}
</div>
<span class="text-gray-600 dark:text-gray-400 truncate">{{ event.title }}</span>
</div>
<span class="text-gray-500 dark:text-gray-500">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div v-if="series.events.length > 3" class="text-xs text-gray-500 dark:text-gray-500 text-center pt-1">
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="text-gray-500 dark:text-gray-500">
{{ formatDateRange(series.startDate, series.endDate) }}
</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 dark:bg-green-900/30 dark:text-green-400' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
]">
{{ series.status }}
</span>
</div>
</div>
</div>
</UContainer>
</section>
<!-- Upcoming Events --> <!-- Upcoming Events -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="py-20 bg-gray-50 dark:bg-gray-800">
<UContainer> <UContainer>
@ -93,6 +171,17 @@
</div> </div>
<div class="p-6"> <div class="p-6">
<!-- Series Badge -->
<div v-if="event.series?.isSeriesEvent" class="mb-3">
<div class="inline-flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400">
<div class="w-4 h-4 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-bold">
{{ event.series.position }}
</div>
<Icon name="heroicons:squares-2x2" class="w-3 h-3" />
{{ event.series.title }}
</div>
</div>
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div :class="[ <div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium', 'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
@ -278,6 +367,8 @@ import 'vue-cal/style.css'
// Fetch events from API // Fetch events from API
const { data: eventsData, pending, error } = await useFetch('/api/events') const { data: eventsData, pending, error } = await useFetch('/api/events')
// Fetch series from API
const { data: seriesData } = await useFetch('/api/series')
// Transform events for calendar display // Transform events for calendar display
const events = computed(() => { const events = computed(() => {
@ -296,10 +387,19 @@ const events = computed(() => {
location: event.location, location: event.location,
registeredCount: event.registeredCount, registeredCount: event.registeredCount,
maxAttendees: event.maxAttendees, maxAttendees: event.maxAttendees,
featureImage: event.featureImage featureImage: event.featureImage,
series: event.series
})) }))
}) })
// Get active event series
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value.filter(series =>
series.status === 'active' || series.isOngoing || series.isUpcoming
)
})
// Get upcoming events (future events) // Get upcoming events (future events)
const upcomingEvents = computed(() => { const upcomingEvents = computed(() => {
const now = new Date() const now = new Date()
@ -355,9 +455,60 @@ const onEventClick = (event) => {
navigateTo(`/events/${event.slug || event.id}`) navigateTo(`/events/${event.slug || event.id}`)
} }
} }
// Series 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 dark:bg-emerald-900/30 dark:text-emerald-400',
'recurring_meetup': 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
'multi_day': 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
'course': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
'tournament': 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}
return classes[type] || 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
const start = new Date(startDate)
const end = new Date(endDate)
const startMonth = start.toLocaleDateString('en-US', { month: 'short' })
const endMonth = end.toLocaleDateString('en-US', { month: 'short' })
const startDay = start.getDate()
const endDay = end.getDate()
const year = end.getFullYear()
if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay}-${endDay}, ${year}`
} else if (start.getFullYear() === end.getFullYear()) {
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`
} else {
return `${formatEventDate(startDate)} - ${formatEventDate(endDate)}`
}
}
</script> </script>
<style> <style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Custom calendar styling to match the site theme */ /* Custom calendar styling to match the site theme */
.custom-calendar { .custom-calendar {
--vuecal-primary-color: #3b82f6; --vuecal-primary-color: #3b82f6;

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>

19
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@nuxt/ui": "^3.3.2", "@nuxt/ui": "^3.3.2",
"@nuxtjs/plausible": "^1.2.0", "@nuxtjs/plausible": "^1.2.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@ -6967,6 +6968,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/chrono-node": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.8.4.tgz",
"integrity": "sha512-F+Rq88qF3H2dwjnFrl3TZrn5v4ZO57XxeQ+AhuL1C685So1hdUV/hT/q8Ja5UbmPYEZfx8VrxFDa72Dgldcxpg==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.10.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
@ -7666,6 +7679,12 @@
"node": ">= 12" "node": ">= 12"
} }
}, },
"node_modules/dayjs": {
"version": "1.11.14",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.14.tgz",
"integrity": "sha512-E8fIdSxUlyqSA8XYGnNa3IkIzxtEmFjI+JU/6ic0P1zmSqyL6HyG5jHnpPjRguDNiaHLpfvHKWFiohNsJLqcJQ==",
"license": "MIT"
},
"node_modules/db0": { "node_modules/db0": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",

View file

@ -17,6 +17,7 @@
"@nuxt/ui": "^3.3.2", "@nuxt/ui": "^3.3.2",
"@nuxtjs/plausible": "^1.2.0", "@nuxtjs/plausible": "^1.2.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",

View file

@ -0,0 +1,187 @@
import { connectDB } from '../server/utils/mongoose.js'
import Event from '../server/models/event.js'
async function seedSeriesEvents() {
try {
await connectDB()
console.log('Connected to database')
// Workshop Series: "Cooperative Game Development Fundamentals"
const workshopSeries = [
{
title: 'Cooperative Business Models in Game Development',
slug: 'coop-business-models-workshop',
tagline: 'Learn the foundations of cooperative business structures',
description: 'An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.',
content: 'This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.',
startDate: new Date('2024-10-15T19:00:00.000Z'),
endDate: new Date('2024-10-15T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 1,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Democratic Decision Making in Creative Projects',
slug: 'democratic-decision-making-workshop',
tagline: 'Practical tools for collaborative project management',
description: 'Learn how to implement democratic decision-making processes that work for creative teams and game development projects.',
content: 'This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.',
startDate: new Date('2024-10-22T19:00:00.000Z'),
endDate: new Date('2024-10-22T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 2,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Funding and Financial Models for Co-ops',
slug: 'coop-funding-workshop',
tagline: 'Sustainable financing for cooperative studios',
description: 'Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.',
content: 'This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.',
startDate: new Date('2024-10-29T19:00:00.000Z'),
endDate: new Date('2024-10-29T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 3,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'Building Your Cooperative Studio',
slug: 'building-coop-studio-workshop',
tagline: 'From concept to reality: launching your co-op',
description: 'A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.',
content: 'This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.',
startDate: new Date('2024-11-05T19:00:00.000Z'),
endDate: new Date('2024-11-05T21:00:00.000Z'),
eventType: 'workshop',
location: '#workshop-fundamentals',
isOnline: true,
membersOnly: false,
registrationRequired: true,
maxAttendees: 50,
series: {
id: 'coop-dev-fundamentals',
title: 'Cooperative Game Development Fundamentals',
description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios',
type: 'workshop_series',
position: 4,
totalEvents: 4,
isSeriesEvent: true
},
createdBy: 'admin'
}
]
// Monthly Community Meetup Series
const meetupSeries = [
{
title: 'October Community Meetup',
slug: 'october-community-meetup',
tagline: 'Monthly gathering for cooperative game developers',
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
startDate: new Date('2024-10-12T18:00:00.000Z'),
endDate: new Date('2024-10-12T20:00:00.000Z'),
eventType: 'community',
location: '#community-meetup',
isOnline: true,
membersOnly: false,
registrationRequired: false,
series: {
id: 'monthly-meetups',
title: 'Monthly Community Meetups',
description: 'Regular monthly gatherings for the cooperative game development community',
type: 'recurring_meetup',
position: 1,
totalEvents: 12,
isSeriesEvent: true
},
createdBy: 'admin'
},
{
title: 'November Community Meetup',
slug: 'november-community-meetup',
tagline: 'Monthly gathering for cooperative game developers',
description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.',
content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.',
startDate: new Date('2024-11-09T18:00:00.000Z'),
endDate: new Date('2024-11-09T20:00:00.000Z'),
eventType: 'community',
location: '#community-meetup',
isOnline: true,
membersOnly: false,
registrationRequired: false,
series: {
id: 'monthly-meetups',
title: 'Monthly Community Meetups',
description: 'Regular monthly gatherings for the cooperative game development community',
type: 'recurring_meetup',
position: 2,
totalEvents: 12,
isSeriesEvent: true
},
createdBy: 'admin'
}
]
// Insert all series events
const allSeriesEvents = [...workshopSeries, ...meetupSeries]
for (const eventData of allSeriesEvents) {
const existingEvent = await Event.findOne({ slug: eventData.slug })
if (!existingEvent) {
const event = new Event(eventData)
await event.save()
console.log(`Created series event: ${event.title}`)
} else {
console.log(`Series event already exists: ${eventData.title}`)
}
}
console.log('Series events seeding completed!')
process.exit(0)
} catch (error) {
console.error('Error seeding series events:', error)
process.exit(1)
}
}
seedSeriesEvents()

View file

@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
const newEvent = new Event({ const eventData = {
...body, ...body,
createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user
startDate: new Date(body.startDate), startDate: new Date(body.startDate),
endDate: new Date(body.endDate), endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null
}) }
// Handle ticket data
if (body.tickets) {
eventData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: 0, // Initialize sold count
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const newEvent = new Event(eventData)
const savedEvent = await newEvent.save() const savedEvent = await newEvent.save()

View file

@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => {
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
updatedAt: new Date() updatedAt: new Date()
} }
// Handle ticket data
if (body.tickets) {
updateData.tickets = {
enabled: body.tickets.enabled || false,
public: {
available: body.tickets.public?.available || false,
name: body.tickets.public?.name || 'Public Ticket',
description: body.tickets.public?.description || '',
price: body.tickets.public?.price || 0,
quantity: body.tickets.public?.quantity || null,
sold: body.tickets.public?.sold || 0,
earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null
}
}
}
const updatedEvent = await Event.findByIdAndUpdate( const updatedEvent = await Event.findByIdAndUpdate(
eventId, eventId,

View file

@ -0,0 +1,60 @@
import Series from '../../models/series.js'
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
// Fetch all series
const series = await Series.find({ isActive: true })
.sort({ createdAt: -1 })
.lean()
// For each series, get event count and statistics
const seriesWithStats = await Promise.all(
series.map(async (s) => {
const events = await Event.find({
'series.id': s.id,
'series.isSeriesEvent': true
}).select('_id startDate endDate registrations').lean()
const now = new Date()
const eventCount = events.length
const completedEvents = events.filter(e => e.endDate < now).length
const upcomingEvents = events.filter(e => e.startDate > now).length
const firstEventDate = events.length > 0 ?
Math.min(...events.map(e => new Date(e.startDate))) : null
const lastEventDate = events.length > 0 ?
Math.max(...events.map(e => new Date(e.endDate))) : null
let status = 'upcoming'
if (lastEventDate && lastEventDate < now) {
status = 'completed'
} else if (firstEventDate && firstEventDate <= now && lastEventDate && lastEventDate >= now) {
status = 'active'
}
return {
...s,
eventCount,
completedEvents,
upcomingEvents,
startDate: firstEventDate,
endDate: lastEventDate,
status,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0)
}
})
)
return seriesWithStats
} catch (error) {
console.error('Error fetching series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch series'
})
}
})

View file

@ -0,0 +1,49 @@
import Series from '../../models/series.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const body = await readBody(event)
// Validate required fields
if (!body.id || !body.title || !body.description) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID, title, and description are required'
})
}
// Create new series
const newSeries = new Series({
id: body.id,
title: body.title,
description: body.description,
type: body.type || 'workshop_series',
totalEvents: body.totalEvents || null,
createdBy: 'admin' // TODO: Get from authentication
})
await newSeries.save()
return {
success: true,
data: newSeries
}
} catch (error) {
console.error('Error creating series:', error)
if (error.code === 11000) {
throw createError({
statusCode: 400,
statusMessage: 'A series with this ID already exists'
})
}
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to create series'
})
}
})

View file

@ -0,0 +1,58 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Remove series relationship from all related events
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.isSeriesEvent': false,
'series.id': '',
'series.title': '',
'series.description': '',
'series.type': 'workshop_series',
'series.position': 1,
'series.totalEvents': null
}
}
)
// Delete the series
await Series.deleteOne({ id: id })
return {
success: true,
message: 'Series deleted and events converted to standalone events'
}
} catch (error) {
console.error('Error deleting series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to delete series'
})
}
})

View file

@ -0,0 +1,62 @@
import Series from '../../../models/series.js'
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Find and update the series
const series = await Series.findOne({ id: id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Update series fields
if (body.title !== undefined) series.title = body.title
if (body.description !== undefined) series.description = body.description
if (body.type !== undefined) series.type = body.type
if (body.totalEvents !== undefined) series.totalEvents = body.totalEvents
if (body.isActive !== undefined) series.isActive = body.isActive
await series.save()
// Also update all related events with the new series information
await Event.updateMany(
{ 'series.id': id, 'series.isSeriesEvent': true },
{
$set: {
'series.title': series.title,
'series.description': series.description,
'series.type': series.type,
'series.totalEvents': series.totalEvents
}
}
)
return {
success: true,
data: series
}
} catch (error) {
console.error('Error updating series:', error)
throw createError({
statusCode: 500,
statusMessage: error.message || 'Failed to update series'
})
}
})

View file

@ -0,0 +1,112 @@
import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required fields for guest registration
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event allows public registration (not members-only)
if (eventData.membersOnly) {
throw createError({
statusCode: 403,
statusMessage: 'This event is for members only. Please become a member to register.'
})
}
// If event requires payment, reject guest registration
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree) {
throw createError({
statusCode: 402,
statusMessage: 'This event requires payment. Please use the payment registration endpoint.'
})
}
// Check if event is full
if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) {
throw createError({
statusCode: 400,
statusMessage: 'Event is full'
})
}
// Check if already registered
const alreadyRegistered = eventData.registrations.some(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (alreadyRegistered) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Add guest registration
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'guest',
isMember: false,
paymentStatus: 'not_required',
amountPaid: 0,
registeredAt: new Date()
})
await eventData.save()
// TODO: Send confirmation email for guest registration
return {
success: true,
message: 'Successfully registered as guest',
registrationId: eventData.registrations[eventData.registrations.length - 1]._id,
note: 'As a guest, you have access to this free public event. Consider becoming a member for access to all events!'
}
} catch (error) {
console.error('Error with guest registration:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to register as guest'
})
}
})

View file

@ -0,0 +1,136 @@
import Event from '../../../models/event.js'
import Member from '../../../models/member.js'
import { connectDB } from '../../../utils/mongoose.js'
import { processHelcimPayment } from '../../../utils/helcim.js'
import mongoose from 'mongoose'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const identifier = getRouterParam(event, 'id')
const body = await readBody(event)
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: 'Event identifier is required'
})
}
// Validate required payment fields
if (!body.name || !body.email || !body.paymentToken) {
throw createError({
statusCode: 400,
statusMessage: 'Name, email, and payment token are required'
})
}
// Fetch the event
let eventData
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier)
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier })
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
})
}
// Check if event requires payment
if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) {
throw createError({
statusCode: 400,
statusMessage: 'This event does not require payment'
})
}
// Check if user is already registered
const existingRegistration = eventData.registrations.find(
reg => reg.email.toLowerCase() === body.email.toLowerCase()
)
if (existingRegistration) {
throw createError({
statusCode: 400,
statusMessage: 'You are already registered for this event'
})
}
// Check if user is a member (members get free access)
const member = await Member.findOne({ email: body.email.toLowerCase() })
if (member) {
// Members get free access - register directly without payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: `${member.circle}-${member.contributionTier}`,
isMember: true,
paymentStatus: 'not_required',
amountPaid: 0
})
await eventData.save()
return {
success: true,
message: 'Successfully registered as a member (no payment required)',
registration: eventData.registrations[eventData.registrations.length - 1]
}
}
// Process payment for non-members
const paymentResult = await processHelcimPayment({
amount: eventData.pricing.publicPrice,
paymentToken: body.paymentToken,
customerData: {
name: body.name,
email: body.email
}
})
if (!paymentResult.success) {
throw createError({
statusCode: 400,
statusMessage: paymentResult.message || 'Payment failed'
})
}
// Add registration with successful payment
eventData.registrations.push({
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: 'non-member',
isMember: false,
paymentStatus: 'completed',
paymentId: paymentResult.transactionId,
amountPaid: eventData.pricing.publicPrice
})
await eventData.save()
return {
success: true,
message: 'Payment successful and registered for event',
paymentId: paymentResult.transactionId,
registration: eventData.registrations[eventData.registrations.length - 1]
}
} catch (error) {
console.error('Error processing event payment:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to process payment and registration'
})
}
})

View file

@ -65,27 +65,41 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Check member status if event is members-only // Check member status and handle different registration scenarios
if (eventData.membersOnly && body.membershipLevel === 'non-member') { const member = await Member.findOne({ email: body.email.toLowerCase() })
// Check if email belongs to a member
const member = await Member.findOne({ email: body.email.toLowerCase() }) if (eventData.membersOnly && !member) {
throw createError({
if (!member) { statusCode: 403,
throw createError({ statusMessage: 'This event is for members only. Please become a member to register.'
statusCode: 403, })
statusMessage: 'This event is for members only. Please become a member to register.' }
})
} // If event requires payment and user is not a member, redirect to payment flow
if (eventData.pricing.paymentRequired && !eventData.pricing.isFree && !member) {
// Update membership level from database throw createError({
body.membershipLevel = `${member.circle}-${member.contributionTier}` statusCode: 402, // Payment Required
statusMessage: 'This event requires payment. Please use the payment registration endpoint.'
})
}
// Set member status and membership level
let isMember = false
let membershipLevel = 'non-member'
if (member) {
isMember = true
membershipLevel = `${member.circle}-${member.contributionTier}`
} }
// Add registration // Add registration
eventData.registrations.push({ eventData.registrations.push({
name: body.name, name: body.name,
email: body.email.toLowerCase(), email: body.email.toLowerCase(),
membershipLevel: body.membershipLevel || 'non-member', membershipLevel,
isMember,
paymentStatus: 'not_required', // Free events or member registrations
amountPaid: 0,
dietary: body.dietary || false, dietary: body.dietary || false,
registeredAt: new Date() registeredAt: new Date()
}) })

View file

@ -0,0 +1,78 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
// Fetch all events in this series
const events = await Event.find({
'series.id': id,
'series.isSeriesEvent': true
})
.sort({ 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
if (events.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Event series not found'
})
}
// Get series metadata from the first event
const seriesInfo = events[0].series
// Calculate series statistics
const now = new Date()
const completedEvents = events.filter(e => e.endDate < now).length
const upcomingEvents = events.filter(e => e.startDate > now).length
const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length
const firstEventDate = events[0].startDate
const lastEventDate = events[events.length - 1].endDate
// Return series with additional metadata
return {
id: id,
title: seriesInfo.title,
description: seriesInfo.description,
type: seriesInfo.type,
totalEvents: seriesInfo.totalEvents,
startDate: firstEventDate,
endDate: lastEventDate,
events: events.map(e => ({
...e,
id: e._id.toString()
})),
statistics: {
totalEvents: events.length,
completedEvents,
upcomingEvents,
ongoingEvents,
isOngoing: firstEventDate <= now && lastEventDate >= now,
isUpcoming: firstEventDate > now,
isCompleted: lastEventDate < now,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0)
}
}
} catch (error) {
console.error('Error fetching event series:', error)
if (error.statusCode) throw error
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -0,0 +1,91 @@
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const query = getQuery(event)
// Build filter for series events only
const filter = {
'series.isSeriesEvent': true,
isVisible: query.includeHidden === 'true' ? { $exists: true } : true
}
// Filter by series type
if (query.seriesType) {
filter['series.type'] = query.seriesType
}
// Filter for upcoming series
if (query.upcoming === 'true') {
filter.startDate = { $gte: new Date() }
}
// Fetch all series events and group them by series.id
const events = await Event.find(filter)
.sort({ 'series.id': 1, 'series.position': 1, startDate: 1 })
.select('-registrations')
.lean()
// Group events by series ID
const seriesMap = new Map()
events.forEach(event => {
const seriesId = event.series?.id
if (!seriesId) return
if (!seriesMap.has(seriesId)) {
seriesMap.set(seriesId, {
id: seriesId,
title: event.series.title,
description: event.series.description,
type: event.series.type,
totalEvents: event.series.totalEvents,
events: [],
firstEventDate: event.startDate,
lastEventDate: event.endDate
})
}
const series = seriesMap.get(seriesId)
series.events.push({
...event,
id: event._id.toString()
})
// Update date range
if (event.startDate < series.firstEventDate) {
series.firstEventDate = event.startDate
}
if (event.endDate > series.lastEventDate) {
series.lastEventDate = event.endDate
}
})
// Convert to array and add computed fields
const seriesArray = Array.from(seriesMap.values()).map(series => {
const now = new Date()
return {
...series,
eventCount: series.events.length,
startDate: series.firstEventDate,
endDate: series.lastEventDate,
isOngoing: series.firstEventDate <= now && series.lastEventDate >= now,
isUpcoming: series.firstEventDate > now,
isCompleted: series.lastEventDate < now,
status: series.lastEventDate < now ? 'completed' :
series.firstEventDate <= now && series.lastEventDate >= now ? 'active' : 'upcoming'
}
})
return seriesArray
} catch (error) {
console.error('Error fetching event series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch event series'
})
}
})

View file

@ -39,6 +39,41 @@ const eventSchema = new mongoose.Schema({
isCancelled: { type: Boolean, default: false }, isCancelled: { type: Boolean, default: false },
cancellationMessage: String, // Custom message for cancelled events cancellationMessage: String, // Custom message for cancelled events
membersOnly: { type: Boolean, default: false }, membersOnly: { type: Boolean, default: false },
// Series information - embedded approach for better performance
series: {
id: String, // Simple string ID to group related events
title: String, // Series title (e.g., "Cooperative Game Development Workshop Series")
description: String, // Series description
type: {
type: String,
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'],
default: 'workshop_series'
},
position: Number, // Order within the series (e.g., 1 = first event, 2 = second, etc.)
totalEvents: Number, // Total planned events in the series
isSeriesEvent: { type: Boolean, default: false } // Flag to identify series events
},
// Event pricing for public attendees (deprecated - use tickets instead)
pricing: {
isFree: { type: Boolean, default: true },
publicPrice: { type: Number, default: 0 }, // Price for non-members
currency: { type: String, default: 'CAD' },
paymentRequired: { type: Boolean, default: false }
},
// Ticket configuration
tickets: {
enabled: { type: Boolean, default: false },
public: {
available: { type: Boolean, default: false },
name: { type: String, default: 'Public Ticket' },
description: String,
price: { type: Number, default: 0 },
quantity: Number, // null = unlimited
sold: { type: Number, default: 0 },
earlyBirdPrice: Number,
earlyBirdDeadline: Date
}
},
// Circle targeting // Circle targeting
targetCircles: [{ targetCircles: [{
type: String, type: String,
@ -58,6 +93,14 @@ const eventSchema = new mongoose.Schema({
name: String, name: String,
email: String, email: String,
membershipLevel: String, membershipLevel: String,
isMember: { type: Boolean, default: false },
paymentStatus: {
type: String,
enum: ['pending', 'completed', 'failed', 'not_required'],
default: 'not_required'
},
paymentId: String, // Helcim transaction ID
amountPaid: { type: Number, default: 0 },
registeredAt: { type: Date, default: Date.now } registeredAt: { type: Date, default: Date.now }
}], }],
createdBy: { type: String, required: true }, createdBy: { type: String, required: true },

35
server/models/series.js Normal file
View file

@ -0,0 +1,35 @@
import mongoose from 'mongoose'
const seriesSchema = new mongoose.Schema({
id: {
type: String,
required: true,
unique: true,
validate: {
validator: function(v) {
return /^[a-z0-9-]+$/.test(v);
},
message: 'Series ID must contain only lowercase letters, numbers, and dashes'
}
},
title: { type: String, required: true },
description: { type: String, required: true },
type: {
type: String,
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'],
default: 'workshop_series'
},
totalEvents: Number,
isActive: { type: Boolean, default: true },
createdBy: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
})
// Update the updatedAt field on save
seriesSchema.pre('save', function(next) {
this.updatedAt = new Date()
next()
})
export default mongoose.models.Series || mongoose.model('Series', seriesSchema)