1129 lines
39 KiB
Vue
1129 lines
39 KiB
Vue
<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/events"
|
|
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">
|
|
{{ editingEvent ? "Edit Event" : "Create New Event" }}
|
|
</h1>
|
|
</div>
|
|
<p class="text-gray-600">
|
|
Fill out the form below to create or update an event
|
|
</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">
|
|
{{
|
|
editingEvent
|
|
? "Event updated successfully!"
|
|
: "Event created successfully!"
|
|
}}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form @submit.prevent="saveEvent">
|
|
<!-- Basic Information -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
|
Basic Information
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-1 gap-6">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Event Title <span class="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
v-model="eventForm.title"
|
|
type="text"
|
|
placeholder="Enter a clear, descriptive event title"
|
|
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.title,
|
|
}"
|
|
/>
|
|
<p v-if="fieldErrors.title" class="mt-1 text-sm text-red-600">
|
|
{{ fieldErrors.title }}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Feature Image</label
|
|
>
|
|
<ImageUpload v-model="eventForm.featureImage" />
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Upload a high-quality image (1200x630px recommended) to
|
|
represent your event
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Event Description <span class="text-red-500">*</span>
|
|
</label>
|
|
<textarea
|
|
v-model="eventForm.description"
|
|
placeholder="Provide a clear description of what attendees can expect from this event"
|
|
required
|
|
rows="4"
|
|
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.description,
|
|
}"
|
|
></textarea>
|
|
<p
|
|
v-if="fieldErrors.description"
|
|
class="mt-1 text-sm text-red-600"
|
|
>
|
|
{{ fieldErrors.description }}
|
|
</p>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
This will be displayed on the event listing and detail pages
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Additional Content</label
|
|
>
|
|
<textarea
|
|
v-model="eventForm.content"
|
|
placeholder="Add detailed information, agenda, requirements, or other important details"
|
|
rows="6"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
></textarea>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Optional: Provide additional context, agenda items, or detailed
|
|
requirements
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Details -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
|
Event Details
|
|
</h2>
|
|
|
|
<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">
|
|
Event Type <span class="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
v-model="eventForm.eventType"
|
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
>
|
|
<option value="community">Community Meetup</option>
|
|
<option value="workshop">Workshop</option>
|
|
<option value="social">Social Event</option>
|
|
<option value="showcase">Showcase</option>
|
|
</select>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Choose the category that best describes your event
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Location <span class="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
v-model="eventForm.location"
|
|
type="text"
|
|
placeholder="e.g., https://zoom.us/j/123... or #channel-name"
|
|
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.location,
|
|
}"
|
|
/>
|
|
<p v-if="fieldErrors.location" class="mt-1 text-sm text-red-600">
|
|
{{ fieldErrors.location }}
|
|
</p>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Enter a video conference link or Slack channel (starting with #)
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
Start Date & Time <span class="text-red-500">*</span>
|
|
</label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.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>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
End Date & Time <span class="text-red-500">*</span>
|
|
</label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.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>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Max Attendees</label
|
|
>
|
|
<input
|
|
v-model="eventForm.maxAttendees"
|
|
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"
|
|
/>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Set a maximum number of attendees (optional)
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Registration Deadline</label
|
|
>
|
|
<NaturalDateInput
|
|
v-model="eventForm.registrationDeadline"
|
|
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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target Audience -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
|
Target Audience
|
|
</h2>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-3"
|
|
>Target Circles</label
|
|
>
|
|
<div class="space-y-3">
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="community"
|
|
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"
|
|
>Community Circle</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
New members and those exploring the community
|
|
</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="founder"
|
|
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"
|
|
>Founder Circle</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Entrepreneurs and business leaders
|
|
</p>
|
|
</div>
|
|
</label>
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="practitioner"
|
|
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"
|
|
>Practitioner Circle</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Experts and professionals sharing knowledge
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<p class="mt-2 text-sm text-gray-500">
|
|
Select which circles this event is most relevant for (leave blank
|
|
for all circles)
|
|
</p>
|
|
</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>
|
|
<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 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 Agenda -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">Event Agenda</h2>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="(item, index) in eventForm.agenda"
|
|
:key="index"
|
|
class="flex gap-2"
|
|
>
|
|
<input
|
|
v-model="eventForm.agenda[index]"
|
|
type="text"
|
|
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
|
|
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
type="button"
|
|
@click="removeAgendaItem(index)"
|
|
class="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
<Icon name="heroicons:trash" class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
@click="addAgendaItem"
|
|
class="flex items-center gap-2 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors font-medium"
|
|
>
|
|
<Icon name="heroicons:plus" class="w-5 h-5" />
|
|
Add Agenda Item
|
|
</button>
|
|
</div>
|
|
|
|
<p class="mt-2 text-sm text-gray-500">
|
|
Add agenda items to help attendees know what to expect during the
|
|
event
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Event Settings -->
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">
|
|
Event Settings
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="space-y-4">
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.isOnline"
|
|
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"
|
|
>Online Event</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Event will be conducted virtually
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.registrationRequired"
|
|
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"
|
|
>Registration Required</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Attendees must register before attending
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.isVisible"
|
|
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"
|
|
>Visible on Public Calendar</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Event will appear on the public events page
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="flex items-start">
|
|
<input
|
|
v-model="eventForm.isCancelled"
|
|
type="checkbox"
|
|
class="rounded border-gray-300 text-red-600 focus:ring-red-500 mt-1"
|
|
/>
|
|
<div class="ml-3">
|
|
<span class="text-sm font-medium text-gray-700"
|
|
>Event Cancelled</span
|
|
>
|
|
<p class="text-xs text-gray-500">
|
|
Mark this event as cancelled
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cancellation Message (conditional) -->
|
|
<div v-if="eventForm.isCancelled" class="mb-8">
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Cancellation Message</label
|
|
>
|
|
<textarea
|
|
v-model="eventForm.cancellationMessage"
|
|
placeholder="Explain why the event was cancelled and any next steps..."
|
|
rows="3"
|
|
class="w-full border border-red-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
></textarea>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
This message will be displayed to users viewing the event page
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Form Actions -->
|
|
<div
|
|
class="flex justify-between items-center pt-6 border-t border-gray-200"
|
|
>
|
|
<NuxtLink
|
|
to="/admin/events"
|
|
class="px-4 py-2 text-gray-600 hover:text-gray-900 font-medium"
|
|
>
|
|
Cancel
|
|
</NuxtLink>
|
|
|
|
<div class="flex gap-3">
|
|
<button
|
|
v-if="!editingEvent"
|
|
type="button"
|
|
@click="saveAndCreateAnother"
|
|
:disabled="creating"
|
|
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
>
|
|
{{ creating ? "Saving..." : "Save & Create Another" }}
|
|
</button>
|
|
|
|
<button
|
|
type="submit"
|
|
:disabled="creating"
|
|
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
|
>
|
|
{{
|
|
creating
|
|
? "Saving..."
|
|
: editingEvent
|
|
? "Update Event"
|
|
: "Create Event"
|
|
}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: "admin",
|
|
});
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
|
|
const creating = ref(false);
|
|
const editingEvent = ref(null);
|
|
const showSuccessMessage = ref(false);
|
|
const formErrors = ref([]);
|
|
const fieldErrors = ref({});
|
|
const selectedSeriesId = ref("");
|
|
const availableSeries = ref([]);
|
|
|
|
const eventForm = reactive({
|
|
title: "",
|
|
description: "",
|
|
content: "",
|
|
featureImage: null,
|
|
startDate: "",
|
|
endDate: "",
|
|
eventType: "community",
|
|
location: "",
|
|
isOnline: true,
|
|
isVisible: true,
|
|
isCancelled: false,
|
|
cancellationMessage: "",
|
|
targetCircles: [],
|
|
maxAttendees: "",
|
|
registrationRequired: false,
|
|
registrationDeadline: "",
|
|
agenda: [],
|
|
tickets: {
|
|
enabled: false,
|
|
public: {
|
|
available: false,
|
|
name: "Public Ticket",
|
|
description: "",
|
|
price: 0,
|
|
quantity: null,
|
|
earlyBirdPrice: null,
|
|
earlyBirdDeadline: "",
|
|
},
|
|
},
|
|
series: {
|
|
isSeriesEvent: false,
|
|
id: "",
|
|
title: "",
|
|
description: "",
|
|
},
|
|
});
|
|
|
|
// Agenda management functions
|
|
const addAgendaItem = () => {
|
|
eventForm.agenda.push("");
|
|
};
|
|
|
|
const removeAgendaItem = (index) => {
|
|
eventForm.agenda.splice(index, 1);
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
} else {
|
|
// Reset series form when no series is selected
|
|
eventForm.series.id = "";
|
|
eventForm.series.title = "";
|
|
eventForm.series.description = "";
|
|
}
|
|
};
|
|
|
|
// Check if we're editing an event
|
|
if (route.query.edit) {
|
|
try {
|
|
const response = await $fetch(`/api/admin/events/${route.query.edit}`);
|
|
const event = response.data;
|
|
|
|
if (event) {
|
|
editingEvent.value = event;
|
|
Object.assign(eventForm, {
|
|
title: event.title,
|
|
description: event.description,
|
|
content: event.content || "",
|
|
featureImage: event.featureImage || null,
|
|
startDate: new Date(event.startDate).toISOString().slice(0, 16),
|
|
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
|
eventType: event.eventType,
|
|
location: event.location || "",
|
|
isOnline: event.isOnline,
|
|
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
|
isCancelled: event.isCancelled || false,
|
|
cancellationMessage: event.cancellationMessage || "",
|
|
targetCircles: event.targetCircles || [],
|
|
maxAttendees: event.maxAttendees || "",
|
|
registrationRequired: event.registrationRequired,
|
|
registrationDeadline: event.registrationDeadline
|
|
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
|
|
: "",
|
|
agenda: event.agenda || [],
|
|
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: "",
|
|
},
|
|
});
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Check if we're duplicating an event
|
|
if (route.query.duplicate && process.client) {
|
|
const duplicateData = sessionStorage.getItem("duplicateEventData");
|
|
if (duplicateData) {
|
|
try {
|
|
const data = JSON.parse(duplicateData);
|
|
Object.assign(eventForm, data);
|
|
sessionStorage.removeItem("duplicateEventData");
|
|
} catch (error) {
|
|
console.error("Failed to load duplicate event data:", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 = {};
|
|
|
|
// Required field validation
|
|
if (!eventForm.title.trim()) {
|
|
formErrors.value.push("Event title is required");
|
|
fieldErrors.value.title = "Please enter an event title";
|
|
}
|
|
|
|
if (!eventForm.description.trim()) {
|
|
formErrors.value.push("Event description is required");
|
|
fieldErrors.value.description =
|
|
"Please provide a description for your event";
|
|
}
|
|
|
|
if (!eventForm.startDate) {
|
|
formErrors.value.push("Start date and time is required");
|
|
fieldErrors.value.startDate = "Please select when the event starts";
|
|
}
|
|
|
|
if (!eventForm.endDate) {
|
|
formErrors.value.push("End date and time is required");
|
|
fieldErrors.value.endDate = "Please select when the event ends";
|
|
}
|
|
|
|
if (!eventForm.location.trim()) {
|
|
formErrors.value.push("Location is required");
|
|
fieldErrors.value.location =
|
|
"Please enter a location (URL or Slack channel)";
|
|
}
|
|
|
|
// Date validation
|
|
if (eventForm.startDate && eventForm.endDate) {
|
|
const startDate = new Date(eventForm.startDate);
|
|
const endDate = new Date(eventForm.endDate);
|
|
|
|
if (startDate >= endDate) {
|
|
formErrors.value.push("End date must be after start date");
|
|
fieldErrors.value.endDate = "End date must be after the start date";
|
|
}
|
|
|
|
if (startDate < new Date()) {
|
|
formErrors.value.push("Start date cannot be in the past");
|
|
fieldErrors.value.startDate = "Event cannot start in the past";
|
|
}
|
|
}
|
|
|
|
// Location format validation
|
|
if (eventForm.location.trim()) {
|
|
const urlPattern = /^https?:\/\/.+/;
|
|
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
|
|
|
|
if (
|
|
!urlPattern.test(eventForm.location) &&
|
|
!slackPattern.test(eventForm.location)
|
|
) {
|
|
formErrors.value.push(
|
|
"Location must be a valid URL or Slack channel (starting with #)",
|
|
);
|
|
fieldErrors.value.location =
|
|
"Enter a video conference link (https://...) or Slack channel (#channel-name)";
|
|
}
|
|
}
|
|
|
|
// Registration deadline validation
|
|
if (eventForm.registrationDeadline && eventForm.startDate) {
|
|
const regDeadline = new Date(eventForm.registrationDeadline);
|
|
const startDate = new Date(eventForm.startDate);
|
|
|
|
if (regDeadline >= startDate) {
|
|
formErrors.value.push(
|
|
"Registration deadline must be before the event starts",
|
|
);
|
|
fieldErrors.value.registrationDeadline =
|
|
"Registration must close before the event starts";
|
|
}
|
|
}
|
|
|
|
return formErrors.value.length === 0;
|
|
};
|
|
|
|
const saveEvent = async (redirect = true) => {
|
|
if (!validateForm()) {
|
|
// Scroll to top to show errors
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
return false;
|
|
}
|
|
|
|
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 && selectedSeriesId.value) {
|
|
// Series will be handled by the selected series
|
|
} else if (eventForm.series.isSeriesEvent) {
|
|
// For now, series creation requires selecting an existing series
|
|
// Individual series creation is handled through the series management page
|
|
}
|
|
|
|
if (editingEvent.value) {
|
|
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
|
method: "PUT",
|
|
body: eventForm,
|
|
});
|
|
} else {
|
|
await $fetch("/api/admin/events", {
|
|
method: "POST",
|
|
body: eventForm,
|
|
});
|
|
}
|
|
|
|
showSuccessMessage.value = true;
|
|
setTimeout(() => {
|
|
showSuccessMessage.value = false;
|
|
}, 5000);
|
|
|
|
if (redirect) {
|
|
setTimeout(() => {
|
|
router.push("/admin/events");
|
|
}, 1500);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Failed to save event:", error);
|
|
formErrors.value = [
|
|
`Failed to ${editingEvent.value ? "update" : "create"} event: ${error.data?.statusMessage || error.message}`,
|
|
];
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
return false;
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
};
|
|
|
|
const saveAndCreateAnother = async () => {
|
|
const success = await saveEvent(false);
|
|
if (success) {
|
|
// Reset form for new event
|
|
Object.assign(eventForm, {
|
|
title: "",
|
|
description: "",
|
|
content: "",
|
|
featureImage: null,
|
|
startDate: "",
|
|
endDate: "",
|
|
eventType: "community",
|
|
location: "",
|
|
isOnline: true,
|
|
isVisible: true,
|
|
isCancelled: false,
|
|
cancellationMessage: "",
|
|
targetCircles: [],
|
|
maxAttendees: "",
|
|
registrationRequired: false,
|
|
registrationDeadline: "",
|
|
agenda: [],
|
|
tickets: {
|
|
enabled: false,
|
|
public: {
|
|
available: false,
|
|
name: "Public Ticket",
|
|
description: "",
|
|
price: 0,
|
|
quantity: null,
|
|
earlyBirdPrice: null,
|
|
earlyBirdDeadline: "",
|
|
},
|
|
},
|
|
series: {
|
|
isSeriesEvent: false,
|
|
id: "",
|
|
title: "",
|
|
description: "",
|
|
},
|
|
});
|
|
|
|
// Clear any existing errors
|
|
formErrors.value = [];
|
|
fieldErrors.value = {};
|
|
|
|
// Scroll to top
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
};
|
|
</script>
|