- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode - C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css) - F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
1234 lines
34 KiB
Vue
1234 lines
34 KiB
Vue
<template>
|
|
<div class="admin-series">
|
|
<!-- Page Header -->
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<div>
|
|
<h1>Series</h1>
|
|
<p>Manage event series and their relationships</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn" @click="showBulkModal = true">Bulk Operations</button>
|
|
<NuxtLink to="/admin/series/create" class="btn btn-primary">Create Series</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats + Filters row -->
|
|
<div class="content-row">
|
|
<div class="content-block">
|
|
<div class="section-label">Overview</div>
|
|
<div class="stat-row">
|
|
<span class="stat-key">Active Series</span>
|
|
<span class="stat-val">{{ activeSeries.length }}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-key">Total Series Events</span>
|
|
<span class="stat-val">{{ totalSeriesEvents }}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-key">Avg Events/Series</span>
|
|
<span class="stat-val">{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-block">
|
|
<div class="section-label">Filters</div>
|
|
<div class="field">
|
|
<label>Search</label>
|
|
<input v-model="searchQuery" placeholder="Search series..." />
|
|
</div>
|
|
<div class="field">
|
|
<label>Status</label>
|
|
<select v-model="statusFilter">
|
|
<option value="">All Status</option>
|
|
<option value="active">Active</option>
|
|
<option value="upcoming">Upcoming</option>
|
|
<option value="completed">Completed</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series List -->
|
|
<div class="series-list">
|
|
<div v-if="pending" class="loading-state">
|
|
<div class="spinner" />
|
|
<span>Loading series...</span>
|
|
</div>
|
|
|
|
<template v-else-if="filteredSeries.length > 0">
|
|
<div v-for="series in filteredSeries" :key="series.id" class="series-card">
|
|
<!-- Series Header -->
|
|
<div class="series-header">
|
|
<div class="series-title-row">
|
|
<div>
|
|
<span class="badge" :class="getSeriesTypeClass(series.type)">{{ formatSeriesType(series.type) }}</span>
|
|
<h2>{{ series.title }}</h2>
|
|
<p class="series-desc">{{ series.description }}</p>
|
|
</div>
|
|
<div class="series-meta">
|
|
<span :class="['status-pill', `status-${series.status}`]">{{ series.status }}</span>
|
|
<span class="event-count">{{ series.eventCount }} events</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Events -->
|
|
<div class="series-events">
|
|
<div v-for="event in series.events" :key="event.id" class="series-event-row">
|
|
<div class="event-pos">{{ event.series?.position || '?' }}</div>
|
|
<div class="event-info">
|
|
<span class="event-name">{{ event.title }}</span>
|
|
<span class="event-date">{{ formatEventDate(event.startDate) }}</span>
|
|
</div>
|
|
<div class="event-actions">
|
|
<span :class="['status-pill', `status-${getEventStatus(event).toLowerCase()}`]">
|
|
{{ getEventStatus(event) }}
|
|
</span>
|
|
<NuxtLink :to="`/events/${event.slug || event.id}`" class="link-btn">View</NuxtLink>
|
|
<button @click="editEvent(event)" class="link-btn">Edit</button>
|
|
<button @click="removeFromSeries(event)" class="link-btn link-btn-danger">Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Ticketing Info -->
|
|
<div v-if="series.tickets?.enabled" class="series-tickets">
|
|
<span class="ticket-label">Series Pass Ticketing Enabled</span>
|
|
<span v-if="series.tickets.public?.available" class="ticket-detail">
|
|
Public: ${{ series.tickets.public.price || 0 }}
|
|
</span>
|
|
<span v-if="series.tickets.member?.available" class="ticket-detail">
|
|
Members: {{ series.tickets.member.isFree ? 'Free' : `$${series.tickets.member.price || 0}` }}
|
|
</span>
|
|
<button @click="manageSeriesTickets(series)" class="link-btn">Manage Tickets</button>
|
|
</div>
|
|
|
|
<!-- Series Footer -->
|
|
<div class="series-footer">
|
|
<span class="date-range">{{ formatDateRange(series.startDate, series.endDate) }}</span>
|
|
<div class="series-actions">
|
|
<button @click="manageSeriesTickets(series)" class="link-btn">Ticketing</button>
|
|
<button @click="editSeries(series)" class="link-btn">Edit</button>
|
|
<button @click="addEventToSeries(series)" class="link-btn">Add Event</button>
|
|
<button @click="deleteSeries(series)" class="link-btn link-btn-danger">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="empty-state">
|
|
<p>No event series found</p>
|
|
<p class="empty-hint">Create events and group them into series to get started</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Series Modal -->
|
|
<div v-if="editingSeriesId" class="modal-overlay" @click.self="cancelEditSeries">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h2>Edit Series</h2>
|
|
<button class="modal-close" @click="cancelEditSeries">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="field">
|
|
<label>Series Title</label>
|
|
<input v-model="editingSeriesData.title" type="text" placeholder="e.g., Co-op Game Dev Workshop Series" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Description</label>
|
|
<textarea v-model="editingSeriesData.description" rows="3" placeholder="Brief description of this series"></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Series Type</label>
|
|
<select v-model="editingSeriesData.type">
|
|
<option value="workshop_series">Workshop Series</option>
|
|
<option value="recurring_meetup">Recurring Meetup</option>
|
|
<option value="multi_day">Multi-Day Event</option>
|
|
<option value="course">Course</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Total Events (optional)</label>
|
|
<input v-model.number="editingSeriesData.totalEvents" type="number" min="1" placeholder="Leave empty for ongoing series" />
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button @click="cancelEditSeries" class="btn">Cancel</button>
|
|
<button @click="saveSeriesEdit" class="btn btn-primary">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bulk Operations Modal -->
|
|
<div v-if="showBulkModal" class="modal-overlay" @click.self="showBulkModal = false">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h2>Bulk Series Operations</h2>
|
|
<button class="modal-close" @click="showBulkModal = false">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="section-label">Series Management Tools</div>
|
|
<button @click="exportSeriesData" class="btn bulk-action">
|
|
<strong>Export Series Data</strong>
|
|
<span>Download series information as JSON</span>
|
|
</button>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button @click="showBulkModal = false" class="btn">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Series Ticketing Modal -->
|
|
<div v-if="editingTicketsSeriesId" class="modal-overlay" @click.self="cancelTicketsEdit">
|
|
<div class="modal modal-xl">
|
|
<div class="modal-header">
|
|
<div>
|
|
<h2>Series Pass Ticketing</h2>
|
|
<p class="modal-subtitle">{{ editingTicketsData.title }}</p>
|
|
</div>
|
|
<button class="modal-close" @click="cancelTicketsEdit">×</button>
|
|
</div>
|
|
|
|
<div class="modal-body modal-body-scroll">
|
|
<!-- Enable Ticketing -->
|
|
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
|
|
<label class="check-label">
|
|
<input v-model="editingTicketsData.tickets.enabled" type="checkbox" />
|
|
<div>
|
|
<strong>Enable Series Pass Ticketing</strong>
|
|
<span class="help-text">Allow users to purchase a pass for all events in this series</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<template v-if="editingTicketsData.tickets.enabled">
|
|
<!-- Ticketing Behavior -->
|
|
<div class="section-label" style="margin-top: 20px;">Ticketing Behavior</div>
|
|
<label class="check-label" style="margin-bottom: 8px;">
|
|
<input v-model="editingTicketsData.tickets.requiresSeriesTicket" type="checkbox" />
|
|
<div>
|
|
<strong>Require Series Pass</strong>
|
|
<span class="help-text">Users must buy the series pass; individual event tickets are not available</span>
|
|
</div>
|
|
</label>
|
|
<label class="check-label" style="margin-bottom: 16px;">
|
|
<input
|
|
v-model="editingTicketsData.tickets.allowIndividualEventTickets"
|
|
type="checkbox"
|
|
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
|
|
/>
|
|
<div>
|
|
<strong>Allow Individual Event Tickets</strong>
|
|
<span class="help-text">Users can attend single events without buying the full series pass</span>
|
|
</div>
|
|
</label>
|
|
|
|
<!-- Member Tickets -->
|
|
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
|
|
<div class="ticket-section-header">
|
|
<div class="section-label" style="margin-bottom: 0;">Member Series Pass</div>
|
|
<label class="check-inline">
|
|
<span>Available</span>
|
|
<input v-model="editingTicketsData.tickets.member.available" type="checkbox" />
|
|
</label>
|
|
</div>
|
|
<template v-if="editingTicketsData.tickets.member.available">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Pass Name</label>
|
|
<input v-model="editingTicketsData.tickets.member.name" placeholder="e.g., Member Series Pass" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Price (CAD)</label>
|
|
<div class="price-row">
|
|
<input
|
|
v-model.number="editingTicketsData.tickets.member.price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
:disabled="editingTicketsData.tickets.member.isFree"
|
|
/>
|
|
<label class="check-inline">
|
|
<span>Free</span>
|
|
<input v-model="editingTicketsData.tickets.member.isFree" type="checkbox" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Description</label>
|
|
<textarea v-model="editingTicketsData.tickets.member.description" rows="2" placeholder="Describe what's included..."></textarea>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Public Tickets -->
|
|
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
|
|
<div class="ticket-section-header">
|
|
<div class="section-label" style="margin-bottom: 0;">Public Series Pass</div>
|
|
<label class="check-inline">
|
|
<span>Available</span>
|
|
<input v-model="editingTicketsData.tickets.public.available" type="checkbox" />
|
|
</label>
|
|
</div>
|
|
<template v-if="editingTicketsData.tickets.public.available">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Pass Name</label>
|
|
<input v-model="editingTicketsData.tickets.public.name" placeholder="e.g., Series Pass" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Price (CAD)</label>
|
|
<input v-model.number="editingTicketsData.tickets.public.price" type="number" min="0" step="0.01" placeholder="0.00" />
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Description</label>
|
|
<textarea v-model="editingTicketsData.tickets.public.description" rows="2" placeholder="Describe what's included..."></textarea>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Quantity Available</label>
|
|
<input v-model.number="editingTicketsData.tickets.public.quantity" type="number" min="1" placeholder="Unlimited" />
|
|
<span class="field-note">{{ editingTicketsData.tickets.public.sold || 0 }} sold, {{ editingTicketsData.tickets.public.reserved || 0 }} reserved</span>
|
|
</div>
|
|
<div class="field">
|
|
<label>Early Bird Price (optional)</label>
|
|
<input v-model.number="editingTicketsData.tickets.public.earlyBirdPrice" type="number" min="0" step="0.01" placeholder="0.00" />
|
|
</div>
|
|
</div>
|
|
<div v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0" class="field">
|
|
<label>Early Bird Deadline</label>
|
|
<input v-model="editingTicketsData.tickets.public.earlyBirdDeadline" type="datetime-local" />
|
|
<span class="field-note">Price increases to ${{ editingTicketsData.tickets.public.price }} after this date</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Capacity -->
|
|
<div class="dashed-box no-hover" style="margin-bottom: 16px;">
|
|
<div class="section-label">Capacity Management</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Total Capacity</label>
|
|
<input v-model.number="editingTicketsData.tickets.capacity.total" type="number" min="1" placeholder="Unlimited" />
|
|
<span class="field-note">Maximum series pass holders across all types</span>
|
|
</div>
|
|
<div class="field">
|
|
<label>Currently Reserved</label>
|
|
<input v-model.number="editingTicketsData.tickets.capacity.reserved" type="number" min="0" disabled />
|
|
<span class="field-note">Auto-calculated during checkout</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Waitlist -->
|
|
<div class="dashed-box no-hover">
|
|
<div class="ticket-section-header">
|
|
<div class="section-label" style="margin-bottom: 0;">Waitlist</div>
|
|
<label class="check-inline">
|
|
<span>Enable Waitlist</span>
|
|
<input v-model="editingTicketsData.tickets.waitlist.enabled" type="checkbox" />
|
|
</label>
|
|
</div>
|
|
<template v-if="editingTicketsData.tickets.waitlist.enabled">
|
|
<div class="field">
|
|
<label>Max Waitlist Size</label>
|
|
<input v-model.number="editingTicketsData.tickets.waitlist.maxSize" type="number" min="1" placeholder="Unlimited" />
|
|
<span class="field-note">{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }} people currently on waitlist</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button @click="cancelTicketsEdit" class="btn">Cancel</button>
|
|
<button @click="saveTicketsEdit" class="btn btn-primary">Save Ticketing Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Confirm Action Modal -->
|
|
<div v-if="confirmAction.show" class="modal-overlay" @click.self="confirmAction.show = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2>{{ confirmAction.heading }}</h2>
|
|
<button class="modal-close" @click="confirmAction.show = false">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>{{ confirmAction.message }}</p>
|
|
<p class="help-text" style="margin-top: 8px;">This action cannot be undone.</p>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn" @click="confirmAction.show = false">Cancel</button>
|
|
<button class="btn btn-danger" :disabled="confirmAction.running" @click="confirmAction.execute">
|
|
{{ confirmAction.running ? 'Working...' : confirmAction.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: '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: [],
|
|
},
|
|
},
|
|
})
|
|
|
|
const {
|
|
data: seriesData,
|
|
pending,
|
|
refresh,
|
|
} = await useFetch('/api/admin/series')
|
|
|
|
const activeSeries = computed(() => 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
|
|
})
|
|
})
|
|
|
|
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 getSeriesTypeClass = (type) => {
|
|
const classes = {
|
|
workshop_series: 'all',
|
|
recurring_meetup: 'all',
|
|
multi_day: 'founder',
|
|
course: 'all',
|
|
}
|
|
return classes[type] || 'all'
|
|
}
|
|
|
|
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 editEvent = (event) => {
|
|
navigateTo(`/admin/events/create?edit=${event.id}`)
|
|
}
|
|
|
|
const confirmAction = reactive({
|
|
show: false,
|
|
heading: '',
|
|
message: '',
|
|
label: '',
|
|
running: false,
|
|
execute: () => {},
|
|
})
|
|
|
|
const removeFromSeries = (event) => {
|
|
confirmAction.heading = 'Remove from Series'
|
|
confirmAction.message = `Remove "${event.title}" from its series?`
|
|
confirmAction.label = 'Remove'
|
|
confirmAction.running = false
|
|
confirmAction.execute = async () => {
|
|
confirmAction.running = true
|
|
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,
|
|
},
|
|
},
|
|
})
|
|
confirmAction.show = false
|
|
await refresh()
|
|
} catch (error) {
|
|
console.error('Failed to remove event from series:', error)
|
|
} finally {
|
|
confirmAction.running = false
|
|
}
|
|
}
|
|
confirmAction.show = true
|
|
}
|
|
|
|
const addEventToSeries = (series) => {
|
|
const seriesPayload = {
|
|
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(seriesPayload))
|
|
navigateTo('/admin/events/create?series=true')
|
|
}
|
|
|
|
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) return
|
|
|
|
try {
|
|
await $fetch('/api/admin/series', {
|
|
method: 'PUT',
|
|
body: { id: editingSeriesId.value, ...editingSeriesData.value },
|
|
})
|
|
await refresh()
|
|
cancelEditSeries()
|
|
} catch (error) {
|
|
console.error('Failed to update series:', error)
|
|
}
|
|
}
|
|
|
|
const deleteSeries = (series) => {
|
|
confirmAction.heading = 'Delete Series'
|
|
confirmAction.message = `Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`
|
|
confirmAction.label = 'Delete'
|
|
confirmAction.running = false
|
|
confirmAction.execute = async () => {
|
|
confirmAction.running = true
|
|
try {
|
|
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 },
|
|
},
|
|
})
|
|
}
|
|
confirmAction.show = false
|
|
await refresh()
|
|
} catch (error) {
|
|
console.error('Failed to delete series:', error)
|
|
} finally {
|
|
confirmAction.running = false
|
|
}
|
|
}
|
|
confirmAction.show = true
|
|
}
|
|
|
|
const manageSeriesTickets = (series) => {
|
|
editingTicketsSeriesId.value = series.id
|
|
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
|
|
}
|
|
|
|
const saveTicketsEdit = async () => {
|
|
try {
|
|
await $fetch('/api/admin/series/tickets', {
|
|
method: 'PUT',
|
|
body: { id: editingTicketsSeriesId.value, tickets: editingTicketsData.value.tickets },
|
|
})
|
|
await refresh()
|
|
cancelTicketsEdit()
|
|
} catch (error) {
|
|
console.error('Failed to update ticketing settings:', error)
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<style scoped>
|
|
.admin-series {}
|
|
|
|
/* ---- PAGE HEADER ---- */
|
|
.page-header {
|
|
padding: 28px 28px 20px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.header-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ---- CONTENT GRID ---- */
|
|
.content-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.content-block {
|
|
padding: 24px 28px;
|
|
border-right: 1px dashed var(--border);
|
|
min-width: 0;
|
|
}
|
|
|
|
.content-block:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
/* ---- STATS ---- */
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px dashed var(--border);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.stat-row:last-of-type {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.stat-key { color: var(--text-faint); }
|
|
.stat-val { color: var(--text-bright); font-weight: 600; }
|
|
|
|
/* ---- SERIES LIST ---- */
|
|
.series-list {
|
|
padding: 24px 28px;
|
|
}
|
|
|
|
.series-card {
|
|
border: 1px dashed var(--border);
|
|
margin-bottom: 20px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.series-card:hover {
|
|
border-color: var(--candle-faint);
|
|
}
|
|
|
|
.series-header {
|
|
padding: 20px 24px 16px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.series-title-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
}
|
|
|
|
.series-header h2 {
|
|
font-family: 'Brygada 1918', serif;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
margin: 8px 0 2px;
|
|
}
|
|
|
|
.series-desc {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.series-meta {
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.event-count {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* ---- SERIES EVENTS ---- */
|
|
.series-events {
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.series-event-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 24px;
|
|
border-bottom: 1px dashed var(--border);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.series-event-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.series-event-row:hover {
|
|
background: var(--surface);
|
|
}
|
|
|
|
.event-pos {
|
|
width: 24px;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--c-founder);
|
|
border: 1px dashed color-mix(in srgb, var(--ember) 40%, transparent);
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.event-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.event-name {
|
|
display: block;
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.event-date {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.event-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ---- SERIES TICKETS BAR ---- */
|
|
.series-tickets {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 24px;
|
|
background: var(--surface);
|
|
border-bottom: 1px dashed var(--border);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.ticket-label {
|
|
color: var(--candle);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ticket-detail {
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
/* ---- SERIES FOOTER ---- */
|
|
.series-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px 24px;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.date-range {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.series-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
/* ---- STATUS PILLS ---- */
|
|
.status-pill {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
padding: 2px 8px;
|
|
border: 1px dashed;
|
|
}
|
|
|
|
.status-active {
|
|
color: var(--green);
|
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
|
}
|
|
|
|
.status-upcoming {
|
|
color: var(--candle);
|
|
border-color: color-mix(in srgb, var(--candle) 30%, transparent);
|
|
}
|
|
|
|
.status-completed {
|
|
color: var(--text-faint);
|
|
border-color: var(--border);
|
|
}
|
|
|
|
.status-ongoing {
|
|
color: var(--green);
|
|
border-color: color-mix(in srgb, var(--green) 30%, transparent);
|
|
}
|
|
|
|
/* ---- LINK BUTTONS ---- */
|
|
.link-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--candle);
|
|
cursor: pointer;
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.link-btn:hover { text-decoration: underline; }
|
|
.link-btn-danger { color: var(--ember); }
|
|
|
|
/* ---- MODALS ---- */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 50;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg);
|
|
border: 1px dashed var(--border);
|
|
max-width: 440px;
|
|
width: 100%;
|
|
margin: 16px;
|
|
max-height: 90vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-wide { max-width: 640px; }
|
|
.modal-xl { max-width: 800px; }
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
padding: 20px 24px 16px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.modal-header h2 {
|
|
font-family: 'Brygada 1918', serif;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
.modal-subtitle {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-faint);
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 0 4px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.modal-close:hover { color: var(--text); }
|
|
|
|
.modal-body {
|
|
padding: 20px 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-body-scroll {
|
|
max-height: 60vh;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
padding: 16px 24px;
|
|
border-top: 1px dashed var(--border);
|
|
}
|
|
|
|
/* ---- BULK ACTION ---- */
|
|
.bulk-action {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 14px 20px;
|
|
border: 1px dashed var(--border);
|
|
background: none;
|
|
cursor: pointer;
|
|
margin-bottom: 8px;
|
|
transition: border-color 0.2s;
|
|
font-family: 'Commit Mono', monospace;
|
|
}
|
|
|
|
.bulk-action:hover {
|
|
border-color: var(--candle-faint);
|
|
}
|
|
|
|
.bulk-action strong {
|
|
display: block;
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.bulk-action span {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
/* ---- TICKETING FORM ---- */
|
|
.ticket-section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.check-label {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: flex-start;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.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-inline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.check-inline input[type="checkbox"] {
|
|
accent-color: var(--candle);
|
|
}
|
|
|
|
.help-text {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
line-height: 1.5;
|
|
display: block;
|
|
}
|
|
|
|
.field-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.field-note {
|
|
display: block;
|
|
font-size: 10px;
|
|
color: var(--text-faint);
|
|
margin-top: 3px;
|
|
}
|
|
|
|
.price-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.price-row input {
|
|
flex: 1;
|
|
}
|
|
|
|
/* ---- STATES ---- */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-hint {
|
|
font-size: 11px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px dashed var(--candle);
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 12px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ---- RESPONSIVE ---- */
|
|
@media (max-width: 768px) {
|
|
.page-header {
|
|
padding: 24px 20px 16px;
|
|
}
|
|
|
|
.header-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.content-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.content-block {
|
|
border-right: none;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.content-block:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.series-list {
|
|
padding: 20px 20px;
|
|
}
|
|
|
|
.series-header,
|
|
.series-event-row,
|
|
.series-tickets,
|
|
.series-footer {
|
|
padding-left: 16px;
|
|
padding-right: 16px;
|
|
}
|
|
|
|
.series-title-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.series-footer {
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.series-actions {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.field-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.event-actions {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|