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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue