From a88aa621985514a9e82ed41752c7c41da7960ef1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 27 Aug 2025 20:40:54 +0100 Subject: [PATCH] Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience. --- app/components/NaturalDateInput.vue | 238 +++++++++ app/layouts/admin.vue | 14 +- app/pages/admin/events/create.vue | 493 ++++++++++++++++- app/pages/admin/events/index.vue | 20 +- app/pages/admin/series-management.vue | 504 ++++++++++++++++++ app/pages/admin/series/create.vue | 268 ++++++++++ app/pages/events/index.vue | 155 +++++- app/pages/series/index.vue | 234 ++++++++ package-lock.json | 19 + package.json | 1 + scripts/seed-series-events.js | 187 +++++++ server/api/admin/events.post.js | 23 +- server/api/admin/events/[id].put.js | 17 + server/api/admin/series.get.js | 60 +++ server/api/admin/series.post.js | 49 ++ server/api/admin/series/[id].delete.js | 58 ++ server/api/admin/series/[id].put.js | 62 +++ server/api/events/[id]/guest-register.post.js | 112 ++++ server/api/events/[id]/payment.post.js | 136 +++++ server/api/events/[id]/register.post.js | 44 +- server/api/series/[id].get.js | 78 +++ server/api/series/index.get.js | 91 ++++ server/models/event.js | 43 ++ server/models/series.js | 35 ++ 24 files changed, 2897 insertions(+), 44 deletions(-) create mode 100644 app/components/NaturalDateInput.vue create mode 100644 app/pages/admin/series-management.vue create mode 100644 app/pages/admin/series/create.vue create mode 100644 app/pages/series/index.vue create mode 100644 scripts/seed-series-events.js create mode 100644 server/api/admin/series.get.js create mode 100644 server/api/admin/series.post.js create mode 100644 server/api/admin/series/[id].delete.js create mode 100644 server/api/admin/series/[id].put.js create mode 100644 server/api/events/[id]/guest-register.post.js create mode 100644 server/api/events/[id]/payment.post.js create mode 100644 server/api/series/[id].get.js create mode 100644 server/api/series/index.get.js create mode 100644 server/models/series.js diff --git a/app/components/NaturalDateInput.vue b/app/components/NaturalDateInput.vue new file mode 100644 index 0000000..c0f8983 --- /dev/null +++ b/app/components/NaturalDateInput.vue @@ -0,0 +1,238 @@ + + + \ No newline at end of file diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index a9194f7..fda4eef 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -57,18 +57,18 @@ - + - Analytics + Series @@ -159,15 +159,15 @@ - Analytics + Series diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 299517a..81db3e0 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -139,12 +139,11 @@ -

{{ fieldErrors.startDate }}

@@ -153,12 +152,11 @@ -

{{ fieldErrors.endDate }}

@@ -177,11 +175,9 @@
-

When should registration close? (optional)

@@ -236,6 +232,273 @@ + +
+

Ticketing

+ +
+ + +
+ + +
+
+
+ + +
+ +
+ + +

Set to 0 for free public events

+
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+

Price increases to regular price after this date

+
+
+
+ +
+

+ Note: Members always get free access to all events regardless of ticket settings. +

+
+
+
+ + +
+

Series Management

+ +
+ + +
+
+ +
+ + + New Series + +
+

+ Select an existing series or create a new one +

+
+ +
+
+
+ + +

+ {{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }} +

+
+ +
+ + +

Order within the series (1, 2, 3, etc.)

+
+
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'Descriptive name for the entire series' }}

+
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}

+
+ +
+
+ + +
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}

+
+
+ +
+

+ Note: This event will be added to the existing "{{ eventForm.series.title }}" series. +

+
+
+
+
+
+

Event Settings

