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