The old "Members always get free access" sat at the bottom of the Ticketing section next to the top-level Enable Ticketing toggle, which conflated the member-vs-public audience split with the ticketing mechanism. Admins read it as "I need to enable ticketing for free public events," the opposite of how the system works. Move the note next to Public Tickets Available (where the audience split actually matters) and rephrase: public pricing applies to non-members; members register from their dashboard regardless.
1243 lines
35 KiB
Vue
1243 lines
35 KiB
Vue
<template>
|
|
<div class="create-form">
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<NuxtLink to="/admin/events" class="back-link">← Events</NuxtLink>
|
|
<h1>{{ editingEvent ? "Edit Event" : "Create Event" }}</h1>
|
|
<p>Fill out the form below to create or update an event</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-body">
|
|
<!-- Error Summary -->
|
|
<div v-if="formErrors.length > 0" class="error-box">
|
|
<h3>Please fix the following errors:</h3>
|
|
<ul>
|
|
<li v-for="error in formErrors" :key="error">{{ error }}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Success Message -->
|
|
<div v-if="showSuccessMessage" class="success-box">
|
|
{{
|
|
editingEvent
|
|
? "Event updated successfully!"
|
|
: "Event created successfully!"
|
|
}}
|
|
</div>
|
|
|
|
<form @submit.prevent="saveEvent">
|
|
<!-- Basic Information -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Basic Information</h2>
|
|
|
|
<div class="field">
|
|
<label> Event Title <span class="required">*</span> </label>
|
|
<UInput
|
|
v-model="eventForm.title"
|
|
placeholder="Enter a clear, descriptive event title"
|
|
required
|
|
:color="fieldErrors.title ? 'error' : undefined"
|
|
class="w-full"
|
|
/>
|
|
<p v-if="fieldErrors.title" class="field-error">
|
|
{{ fieldErrors.title }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Feature Image</label>
|
|
<ImageUpload v-model="eventForm.featureImage" />
|
|
<p class="help-text">
|
|
Upload a high-quality image (1200x630px recommended) to represent
|
|
your event
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label> Event Description <span class="required">*</span> </label>
|
|
<UTextarea
|
|
v-model="eventForm.description"
|
|
placeholder="Provide a clear description of what attendees can expect from this event"
|
|
required
|
|
:rows="4"
|
|
:color="fieldErrors.description ? 'error' : undefined"
|
|
class="w-full"
|
|
/>
|
|
<p v-if="fieldErrors.description" class="field-error">
|
|
{{ fieldErrors.description }}
|
|
</p>
|
|
<p class="help-text">
|
|
This will be displayed on the event listing and detail pages
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Additional Content</label>
|
|
<UTextarea
|
|
v-model="eventForm.content"
|
|
placeholder="Add detailed information, agenda, requirements, or other important details"
|
|
:rows="6"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
Optional: Provide additional context, agenda items, or detailed
|
|
requirements
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Details -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Event Details</h2>
|
|
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label> Event Type <span class="required">*</span> </label>
|
|
<USelect
|
|
v-model="eventForm.eventType"
|
|
aria-label="Event type"
|
|
:items="[
|
|
{ label: 'Community Meetup', value: 'community' },
|
|
{ label: 'Workshop', value: 'workshop' },
|
|
{ label: 'Social Event', value: 'social' },
|
|
{ label: 'Showcase', value: 'showcase' },
|
|
]"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
Choose the category that best describes your event
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label> Location <span class="required">*</span> </label>
|
|
<UInput
|
|
v-model="eventForm.location"
|
|
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
|
|
required
|
|
:color="fieldErrors.location ? 'error' : undefined"
|
|
class="w-full"
|
|
/>
|
|
<p v-if="fieldErrors.location" class="field-error">
|
|
{{ fieldErrors.location }}
|
|
</p>
|
|
<p v-if="!fieldErrors.location" class="help-text">
|
|
Video conference link, Slack channel (#channel-name), or 'TBD' if
|
|
the platform is undecided
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label> Start Date & Time <span class="required">*</span> </label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.startDate"
|
|
placeholder="e.g., 'tomorrow at 3pm', 'next Friday at 9am'"
|
|
:required="true"
|
|
/>
|
|
<p v-if="fieldErrors.startDate" class="field-error">
|
|
{{ fieldErrors.startDate }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label> End Date & Time <span class="required">*</span> </label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.endDate"
|
|
placeholder="e.g., 'tomorrow at 5pm', 'next Friday at 11am'"
|
|
:required="true"
|
|
/>
|
|
<p v-if="fieldErrors.endDate" class="field-error">
|
|
{{ fieldErrors.endDate }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Max Attendees</label>
|
|
<UInput
|
|
v-model="eventForm.maxAttendees"
|
|
type="number"
|
|
min="1"
|
|
placeholder="Leave blank for unlimited"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
Set a maximum number of attendees (optional)
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Registration Deadline</label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.registrationDeadline"
|
|
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
|
|
/>
|
|
<p class="help-text">
|
|
When should registration close? (optional)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Target Audience -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Target Audience</h2>
|
|
|
|
<div class="field">
|
|
<label>Target Circles</label>
|
|
<div class="check-group">
|
|
<label class="check-label">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="community"
|
|
type="checkbox"
|
|
/>
|
|
<div>
|
|
<strong>Community Circle</strong>
|
|
<span class="help-text">
|
|
New members and those exploring the community
|
|
</span>
|
|
</div>
|
|
</label>
|
|
<label class="check-label">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="founder"
|
|
type="checkbox"
|
|
/>
|
|
<div>
|
|
<strong>Founder Circle</strong>
|
|
<span class="help-text">
|
|
Entrepreneurs and business leaders
|
|
</span>
|
|
</div>
|
|
</label>
|
|
<label class="check-label">
|
|
<input
|
|
v-model="eventForm.targetCircles"
|
|
value="practitioner"
|
|
type="checkbox"
|
|
/>
|
|
<div>
|
|
<strong>Practitioner Circle</strong>
|
|
<span class="help-text">
|
|
Experts and professionals sharing knowledge
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<p class="help-text">
|
|
Select which circles this event is most relevant for (leave blank
|
|
for all circles)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Tags</h2>
|
|
|
|
<div class="field">
|
|
<label>Event Tags</label>
|
|
<USelectMenu
|
|
v-model="eventForm.tags"
|
|
:items="tagOptions"
|
|
value-key="value"
|
|
multiple
|
|
placeholder="Select tags..."
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
Tag this event to help with discovery and recommendations
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticketing -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Ticketing</h2>
|
|
|
|
<label class="check-label">
|
|
<input v-model="eventForm.tickets.enabled" type="checkbox" />
|
|
<div>
|
|
<strong>Enable Ticketing</strong>
|
|
<span class="help-text"> Allow ticket sales for this event </span>
|
|
</div>
|
|
</label>
|
|
|
|
<div v-if="eventForm.tickets.enabled" class="nested-section">
|
|
<label class="check-label">
|
|
<input
|
|
v-model="eventForm.tickets.public.available"
|
|
type="checkbox"
|
|
/>
|
|
<div>
|
|
<strong>Public Tickets Available</strong>
|
|
<span class="help-text">
|
|
Allow non-members to purchase tickets
|
|
</span>
|
|
</div>
|
|
</label>
|
|
|
|
<div class="note-box">
|
|
<strong>Note:</strong> Public ticket pricing applies to non-members.
|
|
Members register for events from their dashboard at no charge,
|
|
regardless of public ticket settings.
|
|
</div>
|
|
|
|
<div v-if="eventForm.tickets.public.available">
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label>Ticket Name</label>
|
|
<UInput
|
|
v-model="eventForm.tickets.public.name"
|
|
placeholder="e.g., General Admission"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Price (CAD)</label>
|
|
<UInput
|
|
v-model="eventForm.tickets.public.price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">Set to 0 for free public events</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Ticket Description</label>
|
|
<UTextarea
|
|
v-model="eventForm.tickets.public.description"
|
|
placeholder="What's included with this ticket..."
|
|
:rows="2"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label>Quantity Available</label>
|
|
<UInput
|
|
v-model="eventForm.tickets.public.quantity"
|
|
type="number"
|
|
min="1"
|
|
placeholder="Leave blank for unlimited"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Early Bird Price (Optional)</label>
|
|
<UInput
|
|
v-model="eventForm.tickets.public.earlyBirdPrice"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="eventForm.tickets.public.earlyBirdPrice > 0"
|
|
class="field"
|
|
>
|
|
<label>Early Bird Deadline</label>
|
|
<NaturalDateInput
|
|
v-model="eventForm.tickets.public.earlyBirdDeadline"
|
|
placeholder="e.g., '1 week before event', 'next Monday'"
|
|
/>
|
|
<p class="help-text">
|
|
Price increases to regular price after this date
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Management -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Series Management</h2>
|
|
|
|
<label class="check-label">
|
|
<input v-model="eventForm.series.isSeriesEvent" type="checkbox" />
|
|
<div>
|
|
<strong>Part of Event Series</strong>
|
|
<span class="help-text">
|
|
This event is part of a multi-event series
|
|
</span>
|
|
</div>
|
|
</label>
|
|
|
|
<div v-if="eventForm.series.isSeriesEvent" class="nested-section">
|
|
<div class="field">
|
|
<label> Select Series <span class="required">*</span> </label>
|
|
<div class="series-select-row">
|
|
<USelect
|
|
v-model="selectedSeriesId"
|
|
aria-label="Select series"
|
|
@update:model-value="onSeriesSelect"
|
|
:items="
|
|
availableSeries.map((series) => ({
|
|
label: `${series.title} (${series.eventCount || 0} events)`,
|
|
value: series.id,
|
|
}))
|
|
"
|
|
placeholder="Choose existing series or create new..."
|
|
value-key="value"
|
|
class="w-full"
|
|
/>
|
|
<NuxtLink to="/admin/series/create" class="btn btn-primary">
|
|
New Series
|
|
</NuxtLink>
|
|
</div>
|
|
<p class="help-text">
|
|
Select an existing series or create a new one
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="selectedSeriesId || eventForm.series.id">
|
|
<div class="field">
|
|
<label> Series Title <span class="required">*</span> </label>
|
|
<UInput
|
|
v-model="eventForm.series.title"
|
|
placeholder="e.g., Cooperative Game Development Fundamentals"
|
|
required
|
|
:readonly="selectedSeriesId"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
{{
|
|
selectedSeriesId
|
|
? "From selected series"
|
|
: "Descriptive name for the entire series"
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>
|
|
Series Description <span class="required">*</span>
|
|
</label>
|
|
<UTextarea
|
|
v-model="eventForm.series.description"
|
|
placeholder="Describe what the series covers and its goals"
|
|
required
|
|
:rows="3"
|
|
:readonly="selectedSeriesId"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
{{
|
|
selectedSeriesId
|
|
? "From selected series"
|
|
: "Describe what the series covers and its goals"
|
|
}}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="selectedSeriesId" class="note-box">
|
|
<strong>Note:</strong> This event will be added to the existing
|
|
"{{ eventForm.series.title }}" series.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Agenda -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Event Agenda</h2>
|
|
|
|
<div class="agenda-items">
|
|
<div
|
|
v-for="(item, index) in eventForm.agenda"
|
|
:key="index"
|
|
class="agenda-row"
|
|
>
|
|
<UInput
|
|
v-model="eventForm.agenda[index]"
|
|
placeholder="Enter agenda item (e.g., 'Introduction and welcome - 10 mins')"
|
|
class="w-full"
|
|
/>
|
|
<button
|
|
type="button"
|
|
@click="removeAgendaItem(index)"
|
|
class="link-btn link-btn-danger"
|
|
>
|
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
@click="addAgendaItem"
|
|
class="btn add-agenda-btn"
|
|
>
|
|
+ Add Agenda Item
|
|
</button>
|
|
</div>
|
|
|
|
<p class="help-text">
|
|
Add agenda items to help attendees know what to expect during the
|
|
event
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Event Settings -->
|
|
<div class="form-section">
|
|
<h2 class="section-heading">Event Settings</h2>
|
|
|
|
<div class="form-grid">
|
|
<div class="check-group">
|
|
<label class="check-label">
|
|
<input v-model="eventForm.isOnline" type="checkbox" />
|
|
<div>
|
|
<strong>Online Event</strong>
|
|
<span class="help-text">
|
|
Event will be conducted virtually
|
|
</span>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="check-label">
|
|
<input
|
|
v-model="eventForm.registrationRequired"
|
|
type="checkbox"
|
|
/>
|
|
<div>
|
|
<strong>Registration Required</strong>
|
|
<span class="help-text">
|
|
Attendees must register before attending
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="check-group">
|
|
<label class="check-label">
|
|
<input v-model="eventForm.isVisible" type="checkbox" />
|
|
<div>
|
|
<strong>Visible on Public Calendar</strong>
|
|
<span class="help-text">
|
|
Event will appear on the public events page
|
|
</span>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="check-label">
|
|
<input v-model="eventForm.isCancelled" type="checkbox" />
|
|
<div>
|
|
<strong>Event Cancelled</strong>
|
|
<span class="help-text"> Mark this event as cancelled </span>
|
|
</div>
|
|
</label>
|
|
|
|
<label class="check-label">
|
|
<input v-model="eventForm.membersOnly" type="checkbox" />
|
|
<div>
|
|
<strong>Members Only</strong>
|
|
<span class="help-text">
|
|
Hide this event from the public; only members can see it
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cancellation Message (conditional) -->
|
|
<div v-if="eventForm.isCancelled" class="form-section">
|
|
<div class="field">
|
|
<label>Cancellation Message</label>
|
|
<UTextarea
|
|
v-model="eventForm.cancellationMessage"
|
|
placeholder="Explain why the event was cancelled and any next steps..."
|
|
:rows="3"
|
|
color="error"
|
|
class="w-full"
|
|
/>
|
|
<p class="help-text">
|
|
This message will be displayed to users viewing the event page
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form Actions -->
|
|
<div class="form-actions">
|
|
<NuxtLink to="/admin/events" class="btn"> Cancel </NuxtLink>
|
|
|
|
<div class="form-actions-right">
|
|
<button
|
|
v-if="!editingEvent"
|
|
type="button"
|
|
@click="saveAndCreateAnother"
|
|
:disabled="creating"
|
|
class="btn"
|
|
>
|
|
{{ creating ? "Saving..." : "Save & Create Another" }}
|
|
</button>
|
|
|
|
<button type="submit" :disabled="creating" class="btn btn-primary">
|
|
{{
|
|
creating
|
|
? "Saving..."
|
|
: editingEvent
|
|
? "Update Event"
|
|
: "Create Event"
|
|
}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: "admin",
|
|
middleware: "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(null);
|
|
const availableSeries = ref([]);
|
|
const availableTags = ref([]);
|
|
|
|
const tagOptions = computed(() =>
|
|
availableTags.value.map((t) => ({ label: t.label, value: t.slug }))
|
|
);
|
|
|
|
const eventForm = reactive({
|
|
title: "",
|
|
description: "",
|
|
content: "",
|
|
featureImage: null,
|
|
startDate: "",
|
|
endDate: "",
|
|
eventType: "community",
|
|
location: "",
|
|
isOnline: true,
|
|
isVisible: true,
|
|
isCancelled: false,
|
|
membersOnly: false,
|
|
cancellationMessage: "",
|
|
targetCircles: [],
|
|
tags: [],
|
|
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: "",
|
|
},
|
|
});
|
|
|
|
// Format a Date/ISO value into a datetime-local string using local-time components.
|
|
// `toISOString().slice(0,16)` drifts by the browser's UTC offset on edit round-trip.
|
|
const formatForDatetimeLocal = (value) => {
|
|
if (!value) return "";
|
|
const d = new Date(value);
|
|
if (isNaN(d.getTime())) return "";
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
};
|
|
|
|
// Agenda management functions
|
|
const addAgendaItem = () => {
|
|
eventForm.agenda.push("");
|
|
};
|
|
|
|
const removeAgendaItem = (index) => {
|
|
eventForm.agenda.splice(index, 1);
|
|
};
|
|
|
|
// Load available series and tags
|
|
onMounted(async () => {
|
|
try {
|
|
const [seriesResponse, tagsResponse] = await Promise.all([
|
|
$fetch("/api/admin/series"),
|
|
$fetch("/api/tags"),
|
|
]);
|
|
availableSeries.value = seriesResponse;
|
|
availableTags.value = tagsResponse.tags || [];
|
|
} catch (error) {
|
|
console.error("Failed to load form data:", error);
|
|
}
|
|
});
|
|
|
|
// Handle series selection
|
|
const onSeriesSelect = () => {
|
|
console.log(
|
|
"onSeriesSelect called, selectedSeriesId:",
|
|
selectedSeriesId.value,
|
|
);
|
|
console.log("availableSeries:", availableSeries.value);
|
|
if (selectedSeriesId.value) {
|
|
const series = availableSeries.value.find(
|
|
(s) => s.id === selectedSeriesId.value,
|
|
);
|
|
console.log("Found series:", series);
|
|
if (series) {
|
|
eventForm.series.id = series.id;
|
|
eventForm.series.title = series.title;
|
|
eventForm.series.description = series.description;
|
|
console.log("Updated eventForm.series:", eventForm.series);
|
|
}
|
|
} else {
|
|
// Reset series form when no series is selected
|
|
eventForm.series.id = "";
|
|
eventForm.series.title = "";
|
|
eventForm.series.description = "";
|
|
}
|
|
};
|
|
|
|
function populateEditForm(payload) {
|
|
const event = payload?.data;
|
|
if (!event) return;
|
|
editingEvent.value = event;
|
|
Object.assign(eventForm, {
|
|
title: event.title,
|
|
description: event.description,
|
|
content: event.content || "",
|
|
featureImage: event.featureImage || null,
|
|
startDate: formatForDatetimeLocal(event.startDate),
|
|
endDate: formatForDatetimeLocal(event.endDate),
|
|
eventType: event.eventType,
|
|
location: event.location || "",
|
|
isOnline: event.isOnline,
|
|
isVisible: event.isVisible !== undefined ? event.isVisible : true,
|
|
isCancelled: event.isCancelled || false,
|
|
membersOnly: event.membersOnly || false,
|
|
cancellationMessage: event.cancellationMessage || "",
|
|
targetCircles: event.targetCircles || [],
|
|
tags: event.tags || [],
|
|
maxAttendees: event.maxAttendees || "",
|
|
registrationRequired: event.registrationRequired,
|
|
registrationDeadline: formatForDatetimeLocal(event.registrationDeadline),
|
|
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: "",
|
|
},
|
|
});
|
|
if (event.tickets?.public?.earlyBirdDeadline) {
|
|
eventForm.tickets.public.earlyBirdDeadline = formatForDatetimeLocal(
|
|
event.tickets.public.earlyBirdDeadline,
|
|
);
|
|
}
|
|
}
|
|
|
|
// useFetch forwards auth cookies to SSR; $fetch did not, leaving the
|
|
// SSR-rendered form empty and triggering hydration mismatches that left
|
|
// required textareas DOM-empty in dev.
|
|
if (route.query.edit) {
|
|
const { data: editEvent, error: editError } = await useFetch(
|
|
`/api/admin/events/${route.query.edit}`,
|
|
);
|
|
if (editError.value) {
|
|
console.error("Failed to load event for editing:", editError.value);
|
|
}
|
|
if (editEvent.value) populateEditForm(editEvent.value);
|
|
watch(editEvent, populateEditForm, { immediate: false });
|
|
}
|
|
|
|
// Check if we're duplicating an event
|
|
if (route.query.duplicate && import.meta.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 && import.meta.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 URL, Slack channel, or 'TBD'";
|
|
}
|
|
|
|
// 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 value = eventForm.location.trim();
|
|
const urlPattern = /^https?:\/\/.+/;
|
|
const slackPattern = /^#[a-zA-Z0-9-_]+$/;
|
|
const isTbd = value.toUpperCase() === "TBD";
|
|
|
|
if (!isTbd && !urlPattern.test(value) && !slackPattern.test(value)) {
|
|
formErrors.value.push(
|
|
"Location must be a valid URL, Slack channel (starting with #), or 'TBD'",
|
|
);
|
|
fieldErrors.value.location =
|
|
"Enter a URL (https://...), Slack channel (#channel-name), or 'TBD' if undecided";
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
membersOnly: false,
|
|
cancellationMessage: "",
|
|
targetCircles: [],
|
|
tags: [],
|
|
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>
|
|
|
|
<style scoped>
|
|
.create-form {
|
|
max-width: 800px;
|
|
}
|
|
|
|
.page-header {
|
|
padding: 28px 28px 20px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
line-height: 1.2;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.page-header p {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.back-link {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
text-decoration: none;
|
|
margin-bottom: 8px;
|
|
display: inline-block;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.back-link:hover {
|
|
color: var(--candle);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.form-body {
|
|
padding: 24px 28px;
|
|
}
|
|
|
|
.section-heading {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
padding-bottom: 10px;
|
|
border-bottom: 1px dashed var(--border);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.error-box {
|
|
padding: 16px 20px;
|
|
border: 1px dashed var(--ember);
|
|
margin-bottom: 20px;
|
|
font-size: 12px;
|
|
color: var(--ember);
|
|
}
|
|
|
|
.error-box h3 {
|
|
font-weight: 500;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.error-box ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.error-box li::before {
|
|
content: "\2022 ";
|
|
}
|
|
|
|
.success-box {
|
|
padding: 16px 20px;
|
|
border: 1px dashed var(--candle);
|
|
margin-bottom: 20px;
|
|
font-size: 12px;
|
|
color: var(--candle);
|
|
}
|
|
|
|
.help-text {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.field-error {
|
|
font-size: 11px;
|
|
color: var(--ember);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.required {
|
|
color: var(--ember);
|
|
}
|
|
|
|
.check-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.check-label {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: flex-start;
|
|
cursor: pointer;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.check-label input[type="checkbox"] {
|
|
width: auto;
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
accent-color: var(--candle);
|
|
}
|
|
|
|
.check-label strong {
|
|
display: block;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.check-label .help-text {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.form-section {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding-top: 20px;
|
|
border-top: 1px dashed var(--border);
|
|
}
|
|
|
|
.form-actions-right {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.nested-section {
|
|
padding: 16px 20px;
|
|
border: 1px dashed var(--border);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.note-box {
|
|
padding: 12px 16px;
|
|
border: 1px dashed var(--candle);
|
|
font-size: 12px;
|
|
color: var(--candle);
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.series-select-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.series-select-row .w-full {
|
|
flex: 1;
|
|
}
|
|
|
|
.agenda-items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.agenda-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
.agenda-row .w-full {
|
|
flex: 1;
|
|
}
|
|
|
|
.link-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--candle);
|
|
cursor: pointer;
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
}
|
|
|
|
.link-btn:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.link-btn-danger {
|
|
color: var(--ember);
|
|
}
|
|
|
|
.add-agenda-btn {
|
|
align-self: flex-start;
|
|
color: var(--candle);
|
|
border-color: var(--candle);
|
|
border-style: dashed;
|
|
}
|
|
|
|
.btn:disabled,
|
|
.btn-primary:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.page-header {
|
|
padding: 24px 20px 16px;
|
|
}
|
|
.form-body {
|
|
padding: 20px;
|
|
}
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.series-select-row {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|