+
+
+
+
+ {{ event.series.position }}
+
+
+ {{ event.series.title }}
+
+
+
+
+
+
+ Event Series
+
+
+ Discover our multi-event series designed to take you on a journey of learning and growth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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