ghostguild-org/app/pages/admin/series-management.vue

1308 lines
43 KiB
Vue

<template>
<div>
<div class="bg-elevated border-b border-default">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-highlighted">Series Management</h1>
<p class="text-muted">Manage event series and their relationships</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Series Overview -->
<div class="mb-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-elevated rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full">
<Icon
name="heroicons:squares-2x2"
class="w-6 h-6 text-purple-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-dimmed">Active Series</p>
<p class="text-2xl font-semibold text-highlighted">
{{ activeSeries.length }}
</p>
</div>
</div>
</div>
<div class="bg-elevated rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full">
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-dimmed">Total Series Events</p>
<p class="text-2xl font-semibold text-highlighted">
{{ totalSeriesEvents }}
</p>
</div>
</div>
</div>
<div class="bg-elevated rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full">
<Icon
name="heroicons:chart-bar"
class="w-6 h-6 text-green-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-dimmed">Avg Events/Series</p>
<p class="text-2xl font-semibold text-highlighted">
{{
activeSeries.length > 0
? Math.round(totalSeriesEvents / activeSeries.length)
: 0
}}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Actions Bar -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input
v-model="searchQuery"
placeholder="Search series..."
class="border border-default bg-elevated text-default placeholder-dimmed rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<select
v-model="statusFilter"
class="border border-default bg-elevated text-default rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="upcoming">Upcoming</option>
<option value="completed">Completed</option>
</select>
</div>
<div class="flex gap-3">
<button
@click="showBulkModal = true"
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center"
>
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 mr-2" />
Bulk Operations
</button>
<NuxtLink
to="/admin/series/create"
class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
Create Series
</NuxtLink>
</div>
</div>
<!-- Series List -->
<div v-if="pending" class="text-center py-12">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"
></div>
<p class="text-muted">Loading series...</p>
</div>
<div v-else-if="filteredSeries.length > 0" class="space-y-6">
<div
v-for="series in filteredSeries"
:key="series.id"
class="bg-elevated rounded-lg shadow overflow-hidden"
>
<!-- Series Header -->
<div class="px-6 py-4 bg-muted border-b border-default">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div>
<h3 class="text-lg font-semibold text-highlighted">
{{ series.title }}
</h3>
<p class="text-sm text-muted">{{ series.description }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700'
: 'bg-accented text-default',
]"
>
{{ series.status }}
</span>
<span class="text-sm text-dimmed">
{{ series.eventCount }} events
</span>
</div>
</div>
</div>
<!-- Series Events -->
<div class="divide-y divide-default">
<div
v-for="event in series.events"
:key="event.id"
class="px-6 py-4 hover:bg-muted"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div
class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold"
>
{{ event.series?.position || "?" }}
</div>
<div>
<h4 class="text-sm font-medium text-highlighted">
{{ event.title }}
</h4>
<p class="text-xs text-dimmed">
{{ formatEventDate(event.startDate) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event),
]"
>
{{ getEventStatus(event) }}
</span>
<div class="flex gap-1">
<NuxtLink
:to="`/events/${event.slug || event.id}`"
class="p-1 text-muted hover:text-default rounded"
title="View Event"
>
<Icon name="heroicons:eye" class="w-4 h-4" />
</NuxtLink>
<button
@click="editEvent(event)"
class="p-1 text-muted hover:text-primary rounded"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
@click="removeFromSeries(event)"
class="p-1 text-muted hover:text-red-600 rounded"
title="Remove from Series"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Series Ticketing Info -->
<div
v-if="series.tickets?.enabled"
class="px-6 py-3 bg-blue-50 dark:bg-blue-950/20 border-t border-default"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Icon name="heroicons:ticket" class="w-5 h-5 text-blue-600" />
<div>
<span class="text-sm font-medium text-default">
Series Pass Ticketing Enabled
</span>
<p class="text-xs text-dimmed">
<span v-if="series.tickets.public?.available">
Public: ${{ series.tickets.public.price || 0 }}
</span>
<span v-if="series.tickets.member?.available" class="ml-2">
| Members:
{{
series.tickets.member.isFree
? "Free"
: `$${series.tickets.member.price || 0}`
}}
</span>
</p>
</div>
</div>
<button
@click="manageSeriesTickets(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Manage Tickets
</button>
</div>
</div>
<!-- Series Actions -->
<div class="px-6 py-3 bg-muted border-t border-default">
<div class="flex justify-between items-center">
<div class="text-sm text-dimmed">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="manageSeriesTickets(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Ticketing
</button>
<button
@click="editSeries(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Edit Series
</button>
<button
@click="addEventToSeries(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Add Event
</button>
<button
@click="duplicateSeries(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Duplicate Series
</button>
<button
@click="deleteSeries(series)"
class="text-sm text-red-600 hover:text-red-700 font-medium"
>
Delete Series
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 bg-elevated rounded-lg shadow">
<Icon
name="heroicons:squares-2x2"
class="w-12 h-12 text-muted mx-auto mb-3"
/>
<p class="text-muted">No event series found</p>
<p class="text-sm text-dimmed mt-2">
Create events and group them into series to get started
</p>
</div>
</div>
<!-- Edit Series Modal -->
<div
v-if="editingSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-highlighted">Edit Series</h3>
<button
@click="cancelEditSeries"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Series Title</label
>
<input
v-model="editingSeriesData.title"
type="text"
class="w-full border border-default bg-elevated text-default placeholder-dimmed rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="e.g., Co-op Game Dev Workshop Series"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<textarea
v-model="editingSeriesData.description"
rows="3"
class="w-full border border-default bg-elevated text-default placeholder-dimmed rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Brief description of this series"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Series Type</label
>
<USelect
v-model="editingSeriesData.type"
:items="[
{ label: 'Workshop Series', value: 'workshop_series' },
{ label: 'Recurring Meetup', value: 'recurring_meetup' },
{ label: 'Multi-Day Event', value: 'multi_day' },
{ label: 'Course', value: 'course' },
]"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Total Events (optional)</label
>
<UInput
v-model.number="editingSeriesData.totalEvents"
type="number"
min="1"
placeholder="Leave empty for ongoing series"
class="w-full"
/>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
<button
@click="cancelEditSeries"
class="px-4 py-2 text-muted hover:text-default"
>
Cancel
</button>
<button
@click="saveSeriesEdit"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
<!-- Bulk Operations Modal -->
<div
v-if="showBulkModal"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-highlighted">
Bulk Series Operations
</h3>
<button
@click="showBulkModal = false"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-highlighted mb-3">
Series Management Tools
</h4>
<div class="space-y-3">
<button
@click="reorderAllSeries"
class="w-full text-left p-3 border border-default rounded-lg hover:bg-muted"
>
<div class="flex items-center">
<Icon
name="heroicons:arrows-up-down"
class="w-5 h-5 text-muted mr-3"
/>
<div>
<p class="text-sm font-medium text-highlighted">
Auto-Reorder Series
</p>
<p class="text-xs text-dimmed">
Fix position numbers based on event dates
</p>
</div>
</div>
</button>
<button
@click="validateAllSeries"
class="w-full text-left p-3 border border-default rounded-lg hover:bg-muted"
>
<div class="flex items-center">
<Icon
name="heroicons:check-circle"
class="w-5 h-5 text-muted mr-3"
/>
<div>
<p class="text-sm font-medium text-highlighted">
Validate Series Data
</p>
<p class="text-xs text-dimmed">
Check for consistency issues
</p>
</div>
</div>
</button>
<button
@click="exportSeriesData"
class="w-full text-left p-3 border border-default rounded-lg hover:bg-muted"
>
<div class="flex items-center">
<Icon
name="heroicons:document-arrow-down"
class="w-5 h-5 text-muted mr-3"
/>
<div>
<p class="text-sm font-medium text-highlighted">
Export Series Data
</p>
<p class="text-xs text-dimmed">
Download series information as JSON
</p>
</div>
</div>
</button>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end">
<button
@click="showBulkModal = false"
class="px-4 py-2 text-muted hover:text-default"
>
Close
</button>
</div>
</div>
</div>
<!-- Series Ticketing Modal -->
<div
v-if="editingTicketsSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-highlighted">
Series Pass Ticketing
</h3>
<p class="text-sm text-muted">{{ editingTicketsData.title }}</p>
</div>
<button
@click="cancelTicketsEdit"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<!-- Enable Ticketing Toggle -->
<div class="p-4 bg-muted rounded-lg">
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Enable Series Pass Ticketing</span
>
<p class="text-xs text-dimmed">
Allow users to purchase a pass for all events in this series
</p>
</div>
</label>
</div>
<div v-if="editingTicketsData.tickets.enabled" class="space-y-6">
<!-- Ticketing Behavior -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-highlighted">
Ticketing Behavior
</h4>
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.requiresSeriesTicket"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Require Series Pass</span
>
<p class="text-xs text-dimmed">
Users must buy the series pass; individual event tickets are
not available
</p>
</div>
</label>
<label class="flex items-start cursor-pointer">
<input
v-model="
editingTicketsData.tickets.allowIndividualEventTickets
"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Allow Individual Event Tickets</span
>
<p class="text-xs text-dimmed">
Users can attend single events without buying the full
series pass
</p>
</div>
</label>
</div>
<!-- Member Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Member Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.member.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.member.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-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.member.name"
placeholder="e.g., Member Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<div class="flex items-center gap-2">
<UInput
v-model.number="editingTicketsData.tickets.member.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="editingTicketsData.tickets.member.isFree"
class="flex-1 w-full"
/>
<label
class="flex items-center whitespace-nowrap cursor-pointer"
>
<input
v-model="editingTicketsData.tickets.member.isFree"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
<span class="ml-1 text-xs text-muted">Free</span>
</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.member.description"
placeholder="Describe what's included with the member series pass..."
:rows="2"
class="w-full"
/>
</div>
</div>
</div>
<!-- Public Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Public Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.public.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.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-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.public.name"
placeholder="e.g., Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<UInput
v-model.number="editingTicketsData.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.public.description"
placeholder="Describe what's included with the public series pass..."
:rows="2"
class="w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Quantity Available</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.quantity
"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.public.sold || 0 }} sold,
{{ editingTicketsData.tickets.public.reserved || 0 }}
reserved
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Price (Optional)</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.earlyBirdPrice
"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div
v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0"
>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Deadline</label
>
<UInput
v-model="
editingTicketsData.tickets.public.earlyBirdDeadline
"
type="datetime-local"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Price increases to ${{
editingTicketsData.tickets.public.price
}}
after this date
</p>
</div>
</div>
</div>
<!-- Capacity Management -->
<div class="border border-default rounded-lg p-4">
<h4 class="text-sm font-semibold text-highlighted mb-4">
Capacity Management
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Total Capacity</label
>
<UInput
v-model.number="editingTicketsData.tickets.capacity.total"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Maximum series pass holders across all types
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Currently Reserved</label
>
<UInput
v-model.number="
editingTicketsData.tickets.capacity.reserved
"
type="number"
min="0"
disabled
class="w-full bg-accented"
/>
<p class="text-xs text-dimmed mt-1">
Auto-calculated during checkout
</p>
</div>
</div>
</div>
<!-- Waitlist Configuration -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">Waitlist</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Enable Waitlist</span>
<input
v-model="editingTicketsData.tickets.waitlist.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div v-if="editingTicketsData.tickets.waitlist.enabled">
<label class="block text-sm font-medium text-default mb-2"
>Max Waitlist Size</label
>
<UInput
v-model.number="editingTicketsData.tickets.waitlist.maxSize"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }}
people currently on waitlist
</p>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
<button
@click="cancelTicketsEdit"
class="px-4 py-2 text-muted hover:text-default"
>
Cancel
</button>
<button
@click="saveTicketsEdit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Ticketing Settings
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: "admin",
});
const showBulkModal = ref(false);
const searchQuery = ref("");
const statusFilter = ref("");
const editingSeriesId = ref(null);
const editingSeriesData = ref({
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
});
const editingTicketsSeriesId = ref(null);
const editingTicketsData = ref({
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
});
// Fetch series data
const {
data: seriesData,
pending,
refresh,
} = await useFetch("/api/admin/series");
// Computed properties
const activeSeries = computed(() => {
if (!seriesData.value) return [];
return seriesData.value;
});
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce(
(sum, series) => sum + (series.eventCount || 0),
0,
);
});
const filteredSeries = computed(() => {
if (!activeSeries.value) return [];
return activeSeries.value.filter((series) => {
const matchesSearch =
!searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const matchesStatus =
!statusFilter.value || series.status === statusFilter.value;
return matchesSearch && matchesStatus;
});
});
// Helper functions
const formatSeriesType = (type) => {
const types = {
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
workshop_series: "bg-emerald-100 text-emerald-700",
recurring_meetup: "bg-blue-100 text-blue-700",
multi_day: "bg-purple-100 text-purple-700",
course: "bg-amber-100 text-amber-700",
};
return classes[type] || "bg-gray-100 text-gray-700";
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate);
const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
return `${formatter.format(start)} - ${formatter.format(end)}`;
};
const getEventStatus = (event) => {
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Completed";
};
const getEventStatusClass = (event) => {
const status = getEventStatus(event);
const classes = {
Upcoming: "bg-blue-100 text-blue-700",
Ongoing: "bg-green-100 text-green-700",
Completed: "bg-gray-100 text-gray-700",
};
return classes[status] || "bg-gray-100 text-gray-700";
};
// Actions
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`);
};
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return;
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null,
},
},
});
await refresh();
} catch (error) {
console.error("Failed to remove event from series:", error);
alert("Failed to remove event from series");
}
};
const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled
const seriesData = {
series: {
isSeriesEvent: true,
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents,
},
};
sessionStorage.setItem("seriesEventData", JSON.stringify(seriesData));
navigateTo("/admin/events/create?series=true");
};
const duplicateSeries = (series) => {
// TODO: Implement series duplication
alert("Series duplication coming soon!");
};
const editSeries = (series) => {
editingSeriesId.value = series.id;
editingSeriesData.value = {
title: series.title,
description: series.description,
type: series.type,
totalEvents: series.totalEvents,
};
};
const cancelEditSeries = () => {
editingSeriesId.value = null;
editingSeriesData.value = {
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
};
};
const saveSeriesEdit = async () => {
if (!editingSeriesData.value.title) {
alert("Series title is required");
return;
}
try {
// Update the series record
await $fetch("/api/admin/series", {
method: "PUT",
body: {
id: editingSeriesId.value,
...editingSeriesData.value,
},
});
await refresh();
cancelEditSeries();
alert("Series updated successfully");
} catch (error) {
console.error("Failed to update series:", error);
alert("Failed to update series");
}
};
const deleteSeries = async (series) => {
if (
!confirm(
`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`,
)
)
return;
try {
// Update all events to remove series relationship
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null,
},
},
});
}
await refresh();
alert("Series deleted and events converted to standalone events");
} catch (error) {
console.error("Failed to delete series:", error);
alert("Failed to delete series");
}
};
// Ticketing management functions
const manageSeriesTickets = (series) => {
editingTicketsSeriesId.value = series.id;
// Deep clone the series data to avoid mutating the original
editingTicketsData.value = {
title: series.title,
tickets: {
enabled: series.tickets?.enabled || false,
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
allowIndividualEventTickets:
series.tickets?.allowIndividualEventTickets !== false,
currency: series.tickets?.currency || "CAD",
member: {
available: series.tickets?.member?.available !== false,
isFree: series.tickets?.member?.isFree !== false,
price: series.tickets?.member?.price || 0,
name: series.tickets?.member?.name || "Member Series Pass",
description: series.tickets?.member?.description || "",
},
public: {
available: series.tickets?.public?.available || false,
name: series.tickets?.public?.name || "Series Pass",
description: series.tickets?.public?.description || "",
price: series.tickets?.public?.price || 0,
quantity: series.tickets?.public?.quantity || null,
sold: series.tickets?.public?.sold || 0,
reserved: series.tickets?.public?.reserved || 0,
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
? new Date(series.tickets.public.earlyBirdDeadline)
.toISOString()
.slice(0, 16)
: "",
},
capacity: {
total: series.tickets?.capacity?.total || null,
reserved: series.tickets?.capacity?.reserved || 0,
},
waitlist: {
enabled: series.tickets?.waitlist?.enabled || false,
maxSize: series.tickets?.waitlist?.maxSize || null,
entries: series.tickets?.waitlist?.entries || [],
},
},
};
};
const cancelTicketsEdit = () => {
editingTicketsSeriesId.value = null;
editingTicketsData.value = {
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
};
};
const saveTicketsEdit = async () => {
try {
// Update the series with new ticketing configuration
await $fetch("/api/admin/series/tickets", {
method: "PUT",
body: {
id: editingTicketsSeriesId.value,
tickets: editingTicketsData.value.tickets,
},
});
await refresh();
cancelTicketsEdit();
alert("Ticketing settings updated successfully");
} catch (error) {
console.error("Failed to update ticketing settings:", error);
alert(
`Failed to update ticketing settings: ${error.data?.statusMessage || error.message}`,
);
}
};
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering
alert("Auto-reorder feature coming soon!");
};
const validateAllSeries = async () => {
// TODO: Implement validation
alert("Validation feature coming soon!");
};
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = "event-series-data.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
</script>