ghostguild-org/app/pages/admin/events/create.vue
Jennie Robinson Faber 4a05e91715 feat(admin-events): form layout overhaul + agenda input + date input rewrite
Admin event create form:
- Wraps body in a form-layout/form-main container for upcoming sidebar work.
- Bigger autoresize textareas for description/content; adds an Agenda
  textarea (one item per line, persisted as event.agenda).
- Reorganises settings into Event Settings + conditional Cancellation
  Message sections.
- Pulls event-type options from EVENT_TYPES; location becomes optional;
  passes displayTimezone through to NaturalDateInput.

NaturalDateInput: rewritten to a single always-visible UInput with chrono
parsing and trailing status icon, instead of toggling between input and
parsed-summary blocks. Cleaner state model (rawInput / parsedDate /
isValid / hasError) and timezone-aware update emission.
2026-05-21 17:50:56 +01:00

1352 lines
38 KiB
Vue

<template>
<div class="create-form">
<div class="page-header">
<div class="header-row">
<NuxtLink to="/admin/events" class="back-link">&larr; 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">
<div class="form-layout">
<div class="form-main">
<!-- 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"
:ui="{ base: 'title-input' }"
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="8"
autoresize
: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="12"
autoresize
class="w-full"
/>
<p class="help-text">
Optional: Provide additional context, agenda items, or detailed
requirements
</p>
</div>
<div class="field">
<label>Event Agenda</label>
<UTextarea
v-model="agendaText"
placeholder="Introduction and welcome - 10 mins&#10;Main talk - 30 mins&#10;Q&amp;A - 15 mins"
:rows="6"
autoresize
class="w-full"
/>
<p class="help-text">
One agenda item per line. Help attendees know what to expect
during the event.
</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="EVENT_TYPES"
class="w-full"
/>
<p class="help-text">
Choose the category that best describes your event
</p>
</div>
<div class="field">
<label> Event Timezone <span class="required">*</span> </label>
<USelectMenu
v-model="eventForm.displayTimezone"
:items="timezoneItems"
value-key="value"
searchable
searchable-placeholder="Search timezones..."
placeholder="Select a timezone"
class="w-full"
/>
<p class="help-text">
Dates below are interpreted in this timezone. Attendees see the
event time in this zone.
</p>
</div>
<div class="field">
<label>Location</label>
<UInput
v-model="eventForm.location"
placeholder="e.g., https://zoom.us/j/123..., #channel-name, or TBD"
class="w-full"
/>
<p 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"
:display-timezone="eventForm.displayTimezone"
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"
:display-timezone="eventForm.displayTimezone"
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"
:display-timezone="eventForm.displayTimezone"
placeholder="e.g., 'tomorrow at noon', '1 hour before event'"
/>
<p class="help-text">
When should registration close? (optional)
</p>
</div>
</div>
</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>
</div>
<aside class="form-aside">
<!-- 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"
>
<strong>Community Circle</strong>
</label>
<label class="check-label">
<input
v-model="eventForm.targetCircles"
value="founder"
type="checkbox"
>
<strong>Founder Circle</strong>
</label>
<label class="check-label">
<input
v-model="eventForm.targetCircles"
value="practitioner"
type="checkbox"
>
<strong>Practitioner Circle</strong>
</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
searchable
create-item
placeholder="Select or type to add tags..."
class="w-full"
@create="onTagCreate"
/>
<div class="field new-tag-pool">
<label>New tag pool</label>
<USelect
v-model="newTagPool"
:items="[
{ label: 'Cooperative', value: 'cooperative' },
{ label: 'Craft', value: 'craft' },
]"
value-key="value"
class="w-full"
/>
<p class="help-text">
Pool assigned to any new tag you create from this field.
</p>
</div>
<p class="help-text">
Tag this event to help with discovery and recommendations. Type a
new tag and press enter to add it.
</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"
:display-timezone="eventForm.displayTimezone"
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"
: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"
@update:model-value="onSeriesSelect"
/>
<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>
</aside>
</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"
:disabled="creating"
class="btn"
@click="saveAndCreateAnother"
>
{{ 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>
import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { EVENT_TYPES } from "~/config/eventTypes";
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 newTagPool = ref("cooperative");
const onTagCreate = async (item) => {
const label = typeof item === "string" ? item : item?.label || item?.value;
if (!label?.trim()) return;
try {
const { tag } = await $fetch("/api/admin/tags", {
method: "POST",
body: { label: label.trim(), pool: newTagPool.value },
});
if (!availableTags.value.some((t) => t.slug === tag.slug)) {
availableTags.value.push({ slug: tag.slug, label: tag.label });
}
if (!eventForm.tags.includes(tag.slug)) {
eventForm.tags.push(tag.slug);
}
} catch (err) {
formErrors.value.push(
`Failed to create tag "${label}": ${err?.data?.statusMessage || err?.statusMessage || err?.message || "unknown error"}`,
);
}
};
const eventForm = reactive({
title: "",
description: "",
content: "",
featureImage: null,
startDate: "",
endDate: "",
eventType: "community-meetup",
displayTimezone: "America/Toronto",
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())}`;
};
// Render the form's datetime fields in the event's display timezone.
const formatForEventTZ = (value) => {
if (!value) return "";
return utcToZonedLocal(value, eventForm.displayTimezone) || formatForDatetimeLocal(value);
};
const utcOffsetLabel = (tz) => {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
}).formatToParts(new Date());
const name = parts.find((p) => p.type === "timeZoneName")?.value || "";
if (name === "GMT") return "UTC+00:00";
return name.replace("GMT", "UTC");
} catch {
return "";
}
};
const timezoneItems = computed(() => {
const list = TIMEZONE_OPTIONS.map((t) => {
const off = utcOffsetLabel(t.value);
return { ...t, label: off ? `${t.label} (${off})` : t.label };
});
const saved = eventForm.displayTimezone;
if (saved && !TIMEZONE_OPTIONS.some((t) => t.value === saved)) {
list.unshift({ label: saved, value: saved });
}
return list;
});
const agendaText = computed({
get() {
return (eventForm.agenda || []).join("\n");
},
set(v) {
eventForm.agenda = v.split("\n");
},
});
// 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;
// Pin the form's timezone first so subsequent date conversions use it.
eventForm.displayTimezone = event.displayTimezone || "America/Toronto";
Object.assign(eventForm, {
title: event.title,
description: event.description,
content: event.content || "",
featureImage: event.featureImage || null,
startDate: utcToZonedLocal(event.startDate, eventForm.displayTimezone),
endDate: utcToZonedLocal(event.endDate, eventForm.displayTimezone),
eventType: event.eventType,
displayTimezone: eventForm.displayTimezone,
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: utcToZonedLocal(event.registrationDeadline, eventForm.displayTimezone),
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 = utcToZonedLocal(
event.tickets.public.earlyBirdDeadline,
eventForm.displayTimezone,
);
}
}
// 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";
}
// 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";
}
}
// 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
}
const tz = eventForm.displayTimezone || "America/Toronto";
const toUTC = (v) => {
const d = zonedLocalToUTC(v, tz);
return d ? d.toISOString() : v;
};
const payload = {
...eventForm,
startDate: toUTC(eventForm.startDate),
endDate: toUTC(eventForm.endDate),
registrationDeadline: eventForm.registrationDeadline
? toUTC(eventForm.registrationDeadline)
: eventForm.registrationDeadline,
agenda: (eventForm.agenda || [])
.map((l) => l.trim())
.filter(Boolean),
tickets: {
...eventForm.tickets,
public: {
...eventForm.tickets.public,
earlyBirdDeadline: eventForm.tickets.public.earlyBirdDeadline
? toUTC(eventForm.tickets.public.earlyBirdDeadline)
: eventForm.tickets.public.earlyBirdDeadline,
},
},
};
if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: "PUT",
body: payload,
});
} else {
await $fetch("/api/admin/events", {
method: "POST",
body: payload,
});
}
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-meetup",
displayTimezone: "America/Toronto",
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 {
display: flex;
flex-direction: column;
min-height: 100vh;
position: relative;
}
/* Vertical divider between main + aside, full viewport height */
.create-form::after {
content: "";
position: fixed;
top: 0;
bottom: 0;
right: 340px;
border-left: 1px dashed var(--border);
pointer-events: none;
z-index: 1;
}
.form-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
align-items: stretch;
flex: 1;
}
.form-main {
min-width: 0;
padding: 24px 28px;
}
.form-aside {
padding: 24px 28px;
}
.form-aside .form-section:last-child {
margin-bottom: 0;
}
.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 {
display: flex;
flex-direction: column;
flex: 1;
padding: 0;
}
.form-body > .error-box,
.form-body > .success-box {
margin: 24px 28px 0;
}
.form-body > form {
display: flex;
flex-direction: column;
flex: 1;
}
.section-heading {
font-family: "Brygada 1918", serif;
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-left: -28px;
margin-right: -28px;
padding: 0 28px 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: 20px 28px;
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;
}
.btn:disabled,
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:deep(.title-input) {
font-family: "Brygada 1918", serif;
font-size: 24px;
padding: 12px 14px;
}
@media (max-width: 1024px) {
.create-form::after {
display: none;
}
.form-layout {
grid-template-columns: 1fr;
}
.form-aside {
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.page-header {
padding: 24px 20px 16px;
}
.form-main,
.form-aside,
.form-actions {
padding-left: 20px;
padding-right: 20px;
}
.form-body > .error-box,
.form-body > .success-box {
margin-left: 20px;
margin-right: 20px;
}
.section-heading {
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
}
.form-grid {
grid-template-columns: 1fr;
}
.series-select-row {
flex-direction: column;
}
}
</style>