@@ -354,6 +617,10 @@ const editingEvent = ref(null) const showSuccessMessage = ref(false) const formErrors = ref([]) const fieldErrors = ref({}) +const seriesExists = ref(false) +const existingSeries = ref(null) +const selectedSeriesId = ref('') +const availableSeries = ref([]) const eventForm = reactive({ title: '', @@ -371,9 +638,63 @@ const eventForm = reactive({ targetCircles: [], maxAttendees: '', registrationRequired: false, - registrationDeadline: '' + registrationDeadline: '', + tickets: { + enabled: false, + public: { + available: false, + name: 'Public Ticket', + description: '', + price: 0, + quantity: null, + earlyBirdPrice: null, + earlyBirdDeadline: '' + } + }, + series: { + isSeriesEvent: false, + id: '', + title: '', + description: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) +// Load available series +onMounted(async () => { + try { + const response = await $fetch('/api/admin/series') + availableSeries.value = response + } catch (error) { + console.error('Failed to load series:', error) + } +}) + +// Handle series selection +const onSeriesSelect = () => { + if (selectedSeriesId.value) { + const series = availableSeries.value.find(s => s.id === selectedSeriesId.value) + if (series) { + eventForm.series.id = series.id + eventForm.series.title = series.title + eventForm.series.description = series.description + eventForm.series.type = series.type + eventForm.series.totalEvents = series.totalEvents + eventForm.series.position = (series.eventCount || 0) + 1 + } + } else { + // Reset series form when no series is selected + eventForm.series.id = '' + eventForm.series.title = '' + eventForm.series.description = '' + eventForm.series.type = 'workshop_series' + eventForm.series.position = 1 + eventForm.series.totalEvents = null + } +} + // Check if we're editing an event if (route.query.edit) { try { @@ -398,8 +719,33 @@ if (route.query.edit) { targetCircles: event.targetCircles || [], maxAttendees: event.maxAttendees || '', registrationRequired: event.registrationRequired, - registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '' + registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '', + 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: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) + // Handle early bird deadline formatting + if (event.tickets?.public?.earlyBirdDeadline) { + eventForm.tickets.public.earlyBirdDeadline = new Date(event.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16) + } } } catch (error) { console.error('Failed to load event for editing:', error) @@ -420,6 +766,20 @@ if (route.query.duplicate && process.client) { } } +// Check if we're creating a series event +if (route.query.series && process.client) { + const seriesData = sessionStorage.getItem('seriesEventData') + if (seriesData) { + try { + const data = JSON.parse(seriesData) + Object.assign(eventForm, data) + sessionStorage.removeItem('seriesEventData') + } catch (error) { + console.error('Failed to load series event data:', error) + } + } +} + const validateForm = () => { formErrors.value = [] fieldErrors.value = {} @@ -491,6 +851,63 @@ const validateForm = () => { return formErrors.value.length === 0 } +// Check if a series with this ID already exists +const checkExistingSeries = async () => { + if (!eventForm.series.id || selectedSeriesId.value) { + seriesExists.value = false + existingSeries.value = null + return + } + + try { + // First check in standalone series + const standaloneResponse = await $fetch(`/api/admin/series`) + const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id) + + if (existingStandalone) { + seriesExists.value = true + existingSeries.value = existingStandalone + // Auto-fill series details + if (!eventForm.series.title || eventForm.series.title === '') { + eventForm.series.title = existingStandalone.title + } + if (!eventForm.series.description || eventForm.series.description === '') { + eventForm.series.description = existingStandalone.description + } + if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { + eventForm.series.type = existingStandalone.type + } + if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { + eventForm.series.totalEvents = existingStandalone.totalEvents + } + return + } + + // Fallback to legacy series check (events with series data) + const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`) + if (legacyResponse) { + seriesExists.value = true + existingSeries.value = legacyResponse + if (!eventForm.series.title || eventForm.series.title === '') { + eventForm.series.title = legacyResponse.title + } + if (!eventForm.series.description || eventForm.series.description === '') { + eventForm.series.description = legacyResponse.description + } + if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { + eventForm.series.type = legacyResponse.type + } + if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { + eventForm.series.totalEvents = legacyResponse.totalEvents + } + } + } catch (error) { + // Series doesn't exist yet + seriesExists.value = false + existingSeries.value = null + } +} + const saveEvent = async (redirect = true) => { if (!validateForm()) { // Scroll to top to show errors @@ -500,6 +917,27 @@ const saveEvent = async (redirect = true) => { 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 && eventForm.series.id && !selectedSeriesId.value) { + try { + await $fetch('/api/admin/series', { + method: 'POST', + body: { + id: eventForm.series.id, + title: eventForm.series.title, + description: eventForm.series.description, + type: eventForm.series.type, + totalEvents: eventForm.series.totalEvents + } + }) + } catch (seriesError) { + // Series might already exist, that's ok + if (!seriesError.data?.statusMessage?.includes('already exists')) { + throw seriesError + } + } + } + if (editingEvent.value) { await $fetch(`/api/admin/events/${editingEvent.value._id}`, { method: 'PUT', @@ -552,7 +990,28 @@ const saveAndCreateAnother = async () => { targetCircles: [], maxAttendees: '', registrationRequired: false, - registrationDeadline: '' + registrationDeadline: '', + tickets: { + enabled: false, + public: { + available: false, + name: 'Public Ticket', + description: '', + price: 0, + quantity: null, + earlyBirdPrice: null, + earlyBirdDeadline: '' + } + }, + series: { + isSeriesEvent: false, + id: '', + title: '', + description: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) // Clear any existing errors diff --git a/app/pages/admin/events/index.vue b/app/pages/admin/events/index.vue index de6cf39..8be0c2a 100644 --- a/app/pages/admin/events/index.vue +++ b/app/pages/admin/events/index.vue @@ -27,6 +27,11 @@ +
@@ -77,6 +82,14 @@
{{ event.title }}
{{ event.description.substring(0, 100) }}...
+
+
+
+ {{ event.series.position }} +
+ {{ event.series.title }} +
+
@@ -193,6 +206,7 @@ const { data: events, pending, error, refresh } = await useFetch("/api/admin/eve const searchQuery = ref('') const typeFilter = ref('') const statusFilter = ref('') +const seriesFilter = ref('') const filteredEvents = computed(() => { if (!events.value) return [] @@ -207,7 +221,11 @@ const filteredEvents = computed(() => { const eventStatus = getEventStatus(event) const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value - return matchesSearch && matchesType && matchesStatus + const matchesSeries = !seriesFilter.value || + (seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) || + (seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent) + + return matchesSearch && matchesType && matchesStatus && matchesSeries }) }) diff --git a/app/pages/admin/series-management.vue b/app/pages/admin/series-management.vue new file mode 100644 index 0000000..4bf8198 --- /dev/null +++ b/app/pages/admin/series-management.vue @@ -0,0 +1,504 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/series/create.vue b/app/pages/admin/series/create.vue new file mode 100644 index 0000000..9799d8f --- /dev/null +++ b/app/pages/admin/series/create.vue @@ -0,0 +1,268 @@ + + + \ No newline at end of file diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 5c23b52..7029d4a 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -66,6 +66,84 @@ + +
+ +
+

+ Active Event Series +

+

+ Multi-part workshops and recurring events designed to deepen your knowledge and build community connections. +

+
+ +
+
+
+
+ {{ formatSeriesType(series.type) }} +
+
+ + {{ series.eventCount }} events +
+
+ +

+ {{ series.title }} +

+ +

+ {{ series.description }} +

+ +
+
+
+
+ {{ event.series?.position || '?' }} +
+ {{ event.title }} +
+ + {{ formatEventDate(event.startDate) }} + +
+
+ +{{ series.events.length - 3 }} more events +
+
+ +
+
+ {{ formatDateRange(series.startDate, series.endDate) }} +
+ + {{ series.status }} + +
+
+
+
+
+
@@ -93,6 +171,17 @@
+ +
+
+
+ {{ event.series.position }} +
+ + {{ event.series.title }} +
+
+
+ +
+

+ Event Series +

+

+ Discover our multi-event series designed to take you on a journey of learning and growth +

+
+
+
+ + +
+ +
+
+

Loading series...

+
+ +
+
+ +
+
+
+
+
+ {{ formatSeriesType(series.type) }} +
+ + {{ series.status }} + +
+

{{ series.title }}

+

{{ series.description }}

+
+
+
{{ series.eventCount }}
+
Events
+
+ of {{ series.totalEvents }} planned +
+
+
+
+ + +
+
+
+
+
+ {{ event.series?.position || '?' }} +
+
+

{{ event.title }}

+
+
+ + {{ formatEventDate(event.startDate) }} +
+
+ + {{ formatEventTime(event.startDate) }} +
+
+ + {{ event.registrations.length }} registered +
+
+
+
+
+ + {{ getEventStatus(event) }} + + + View Event + +
+
+
+
+ + +
+
+
+ + Series runs from {{ formatDateRange(series.startDate, series.endDate) }} +
+
+ + {{ series.totalRegistrations }} total registrations +
+
+
+
+
+ +
+ +

No Event Series Available

+

+ We're currently planning exciting event series. Check back soon for multi-event learning journeys! +

+
+
+
+
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c350064..4be3954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "chrono-node": "^2.8.4", "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", @@ -6967,6 +6968,18 @@ "node": ">=18" } }, + "node_modules/chrono-node": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.8.4.tgz", + "integrity": "sha512-F+Rq88qF3H2dwjnFrl3TZrn5v4ZO57XxeQ+AhuL1C685So1hdUV/hT/q8Ja5UbmPYEZfx8VrxFDa72Dgldcxpg==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.10.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -7666,6 +7679,12 @@ "node": ">= 12" } }, + "node_modules/dayjs": { + "version": "1.11.14", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.14.tgz", + "integrity": "sha512-E8fIdSxUlyqSA8XYGnNa3IkIzxtEmFjI+JU/6ic0P1zmSqyL6HyG5jHnpPjRguDNiaHLpfvHKWFiohNsJLqcJQ==", + "license": "MIT" + }, "node_modules/db0": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz", diff --git a/package.json b/package.json index 241bacf..289240f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "chrono-node": "^2.8.4", "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", diff --git a/scripts/seed-series-events.js b/scripts/seed-series-events.js new file mode 100644 index 0000000..1a2d765 --- /dev/null +++ b/scripts/seed-series-events.js @@ -0,0 +1,187 @@ +import { connectDB } from '../server/utils/mongoose.js' +import Event from '../server/models/event.js' + +async function seedSeriesEvents() { + try { + await connectDB() + console.log('Connected to database') + + // Workshop Series: "Cooperative Game Development Fundamentals" + const workshopSeries = [ + { + title: 'Cooperative Business Models in Game Development', + slug: 'coop-business-models-workshop', + tagline: 'Learn the foundations of cooperative business structures', + description: 'An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.', + content: 'This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.', + startDate: new Date('2024-10-15T19:00:00.000Z'), + endDate: new Date('2024-10-15T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 1, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Democratic Decision Making in Creative Projects', + slug: 'democratic-decision-making-workshop', + tagline: 'Practical tools for collaborative project management', + description: 'Learn how to implement democratic decision-making processes that work for creative teams and game development projects.', + content: 'This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.', + startDate: new Date('2024-10-22T19:00:00.000Z'), + endDate: new Date('2024-10-22T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 2, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Funding and Financial Models for Co-ops', + slug: 'coop-funding-workshop', + tagline: 'Sustainable financing for cooperative studios', + description: 'Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.', + content: 'This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.', + startDate: new Date('2024-10-29T19:00:00.000Z'), + endDate: new Date('2024-10-29T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 3, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Building Your Cooperative Studio', + slug: 'building-coop-studio-workshop', + tagline: 'From concept to reality: launching your co-op', + description: 'A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.', + content: 'This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.', + startDate: new Date('2024-11-05T19:00:00.000Z'), + endDate: new Date('2024-11-05T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 4, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + } + ] + + // Monthly Community Meetup Series + const meetupSeries = [ + { + title: 'October Community Meetup', + slug: 'october-community-meetup', + tagline: 'Monthly gathering for cooperative game developers', + description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.', + content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.', + startDate: new Date('2024-10-12T18:00:00.000Z'), + endDate: new Date('2024-10-12T20:00:00.000Z'), + eventType: 'community', + location: '#community-meetup', + isOnline: true, + membersOnly: false, + registrationRequired: false, + series: { + id: 'monthly-meetups', + title: 'Monthly Community Meetups', + description: 'Regular monthly gatherings for the cooperative game development community', + type: 'recurring_meetup', + position: 1, + totalEvents: 12, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'November Community Meetup', + slug: 'november-community-meetup', + tagline: 'Monthly gathering for cooperative game developers', + description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.', + content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.', + startDate: new Date('2024-11-09T18:00:00.000Z'), + endDate: new Date('2024-11-09T20:00:00.000Z'), + eventType: 'community', + location: '#community-meetup', + isOnline: true, + membersOnly: false, + registrationRequired: false, + series: { + id: 'monthly-meetups', + title: 'Monthly Community Meetups', + description: 'Regular monthly gatherings for the cooperative game development community', + type: 'recurring_meetup', + position: 2, + totalEvents: 12, + isSeriesEvent: true + }, + createdBy: 'admin' + } + ] + + // Insert all series events + const allSeriesEvents = [...workshopSeries, ...meetupSeries] + + for (const eventData of allSeriesEvents) { + const existingEvent = await Event.findOne({ slug: eventData.slug }) + if (!existingEvent) { + const event = new Event(eventData) + await event.save() + console.log(`Created series event: ${event.title}`) + } else { + console.log(`Series event already exists: ${eventData.title}`) + } + } + + console.log('Series events seeding completed!') + process.exit(0) + } catch (error) { + console.error('Error seeding series events:', error) + process.exit(1) + } +} + +seedSeriesEvents() \ No newline at end of file diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 5e34869..4877eb3 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => { await connectDB() - const newEvent = new Event({ + const eventData = { ...body, createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user startDate: new Date(body.startDate), endDate: new Date(body.endDate), registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null - }) + } + + // Handle ticket data + if (body.tickets) { + eventData.tickets = { + enabled: body.tickets.enabled || false, + public: { + available: body.tickets.public?.available || false, + name: body.tickets.public?.name || 'Public Ticket', + description: body.tickets.public?.description || '', + price: body.tickets.public?.price || 0, + quantity: body.tickets.public?.quantity || null, + sold: 0, // Initialize sold count + earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null, + earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null + } + } + } + + const newEvent = new Event(eventData) const savedEvent = await newEvent.save() diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 2873689..50ae589 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => { registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, updatedAt: new Date() } + + // Handle ticket data + if (body.tickets) { + updateData.tickets = { + enabled: body.tickets.enabled || false, + public: { + available: body.tickets.public?.available || false, + name: body.tickets.public?.name || 'Public Ticket', + description: body.tickets.public?.description || '', + price: body.tickets.public?.price || 0, + quantity: body.tickets.public?.quantity || null, + sold: body.tickets.public?.sold || 0, + earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null, + earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null + } + } + } const updatedEvent = await Event.findByIdAndUpdate( eventId, diff --git a/server/api/admin/series.get.js b/server/api/admin/series.get.js new file mode 100644 index 0000000..df3a84d --- /dev/null +++ b/server/api/admin/series.get.js @@ -0,0 +1,60 @@ +import Series from '../../models/series.js' +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + // Fetch all series + const series = await Series.find({ isActive: true }) + .sort({ createdAt: -1 }) + .lean() + + // For each series, get event count and statistics + const seriesWithStats = await Promise.all( + series.map(async (s) => { + const events = await Event.find({ + 'series.id': s.id, + 'series.isSeriesEvent': true + }).select('_id startDate endDate registrations').lean() + + const now = new Date() + const eventCount = events.length + const completedEvents = events.filter(e => e.endDate < now).length + const upcomingEvents = events.filter(e => e.startDate > now).length + + const firstEventDate = events.length > 0 ? + Math.min(...events.map(e => new Date(e.startDate))) : null + const lastEventDate = events.length > 0 ? + Math.max(...events.map(e => new Date(e.endDate))) : null + + let status = 'upcoming' + if (lastEventDate && lastEventDate < now) { + status = 'completed' + } else if (firstEventDate && firstEventDate <= now && lastEventDate && lastEventDate >= now) { + status = 'active' + } + + return { + ...s, + eventCount, + completedEvents, + upcomingEvents, + startDate: firstEventDate, + endDate: lastEventDate, + status, + totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) + } + }) + ) + + return seriesWithStats + } catch (error) { + console.error('Error fetching series:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series.post.js b/server/api/admin/series.post.js new file mode 100644 index 0000000..2041e23 --- /dev/null +++ b/server/api/admin/series.post.js @@ -0,0 +1,49 @@ +import Series from '../../models/series.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const body = await readBody(event) + + // Validate required fields + if (!body.id || !body.title || !body.description) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID, title, and description are required' + }) + } + + // Create new series + const newSeries = new Series({ + id: body.id, + title: body.title, + description: body.description, + type: body.type || 'workshop_series', + totalEvents: body.totalEvents || null, + createdBy: 'admin' // TODO: Get from authentication + }) + + await newSeries.save() + + return { + success: true, + data: newSeries + } + } catch (error) { + console.error('Error creating series:', error) + + if (error.code === 11000) { + throw createError({ + statusCode: 400, + statusMessage: 'A series with this ID already exists' + }) + } + + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to create series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series/[id].delete.js b/server/api/admin/series/[id].delete.js new file mode 100644 index 0000000..cc80202 --- /dev/null +++ b/server/api/admin/series/[id].delete.js @@ -0,0 +1,58 @@ +import Series from '../../../models/series.js' +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Find the series + const series = await Series.findOne({ id: id }) + + if (!series) { + throw createError({ + statusCode: 404, + statusMessage: 'Series not found' + }) + } + + // Remove series relationship from all related events + await Event.updateMany( + { 'series.id': id, 'series.isSeriesEvent': true }, + { + $set: { + 'series.isSeriesEvent': false, + 'series.id': '', + 'series.title': '', + 'series.description': '', + 'series.type': 'workshop_series', + 'series.position': 1, + 'series.totalEvents': null + } + } + ) + + // Delete the series + await Series.deleteOne({ id: id }) + + return { + success: true, + message: 'Series deleted and events converted to standalone events' + } + } catch (error) { + console.error('Error deleting series:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to delete series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series/[id].put.js b/server/api/admin/series/[id].put.js new file mode 100644 index 0000000..0143938 --- /dev/null +++ b/server/api/admin/series/[id].put.js @@ -0,0 +1,62 @@ +import Series from '../../../models/series.js' +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Find and update the series + const series = await Series.findOne({ id: id }) + + if (!series) { + throw createError({ + statusCode: 404, + statusMessage: 'Series not found' + }) + } + + // Update series fields + if (body.title !== undefined) series.title = body.title + if (body.description !== undefined) series.description = body.description + if (body.type !== undefined) series.type = body.type + if (body.totalEvents !== undefined) series.totalEvents = body.totalEvents + if (body.isActive !== undefined) series.isActive = body.isActive + + await series.save() + + // Also update all related events with the new series information + await Event.updateMany( + { 'series.id': id, 'series.isSeriesEvent': true }, + { + $set: { + 'series.title': series.title, + 'series.description': series.description, + 'series.type': series.type, + 'series.totalEvents': series.totalEvents + } + } + ) + + return { + success: true, + data: series + } + } catch (error) { + console.error('Error updating series:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to update series' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/guest-register.post.js b/server/api/events/[id]/guest-register.post.js new file mode 100644 index 0000000..3d31737 --- /dev/null +++ b/server/api/events/[id]/guest-register.post.js @@ -0,0 +1,112 @@ +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const identifier = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + // Validate required fields for guest registration + if (!body.name || !body.email) { + throw createError({ + statusCode: 400, + statusMessage: 'Name and email are required' + }) + } + + // Fetch the event + let eventData + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + } + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // Check if event allows public registration (not members-only) + if (eventData.membersOnly) { + throw createError({ + statusCode: 403, + statusMessage: 'This event is for members only. Please become a member to register.' + }) + } + + // If event requires payment, reject guest registration + if (eventData.pricing.paymentRequired && !eventData.pricing.isFree) { + throw createError({ + statusCode: 402, + statusMessage: 'This event requires payment. Please use the payment registration endpoint.' + }) + } + + // Check if event is full + if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) { + throw createError({ + statusCode: 400, + statusMessage: 'Event is full' + }) + } + + // Check if already registered + const alreadyRegistered = eventData.registrations.some( + reg => reg.email.toLowerCase() === body.email.toLowerCase() + ) + + if (alreadyRegistered) { + throw createError({ + statusCode: 400, + statusMessage: 'You are already registered for this event' + }) + } + + // Add guest registration + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: 'guest', + isMember: false, + paymentStatus: 'not_required', + amountPaid: 0, + registeredAt: new Date() + }) + + await eventData.save() + + // TODO: Send confirmation email for guest registration + + return { + success: true, + message: 'Successfully registered as guest', + registrationId: eventData.registrations[eventData.registrations.length - 1]._id, + note: 'As a guest, you have access to this free public event. Consider becoming a member for access to all events!' + } + + } catch (error) { + console.error('Error with guest registration:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to register as guest' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/payment.post.js b/server/api/events/[id]/payment.post.js new file mode 100644 index 0000000..6d3c634 --- /dev/null +++ b/server/api/events/[id]/payment.post.js @@ -0,0 +1,136 @@ +import Event from '../../../models/event.js' +import Member from '../../../models/member.js' +import { connectDB } from '../../../utils/mongoose.js' +import { processHelcimPayment } from '../../../utils/helcim.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const identifier = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + // Validate required payment fields + if (!body.name || !body.email || !body.paymentToken) { + throw createError({ + statusCode: 400, + statusMessage: 'Name, email, and payment token are required' + }) + } + + // Fetch the event + let eventData + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + } + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // Check if event requires payment + if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) { + throw createError({ + statusCode: 400, + statusMessage: 'This event does not require payment' + }) + } + + // Check if user is already registered + const existingRegistration = eventData.registrations.find( + reg => reg.email.toLowerCase() === body.email.toLowerCase() + ) + + if (existingRegistration) { + throw createError({ + statusCode: 400, + statusMessage: 'You are already registered for this event' + }) + } + + // Check if user is a member (members get free access) + const member = await Member.findOne({ email: body.email.toLowerCase() }) + + if (member) { + // Members get free access - register directly without payment + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: `${member.circle}-${member.contributionTier}`, + isMember: true, + paymentStatus: 'not_required', + amountPaid: 0 + }) + + await eventData.save() + + return { + success: true, + message: 'Successfully registered as a member (no payment required)', + registration: eventData.registrations[eventData.registrations.length - 1] + } + } + + // Process payment for non-members + const paymentResult = await processHelcimPayment({ + amount: eventData.pricing.publicPrice, + paymentToken: body.paymentToken, + customerData: { + name: body.name, + email: body.email + } + }) + + if (!paymentResult.success) { + throw createError({ + statusCode: 400, + statusMessage: paymentResult.message || 'Payment failed' + }) + } + + // Add registration with successful payment + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: 'non-member', + isMember: false, + paymentStatus: 'completed', + paymentId: paymentResult.transactionId, + amountPaid: eventData.pricing.publicPrice + }) + + await eventData.save() + + return { + success: true, + message: 'Payment successful and registered for event', + paymentId: paymentResult.transactionId, + registration: eventData.registrations[eventData.registrations.length - 1] + } + + } catch (error) { + console.error('Error processing event payment:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to process payment and registration' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js index 618f81f..e5ecfe2 100644 --- a/server/api/events/[id]/register.post.js +++ b/server/api/events/[id]/register.post.js @@ -65,27 +65,41 @@ export default defineEventHandler(async (event) => { }) } - // Check member status if event is members-only - if (eventData.membersOnly && body.membershipLevel === 'non-member') { - // Check if email belongs to a member - const member = await Member.findOne({ email: body.email.toLowerCase() }) - - if (!member) { - throw createError({ - statusCode: 403, - statusMessage: 'This event is for members only. Please become a member to register.' - }) - } - - // Update membership level from database - body.membershipLevel = `${member.circle}-${member.contributionTier}` + // Check member status and handle different registration scenarios + const member = await Member.findOne({ email: body.email.toLowerCase() }) + + if (eventData.membersOnly && !member) { + throw createError({ + statusCode: 403, + statusMessage: 'This event is for members only. Please become a member to register.' + }) + } + + // If event requires payment and user is not a member, redirect to payment flow + if (eventData.pricing.paymentRequired && !eventData.pricing.isFree && !member) { + throw createError({ + statusCode: 402, // Payment Required + statusMessage: 'This event requires payment. Please use the payment registration endpoint.' + }) + } + + // Set member status and membership level + let isMember = false + let membershipLevel = 'non-member' + + if (member) { + isMember = true + membershipLevel = `${member.circle}-${member.contributionTier}` } // Add registration eventData.registrations.push({ name: body.name, email: body.email.toLowerCase(), - membershipLevel: body.membershipLevel || 'non-member', + membershipLevel, + isMember, + paymentStatus: 'not_required', // Free events or member registrations + amountPaid: 0, dietary: body.dietary || false, registeredAt: new Date() }) diff --git a/server/api/series/[id].get.js b/server/api/series/[id].get.js new file mode 100644 index 0000000..ea372e3 --- /dev/null +++ b/server/api/series/[id].get.js @@ -0,0 +1,78 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Fetch all events in this series + const events = await Event.find({ + 'series.id': id, + 'series.isSeriesEvent': true + }) + .sort({ 'series.position': 1, startDate: 1 }) + .select('-registrations') + .lean() + + if (events.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: 'Event series not found' + }) + } + + // Get series metadata from the first event + const seriesInfo = events[0].series + + // Calculate series statistics + const now = new Date() + const completedEvents = events.filter(e => e.endDate < now).length + const upcomingEvents = events.filter(e => e.startDate > now).length + const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length + + const firstEventDate = events[0].startDate + const lastEventDate = events[events.length - 1].endDate + + // Return series with additional metadata + return { + id: id, + title: seriesInfo.title, + description: seriesInfo.description, + type: seriesInfo.type, + totalEvents: seriesInfo.totalEvents, + startDate: firstEventDate, + endDate: lastEventDate, + events: events.map(e => ({ + ...e, + id: e._id.toString() + })), + statistics: { + totalEvents: events.length, + completedEvents, + upcomingEvents, + ongoingEvents, + isOngoing: firstEventDate <= now && lastEventDate >= now, + isUpcoming: firstEventDate > now, + isCompleted: lastEventDate < now, + totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) + } + } + } catch (error) { + console.error('Error fetching event series:', error) + if (error.statusCode) throw error + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch event series' + }) + } +}) \ No newline at end of file diff --git a/server/api/series/index.get.js b/server/api/series/index.get.js new file mode 100644 index 0000000..1dfd52d --- /dev/null +++ b/server/api/series/index.get.js @@ -0,0 +1,91 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const query = getQuery(event) + + // Build filter for series events only + const filter = { + 'series.isSeriesEvent': true, + isVisible: query.includeHidden === 'true' ? { $exists: true } : true + } + + // Filter by series type + if (query.seriesType) { + filter['series.type'] = query.seriesType + } + + // Filter for upcoming series + if (query.upcoming === 'true') { + filter.startDate = { $gte: new Date() } + } + + // Fetch all series events and group them by series.id + const events = await Event.find(filter) + .sort({ 'series.id': 1, 'series.position': 1, startDate: 1 }) + .select('-registrations') + .lean() + + // Group events by series ID + const seriesMap = new Map() + + events.forEach(event => { + const seriesId = event.series?.id + if (!seriesId) return + + if (!seriesMap.has(seriesId)) { + seriesMap.set(seriesId, { + id: seriesId, + title: event.series.title, + description: event.series.description, + type: event.series.type, + totalEvents: event.series.totalEvents, + events: [], + firstEventDate: event.startDate, + lastEventDate: event.endDate + }) + } + + const series = seriesMap.get(seriesId) + series.events.push({ + ...event, + id: event._id.toString() + }) + + // Update date range + if (event.startDate < series.firstEventDate) { + series.firstEventDate = event.startDate + } + if (event.endDate > series.lastEventDate) { + series.lastEventDate = event.endDate + } + }) + + // Convert to array and add computed fields + const seriesArray = Array.from(seriesMap.values()).map(series => { + const now = new Date() + return { + ...series, + eventCount: series.events.length, + startDate: series.firstEventDate, + endDate: series.lastEventDate, + isOngoing: series.firstEventDate <= now && series.lastEventDate >= now, + isUpcoming: series.firstEventDate > now, + isCompleted: series.lastEventDate < now, + status: series.lastEventDate < now ? 'completed' : + series.firstEventDate <= now && series.lastEventDate >= now ? 'active' : 'upcoming' + } + }) + + return seriesArray + } catch (error) { + console.error('Error fetching event series:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch event series' + }) + } +}) \ No newline at end of file diff --git a/server/models/event.js b/server/models/event.js index 312f5ca..d160776 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -39,6 +39,41 @@ const eventSchema = new mongoose.Schema({ isCancelled: { type: Boolean, default: false }, cancellationMessage: String, // Custom message for cancelled events membersOnly: { type: Boolean, default: false }, + // Series information - embedded approach for better performance + series: { + id: String, // Simple string ID to group related events + title: String, // Series title (e.g., "Cooperative Game Development Workshop Series") + description: String, // Series description + type: { + type: String, + enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'], + default: 'workshop_series' + }, + position: Number, // Order within the series (e.g., 1 = first event, 2 = second, etc.) + totalEvents: Number, // Total planned events in the series + isSeriesEvent: { type: Boolean, default: false } // Flag to identify series events + }, + // Event pricing for public attendees (deprecated - use tickets instead) + pricing: { + isFree: { type: Boolean, default: true }, + publicPrice: { type: Number, default: 0 }, // Price for non-members + currency: { type: String, default: 'CAD' }, + paymentRequired: { type: Boolean, default: false } + }, + // Ticket configuration + tickets: { + enabled: { type: Boolean, default: false }, + public: { + available: { type: Boolean, default: false }, + name: { type: String, default: 'Public Ticket' }, + description: String, + price: { type: Number, default: 0 }, + quantity: Number, // null = unlimited + sold: { type: Number, default: 0 }, + earlyBirdPrice: Number, + earlyBirdDeadline: Date + } + }, // Circle targeting targetCircles: [{ type: String, @@ -58,6 +93,14 @@ const eventSchema = new mongoose.Schema({ name: String, email: String, membershipLevel: String, + isMember: { type: Boolean, default: false }, + paymentStatus: { + type: String, + enum: ['pending', 'completed', 'failed', 'not_required'], + default: 'not_required' + }, + paymentId: String, // Helcim transaction ID + amountPaid: { type: Number, default: 0 }, registeredAt: { type: Date, default: Date.now } }], createdBy: { type: String, required: true }, diff --git a/server/models/series.js b/server/models/series.js new file mode 100644 index 0000000..bd79067 --- /dev/null +++ b/server/models/series.js @@ -0,0 +1,35 @@ +import mongoose from 'mongoose' + +const seriesSchema = new mongoose.Schema({ + id: { + type: String, + required: true, + unique: true, + validate: { + validator: function(v) { + return /^[a-z0-9-]+$/.test(v); + }, + message: 'Series ID must contain only lowercase letters, numbers, and dashes' + } + }, + title: { type: String, required: true }, + description: { type: String, required: true }, + type: { + type: String, + enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'], + default: 'workshop_series' + }, + totalEvents: Number, + isActive: { type: Boolean, default: true }, + createdBy: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}) + +// Update the updatedAt field on save +seriesSchema.pre('save', function(next) { + this.updatedAt = new Date() + next() +}) + +export default mongoose.models.Series || mongoose.model('Series', seriesSchema) \ No newline at end of file