Add aria-labels to form controls (selects, checkboxes, switches), set html lang attribute and page title, fix color contrast for --candle-dim and --text-faint tokens, underline inline links, remove opacity hack. Harden dev login endpoints with atomic findOneAndUpdate and tokenVersion in JWT. Update Playwright timeouts and E2E test helpers.
1230 lines
34 KiB
Vue
1230 lines
34 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... or #channel-name"
|
|
required
|
|
:color="fieldErrors.location ? 'error' : undefined"
|
|
class="w-full"
|
|
/>
|
|
<p v-if="fieldErrors.location" class="field-error">
|
|
{{ fieldErrors.location }}
|
|
</p>
|
|
<p class="help-text">
|
|
Enter a video conference link or Slack channel (starting with #)
|
|
</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>
|
|
|
|
<!-- 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 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 class="note-box">
|
|
<strong>Note:</strong> Members always get free access to all
|
|
events regardless of ticket settings.
|
|
</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="agenda-remove"
|
|
>
|
|
<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>
|
|
</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 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");
|
|
console.log("Loaded series:", response);
|
|
availableSeries.value = response;
|
|
console.log("availableSeries.value:", availableSeries.value);
|
|
} catch (error) {
|
|
console.error("Failed to load series:", 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 = "";
|
|
}
|
|
};
|
|
|
|
// 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>
|
|
|
|
<style scoped>
|
|
.create-form {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.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: 12px;
|
|
color: var(--candle);
|
|
text-decoration: none;
|
|
margin-bottom: 8px;
|
|
display: inline-block;
|
|
}
|
|
|
|
.back-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.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"] {
|
|
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;
|
|
}
|
|
|
|
.agenda-remove {
|
|
padding: 6px;
|
|
color: var(--ember);
|
|
background: none;
|
|
border: 1px dashed transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.agenda-remove:hover {
|
|
border-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>
|