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:
parent
c3a29fa47c
commit
a88aa62198
24 changed files with 2897 additions and 44 deletions
238
app/components/NaturalDateInput.vue
Normal file
238
app/components/NaturalDateInput.vue
Normal 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>
|
||||
|
|
@ -57,18 +57,18 @@
|
|||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/admin/analytics"
|
||||
to="/admin/series-management"
|
||||
:class="[
|
||||
'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'
|
||||
: '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">
|
||||
<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>
|
||||
Analytics
|
||||
Series
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,15 +159,15 @@
|
|||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/admin/analytics"
|
||||
to="/admin/series-management"
|
||||
:class="[
|
||||
'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'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
]"
|
||||
>
|
||||
Analytics
|
||||
Series
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -139,12 +139,11 @@
|
|||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Start Date & Time <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.startDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
|
||||
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
||||
:required="true"
|
||||
:input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.startDate }"
|
||||
/>
|
||||
<p v-if="fieldErrors.startDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.startDate }}</p>
|
||||
</div>
|
||||
|
|
@ -153,12 +152,11 @@
|
|||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
End Date & Time <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.endDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
:class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
|
||||
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
||||
:required="true"
|
||||
:input-class="{ 'border-red-300 focus:ring-red-500': fieldErrors.endDate }"
|
||||
/>
|
||||
<p v-if="fieldErrors.endDate" class="mt-1 text-sm text-red-600">{{ fieldErrors.endDate }}</p>
|
||||
</div>
|
||||
|
|
@ -177,11 +175,9 @@
|
|||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Registration Deadline</label>
|
||||
<input
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.registrationDeadline"
|
||||
type="datetime-local"
|
||||
placeholder="Optional"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-gray-500">When should registration close? (optional)</p>
|
||||
</div>
|
||||
|
|
@ -236,6 +232,273 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticketing -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Ticketing</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.tickets.enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Enable Ticketing</span>
|
||||
<p class="text-xs text-gray-500">Allow ticket sales for this event</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="eventForm.tickets.enabled" class="ml-6 space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.tickets.public.available"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Public Tickets Available</span>
|
||||
<p class="text-xs text-gray-500">Allow non-members to purchase tickets</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="eventForm.tickets.public.available" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Ticket Name</label>
|
||||
<input
|
||||
v-model="eventForm.tickets.public.name"
|
||||
type="text"
|
||||
placeholder="e.g., General Admission"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Price (CAD)</label>
|
||||
<input
|
||||
v-model="eventForm.tickets.public.price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Set to 0 for free public events</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Ticket Description</label>
|
||||
<textarea
|
||||
v-model="eventForm.tickets.public.description"
|
||||
placeholder="What's included with this ticket..."
|
||||
rows="2"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Quantity Available</label>
|
||||
<input
|
||||
v-model="eventForm.tickets.public.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for unlimited"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Price (Optional)</label>
|
||||
<input
|
||||
v-model="eventForm.tickets.public.earlyBirdPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="eventForm.tickets.public.earlyBirdPrice > 0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Early Bird Deadline</label>
|
||||
<div class="md:w-1/2">
|
||||
<NaturalDateInput
|
||||
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
||||
placeholder="e.g., '1 week before event', 'next Monday'"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Price increases to regular price after this date</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>Note:</strong> Members always get free access to all events regardless of ticket settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Management -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Series Management</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start">
|
||||
<input
|
||||
v-model="eventForm.series.isSeriesEvent"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 mt-1"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-700">Part of Event Series</span>
|
||||
<p class="text-xs text-gray-500">This event is part of a multi-event series</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="eventForm.series.isSeriesEvent" class="ml-6 space-y-4 p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Series <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="selectedSeriesId"
|
||||
@change="onSeriesSelect"
|
||||
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Choose existing series or create new...</option>
|
||||
<option v-for="series in availableSeries" :key="series.id" :value="series.id">
|
||||
{{ series.title }} ({{ series.eventCount || 0 }} events)
|
||||
</option>
|
||||
</select>
|
||||
<NuxtLink
|
||||
to="/admin/series/create"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
New Series
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Select an existing series or create a new one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSeriesId || eventForm.series.id" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Series ID <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="eventForm.series.id"
|
||||
type="text"
|
||||
placeholder="e.g., coop-dev-fundamentals"
|
||||
required
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
@input="!selectedSeriesId && checkExistingSeries()"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Position in Series <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="eventForm.series.position"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Order within the series (1, 2, 3, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Series Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="eventForm.series.title"
|
||||
type="text"
|
||||
placeholder="e.g., Cooperative Game Development Fundamentals"
|
||||
required
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Descriptive name for the entire series' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Series Description <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="eventForm.series.description"
|
||||
placeholder="Describe what the series covers and its goals"
|
||||
required
|
||||
rows="3"
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Series Type</label>
|
||||
<select
|
||||
v-model="eventForm.series.type"
|
||||
:disabled="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
>
|
||||
<option value="workshop_series">Workshop Series</option>
|
||||
<option value="recurring_meetup">Recurring Meetup</option>
|
||||
<option value="multi_day">Multi-Day Event</option>
|
||||
<option value="course">Course</option>
|
||||
<option value="tournament">Tournament</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Total Events Planned</label>
|
||||
<input
|
||||
v-model.number="eventForm.series.totalEvents"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="e.g., 4"
|
||||
:readonly="selectedSeriesId"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
:class="{ 'bg-gray-100': selectedSeriesId }"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSeriesId" class="p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>Note:</strong> This event will be added to the existing "{{ eventForm.series.title }}" series.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Settings -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Settings</h2>
|
||||
|
|
@ -354,6 +617,10 @@ const editingEvent = ref(null)
|
|||
const showSuccessMessage = ref(false)
|
||||
const formErrors = ref([])
|
||||
const fieldErrors = ref({})
|
||||
const seriesExists = ref(false)
|
||||
const existingSeries = ref(null)
|
||||
const selectedSeriesId = ref('')
|
||||
const availableSeries = ref([])
|
||||
|
||||
const eventForm = reactive({
|
||||
title: '',
|
||||
|
|
@ -371,9 +638,63 @@ const eventForm = reactive({
|
|||
targetCircles: [],
|
||||
maxAttendees: '',
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
registrationDeadline: '',
|
||||
tickets: {
|
||||
enabled: false,
|
||||
public: {
|
||||
available: false,
|
||||
name: 'Public Ticket',
|
||||
description: '',
|
||||
price: 0,
|
||||
quantity: null,
|
||||
earlyBirdPrice: null,
|
||||
earlyBirdDeadline: ''
|
||||
}
|
||||
},
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
})
|
||||
|
||||
// Load available series
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/series')
|
||||
availableSeries.value = response
|
||||
} catch (error) {
|
||||
console.error('Failed to load series:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle series selection
|
||||
const onSeriesSelect = () => {
|
||||
if (selectedSeriesId.value) {
|
||||
const series = availableSeries.value.find(s => s.id === selectedSeriesId.value)
|
||||
if (series) {
|
||||
eventForm.series.id = series.id
|
||||
eventForm.series.title = series.title
|
||||
eventForm.series.description = series.description
|
||||
eventForm.series.type = series.type
|
||||
eventForm.series.totalEvents = series.totalEvents
|
||||
eventForm.series.position = (series.eventCount || 0) + 1
|
||||
}
|
||||
} else {
|
||||
// Reset series form when no series is selected
|
||||
eventForm.series.id = ''
|
||||
eventForm.series.title = ''
|
||||
eventForm.series.description = ''
|
||||
eventForm.series.type = 'workshop_series'
|
||||
eventForm.series.position = 1
|
||||
eventForm.series.totalEvents = null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're editing an event
|
||||
if (route.query.edit) {
|
||||
try {
|
||||
|
|
@ -398,8 +719,33 @@ if (route.query.edit) {
|
|||
targetCircles: event.targetCircles || [],
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
|
||||
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '',
|
||||
tickets: event.tickets || {
|
||||
enabled: false,
|
||||
public: {
|
||||
available: false,
|
||||
name: 'Public Ticket',
|
||||
description: '',
|
||||
price: 0,
|
||||
quantity: null,
|
||||
earlyBirdPrice: null,
|
||||
earlyBirdDeadline: ''
|
||||
}
|
||||
},
|
||||
series: event.series || {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
})
|
||||
// Handle early bird deadline formatting
|
||||
if (event.tickets?.public?.earlyBirdDeadline) {
|
||||
eventForm.tickets.public.earlyBirdDeadline = new Date(event.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load event for editing:', error)
|
||||
|
|
@ -420,6 +766,20 @@ if (route.query.duplicate && process.client) {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if we're creating a series event
|
||||
if (route.query.series && process.client) {
|
||||
const seriesData = sessionStorage.getItem('seriesEventData')
|
||||
if (seriesData) {
|
||||
try {
|
||||
const data = JSON.parse(seriesData)
|
||||
Object.assign(eventForm, data)
|
||||
sessionStorage.removeItem('seriesEventData')
|
||||
} catch (error) {
|
||||
console.error('Failed to load series event data:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
formErrors.value = []
|
||||
fieldErrors.value = {}
|
||||
|
|
@ -491,6 +851,63 @@ const validateForm = () => {
|
|||
return formErrors.value.length === 0
|
||||
}
|
||||
|
||||
// Check if a series with this ID already exists
|
||||
const checkExistingSeries = async () => {
|
||||
if (!eventForm.series.id || selectedSeriesId.value) {
|
||||
seriesExists.value = false
|
||||
existingSeries.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// First check in standalone series
|
||||
const standaloneResponse = await $fetch(`/api/admin/series`)
|
||||
const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id)
|
||||
|
||||
if (existingStandalone) {
|
||||
seriesExists.value = true
|
||||
existingSeries.value = existingStandalone
|
||||
// Auto-fill series details
|
||||
if (!eventForm.series.title || eventForm.series.title === '') {
|
||||
eventForm.series.title = existingStandalone.title
|
||||
}
|
||||
if (!eventForm.series.description || eventForm.series.description === '') {
|
||||
eventForm.series.description = existingStandalone.description
|
||||
}
|
||||
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
|
||||
eventForm.series.type = existingStandalone.type
|
||||
}
|
||||
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
|
||||
eventForm.series.totalEvents = existingStandalone.totalEvents
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to legacy series check (events with series data)
|
||||
const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`)
|
||||
if (legacyResponse) {
|
||||
seriesExists.value = true
|
||||
existingSeries.value = legacyResponse
|
||||
if (!eventForm.series.title || eventForm.series.title === '') {
|
||||
eventForm.series.title = legacyResponse.title
|
||||
}
|
||||
if (!eventForm.series.description || eventForm.series.description === '') {
|
||||
eventForm.series.description = legacyResponse.description
|
||||
}
|
||||
if (!eventForm.series.type || eventForm.series.type === 'workshop_series') {
|
||||
eventForm.series.type = legacyResponse.type
|
||||
}
|
||||
if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) {
|
||||
eventForm.series.totalEvents = legacyResponse.totalEvents
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Series doesn't exist yet
|
||||
seriesExists.value = false
|
||||
existingSeries.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const saveEvent = async (redirect = true) => {
|
||||
if (!validateForm()) {
|
||||
// Scroll to top to show errors
|
||||
|
|
@ -500,6 +917,27 @@ const saveEvent = async (redirect = true) => {
|
|||
|
||||
creating.value = true
|
||||
try {
|
||||
// If this is a series event and not using an existing series, create the standalone series first
|
||||
if (eventForm.series.isSeriesEvent && eventForm.series.id && !selectedSeriesId.value) {
|
||||
try {
|
||||
await $fetch('/api/admin/series', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: eventForm.series.id,
|
||||
title: eventForm.series.title,
|
||||
description: eventForm.series.description,
|
||||
type: eventForm.series.type,
|
||||
totalEvents: eventForm.series.totalEvents
|
||||
}
|
||||
})
|
||||
} catch (seriesError) {
|
||||
// Series might already exist, that's ok
|
||||
if (!seriesError.data?.statusMessage?.includes('already exists')) {
|
||||
throw seriesError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editingEvent.value) {
|
||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -552,7 +990,28 @@ const saveAndCreateAnother = async () => {
|
|||
targetCircles: [],
|
||||
maxAttendees: '',
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
registrationDeadline: '',
|
||||
tickets: {
|
||||
enabled: false,
|
||||
public: {
|
||||
available: false,
|
||||
name: 'Public Ticket',
|
||||
description: '',
|
||||
price: 0,
|
||||
quantity: null,
|
||||
earlyBirdPrice: null,
|
||||
earlyBirdDeadline: ''
|
||||
}
|
||||
},
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
})
|
||||
|
||||
// Clear any existing errors
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@
|
|||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="">All Events</option>
|
||||
<option value="series-only">Series Events Only</option>
|
||||
<option value="standalone-only">Standalone Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
|
||||
|
|
@ -77,6 +82,14 @@
|
|||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
|
||||
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
|
||||
<div class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full">
|
||||
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{{ event.series.position }}
|
||||
</div>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 mt-2">
|
||||
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600">
|
||||
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
|
||||
|
|
@ -193,6 +206,7 @@ const { data: events, pending, error, refresh } = await useFetch("/api/admin/eve
|
|||
const searchQuery = ref('')
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const seriesFilter = ref('')
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
|
|
@ -207,7 +221,11 @@ const filteredEvents = computed(() => {
|
|||
const eventStatus = getEventStatus(event)
|
||||
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus
|
||||
const matchesSeries = !seriesFilter.value ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus && matchesSeries
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
504
app/pages/admin/series-management.vue
Normal file
504
app/pages/admin/series-management.vue
Normal 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>
|
||||
268
app/pages/admin/series/create.vue
Normal file
268
app/pages/admin/series/create.vue
Normal 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>
|
||||
|
|
@ -66,6 +66,84 @@
|
|||
</UContainer>
|
||||
</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 -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<UContainer>
|
||||
|
|
@ -93,6 +171,17 @@
|
|||
</div>
|
||||
|
||||
<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="[
|
||||
'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
|
||||
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
|
||||
const events = computed(() => {
|
||||
|
|
@ -296,10 +387,19 @@ const events = computed(() => {
|
|||
location: event.location,
|
||||
registeredCount: event.registeredCount,
|
||||
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)
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date()
|
||||
|
|
@ -355,9 +455,60 @@ const onEventClick = (event) => {
|
|||
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>
|
||||
|
||||
<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 {
|
||||
--vuecal-primary-color: #3b82f6;
|
||||
|
|
|
|||
234
app/pages/series/index.vue
Normal file
234
app/pages/series/index.vue
Normal 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
19
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"@nuxt/ui": "^3.3.2",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
@ -6967,6 +6968,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": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz",
|
||||
|
|
@ -7666,6 +7679,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": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@nuxt/ui": "^3.3.2",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chrono-node": "^2.8.4",
|
||||
"cloudinary": "^2.7.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
|
|||
187
scripts/seed-series-events.js
Normal file
187
scripts/seed-series-events.js
Normal 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()
|
||||
|
|
@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
await connectDB()
|
||||
|
||||
const newEvent = new Event({
|
||||
const eventData = {
|
||||
...body,
|
||||
createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user
|
||||
startDate: new Date(body.startDate),
|
||||
endDate: new Date(body.endDate),
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => {
|
|||
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
|
||||
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(
|
||||
eventId,
|
||||
|
|
|
|||
60
server/api/admin/series.get.js
Normal file
60
server/api/admin/series.get.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
49
server/api/admin/series.post.js
Normal file
49
server/api/admin/series.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
58
server/api/admin/series/[id].delete.js
Normal file
58
server/api/admin/series/[id].delete.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
62
server/api/admin/series/[id].put.js
Normal file
62
server/api/admin/series/[id].put.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
112
server/api/events/[id]/guest-register.post.js
Normal file
112
server/api/events/[id]/guest-register.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
136
server/api/events/[id]/payment.post.js
Normal file
136
server/api/events/[id]/payment.post.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -65,27 +65,41 @@ export default defineEventHandler(async (event) => {
|
|||
})
|
||||
}
|
||||
|
||||
// Check member status if event is members-only
|
||||
if (eventData.membersOnly && body.membershipLevel === 'non-member') {
|
||||
// Check if email belongs to a member
|
||||
const member = await Member.findOne({ email: body.email.toLowerCase() })
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'This event is for members only. Please become a member to register.'
|
||||
})
|
||||
}
|
||||
|
||||
// Update membership level from database
|
||||
body.membershipLevel = `${member.circle}-${member.contributionTier}`
|
||||
// Check member status and handle different registration scenarios
|
||||
const member = await Member.findOne({ email: body.email.toLowerCase() })
|
||||
|
||||
if (eventData.membersOnly && !member) {
|
||||
throw createError({
|
||||
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) {
|
||||
throw createError({
|
||||
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
|
||||
eventData.registrations.push({
|
||||
name: body.name,
|
||||
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,
|
||||
registeredAt: new Date()
|
||||
})
|
||||
|
|
|
|||
78
server/api/series/[id].get.js
Normal file
78
server/api/series/[id].get.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
91
server/api/series/index.get.js
Normal file
91
server/api/series/index.get.js
Normal 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -39,6 +39,41 @@ const eventSchema = new mongoose.Schema({
|
|||
isCancelled: { type: Boolean, default: false },
|
||||
cancellationMessage: String, // Custom message for cancelled events
|
||||
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
|
||||
targetCircles: [{
|
||||
type: String,
|
||||
|
|
@ -58,6 +93,14 @@ const eventSchema = new mongoose.Schema({
|
|||
name: String,
|
||||
email: 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 }
|
||||
}],
|
||||
createdBy: { type: String, required: true },
|
||||
|
|
|
|||
35
server/models/series.js
Normal file
35
server/models/series.js
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue