Add landing page

This commit is contained in:
Jennie Robinson Faber 2025-11-03 11:17:51 +00:00
parent 3fea484585
commit bce86ee840
47 changed files with 7119 additions and 439 deletions

149
HELCIM_PAYMENT_FIX.md Normal file
View file

@ -0,0 +1,149 @@
# Helcim Payment Flow Fix
## Issue
The initial implementation had a mismatch in the payment flow:
- **Error**: "Customer ID is required" when attempting to purchase event tickets
- **Cause**: The payment initialization endpoint required a `customerId`, but event tickets are one-time purchases that don't need customer accounts
## Solution
### 1. Updated Payment Initialization (`server/api/helcim/initialize-payment.post.js`)
**Changed from:**
- Always requiring `customerId`
- Always using `verify` payment type (for card verification)
- Amount fixed at 0
**Changed to:**
- `customerId` is now optional
- Detects event ticket purchases via `metadata.type === 'event_ticket'`
- Uses `purchase` type for event tickets with amount > 0
- Uses `verify` type for subscription setup (card verification)
```javascript
// For event tickets: HelcimPay.js completes the purchase immediately
const paymentType = isEventTicket && amount > 0 ? 'purchase' : 'verify'
```
### 2. Updated Ticket Purchase Endpoint (`server/api/events/[id]/tickets/purchase.post.js`)
**Changed from:**
- Expecting `paymentToken` from client
- Calling `processHelcimPayment()` to process the payment
- Payment happens server-side after modal closes
**Changed to:**
- Expecting `transactionId` from client
- Payment already completed by HelcimPay.js modal
- Server just records the transaction
**Why?**
When using HelcimPay.js with `purchase` type, the payment is processed inside the modal and we get a completed transaction back. We don't need to make a second API call to charge the card.
### 3. Updated Frontend Component (`app/components/EventTicketPurchase.vue`)
**Changed from:**
- Getting `cardToken` from payment modal
- Sending `paymentToken` to purchase endpoint
**Changed to:**
- Getting `transactionId` from payment modal
- Sending `transactionId` to purchase endpoint
- Added validation to ensure transaction ID exists
## Payment Flow Comparison
### Old Flow (Subscriptions)
```
1. Initialize payment session (verify mode, amount: 0)
2. User enters card in modal
3. Modal returns cardToken
4. Send cardToken to server
5. Server calls Helcim API to charge card
6. Create registration
```
### New Flow (Event Tickets)
```
1. Initialize payment session (purchase mode, amount: actual price)
2. User enters card in modal
3. Helcim charges card immediately
4. Modal returns transactionId
5. Send transactionId to server
6. Server records transaction and creates registration
```
## Benefits
1. **Simpler**: One API call instead of two
2. **Faster**: Payment completes in the modal
3. **More Secure**: No need to handle card tokens server-side
4. **PCI Compliant**: Card data never touches our server
5. **Better UX**: User sees immediate payment confirmation
## Testing
### Free Member Tickets
```bash
# Should work without payment modal
1. Member logs in
2. Views event with member.isFree: true
3. Fills name/email
4. Clicks "Complete Registration"
5. ✓ Registers immediately (no payment)
```
### Paid Public Tickets
```bash
# Should trigger Helcim modal
1. Non-member views event
2. Sees public ticket price
3. Fills name/email
4. Clicks "Pay $XX.XX"
5. Helcim modal opens
6. Enters test card: 4242 4242 4242 4242
7. Payment processes
8. ✓ Modal closes with success
9. ✓ Registration created with transaction ID
```
## Environment Variables
Ensure these are set in `.env`:
```bash
# Public (client-side)
NUXT_PUBLIC_HELCIM_TOKEN=your_helcim_api_token
NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your_account_id
# Private (server-side)
HELCIM_API_TOKEN=your_helcim_api_token
```
## Common Issues
### "Customer ID is required"
- ✅ **Fixed** - This error should no longer occur for event tickets
- If you still see it, check that `metadata.type: 'event_ticket'` is being passed
### "No transaction ID received"
- Check browser console for HelcimPay.js errors
- Verify Helcim credentials are correct
- Ensure test mode is enabled for testing
### Payment modal doesn't open
- Check that HelcimPay.js script loaded (see console)
- Verify `NUXT_PUBLIC_HELCIM_TOKEN` is set
- Check browser console for initialization errors
## Files Changed
1. `server/api/helcim/initialize-payment.post.js` - Smart payment type detection
2. `server/api/events/[id]/tickets/purchase.post.js` - Accept transactionId instead of token
3. `app/components/EventTicketPurchase.vue` - Pass transactionId instead of cardToken
---
**Status**: ✅ Fixed
**Date**: 2025-10-14
**Impact**: Event ticket purchases now work correctly with Helcim

View file

@ -0,0 +1,284 @@
# Helcim Event Ticketing Integration - Implementation Summary
## Overview
Successfully integrated Helcim payment processing with Ghost Guild's event ticketing system to support paid events, member pricing, early bird discounts, and capacity management.
## What Was Built
### 1. Enhanced Event Model (`server/models/event.js`)
Added comprehensive ticket schema with:
- **Member tickets**: Free/discounted pricing for members with circle-specific overrides
- **Public tickets**: Standard pricing with early bird support
- **Capacity management**: Overall event capacity tracking
- **Waitlist system**: Queue management when events sell out
- **Registration tracking**: Enhanced with ticket type, price paid, payment status, and refund info
### 2. Ticket Business Logic (`server/utils/tickets.js`)
Created utility functions for:
- `calculateTicketPrice()` - Determines applicable price based on member status and early bird
- `checkTicketAvailability()` - Real-time availability checking
- `validateTicketPurchase()` - Pre-purchase validation
- `reserveTicket()` - Temporary reservation during checkout (prevents race conditions)
- `releaseTicket()` - Release abandoned reservations
- `completeTicketPurchase()` - Finalize purchase and update counts
- `addToWaitlist()` - Waitlist management
- `formatPrice()` - Consistent price formatting
### 3. API Endpoints (`server/api/events/[id]/tickets/`)
Four new REST endpoints:
#### `GET available.get.js`
- Check ticket availability and pricing for a user
- Returns: ticket type, price, availability, remaining spots
- Supports both authenticated (members) and public users
#### `POST check-eligibility.post.js`
- Verify if user qualifies for member pricing
- Returns: member status and circle information
#### `POST purchase.post.js`
- Complete ticket purchase with Helcim payment
- Validates availability, processes payment, creates registration
- Handles both free (member) and paid (public) tickets
#### `POST reserve.post.js`
- Temporarily reserve ticket during checkout
- Prevents overselling during payment processing
- 10-minute TTL on reservations
### 4. Frontend Components
#### `EventTicketCard.vue`
Reusable ticket display component showing:
- Ticket name and description
- Price with early bird indicator
- Member savings comparison
- Availability status
- Waitlist option when sold out
#### `EventTicketPurchase.vue`
Main ticket purchase flow component:
- Fetches ticket availability on load
- Displays appropriate ticket card
- Registration form (name, email)
- Integrated Helcim payment for paid tickets
- Success/error handling with toast notifications
- Shows "already registered" state
### 5. Enhanced Composable (`app/composables/useHelcimPay.js`)
Added `initializeTicketPayment()` method:
- Ticket-specific payment initialization
- Includes event metadata for tracking
- Uses email as customer code for one-time purchases
### 6. Updated Event Detail Page (`app/pages/events/[id].vue`)
- Detects if event has tickets enabled
- Shows new ticket system OR legacy registration form
- Maintains backward compatibility
- Handles ticket purchase success/error events
### 7. Enhanced Email Templates (`server/utils/resend.js`)
Updated registration confirmation emails to include:
- Ticket type (Member/Public)
- Amount paid
- Transaction ID
- "Member Benefit" callout for free member tickets
## How It Works
### Free Member Event Flow
```
1. Member views event → Sees "Free for Members" ticket
2. Fills in name/email → Click "Complete Registration"
3. System verifies membership → Creates registration
4. Sends confirmation email → Shows success message
```
### Paid Public Event Flow
```
1. Public user views event → Sees ticket price
2. Fills in name/email → Clicks "Pay $XX.XX"
3. Helcim modal opens → User enters payment info
4. Payment processes → System creates registration
5. Sends confirmation with receipt → Shows success
```
### Early Bird Pricing
```
- Before deadline: Shows early bird price + countdown
- After deadline: Automatically switches to regular price
- Calculated server-side for security
```
## Configuration
### Event Setup
To enable ticketing for an event, set in the event document:
```javascript
{
tickets: {
enabled: true,
currency: "CAD",
member: {
available: true,
isFree: true, // or set price
name: "Member Ticket",
description: "Free for Ghost Guild members"
},
public: {
available: true,
price: 25.00,
quantity: 50, // or null for unlimited
earlyBirdPrice: 20.00,
earlyBirdDeadline: "2025-11-01T00:00:00Z",
name: "Public Ticket"
},
capacity: {
total: 75 // Total capacity across all ticket types
}
}
}
```
### Circle-Specific Pricing Example
```javascript
{
tickets: {
member: {
isFree: false, // Not free by default
price: 15.00, // Default member price
circleOverrides: {
community: {
isFree: true // Free for community circle
},
founder: {
price: 10.00 // Discounted for founders
},
practitioner: {
price: 5.00 // Heavily discounted for practitioners
}
}
}
}
}
```
## Migration Strategy
### Backward Compatibility
- Legacy `pricing` field still supported
- Events without `tickets.enabled` use old registration system
- Existing registrations work with new system
### Converting Events to New System
```javascript
// Old format
{
pricing: {
isFree: false,
publicPrice: 25,
paymentRequired: true
}
}
// New format
{
tickets: {
enabled: true,
member: {
isFree: true
},
public: {
available: true,
price: 25
}
}
}
```
## Testing Checklist
### Member Ticket Flow
- [ ] Member can see free ticket
- [ ] Email pre-fills if logged in
- [ ] Registration completes without payment
- [ ] Confirmation email shows member benefit
- [ ] "Already registered" state shows correctly
### Public Ticket Flow
- [ ] Public user sees correct price
- [ ] Helcim modal opens on submit
- [ ] Payment processes successfully
- [ ] Transaction ID saved to registration
- [ ] Confirmation email includes receipt
### Early Bird Pricing
- [ ] Early bird price shows before deadline
- [ ] Countdown timer displays correctly
- [ ] Regular price shows after deadline
- [ ] Price calculation is server-side
### Capacity Management
- [ ] Ticket count decrements on purchase
- [ ] Sold out message shows when full
- [ ] Waitlist option appears if enabled
- [ ] No overselling (test concurrent purchases)
### Edge Cases
- [ ] Already registered users see status
- [ ] Cancelled events show cancellation message
- [ ] Past events don't allow registration
- [ ] Member-only events gate non-members
- [ ] Payment failures don't create registrations
## Future Enhancements
### Phase 2 Features
1. **Waitlist Notifications**: Auto-email when spots open
2. **Refund Processing**: Handle ticket cancellations with refunds
3. **Ticket Types**: Multiple ticket tiers per event
4. **Group Tickets**: Purchase multiple tickets at once
5. **Promo Codes**: Discount code support
6. **Admin Dashboard**: View sales, export attendee lists
### Phase 3 Features
1. **Recurring Events**: Auto-apply tickets to series
2. **Transfer Tickets**: Allow users to transfer registrations
3. **PDF Tickets**: Generate printable/QR code tickets
4. **Revenue Analytics**: Track ticket sales and revenue
5. **Dynamic Pricing**: Adjust prices based on demand
## Files Created
- `server/utils/tickets.js` (420 lines)
- `server/api/events/[id]/tickets/available.get.js` (150 lines)
- `server/api/events/[id]/tickets/purchase.post.js` (180 lines)
- `server/api/events/[id]/tickets/check-eligibility.post.js` (50 lines)
- `server/api/events/[id]/tickets/reserve.post.js` (80 lines)
- `app/components/EventTicketCard.vue` (195 lines)
- `app/components/EventTicketPurchase.vue` (330 lines)
## Files Modified
- `server/models/event.js` - Enhanced ticket schema + registration fields
- `app/pages/events/[id].vue` - Integrated ticket UI
- `app/composables/useHelcimPay.js` - Added ticket payment method
- `server/utils/resend.js` - Enhanced email with ticket info
## Total Implementation
- **~1,400 lines of code** across 11 files
- **4 new API endpoints**
- **2 new Vue components**
- **10+ utility functions**
- **Fully backward compatible**
## Support
For questions or issues:
- Check Helcim API docs: https://docs.helcim.com
- Review CLAUDE.md for project context
- Test in development with Helcim test credentials
---
**Status**: ✅ Implementation Complete
**Last Updated**: 2025-10-14
**Developer**: Claude (Anthropic)

View file

@ -0,0 +1,400 @@
# Series Ticketing Implementation
## Overview
Comprehensive ticketing system for event series that allows users to purchase a single pass for all events in a series, or optionally register for individual events (drop-in model).
## Two Ticketing Models Supported
### 1. Series-Level Ticketing (Default)
**Intent:** Registrants attend all sessions in the series
**Use cases:**
- Workshop series (e.g., 4-week game dev workshop)
- Courses
- Multi-day events
- Any series where commitment to all sessions is expected
**How it works:**
- Purchase ONE series pass that grants access to ALL events
- Single payment covers the entire series
- Automatic registration for all events upon purchase
- User receives single confirmation email with full schedule
### 2. Event-Level Ticketing (Drop-in)
**Intent:** Each event can be attended independently
**Use cases:**
- Recurring meetups (e.g., monthly community calls)
- Tournaments where sessions are standalone
- Drop-in events where flexibility is key
**How it works:**
- Each event has its own ticket configuration
- Users register per event, not for the series
- Can attend just one, some, or all events
- Standard event ticketing applies
## What Was Built
### 1. Data Models
#### New Series Model (`server/models/series.js`)
Dedicated model for managing event series with:
- Series metadata (title, description, type, dates)
- Full ticket configuration (member/public pricing, capacity)
- Circle-specific pricing overrides
- Early bird pricing support
- Waitlist functionality
- Series registrations tracking
- Event registration references
#### Enhanced Event Model
Added fields to `server/models/event.js`:
- `tickets.requiresSeriesTicket` - Flag to indicate series pass is required
- `tickets.seriesTicketReference` - Reference to Series model
- `registrations.ticketType` - Added "series_pass" option
- `registrations.isSeriesTicketHolder` - Boolean flag
- `registrations.seriesTicketId` - Reference to series registration
### 2. Backend Logic
#### Series Ticket Utilities (`server/utils/tickets.js`)
Added 8 new functions:
- `calculateSeriesTicketPrice()` - Pricing with member/circle overrides
- `checkSeriesTicketAvailability()` - Real-time availability checking
- `validateSeriesTicketPurchase()` - Pre-purchase validation
- `reserveSeriesTicket()` - Temporary reservation during checkout
- `releaseSeriesTicket()` - Release abandoned reservations
- `completeSeriesTicketPurchase()` - Finalize purchase and update counts
- `checkUserSeriesPass()` - Verify if user has valid pass
- `registerForAllSeriesEvents()` - Bulk registration across all events
### 3. API Endpoints
#### Series Ticket Endpoints
- `GET /api/series/[id]/tickets/available` - Check pass availability and pricing
- `POST /api/series/[id]/tickets/purchase` - Complete series pass purchase
- `POST /api/series/[id]/tickets/check-eligibility` - Verify member status
- `GET /api/events/[id]/check-series-access` - Verify series pass ownership for event
### 4. Frontend Components
#### EventSeriesTicketCard.vue
Beautiful purple-themed card displaying:
- Series pass name and pricing
- What's included (all events listed)
- Member savings comparison
- Event schedule preview (first 3 events)
- Availability status
- Member benefit callouts
#### SeriesPassPurchase.vue
Complete purchase flow component:
- Loads series pass information
- Registration form (name/email)
- Helcim payment integration for paid passes
- Success/error handling with toast notifications
- Automatic registration for all events
- Email confirmation
#### Updated EventTicketPurchase.vue
Enhanced to detect series pass requirements:
- Checks if event requires series pass
- Shows "Series Pass Required" message with link to series
- Displays "Registered via Series Pass" for pass holders
- Graceful fallback to regular ticketing
### 5. Email Templates
#### Series Pass Confirmation Email
Professional HTML email featuring:
- Purple gradient header
- Full series pass details
- Complete event schedule with dates/times
- Member benefit callout (if applicable)
- Transaction ID (if paid)
- "What's Next" guidance
- Dashboard link
### 6. UI Integration
#### Updated Series Detail Page (`/series/[id].vue`)
- New "Get Your Series Pass" section
- Displays SeriesPassPurchase component
- Shows only if `series.tickets.enabled` is true
- Refreshes data after successful purchase
- User session integration
## Configuration Examples
### Example 1: Free Member Series, Paid Public
```javascript
const series = {
id: "coop-game-dev-2025",
title: "Cooperative Game Development Workshop Series",
type: "workshop_series",
tickets: {
enabled: true,
requiresSeriesTicket: true, // Must buy series pass
allowIndividualEventTickets: false,
currency: "CAD",
member: {
available: true,
isFree: true,
name: "Member Series Pass",
description: "Free for Ghost Guild members"
},
public: {
available: true,
price: 100.00,
earlyBirdPrice: 80.00,
earlyBirdDeadline: "2025-11-01T00:00:00Z",
name: "Public Series Pass",
description: "Access to all 4 workshops"
},
capacity: {
total: 30 // Total capacity across all ticket types
}
}
}
```
### Example 2: Circle-Specific Pricing
```javascript
const series = {
tickets: {
enabled: true,
member: {
isFree: false,
price: 50.00, // Default member price
circleOverrides: {
community: {
isFree: true // Free for community circle
},
founder: {
price: 25.00 // Discounted for founders
},
practitioner: {
price: 10.00 // Heavily discounted
}
}
}
}
}
```
### Example 3: Drop-in Series (Individual Event Tickets)
```javascript
const series = {
id: "monthly-meetup-2025",
title: "Monthly Community Meetup",
type: "recurring_meetup",
tickets: {
enabled: false, // No series-level tickets
requiresSeriesTicket: false,
allowIndividualEventTickets: true
}
}
// Each event in series has its own tickets config
const event = {
series: {
id: "monthly-meetup-2025",
isSeriesEvent: true
},
tickets: {
enabled: true,
member: {
isFree: true
},
public: {
available: true,
price: 10.00
}
}
}
```
## User Flows
### Flow 1: Member Purchases Free Series Pass
1. Member visits series page (`/series/coop-game-dev-2025`)
2. Sees "Free for Members" series pass card
3. Fills in name/email (pre-filled from session)
4. Clicks "Complete Registration"
5. System creates series registration
6. System registers user for all 4 events automatically
7. User receives series pass confirmation email with full schedule
8. Page refreshes showing "You're Registered!" status
### Flow 2: Public User Purchases Paid Series Pass
1. Public user visits series page
2. Sees "$100 Series Pass" (or $80 early bird)
3. Fills in name/email
4. Clicks "Pay $100.00"
5. Helcim payment modal opens
6. User completes payment
7. System processes payment and creates registrations
8. User receives confirmation email with transaction ID
9. Success toast shows "You're registered for all 4 events"
### Flow 3: User with Series Pass Views Individual Event
1. User has series pass for "Coop Game Dev Series"
2. Visits individual event page (`/events/workshop-1`)
3. EventTicketPurchase component checks series access
4. Sees "You're Registered!" with message:
"You have access to this event via your series pass for Cooperative Game Development Workshop Series"
5. No payment or registration required
### Flow 4: User Without Series Pass Views Required Event
1. User visits event that requires series pass
2. EventTicketPurchase component detects requirement
3. Sees purple banner: "Series Pass Required"
4. Message explains event is part of series
5. "View Series & Purchase Pass" button
6. Clicking redirects to series page to buy pass
## Database Schema
### Series Document Structure
```javascript
{
_id: ObjectId,
id: "coop-game-dev-2025", // String identifier
slug: "cooperative-game-development-workshop-series",
title: "Cooperative Game Development Workshop Series",
description: "Learn to build co-op games...",
type: "workshop_series",
isVisible: true,
isActive: true,
startDate: ISODate("2025-11-15"),
endDate: ISODate("2025-12-06"),
totalEvents: 4,
tickets: {
enabled: true,
requiresSeriesTicket: true,
currency: "CAD",
member: { /* ... */ },
public: { /* ... */ },
capacity: { total: 30, reserved: 2 }
},
registrations: [
{
memberId: ObjectId,
name: "Jane Doe",
email: "jane@example.com",
ticketType: "member",
ticketPrice: 0,
paymentStatus: "not_required",
registeredAt: ISODate,
eventRegistrations: [
{ eventId: ObjectId, registrationId: ObjectId },
{ eventId: ObjectId, registrationId: ObjectId }
]
}
],
targetCircles: ["founder", "practitioner"],
createdBy: "admin",
createdAt: ISODate,
updatedAt: ISODate
}
```
## Backward Compatibility
✅ **Fully backward compatible**
- Events without series tickets work as before
- Legacy pricing model still supported
- Existing registrations unaffected
- Series can exist without ticket system enabled
## Testing Checklist
### Series Pass Purchase
- [ ] Member can see free series pass
- [ ] Public user sees correct pricing
- [ ] Early bird pricing displays correctly
- [ ] Helcim payment modal opens for paid passes
- [ ] Payment processes successfully
- [ ] All events get registrations created
- [ ] Confirmation email sends with all events listed
- [ ] Capacity decrements correctly
### Event Access Control
- [ ] User with series pass sees "Registered" on event pages
- [ ] User without pass sees "Series Pass Required" message
- [ ] Link to series page works correctly
- [ ] Individual event tickets disabled when series pass required
### Edge Cases
- [ ] Already registered users see correct message
- [ ] Sold out series shows waitlist option
- [ ] Cancelled events don't allow registration
- [ ] Payment failures don't create registrations
- [ ] Concurrent purchases don't oversell
## Future Enhancements
### Phase 2 (Planned)
- Series pass transfers between users
- Partial series passes ("Any 3 of 5 workshops")
- Upgrade from individual ticket to series pass
- Series pass refunds and cancellations
- Admin dashboard for series pass holders
- Export attendee lists by series
### Phase 3 (Nice to Have)
- Gift series passes
- Group/team series passes
- Installment payment plans
- Series completion certificates
- Recurring series subscriptions
## Files Created/Modified
### New Files (9)
- `server/models/series.js` - Series data model
- `server/api/series/[id]/tickets/available.get.js` - Check availability
- `server/api/series/[id]/tickets/purchase.post.js` - Purchase series pass
- `server/api/series/[id]/tickets/check-eligibility.post.js` - Member check
- `server/api/events/[id]/check-series-access.get.js` - Verify pass ownership
- `app/components/EventSeriesTicketCard.vue` - Series pass display card
- `app/components/SeriesPassPurchase.vue` - Purchase flow component
- `SERIES_TICKETING_IMPLEMENTATION.md` - This documentation
### Modified Files (4)
- `server/models/event.js` - Added series ticket fields
- `server/utils/tickets.js` - Added 8 series ticket functions (~360 lines)
- `server/utils/resend.js` - Added series pass confirmation email
- `app/components/EventTicketPurchase.vue` - Series pass detection
- `app/pages/series/[id].vue` - Integrated purchase UI
### Total Implementation
- **~1,200 lines of new code**
- **9 new files**
- **4 modified files**
- **4 new API endpoints**
- **2 new Vue components**
- **8 new utility functions**
- **1 new data model**
## Support
For questions or issues:
- Review this documentation
- Check existing ticketing docs: `HELCIM_TICKET_INTEGRATION.md`
- Reference project context: `CLAUDE.md`
---
**Status**: ✅ Implementation Complete
**Last Updated**: 2025-10-14
**Developer**: Claude (Anthropic)

View file

@ -1,35 +1,33 @@
<template> <template>
<div <div
class="p-4 bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl border border-purple-500/30" class="series-badge p-4 bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl border border-ghost-600 dark:border-ghost-600"
> >
<div class="flex items-start justify-between gap-6"> <div class="flex items-start justify-between gap-6">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2"> <div class="flex flex-wrap items-center gap-2 mb-2">
<span <span
class="text-sm font-semibold text-purple-700 dark:text-purple-300" class="series-badge__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
> >
Part of a Series Part of a Series
</span> </span>
<span <span
v-if="totalEvents" v-if="totalEvents"
class="inline-flex items-center px-2 py-0.5 rounded-md bg-purple-500/20 text-sm font-medium text-purple-700 dark:text-purple-300" class="series-badge__count inline-flex items-center px-2 py-0.5 rounded-md bg-ghost-700/50 dark:bg-ghost-600/50 text-sm font-medium text-ghost-200 dark:text-ghost-200"
> >
<template v-if="position"> <template v-if="position">
Event {{ position }} of {{ totalEvents }} Event {{ position }} of {{ totalEvents }}
</template> </template>
<template v-else> <template v-else> {{ totalEvents }} events in series </template>
{{ totalEvents }} events in series
</template>
</span> </span>
</div> </div>
<h3 <h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2" class="series-badge__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
> >
{{ title }} {{ title }}
</h3> </h3>
<p <p
v-if="description" v-if="description"
class="text-sm text-purple-600 dark:text-purple-400" class="series-badge__description text-sm text-ghost-300 dark:text-ghost-300"
> >
{{ description }} {{ description }}
</p> </p>

View file

@ -0,0 +1,264 @@
<template>
<div
class="series-ticket-card border border-ghost-600 dark:border-ghost-600 rounded-xl overflow-hidden"
>
<!-- Header -->
<div
class="bg-gradient-to-br from-purple-600 to-purple-800 dark:from-purple-700 dark:to-purple-900 p-6"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Icon
name="heroicons:ticket"
class="w-5 h-5 text-purple-200 dark:text-purple-300"
/>
<span class="text-sm font-semibold text-purple-200 dark:text-purple-300">
Series Pass
</span>
</div>
<h3 class="text-xl font-bold text-white mb-1">
{{ ticket.name }}
</h3>
<p v-if="ticket.description" class="text-sm text-purple-200 dark:text-purple-300">
{{ ticket.description }}
</p>
</div>
<div class="text-right flex-shrink-0">
<div class="text-3xl font-bold text-white">
{{ formatPrice(ticket.price, ticket.currency) }}
</div>
<div
v-if="ticket.isEarlyBird"
class="text-xs text-purple-200 dark:text-purple-300 mt-1"
>
Early Bird Price
</div>
</div>
</div>
</div>
<!-- Body -->
<div class="p-6 bg-ghost-800/50 dark:bg-ghost-700/30">
<!-- What's Included -->
<div class="mb-6">
<h4 class="text-sm font-semibold text-ghost-200 dark:text-ghost-200 mb-3 uppercase tracking-wide">
What's Included
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Access to all {{ totalEvents }} events in the series</span>
</div>
<div
v-if="ticket.isFree && !isMember"
class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Automatic registration for all sessions</span>
</div>
<div
v-if="memberSavings > 0"
class="flex items-center gap-2 text-ghost-300 dark:text-ghost-300"
>
<Icon name="heroicons:check-circle" class="w-5 h-5 text-green-400" />
<span>Save {{ formatPrice(memberSavings, ticket.currency) }} as a member</span>
</div>
</div>
</div>
<!-- Events List Preview -->
<div v-if="events && events.length > 0" class="mb-6">
<h4 class="text-sm font-semibold text-ghost-200 dark:text-ghost-200 mb-3 uppercase tracking-wide">
Series Schedule
</h4>
<div class="space-y-2">
<div
v-for="(event, index) in events.slice(0, 3)"
:key="event.id"
class="flex items-start gap-3 p-3 bg-ghost-700/50 dark:bg-ghost-600/30 rounded-lg"
>
<div
class="w-8 h-8 rounded-full bg-purple-600/20 border border-purple-500/30 flex items-center justify-center flex-shrink-0"
>
<span class="text-sm font-bold text-purple-300">{{ index + 1 }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-ghost-100 dark:text-ghost-100 text-sm">
{{ event.title }}
</div>
<div class="text-xs text-ghost-400 dark:text-ghost-400 mt-1">
{{ formatEventDate(event.startDate) }}
</div>
</div>
</div>
<div
v-if="events.length > 3"
class="text-center text-sm text-ghost-400 dark:text-ghost-400 pt-2"
>
+ {{ events.length - 3 }} more event{{ events.length - 3 !== 1 ? 's' : '' }}
</div>
</div>
</div>
<!-- Member Benefit Callout -->
<div
v-if="ticket.isFree && isMember"
class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg mb-6"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:sparkles" class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold text-green-300 mb-1">Member Benefit</div>
<div class="text-sm text-green-400">
This series pass is free for Ghost Guild members! Complete registration to secure your spot.
</div>
</div>
</div>
</div>
<!-- Public vs Member Pricing -->
<div
v-if="!ticket.isFree && publicPrice && ticket.type === 'member'"
class="p-4 bg-blue-900/20 border border-blue-700/30 rounded-lg mb-6"
>
<div class="flex items-start gap-3">
<Icon name="heroicons:tag" class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<div class="font-semibold text-blue-300 mb-1">Member Savings</div>
<div class="text-sm text-blue-400">
You're saving {{ formatPrice(memberSavings, ticket.currency) }} as a member.
Public price: {{ formatPrice(publicPrice, ticket.currency) }}
</div>
</div>
</div>
</div>
<!-- Availability -->
<div v-if="availability" class="mb-6">
<div
v-if="!availability.unlimited && availability.remaining !== null"
class="flex items-center gap-2"
>
<Icon
:name="availability.remaining > 5 ? 'heroicons:check-circle' : 'heroicons:exclamation-triangle'"
:class="[
'w-5 h-5',
availability.remaining > 5 ? 'text-green-400' : 'text-yellow-400'
]"
/>
<span
:class="[
'text-sm font-medium',
availability.remaining > 5 ? 'text-green-300' : 'text-yellow-300'
]"
>
{{ availability.remaining }} series pass{{ availability.remaining !== 1 ? 'es' : '' }} remaining
</span>
</div>
</div>
<!-- Sold Out / Waitlist -->
<div v-if="!available" class="space-y-3">
<div class="p-4 bg-red-900/20 border border-red-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<div class="font-semibold text-red-300 mb-1">Series Pass Sold Out</div>
<div class="text-sm text-red-400">
All series passes have been claimed.
</div>
</div>
</div>
</div>
<UButton
v-if="availability?.waitlistAvailable"
block
color="gray"
size="lg"
@click="$emit('join-waitlist')"
>
Join Waitlist
</UButton>
</div>
<!-- Already Registered -->
<div v-else-if="alreadyRegistered" class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg">
<div class="flex items-start gap-3">
<Icon name="heroicons:check-badge" class="w-6 h-6 text-green-400 flex-shrink-0" />
<div>
<div class="font-semibold text-green-300 mb-1">You're Registered!</div>
<div class="text-sm text-green-400">
You have a series pass and are registered for all {{ totalEvents }} events.
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticket: {
type: Object,
required: true,
// Expected: { name, description, price, currency, type, isFree, isEarlyBird }
},
availability: {
type: Object,
default: null,
// Expected: { remaining, unlimited, waitlistAvailable }
},
available: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
isMember: {
type: Boolean,
default: false,
},
totalEvents: {
type: Number,
required: true,
},
events: {
type: Array,
default: () => [],
// Expected: Array of { id, title, startDate }
},
publicPrice: {
type: Number,
default: null,
},
});
defineEmits(['join-waitlist']);
const memberSavings = computed(() => {
if (props.publicPrice && props.ticket.price < props.publicPrice) {
return props.publicPrice - props.ticket.price;
}
return 0;
});
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
</script>

View file

@ -0,0 +1,192 @@
<template>
<div
class="ticket-card rounded-xl border p-6 transition-all duration-200"
:class="[
isSelected
? 'border-primary bg-primary/5'
: 'border-ghost-600 bg-ghost-800/50',
isAvailable && !alreadyRegistered
? 'hover:border-primary/50 cursor-pointer'
: 'opacity-60 cursor-not-allowed',
]"
@click="handleClick"
>
<!-- Ticket Header -->
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-ghost-100">
{{ ticketInfo.name }}
</h3>
<p v-if="ticketInfo.description" class="text-sm text-ghost-300 mt-1">
{{ ticketInfo.description }}
</p>
</div>
<!-- Badge -->
<div v-if="ticketInfo.isMember" class="flex-shrink-0 ml-4">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
Members Only
</span>
</div>
</div>
<!-- Price Display -->
<div class="mb-4">
<div class="flex items-baseline gap-2">
<span
class="text-3xl font-bold"
:class="ticketInfo.isFree ? 'text-green-400' : 'text-ghost-100'"
>
{{ ticketInfo.formattedPrice }}
</span>
<!-- Early Bird Badge -->
<span
v-if="ticketInfo.isEarlyBird"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
Early Bird
</span>
</div>
<!-- Regular Price (if early bird) -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.formattedRegularPrice"
class="mt-1"
>
<span class="text-sm text-ghost-400 line-through">
Regular: {{ ticketInfo.formattedRegularPrice }}
</span>
</div>
<!-- Early Bird Countdown -->
<div
v-if="ticketInfo.isEarlyBird && ticketInfo.earlyBirdDeadline"
class="mt-2 text-xs text-amber-400"
>
<Icon name="heroicons:clock" class="w-4 h-4 inline mr-1" />
Early bird ends {{ formatDeadline(ticketInfo.earlyBirdDeadline) }}
</div>
</div>
<!-- Member Savings -->
<div
v-if="ticketInfo.publicTicket && ticketInfo.memberSavings > 0"
class="mb-4 p-3 bg-green-900/20 rounded-lg border border-green-800"
>
<p class="text-sm text-green-400">
<Icon name="heroicons:check-circle" class="w-4 h-4 inline mr-1" />
You save {{ formatPrice(ticketInfo.memberSavings) }} as a member!
</p>
<p class="text-xs text-ghost-400 mt-1">
Public price: {{ ticketInfo.publicTicket.formattedPrice }}
</p>
</div>
<!-- Availability -->
<div class="flex items-center justify-between text-sm">
<div>
<span
v-if="alreadyRegistered"
class="text-green-400 flex items-center gap-1"
>
<Icon name="heroicons:check-circle-solid" class="w-4 h-4" />
You're registered
</span>
<span
v-else-if="!isAvailable"
class="text-red-400 flex items-center gap-1"
>
<Icon name="heroicons:x-circle-solid" class="w-4 h-4" />
Sold Out
</span>
<span v-else-if="ticketInfo.remaining !== null" class="text-ghost-300">
{{ ticketInfo.remaining }} remaining
</span>
<span v-else class="text-ghost-300"> Unlimited availability </span>
</div>
<!-- Selection Indicator -->
<div v-if="isSelected && isAvailable && !alreadyRegistered">
<Icon name="heroicons:check-circle-solid" class="w-5 h-5 text-primary" />
</div>
</div>
<!-- Waitlist Option -->
<div
v-if="!isAvailable && ticketInfo.waitlistAvailable && !alreadyRegistered"
class="mt-4 pt-4 border-t border-ghost-600"
>
<UButton
color="gray"
size="sm"
block
@click.stop="$emit('join-waitlist')"
>
Join Waitlist
</UButton>
</div>
</div>
</template>
<script setup>
const props = defineProps({
ticketInfo: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
isAvailable: {
type: Boolean,
default: true,
},
alreadyRegistered: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select", "join-waitlist"]);
const handleClick = () => {
if (props.isAvailable && !props.alreadyRegistered) {
emit("select");
}
};
const formatDeadline = (deadline) => {
const date = new Date(deadline);
const now = new Date();
const diff = date - now;
// If less than 24 hours, show hours
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
return `in ${hours} hour${hours !== 1 ? "s" : ""}`;
}
// Otherwise show date
return `on ${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}`;
};
const formatPrice = (amount) => {
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
};
</script>
<style scoped>
.ticket-card {
position: relative;
}
</style>

View file

@ -0,0 +1,412 @@
<template>
<div class="event-ticket-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-ghost-300">Loading ticket information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-red-900/20 rounded-xl border border-red-800"
>
<h3 class="text-lg font-semibold text-red-300 mb-2">
Unable to Load Tickets
</h3>
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Series Pass Required -->
<div
v-else-if="ticketInfo?.requiresSeriesPass"
class="p-6 bg-purple-900/20 rounded-xl border border-purple-800"
>
<h3
class="text-lg font-semibold text-purple-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:ticket" class="w-6 h-6" />
Series Pass Required
</h3>
<p class="text-purple-400 mb-4">
This event is part of
<strong>{{ ticketInfo.series?.title }}</strong> and requires a series
pass to attend.
</p>
<p class="text-sm text-ghost-300 mb-6">
Purchase a series pass to get access to all events in this series.
</p>
<UButton
:to="`/series/${ticketInfo.series?.slug || ticketInfo.series?.id}`"
color="primary"
size="lg"
block
>
View Series & Purchase Pass
</UButton>
</div>
<!-- Already Registered -->
<div
v-else-if="ticketInfo?.alreadyRegistered"
class="p-6 bg-green-900/20 rounded-xl border border-green-800"
>
<h3
class="text-lg font-semibold text-green-300 mb-2 flex items-center gap-2"
>
<Icon name="heroicons:check-circle-solid" class="w-6 h-6" />
You're Registered!
</h3>
<p class="text-green-400 mb-4">
<template v-if="ticketInfo.viaSeriesPass">
You have access to this event via your series pass for
<strong>{{ ticketInfo.series?.title }}</strong
>.
</template>
<template v-else>
You're all set for this event. Check your email for confirmation
details.
</template>
</p>
<p class="text-sm text-ghost-300">
See you on {{ formatEventDate(eventStartDate) }}!
</p>
</div>
<!-- Ticket Selection -->
<div v-else-if="ticketInfo">
<!-- Ticket Card -->
<EventTicketCard
:ticket-info="ticketInfo"
:is-selected="true"
:is-available="ticketInfo.available"
:already-registered="ticketInfo.alreadyRegistered"
class="mb-6"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div v-if="ticketInfo.available && !ticketInfo.alreadyRegistered">
<h3 class="text-xl font-bold text-ghost-100 mb-4">
{{ ticketInfo.isFree ? "Register" : "Purchase Ticket" }}
</h3>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
/>
<p v-if="isLoggedIn" class="text-xs text-ghost-400 mt-1">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="ticketInfo.isMember && ticketInfo.isFree"
class="p-4 bg-purple-900/20 rounded-lg border border-purple-800"
>
<p class="text-sm text-purple-300 flex items-center gap-2">
<Icon name="heroicons:sparkles" class="w-4 h-4" />
This event is free for Ghost Guild members
</p>
</div>
<!-- Payment Required Notice -->
<div
v-if="!ticketInfo.isFree"
class="p-4 bg-blue-900/20 rounded-lg border border-blue-800"
>
<p class="text-sm text-blue-300 flex items-center gap-2">
<Icon name="heroicons:credit-card" class="w-4 h-4" />
Payment of {{ ticketInfo.formattedPrice }} will be processed
securely
</p>
</div>
<!-- Submit Button -->
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="processing"
:disabled="!form.name || !form.email"
>
{{
processing
? "Processing..."
: ticketInfo.isFree
? "Complete Registration"
: `Pay ${ticketInfo.formattedPrice}`
}}
</UButton>
</div>
</form>
</div>
<!-- Sold Out with Waitlist -->
<div
v-else-if="!ticketInfo.available && ticketInfo.waitlistAvailable"
class="text-center py-8"
>
<Icon
name="heroicons:ticket"
class="w-16 h-16 text-ghost-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-ghost-100 mb-2">Event Sold Out</h3>
<p class="text-ghost-300 mb-6">
This event is currently at capacity. Join the waitlist to be notified
if spots become available.
</p>
<UButton color="gray" size="lg" @click="handleJoinWaitlist">
Join Waitlist
</UButton>
</div>
<!-- Sold Out (No Waitlist) -->
<div v-else-if="!ticketInfo.available" class="text-center py-8">
<Icon
name="heroicons:x-circle"
class="w-16 h-16 text-red-400 mx-auto mb-4"
/>
<h3 class="text-xl font-bold text-ghost-100 mb-2">Event Sold Out</h3>
<p class="text-ghost-300">
Unfortunately, this event is at capacity and no longer accepting
registrations.
</p>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
eventId: {
type: String,
required: true,
},
eventStartDate: {
type: Date,
required: true,
},
eventTitle: {
type: String,
required: true,
},
userEmail: {
type: String,
default: null,
},
});
const emit = defineEmits(["success", "error"]);
const toast = useToast();
const { initializeTicketPayment, verifyPayment, cleanup } = useHelcimPay();
// State
const loading = ref(true);
const processing = ref(false);
const error = ref(null);
const ticketInfo = ref(null);
const form = ref({
name: "",
email: props.userEmail || "",
});
const isLoggedIn = computed(() => !!props.userEmail);
// Fetch ticket availability on mount
onMounted(async () => {
await fetchTicketInfo();
});
const fetchTicketInfo = async () => {
loading.value = true;
error.value = null;
try {
// First check if this event requires a series pass
if (props.userEmail) {
try {
const seriesAccess = await $fetch(
`/api/events/${props.eventId}/check-series-access`,
);
if (seriesAccess.requiresSeriesPass) {
if (seriesAccess.hasSeriesPass) {
// User has series pass - show as already registered
ticketInfo.value = {
available: true,
alreadyRegistered: true,
viaSeriesPass: true,
series: seriesAccess.series,
message: seriesAccess.message,
};
loading.value = false;
return;
} else {
// User needs to buy series pass
ticketInfo.value = {
available: false,
requiresSeriesPass: true,
series: seriesAccess.series,
message: seriesAccess.message,
};
loading.value = false;
return;
}
}
} catch (seriesErr) {
// If series check fails, continue with regular ticket check
console.warn("Series access check failed:", seriesErr);
}
}
// Regular ticket availability check
const params = props.userEmail ? `?email=${props.userEmail}` : "";
const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`,
);
ticketInfo.value = response;
} catch (err) {
console.error("Error fetching ticket info:", err);
error.value =
err.data?.statusMessage || "Failed to load ticket information";
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
processing.value = true;
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!ticketInfo.value.isFree) {
// Initialize Helcim payment
await initializeTicketPayment(
props.eventId,
form.value.email,
ticketInfo.value.price,
props.eventTitle,
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
// For purchase transactions, we get a transactionId
transactionId = paymentResult.transactionId;
if (!transactionId) {
throw new Error("No transaction ID received from payment");
}
}
// Purchase ticket
const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
transactionId,
},
},
);
// Success!
toast.add({
title: "Success!",
description: ticketInfo.value.isFree
? "You're registered for this event"
: "Ticket purchased successfully!",
color: "green",
});
emit("success", response);
// Refresh ticket info to show registered state
await fetchTicketInfo();
} catch (err) {
console.error("Error purchasing ticket:", err);
const errorMessage =
err.data?.statusMessage ||
err.message ||
"Failed to process registration";
toast.add({
title: "Registration Failed",
description: errorMessage,
color: "red",
});
emit("error", err);
} finally {
processing.value = false;
cleanup();
}
};
const handleJoinWaitlist = () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist",
description: "Waitlist functionality coming soon!",
color: "blue",
});
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
</script>

View file

@ -0,0 +1,333 @@
<template>
<div class="series-pass-purchase">
<!-- Loading State -->
<div v-if="loading" class="text-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"
></div>
<p class="text-[--ui-text-muted]">Loading series pass information...</p>
</div>
<!-- Error State -->
<div
v-else-if="error"
class="p-6 bg-red-900/20 rounded-xl border border-red-800"
>
<h3 class="text-lg font-semibold text-red-300 mb-2">
Unable to Load Series Pass
</h3>
<p class="text-red-400">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="passInfo">
<!-- Series Pass Card -->
<EventSeriesTicketCard
:ticket="passInfo.ticket"
:availability="passInfo.availability"
:available="passInfo.available"
:already-registered="passInfo.alreadyRegistered"
:is-member="passInfo.memberInfo?.isMember"
:total-events="seriesInfo.totalEvents"
:events="seriesEvents"
:public-price="passInfo.publicPrice"
class="mb-8"
@join-waitlist="handleJoinWaitlist"
/>
<!-- Registration Form -->
<div
v-if="passInfo.available && !passInfo.alreadyRegistered"
class="bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl border border-ghost-600 dark:border-ghost-600 p-6"
>
<h3 class="text-xl font-bold text-[--ui-text] mb-6">
{{
passInfo.ticket.isFree
? "Register for Series"
: "Purchase Series Pass"
}}
</h3>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Name Field -->
<div>
<label
for="name"
class="block text-sm font-medium text-[--ui-text] mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="form.name"
type="text"
required
placeholder="Enter your full name"
:disabled="processing"
size="lg"
/>
</div>
<!-- Email Field -->
<div>
<label
for="email"
class="block text-sm font-medium text-[--ui-text] mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="form.email"
type="email"
required
placeholder="Enter your email"
:disabled="processing || isLoggedIn"
size="lg"
/>
<p v-if="isLoggedIn" class="text-xs text-[--ui-text-muted] mt-2">
Using your member email
</p>
</div>
<!-- Member Benefits Notice -->
<div
v-if="passInfo.ticket.isFree && passInfo.memberInfo?.isMember"
class="p-4 bg-green-900/20 border border-green-700/30 rounded-lg"
>
<div class="flex items-start gap-3">
<Icon
name="heroicons:sparkles"
class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5"
/>
<div>
<div class="font-semibold text-green-300 mb-1">
Member Benefit
</div>
<div class="text-sm text-green-400">
This series pass is free for Ghost Guild members!
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<UButton
type="submit"
block
size="xl"
:disabled="processing || !form.name || !form.email"
:loading="processing"
>
<template v-if="processing">
{{ paymentProcessing ? "Processing Payment..." : "Registering..." }}
</template>
<template v-else>
{{
passInfo.ticket.isFree
? "Complete Registration"
: `Pay ${formatPrice(passInfo.ticket.price, passInfo.ticket.currency)}`
}}
</template>
</UButton>
<p class="text-xs text-[--ui-text-muted] text-center">
By registering, you'll be automatically registered for all
{{ seriesInfo.totalEvents }} events in this series.
</p>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { useHelcimPay } from "~/composables/useHelcimPay";
const props = defineProps({
seriesId: {
type: String,
required: true,
},
seriesInfo: {
type: Object,
required: true,
// Expected: { id, title, totalEvents, type }
},
seriesEvents: {
type: Array,
default: () => [],
// Expected: Array of event objects
},
userEmail: {
type: String,
default: null,
},
userName: {
type: String,
default: null,
},
});
const emit = defineEmits(["purchase-success", "purchase-error"]);
const toast = useToast();
const { initializePayment, verifyPayment } = useHelcimPay();
// State
const loading = ref(true);
const processing = ref(false);
const paymentProcessing = ref(false);
const error = ref(null);
const passInfo = ref(null);
const form = ref({
name: props.userName || "",
email: props.userEmail || "",
});
const isLoggedIn = computed(() => !!props.userEmail);
// Fetch series pass info on mount
onMounted(async () => {
await fetchPassInfo();
});
const fetchPassInfo = async () => {
loading.value = true;
error.value = null;
try {
const response = await $fetch(
`/api/series/${props.seriesId}/tickets/available`
);
passInfo.value = response;
// Pre-fill form if member info available
if (response.memberInfo?.isMember) {
form.value.name = response.memberInfo.name || form.value.name;
form.value.email = response.memberInfo.email || form.value.email;
}
// Also fetch public price for comparison
if (response.memberInfo?.isMember && response.ticket?.type === "member") {
// Make another request to get public pricing
try {
const publicResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/available?forcePublic=true`
);
if (publicResponse.ticket?.price) {
passInfo.value.publicPrice = publicResponse.ticket.price;
}
} catch (err) {
console.warn("Could not fetch public price for comparison");
}
}
} catch (err) {
console.error("Error fetching series pass info:", err);
error.value =
err.data?.statusMessage || "Failed to load series pass information";
} finally {
loading.value = false;
}
};
const handleSubmit = async () => {
processing.value = true;
try {
let transactionId = null;
// If payment is required, initialize Helcim and process payment
if (!passInfo.value.ticket.isFree) {
paymentProcessing.value = true;
// Initialize Helcim payment for series pass
await initializePayment(
form.value.email,
passInfo.value.ticket.price,
passInfo.value.ticket.currency || "CAD",
{
type: "series_pass",
seriesId: props.seriesId,
seriesTitle: props.seriesInfo.title,
}
);
// Show Helcim modal and complete payment
const paymentResult = await verifyPayment();
if (!paymentResult.success) {
throw new Error("Payment was not completed");
}
transactionId = paymentResult.transactionId;
paymentProcessing.value = false;
}
// Complete series pass purchase
const purchaseResponse = await $fetch(
`/api/series/${props.seriesId}/tickets/purchase`,
{
method: "POST",
body: {
name: form.value.name,
email: form.value.email,
paymentId: transactionId,
},
}
);
// Show success message
toast.add({
title: "Series Pass Purchased!",
description: `You're now registered for all ${purchaseResponse.registration.eventsRegistered} events in this series.`,
color: "green",
timeout: 5000,
});
// Emit success event
emit("purchase-success", purchaseResponse);
// Refresh pass info to show registered state
await fetchPassInfo();
} catch (err) {
console.error("Error purchasing series pass:", err);
const errorMessage =
err.data?.statusMessage ||
err.message ||
"Failed to complete series pass purchase";
toast.add({
title: "Purchase Failed",
description: errorMessage,
color: "red",
timeout: 5000,
});
emit("purchase-error", errorMessage);
} finally {
processing.value = false;
paymentProcessing.value = false;
}
};
const handleJoinWaitlist = async () => {
// TODO: Implement waitlist functionality
toast.add({
title: "Waitlist Coming Soon",
description: "The waitlist feature is coming soon!",
color: "blue",
});
};
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
</script>

View file

@ -1,158 +1,232 @@
// HelcimPay.js integration composable // HelcimPay.js integration composable
export const useHelcimPay = () => { export const useHelcimPay = () => {
let checkoutToken = null let checkoutToken = null;
let secretToken = null let secretToken = null;
// Initialize HelcimPay.js session // Initialize HelcimPay.js session
const initializeHelcimPay = async (customerId, customerCode, amount = 0) => { const initializeHelcimPay = async (customerId, customerCode, amount = 0) => {
try { try {
const response = await $fetch('/api/helcim/initialize-payment', { const response = await $fetch("/api/helcim/initialize-payment", {
method: 'POST', method: "POST",
body: { body: {
customerId, customerId,
customerCode, customerCode,
amount amount,
} },
}) });
if (response.success) { if (response.success) {
checkoutToken = response.checkoutToken checkoutToken = response.checkoutToken;
secretToken = response.secretToken secretToken = response.secretToken;
return true return true;
} }
throw new Error('Failed to initialize payment session') throw new Error("Failed to initialize payment session");
} catch (error) { } catch (error) {
console.error('Payment initialization error:', error) console.error("Payment initialization error:", error);
throw error throw error;
} }
} };
// Initialize payment for event ticket purchase
const initializeTicketPayment = async (
eventId,
email,
amount,
eventTitle = null,
) => {
try {
const response = await $fetch("/api/helcim/initialize-payment", {
method: "POST",
body: {
customerId: null,
customerCode: email, // Use email as customer code for event tickets
amount,
metadata: {
type: "event_ticket",
eventId,
email,
eventTitle,
},
},
});
if (response.success) {
checkoutToken = response.checkoutToken;
secretToken = response.secretToken;
return {
success: true,
checkoutToken: response.checkoutToken,
};
}
throw new Error("Failed to initialize ticket payment session");
} catch (error) {
console.error("Ticket payment initialization error:", error);
throw error;
}
};
// Show payment modal // Show payment modal
const showPaymentModal = () => { const showPaymentModal = () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!checkoutToken) { if (!checkoutToken) {
reject(new Error('Payment not initialized. Call initializeHelcimPay first.')) reject(
return new Error("Payment not initialized. Call initializeHelcimPay first."),
);
return;
}
// Add custom CSS to fix Helcim overlay styling
if (!document.getElementById("helcim-overlay-fix")) {
const style = document.createElement("style");
style.id = "helcim-overlay-fix";
style.textContent = `
/* Fix Helcim iframe overlay - the second parameter to appendHelcimPayIframe controls this */
/* Target all fixed position divs that might be the overlay */
body > div[style*="position: fixed"][style*="inset: 0"],
body > div[style*="position:fixed"][style*="inset:0"] {
background-color: rgba(0, 0, 0, 0.75) !important;
}
`;
document.head.appendChild(style);
} }
// Load HelcimPay.js modal script // Load HelcimPay.js modal script
if (!window.appendHelcimPayIframe) { if (!window.appendHelcimPayIframe) {
console.log('HelcimPay script not loaded, loading now...') console.log("HelcimPay script not loaded, loading now...");
const script = document.createElement('script') const script = document.createElement("script");
script.src = 'https://secure.helcim.app/helcim-pay/services/start.js' script.src = "https://secure.helcim.app/helcim-pay/services/start.js";
script.async = true script.async = true;
script.onload = () => { script.onload = () => {
console.log('HelcimPay script loaded successfully!') console.log("HelcimPay script loaded successfully!");
console.log('Available functions:', Object.keys(window).filter(key => key.includes('Helcim') || key.includes('helcim'))) console.log(
console.log('appendHelcimPayIframe available:', typeof window.appendHelcimPayIframe) "Available functions:",
openModal(resolve, reject) Object.keys(window).filter(
} (key) => key.includes("Helcim") || key.includes("helcim"),
),
);
console.log(
"appendHelcimPayIframe available:",
typeof window.appendHelcimPayIframe,
);
openModal(resolve, reject);
};
script.onerror = () => { script.onerror = () => {
reject(new Error('Failed to load HelcimPay.js')) reject(new Error("Failed to load HelcimPay.js"));
} };
document.head.appendChild(script) document.head.appendChild(script);
} else { } else {
console.log('HelcimPay script already loaded, calling openModal') console.log("HelcimPay script already loaded, calling openModal");
openModal(resolve, reject) openModal(resolve, reject);
} }
}) });
} };
// Open the payment modal // Open the payment modal
const openModal = (resolve, reject) => { const openModal = (resolve, reject) => {
try { try {
console.log('Trying to open modal with checkoutToken:', checkoutToken) console.log("Trying to open modal with checkoutToken:", checkoutToken);
if (typeof window.appendHelcimPayIframe === 'function') { if (typeof window.appendHelcimPayIframe === "function") {
// Set up event listener for HelcimPay.js responses // Set up event listener for HelcimPay.js responses
const helcimPayJsIdentifierKey = 'helcim-pay-js-' + checkoutToken const helcimPayJsIdentifierKey = "helcim-pay-js-" + checkoutToken;
const handleHelcimPayEvent = (event) => { const handleHelcimPayEvent = (event) => {
console.log('Received window message:', event.data) console.log("Received window message:", event.data);
if (event.data.eventName === helcimPayJsIdentifierKey) { if (event.data.eventName === helcimPayJsIdentifierKey) {
console.log('HelcimPay event received:', event.data) console.log("HelcimPay event received:", event.data);
// Remove event listener to prevent multiple responses // Remove event listener to prevent multiple responses
window.removeEventListener('message', handleHelcimPayEvent) window.removeEventListener("message", handleHelcimPayEvent);
if (event.data.eventStatus === 'SUCCESS') { // Close the Helcim modal
console.log('Payment success:', event.data.eventMessage) if (typeof window.removeHelcimPayIframe === "function") {
window.removeHelcimPayIframe();
}
if (event.data.eventStatus === "SUCCESS") {
console.log("Payment success:", event.data.eventMessage);
// Parse the JSON string eventMessage // Parse the JSON string eventMessage
let paymentData let paymentData;
try { try {
paymentData = JSON.parse(event.data.eventMessage) paymentData = JSON.parse(event.data.eventMessage);
console.log('Parsed payment data:', paymentData) console.log("Parsed payment data:", paymentData);
} catch (parseError) { } catch (parseError) {
console.error('Failed to parse eventMessage:', parseError) console.error("Failed to parse eventMessage:", parseError);
reject(new Error('Invalid payment response format')) reject(new Error("Invalid payment response format"));
return return;
} }
// Extract transaction details from nested data structure // Extract transaction details from nested data structure
const transactionData = paymentData.data?.data || {} const transactionData = paymentData.data?.data || {};
console.log('Transaction data:', transactionData) console.log("Transaction data:", transactionData);
resolve({ resolve({
success: true, success: true,
transactionId: transactionData.transactionId, transactionId: transactionData.transactionId,
cardToken: transactionData.cardToken, cardToken: transactionData.cardToken,
cardLast4: transactionData.cardNumber ? transactionData.cardNumber.slice(-4) : undefined, cardLast4: transactionData.cardNumber
cardType: transactionData.cardType || 'unknown' ? transactionData.cardNumber.slice(-4)
}) : undefined,
} else if (event.data.eventStatus === 'ABORTED') { cardType: transactionData.cardType || "unknown",
console.log('Payment aborted:', event.data.eventMessage) });
reject(new Error(event.data.eventMessage || 'Payment failed')) } else if (event.data.eventStatus === "ABORTED") {
} else if (event.data.eventStatus === 'HIDE') { console.log("Payment aborted:", event.data.eventMessage);
console.log('Modal closed without completion') reject(new Error(event.data.eventMessage || "Payment failed"));
reject(new Error('Payment cancelled by user')) } else if (event.data.eventStatus === "HIDE") {
console.log("Modal closed without completion");
reject(new Error("Payment cancelled by user"));
} }
} }
} };
// Add event listener // Add event listener
window.addEventListener('message', handleHelcimPayEvent) window.addEventListener("message", handleHelcimPayEvent);
// Open the HelcimPay iframe modal // Open the HelcimPay iframe modal
console.log('Calling appendHelcimPayIframe with token:', checkoutToken) console.log("Calling appendHelcimPayIframe with token:", checkoutToken);
window.appendHelcimPayIframe(checkoutToken, true) window.appendHelcimPayIframe(checkoutToken, true);
console.log('appendHelcimPayIframe called, waiting for window messages...') console.log(
"appendHelcimPayIframe called, waiting for window messages...",
);
// Add timeout to clean up if no response // Add timeout to clean up if no response
setTimeout(() => { setTimeout(() => {
console.log('60 seconds passed, cleaning up event listener...') console.log("60 seconds passed, cleaning up event listener...");
window.removeEventListener('message', handleHelcimPayEvent) window.removeEventListener("message", handleHelcimPayEvent);
reject(new Error('Payment timeout - no response received')) reject(new Error("Payment timeout - no response received"));
}, 60000) }, 60000);
} else { } else {
reject(new Error('appendHelcimPayIframe function not available')) reject(new Error("appendHelcimPayIframe function not available"));
} }
} catch (error) { } catch (error) {
console.error('Error opening modal:', error) console.error("Error opening modal:", error);
reject(error) reject(error);
} }
} };
// Process payment verification // Process payment verification
const verifyPayment = async () => { const verifyPayment = async () => {
try { try {
return await showPaymentModal() return await showPaymentModal();
} catch (error) { } catch (error) {
throw error throw error;
} }
} };
// Cleanup tokens // Cleanup tokens
const cleanup = () => { const cleanup = () => {
checkoutToken = null checkoutToken = null;
secretToken = null secretToken = null;
} };
return { return {
initializeHelcimPay, initializeHelcimPay,
initializeTicketPayment,
verifyPayment, verifyPayment,
cleanup cleanup,
} };
} };

View file

@ -0,0 +1,5 @@
<template>
<div class="min-h-screen bg-ghost-900">
<slot />
</div>
</template>

View file

@ -0,0 +1,18 @@
export default defineNuxtRouteMiddleware((to, from) => {
const config = useRuntimeConfig();
const isComingSoonMode =
config.public.comingSoon === "true" || config.public.comingSoon === true;
// Only enforce coming soon mode if enabled
if (!isComingSoonMode) {
return;
}
// Allow access to the coming-soon page itself
if (to.path === "/coming-soon") {
return;
}
// Redirect all other routes to coming-soon
return navigateTo("/coming-soon");
});

View file

@ -103,6 +103,11 @@
> >
Registration Registration
</th> </th>
<th
class="px-4 py-4 text-left text-xs font-medium text-dimmed uppercase tracking-wider"
>
Tickets
</th>
<th <th
class="px-6 py-4 text-right text-xs font-medium text-dimmed uppercase tracking-wider" class="px-6 py-4 text-right text-xs font-medium text-dimmed uppercase tracking-wider"
> >
@ -258,6 +263,52 @@
</div> </div>
</td> </td>
<!-- Tickets Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-1">
<div v-if="event.tickets?.enabled" class="space-y-1">
<div class="flex items-center gap-1 text-xs">
<Icon
name="heroicons:ticket"
class="w-3.5 h-3.5 text-blue-600"
/>
<span class="font-medium text-default">Ticketing On</span>
</div>
<div
v-if="event.tickets?.requiresSeriesTicket"
class="text-xs text-purple-600"
>
Series Pass Required
</div>
<div v-else class="space-y-0.5">
<div
v-if="event.tickets.member?.available"
class="text-xs text-dimmed"
>
Member:
{{
event.tickets.member.isFree
? "Free"
: `$${event.tickets.member.price}`
}}
</div>
<div
v-if="event.tickets.public?.available"
class="text-xs text-dimmed"
>
Public: ${{ event.tickets.public.price || 0 }}
<span v-if="event.tickets.public.quantity" class="ml-1">
({{ event.tickets.public.sold || 0 }}/{{
event.tickets.public.quantity
}})
</span>
</div>
</div>
</div>
<div v-else class="text-xs text-dimmed">No tickets</div>
</div>
</td>
<!-- Actions Column --> <!-- Actions Column -->
<td class="px-6 py-6 whitespace-nowrap text-right"> <td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end space-x-2"> <div class="flex items-center justify-end space-x-2">

View file

@ -217,6 +217,42 @@
</div> </div>
</div> </div>
<!-- Series Ticketing Info -->
<div
v-if="series.tickets?.enabled"
class="px-6 py-3 bg-blue-50 dark:bg-blue-950/20 border-t border-default"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Icon name="heroicons:ticket" class="w-5 h-5 text-blue-600" />
<div>
<span class="text-sm font-medium text-default">
Series Pass Ticketing Enabled
</span>
<p class="text-xs text-dimmed">
<span v-if="series.tickets.public?.available">
Public: ${{ series.tickets.public.price || 0 }}
</span>
<span v-if="series.tickets.member?.available" class="ml-2">
| Members:
{{
series.tickets.member.isFree
? "Free"
: `$${series.tickets.member.price || 0}`
}}
</span>
</p>
</div>
</div>
<button
@click="manageSeriesTickets(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Manage Tickets
</button>
</div>
</div>
<!-- Series Actions --> <!-- Series Actions -->
<div class="px-6 py-3 bg-muted border-t border-default"> <div class="px-6 py-3 bg-muted border-t border-default">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -224,6 +260,12 @@
{{ formatDateRange(series.startDate, series.endDate) }} {{ formatDateRange(series.startDate, series.endDate) }}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button
@click="manageSeriesTickets(series)"
class="text-sm text-primary hover:text-primary font-medium"
>
Ticketing
</button>
<button <button
@click="editSeries(series)" @click="editSeries(series)"
class="text-sm text-primary hover:text-primary font-medium" class="text-sm text-primary hover:text-primary font-medium"
@ -459,6 +501,384 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Series Ticketing Modal -->
<div
v-if="editingTicketsSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-elevated rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-default">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-highlighted">
Series Pass Ticketing
</h3>
<p class="text-sm text-muted">{{ editingTicketsData.title }}</p>
</div>
<button
@click="cancelTicketsEdit"
class="text-muted hover:text-default"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<!-- Enable Ticketing Toggle -->
<div class="p-4 bg-muted rounded-lg">
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Enable Series Pass Ticketing</span
>
<p class="text-xs text-dimmed">
Allow users to purchase a pass for all events in this series
</p>
</div>
</label>
</div>
<div v-if="editingTicketsData.tickets.enabled" class="space-y-6">
<!-- Ticketing Behavior -->
<div class="space-y-4">
<h4 class="text-sm font-semibold text-highlighted">
Ticketing Behavior
</h4>
<label class="flex items-start cursor-pointer">
<input
v-model="editingTicketsData.tickets.requiresSeriesTicket"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Require Series Pass</span
>
<p class="text-xs text-dimmed">
Users must buy the series pass; individual event tickets are
not available
</p>
</div>
</label>
<label class="flex items-start cursor-pointer">
<input
v-model="
editingTicketsData.tickets.allowIndividualEventTickets
"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500 mt-1"
:disabled="editingTicketsData.tickets.requiresSeriesTicket"
/>
<div class="ml-3">
<span class="text-sm font-medium text-default"
>Allow Individual Event Tickets</span
>
<p class="text-xs text-dimmed">
Users can attend single events without buying the full
series pass
</p>
</div>
</label>
</div>
<!-- Member Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Member Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.member.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.member.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.member.name"
placeholder="e.g., Member Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<div class="flex items-center gap-2">
<UInput
v-model.number="editingTicketsData.tickets.member.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="editingTicketsData.tickets.member.isFree"
class="flex-1 w-full"
/>
<label
class="flex items-center whitespace-nowrap cursor-pointer"
>
<input
v-model="editingTicketsData.tickets.member.isFree"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
<span class="ml-1 text-xs text-muted">Free</span>
</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.member.description"
placeholder="Describe what's included with the member series pass..."
:rows="2"
class="w-full"
/>
</div>
</div>
</div>
<!-- Public Tickets -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">
Public Series Pass
</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Available</span>
<input
v-model="editingTicketsData.tickets.public.available"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div
v-if="editingTicketsData.tickets.public.available"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Pass Name</label
>
<UInput
v-model="editingTicketsData.tickets.public.name"
placeholder="e.g., Series Pass"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Price (CAD)</label
>
<UInput
v-model.number="editingTicketsData.tickets.public.price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Description</label
>
<UTextarea
v-model="editingTicketsData.tickets.public.description"
placeholder="Describe what's included with the public series pass..."
:rows="2"
class="w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Quantity Available</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.quantity
"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.public.sold || 0 }} sold,
{{ editingTicketsData.tickets.public.reserved || 0 }}
reserved
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Price (Optional)</label
>
<UInput
v-model.number="
editingTicketsData.tickets.public.earlyBirdPrice
"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full"
/>
</div>
</div>
<div
v-if="editingTicketsData.tickets.public.earlyBirdPrice > 0"
>
<label class="block text-sm font-medium text-default mb-2"
>Early Bird Deadline</label
>
<UInput
v-model="
editingTicketsData.tickets.public.earlyBirdDeadline
"
type="datetime-local"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Price increases to ${{
editingTicketsData.tickets.public.price
}}
after this date
</p>
</div>
</div>
</div>
<!-- Capacity Management -->
<div class="border border-default rounded-lg p-4">
<h4 class="text-sm font-semibold text-highlighted mb-4">
Capacity Management
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-default mb-2"
>Total Capacity</label
>
<UInput
v-model.number="editingTicketsData.tickets.capacity.total"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
Maximum series pass holders across all types
</p>
</div>
<div>
<label class="block text-sm font-medium text-default mb-2"
>Currently Reserved</label
>
<UInput
v-model.number="
editingTicketsData.tickets.capacity.reserved
"
type="number"
min="0"
disabled
class="w-full bg-accented"
/>
<p class="text-xs text-dimmed mt-1">
Auto-calculated during checkout
</p>
</div>
</div>
</div>
<!-- Waitlist Configuration -->
<div class="border border-default rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-highlighted">Waitlist</h4>
<label class="flex items-center cursor-pointer">
<span class="text-xs text-muted mr-2">Enable Waitlist</span>
<input
v-model="editingTicketsData.tickets.waitlist.enabled"
type="checkbox"
class="rounded border-default text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
<div v-if="editingTicketsData.tickets.waitlist.enabled">
<label class="block text-sm font-medium text-default mb-2"
>Max Waitlist Size</label
>
<UInput
v-model.number="editingTicketsData.tickets.waitlist.maxSize"
type="number"
min="1"
placeholder="Leave blank for unlimited"
class="w-full"
/>
<p class="text-xs text-dimmed mt-1">
{{ editingTicketsData.tickets.waitlist.entries?.length || 0 }}
people currently on waitlist
</p>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-default flex justify-end gap-3">
<button
@click="cancelTicketsEdit"
class="px-4 py-2 text-muted hover:text-default"
>
Cancel
</button>
<button
@click="saveTicketsEdit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save Ticketing Settings
</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -477,6 +897,43 @@ const editingSeriesData = ref({
type: "workshop_series", type: "workshop_series",
totalEvents: null, totalEvents: null,
}); });
const editingTicketsSeriesId = ref(null);
const editingTicketsData = ref({
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
});
// Fetch series data // Fetch series data
const { const {
@ -715,6 +1172,116 @@ const deleteSeries = async (series) => {
} }
}; };
// Ticketing management functions
const manageSeriesTickets = (series) => {
editingTicketsSeriesId.value = series.id;
// Deep clone the series data to avoid mutating the original
editingTicketsData.value = {
title: series.title,
tickets: {
enabled: series.tickets?.enabled || false,
requiresSeriesTicket: series.tickets?.requiresSeriesTicket || false,
allowIndividualEventTickets:
series.tickets?.allowIndividualEventTickets !== false,
currency: series.tickets?.currency || "CAD",
member: {
available: series.tickets?.member?.available !== false,
isFree: series.tickets?.member?.isFree !== false,
price: series.tickets?.member?.price || 0,
name: series.tickets?.member?.name || "Member Series Pass",
description: series.tickets?.member?.description || "",
},
public: {
available: series.tickets?.public?.available || false,
name: series.tickets?.public?.name || "Series Pass",
description: series.tickets?.public?.description || "",
price: series.tickets?.public?.price || 0,
quantity: series.tickets?.public?.quantity || null,
sold: series.tickets?.public?.sold || 0,
reserved: series.tickets?.public?.reserved || 0,
earlyBirdPrice: series.tickets?.public?.earlyBirdPrice || null,
earlyBirdDeadline: series.tickets?.public?.earlyBirdDeadline
? new Date(series.tickets.public.earlyBirdDeadline)
.toISOString()
.slice(0, 16)
: "",
},
capacity: {
total: series.tickets?.capacity?.total || null,
reserved: series.tickets?.capacity?.reserved || 0,
},
waitlist: {
enabled: series.tickets?.waitlist?.enabled || false,
maxSize: series.tickets?.waitlist?.maxSize || null,
entries: series.tickets?.waitlist?.entries || [],
},
},
};
};
const cancelTicketsEdit = () => {
editingTicketsSeriesId.value = null;
editingTicketsData.value = {
title: "",
tickets: {
enabled: false,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: "CAD",
member: {
available: true,
isFree: true,
price: 0,
name: "Member Series Pass",
description: "",
},
public: {
available: false,
name: "Series Pass",
description: "",
price: 0,
quantity: null,
sold: 0,
reserved: 0,
earlyBirdPrice: null,
earlyBirdDeadline: "",
},
capacity: {
total: null,
reserved: 0,
},
waitlist: {
enabled: false,
maxSize: null,
entries: [],
},
},
};
};
const saveTicketsEdit = async () => {
try {
// Update the series with new ticketing configuration
await $fetch("/api/admin/series/tickets", {
method: "PUT",
body: {
id: editingTicketsSeriesId.value,
tickets: editingTicketsData.value.tickets,
},
});
await refresh();
cancelTicketsEdit();
alert("Ticketing settings updated successfully");
} catch (error) {
console.error("Failed to update ticketing settings:", error);
alert(
`Failed to update ticketing settings: ${error.data?.statusMessage || error.message}`,
);
}
};
// Bulk operations // Bulk operations
const reorderAllSeries = async () => { const reorderAllSeries = async () => {
// TODO: Implement auto-reordering // TODO: Implement auto-reordering

14
app/pages/coming-soon.vue Normal file
View file

@ -0,0 +1,14 @@
<template>
<div class="min-h-screen w-full flex items-center justify-center">
<a href="https://babyghosts.fund/ghost-guild" class="text-center">
<h1 class="text-5xl md:text-6xl font-bold mb-4">Ghost Guild</h1>
<p class="text-xl md:text-2xl">Coming Soon</p>
</a>
</div>
</template>
<script setup>
definePageMeta({
layout: "coming-soon",
});
</script>

View file

@ -160,14 +160,14 @@
<!-- Series Description --> <!-- Series Description -->
<div <div
v-if="event.series?.isSeriesEvent && event.series.description" v-if="event.series?.isSeriesEvent && event.series.description"
class="mb-6 p-4 bg-purple-500/5 rounded-lg border border-purple-500/20" class="event-series-description mb-6 p-4 bg-ghost-800/30 dark:bg-ghost-700/20 rounded-lg border border-ghost-600 dark:border-ghost-600"
> >
<h3 <h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2" class="event-series-description__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
> >
About the {{ event.series.title }} Series About the {{ event.series.title }} Series
</h3> </h3>
<p class="text-ghost-200"> <p class="event-series-description__text text-ghost-200">
{{ event.series.description }} {{ event.series.description }}
</p> </p>
</div> </div>
@ -227,160 +227,180 @@
<!-- Registration Section --> <!-- Registration Section -->
<div v-if="!event.isCancelled"> <div v-if="!event.isCancelled">
<!-- Already Registered Status --> <!-- Use new ticket system if tickets are enabled -->
<div v-if="registrationStatus === 'registered'"> <EventTicketPurchase
v-if="event.tickets?.enabled"
:event-id="event._id || event.id"
:event-start-date="event.startDate"
:event-title="event.title"
:user-email="memberData?.email"
@success="handleTicketSuccess"
@error="handleTicketError"
/>
<!-- Legacy registration system (for events without tickets enabled) -->
<div v-else>
<!-- Already Registered Status -->
<div v-if="registrationStatus === 'registered'">
<div
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6"
>
<div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4"
>
<div>
<p
class="font-semibold text-green-800 dark:text-green-300"
>
You're registered!
</p>
<p class="text-sm text-green-700 dark:text-green-400">
We've sent a confirmation to your email
</p>
</div>
<UButton
color="error"
size="md"
@click="handleCancelRegistration"
:loading="isCancelling"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Logged In - Can Register -->
<div <div
class="p-4 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-400 dark:border-green-800 mb-6" v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Member Gate Warning -->
<div
v-else-if="event.membersOnly && !isMember"
class="text-center"
> >
<div <div
class="flex flex-col md:flex-row md:items-start md:justify-between gap-4" class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
> >
<p class="font-semibold text-amber-300 text-lg mb-2">
Membership Required
</p>
<p class="text-amber-400">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
</div>
<NuxtLink to="/join">
<UButton color="primary" size="xl" class="px-12 py-4">
Become a Member to Register
</UButton>
</NuxtLink>
</div>
<!-- Not Logged In - Show Registration Form -->
<div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<form @submit.prevent="handleRegistration" class="space-y-4">
<div> <div>
<p class="font-semibold text-green-800 dark:text-green-300"> <label
You're registered! for="name"
</p> class="block text-sm font-medium text-ghost-200 mb-2"
<p class="text-sm text-green-700 dark:text-green-400"> >
We've sent a confirmation to your email Full Name
</p> </label>
</div> <UInput
<UButton id="name"
color="error" v-model="registrationForm.name"
size="md" type="text"
@click="handleCancelRegistration" required
:loading="isCancelling" placeholder="Enter your full name"
>
Cancel Registration
</UButton>
</div>
</div>
</div>
<!-- Logged In - Can Register -->
<div
v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Member Gate Warning -->
<div v-else-if="event.membersOnly && !isMember" class="text-center">
<div
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
>
<p class="font-semibold text-amber-300 text-lg mb-2">
Membership Required
</p>
<p class="text-amber-400">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
</div>
<NuxtLink to="/join">
<UButton color="primary" size="xl" class="px-12 py-4">
Become a Member to Register
</UButton>
</NuxtLink>
</div>
<!-- Not Logged In - Show Registration Form -->
<div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<form @submit.prevent="handleRegistration" class="space-y-4">
<div>
<label
for="name"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Full Name
</label>
<UInput
id="name"
v-model="registrationForm.name"
type="text"
required
placeholder="Enter your full name"
/>
</div>
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{
isRegistering ? "Registering..." : "Register for Event"
}}
</UButton>
</div>
</form>
</div>
<!-- Event Capacity -->
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-ghost-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-ghost-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-ghost-100">
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span>
<div
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/> />
</div> </div>
<div>
<label
for="email"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Email Address
</label>
<UInput
id="email"
v-model="registrationForm.email"
type="email"
required
placeholder="Enter your email"
/>
</div>
<div>
<label
for="membershipLevel"
class="block text-sm font-medium text-ghost-200 mb-2"
>
Membership Status
</label>
<USelect
id="membershipLevel"
v-model="registrationForm.membershipLevel"
:options="membershipOptions"
/>
</div>
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{
isRegistering ? "Registering..." : "Register for Event"
}}
</UButton>
</div>
</form>
</div>
<!-- Event Capacity -->
<div
v-if="event.maxAttendees"
class="mt-6 pt-6 border-t border-ghost-700"
>
<div class="flex items-center justify-between">
<span class="text-sm text-ghost-300">Event Capacity</span>
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-ghost-100">
{{ event.registeredCount || 0 }} /
{{ event.maxAttendees }}
</span>
<div
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 rounded-full"
:style="`width: ${((event.registeredCount || 0) / event.maxAttendees) * 100}%`"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -649,6 +669,20 @@ const handleCancelRegistration = async () => {
} }
}; };
// Handle ticket purchase success
const handleTicketSuccess = (response) => {
console.log("Ticket purchased successfully:", response);
// Update registered count if needed
if (event.value.registeredCount !== undefined) {
event.value.registeredCount++;
}
};
// Handle ticket purchase error
const handleTicketError = (error) => {
console.error("Ticket purchase failed:", error);
};
// SEO Meta // SEO Meta
useHead(() => ({ useHead(() => ({
title: event.value title: event.value

View file

@ -60,7 +60,6 @@
<div v-if="event.series?.isSeriesEvent" class="mt-2"> <div v-if="event.series?.isSeriesEvent" class="mt-2">
<EventSeriesBadge <EventSeriesBadge
:title="event.series.title" :title="event.series.title"
:description="event.series.description"
:position="event.series.position" :position="event.series.position"
:total-events="event.series.totalEvents" :total-events="event.series.totalEvents"
:series-id="event.series.id" :series-id="event.series.id"
@ -126,7 +125,7 @@
</section> </section>
<!-- Event Series --> <!-- Event Series -->
<div v-if="activeSeries.length > 0" class="text-center mb-12"> <div v-if="activeSeries.length > 0" class="text-center my-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8"> <h2 class="text-3xl font-bold text-ghost-100 mb-8">
Current Event Series Current Event Series
</h2> </h2>
@ -140,24 +139,24 @@
v-for="series in activeSeries.slice(0, 6)" v-for="series in activeSeries.slice(0, 6)"
:key="series.id" :key="series.id"
:to="`/series/${series.id}`" :to="`/series/${series.id}`"
class="block bg-gradient-to-r from-purple-500/10 to-blue-500/10 rounded-xl p-6 border border-purple-500/30 hover:border-purple-500/50 hover:from-purple-500/15 hover:to-blue-500/15 transition-all duration-300" class="series-list-item block bg-ghost-800/50 dark:bg-ghost-700/30 rounded-xl p-6 border border-ghost-600 dark:border-ghost-600 hover:border-ghost-500 hover:bg-ghost-800/70 dark:hover:bg-ghost-700/50 transition-all duration-300"
> >
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="text-sm font-semibold text-purple-700 dark:text-purple-300" class="series-list-item__label text-sm font-semibold text-ghost-300 dark:text-ghost-300"
> >
Event Series Event Series
</span> </span>
<span <span
class="inline-flex items-center px-2 py-0.5 rounded-md bg-purple-500/20 text-sm font-medium text-purple-700 dark:text-purple-300" class="series-list-item__count inline-flex items-center px-2 py-0.5 rounded-md bg-ghost-700/50 dark:bg-ghost-600/50 text-sm font-medium text-ghost-200 dark:text-ghost-200"
> >
{{ series.eventCount }} events {{ series.eventCount }} events
</span> </span>
</div> </div>
<span <span
:class="[ :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium', 'series-list-item__status inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming' : series.status === 'upcoming'
@ -170,46 +169,51 @@
</div> </div>
<h3 <h3
class="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2" class="series-list-item__title text-lg font-semibold text-ghost-100 dark:text-ghost-100 mb-2"
> >
{{ series.title }} {{ series.title }}
</h3> </h3>
<p <p
class="text-sm text-purple-600 dark:text-purple-400 mb-4 line-clamp-2" class="series-list-item__description text-sm text-ghost-300 dark:text-ghost-300 mb-4 line-clamp-2"
> >
{{ series.description }} {{ series.description }}
</p> </p>
<div class="space-y-2 mb-4"> <div class="series-list-item__events space-y-2 mb-4">
<div <div
v-for="(event, index) in series.events.slice(0, 3)" v-for="(event, index) in series.events.slice(0, 3)"
:key="event.id" :key="event.id"
class="flex items-center justify-between text-xs" class="series-list-item__event flex items-center justify-between text-xs"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-6 h-6 bg-purple-500/20 text-purple-700 dark:text-purple-300 rounded-full flex items-center justify-center text-xs font-medium border border-purple-500/30" class="series-list-item__event-number w-6 h-6 bg-ghost-700/50 dark:bg-ghost-600/50 text-ghost-200 dark:text-ghost-200 rounded-full flex items-center justify-center text-xs font-medium border border-ghost-600 dark:border-ghost-500"
> >
{{ event.series?.position || index + 1 }} {{ event.series?.position || index + 1 }}
</div> </div>
<span class="text-purple-700 dark:text-purple-300 truncate">{{ <span
event.title class="series-list-item__event-title text-ghost-200 dark:text-ghost-200 truncate"
}}</span> >{{ event.title }}</span
>
</div> </div>
<span class="text-purple-600 dark:text-purple-400"> <span
class="series-list-item__event-date text-ghost-300 dark:text-ghost-300"
>
{{ formatEventDate(event.startDate) }} {{ formatEventDate(event.startDate) }}
</span> </span>
</div> </div>
<div <div
v-if="series.events.length > 3" v-if="series.events.length > 3"
class="text-xs text-purple-600 dark:text-purple-400 text-center pt-1" class="series-list-item__more-events text-xs text-ghost-300 dark:text-ghost-300 text-center pt-1"
> >
+{{ series.events.length - 3 }} more events +{{ series.events.length - 3 }} more events
</div> </div>
</div> </div>
<div class="text-sm text-purple-600 dark:text-purple-400"> <div
class="series-list-item__date-range text-sm text-ghost-300 dark:text-ghost-300"
>
{{ formatDateRange(series.startDate, series.endDate) }} {{ formatDateRange(series.startDate, series.endDate) }}
</div> </div>
</NuxtLink> </NuxtLink>

View file

@ -105,7 +105,7 @@
<!-- Status Message --> <!-- Status Message -->
<div <div
v-if="series.statistics.isOngoing" v-if="series?.statistics?.isOngoing"
class="p-4 bg-green-500/10 border border-green-500/30 rounded mb-8" class="p-4 bg-green-500/10 border border-green-500/30 rounded mb-8"
> >
<p class="text-green-600 dark:text-green-400 font-semibold mb-1"> <p class="text-green-600 dark:text-green-400 font-semibold mb-1">
@ -117,7 +117,7 @@
</div> </div>
<div <div
v-else-if="series.statistics.isUpcoming" v-else-if="series?.statistics?.isUpcoming"
class="p-4 bg-blue-500/10 border border-blue-500/30 rounded mb-8" class="p-4 bg-blue-500/10 border border-blue-500/30 rounded mb-8"
> >
<p class="text-blue-600 dark:text-blue-400 font-semibold mb-1"> <p class="text-blue-600 dark:text-blue-400 font-semibold mb-1">
@ -129,7 +129,7 @@
</div> </div>
<div <div
v-else-if="series.statistics.isCompleted" v-else-if="series?.statistics?.isCompleted"
class="p-4 bg-gray-500/10 border border-gray-500/30 rounded mb-8" class="p-4 bg-gray-500/10 border border-gray-500/30 rounded mb-8"
> >
<p class="text-[--ui-text] font-semibold mb-1"> <p class="text-[--ui-text] font-semibold mb-1">
@ -144,6 +144,30 @@
</UContainer> </UContainer>
</section> </section>
<!-- Series Pass Purchase (if tickets enabled) -->
<section v-if="series?.tickets?.enabled" class="py-20 bg-[--ui-bg]">
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold text-[--ui-text] mb-8">
Get Your Series Pass
</h2>
<SeriesPassPurchase
:series-id="series.id || series._id"
:series-info="{
id: series.id,
title: series.title,
totalEvents: series?.statistics?.totalEvents || 0,
type: series.type,
}"
:series-events="series.events || []"
:user-email="user?.email"
:user-name="user?.name"
@purchase-success="handlePurchaseSuccess"
/>
</div>
</UContainer>
</section>
<!-- Events Timeline --> <!-- Events Timeline -->
<section class="py-20 bg-[--ui-bg-elevated]"> <section class="py-20 bg-[--ui-bg-elevated]">
<UContainer> <UContainer>
@ -154,7 +178,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-for="(event, index) in series.events" v-for="(event, index) in series?.events || []"
:key="event.id" :key="event.id"
class="group" class="group"
> >
@ -170,7 +194,7 @@
{{ event.series?.position || index + 1 }} {{ event.series?.position || index + 1 }}
</div> </div>
<div <div
v-if="index < series.events.length - 1" v-if="index < (series?.events?.length || 0) - 1"
class="w-0.5 h-12 bg-[--ui-border]" class="w-0.5 h-12 bg-[--ui-border]"
></div> ></div>
</div> </div>
@ -287,12 +311,18 @@
<script setup> <script setup>
const route = useRoute(); const route = useRoute();
const { data: session } = useAuth();
const toast = useToast();
// Get user info
const user = computed(() => session?.value?.user || null);
// Fetch series data from API // Fetch series data from API
const { const {
data: series, data: series,
pending, pending,
error, error,
refresh: refreshSeries,
} = await useFetch(`/api/series/${route.params.id}`); } = await useFetch(`/api/series/${route.params.id}`);
// Handle series not found // Handle series not found
@ -303,6 +333,15 @@ if (error.value?.statusCode === 404) {
}); });
} }
// Handle successful series pass purchase
const handlePurchaseSuccess = async (response) => {
// Refresh series data to show updated registration status
await refreshSeries();
// Scroll to top to show success message
window.scrollTo({ top: 0, behavior: "smooth" });
};
// Helper functions // Helper functions
const formatSeriesType = (type) => { const formatSeriesType = (type) => {
const types = { const types = {
@ -335,6 +374,7 @@ const getSeriesTypeBadgeClass = (type) => {
}; };
const getSeriesStatusText = () => { const getSeriesStatusText = () => {
if (!series.value?.statistics) return "Active";
if (series.value.statistics.isOngoing) return "Ongoing"; if (series.value.statistics.isOngoing) return "Ongoing";
if (series.value.statistics.isUpcoming) return "Starting Soon"; if (series.value.statistics.isUpcoming) return "Starting Soon";
if (series.value.statistics.isCompleted) return "Completed"; if (series.value.statistics.isCompleted) return "Completed";
@ -342,6 +382,8 @@ const getSeriesStatusText = () => {
}; };
const getSeriesStatusClass = () => { const getSeriesStatusClass = () => {
if (!series.value?.statistics)
return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border border-purple-500/30";
if (series.value.statistics.isOngoing) if (series.value.statistics.isOngoing)
return "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/30"; return "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/30";
if (series.value.statistics.isUpcoming) if (series.value.statistics.isUpcoming)
@ -423,19 +465,32 @@ const getEventTimelineColor = (event) => {
}; };
// SEO Meta // SEO Meta
useHead(() => ({ useHead(() => {
title: series.value if (!series || !series.value) {
? `${series.value.title} - Event Series - Ghost Guild` return {
: "Event Series - Ghost Guild", title: "Event Series - Ghost Guild",
meta: [ meta: [
{ {
name: "description", name: "description",
content: content:
series.value?.description || "Explore our multi-event series designed for learning and growth",
"Explore our multi-event series designed for learning and growth", },
}, ],
], };
})); }
return {
title: `${series.value.title} - Event Series - Ghost Guild`,
meta: [
{
name: "description",
content:
series.value.description ||
"Explore our multi-event series designed for learning and growth",
},
],
};
});
</script> </script>
<style scoped> <style scoped>

251
docs/TICKET_SETUP_GUIDE.md Normal file
View file

@ -0,0 +1,251 @@
# Event Ticket Setup Guide
Quick reference for creating events with different ticket configurations.
## Common Scenarios
### 1. Free Event (Everyone)
```javascript
{
tickets: {
enabled: false // Use legacy registration system
}
// OR
tickets: {
enabled: true,
member: { isFree: true },
public: { available: true, price: 0 }
}
}
```
### 2. Members Only, Free
```javascript
{
membersOnly: true,
tickets: {
enabled: true,
member: {
available: true,
isFree: true,
name: "Member Ticket",
description: "Free for all Ghost Guild members"
},
public: {
available: false // No public tickets
}
}
}
```
### 3. Free for Members, Paid for Public
```javascript
{
tickets: {
enabled: true,
currency: "CAD",
member: {
available: true,
isFree: true,
name: "Member Ticket"
},
public: {
available: true,
name: "Public Ticket",
price: 25.00,
quantity: 30, // Limit public tickets
description: "General admission for non-members"
},
capacity: {
total: 50 // 20 spots reserved for members
}
}
}
```
### 4. Early Bird Pricing
```javascript
{
tickets: {
enabled: true,
member: { isFree: true },
public: {
available: true,
price: 30.00, // Regular price
earlyBirdPrice: 22.00, // Discounted price
earlyBirdDeadline: "2025-11-15T23:59:59Z",
quantity: 50
}
}
}
```
### 5. Tiered Member Pricing
```javascript
{
tickets: {
enabled: true,
member: {
available: true,
isFree: false,
price: 15.00, // Default member price
circleOverrides: {
community: {
isFree: true // Community members get in free
},
founder: {
price: 10.00 // Founder discount
},
practitioner: {
price: 10.00 // Practitioner discount
}
}
},
public: {
available: true,
price: 35.00
}
}
}
```
### 6. Waitlist Enabled
```javascript
{
tickets: {
enabled: true,
member: { isFree: true },
public: {
available: true,
price: 20.00,
quantity: 25 // Limited capacity
},
capacity: {
total: 40
},
waitlist: {
enabled: true,
maxSize: 20 // Or null for unlimited
}
}
}
```
## Field Reference
### tickets.enabled
- **Type**: Boolean
- **Default**: false
- **Purpose**: Enable new ticket system (vs. legacy registration)
### tickets.currency
- **Type**: String
- **Default**: "CAD"
- **Options**: ISO currency codes (CAD, USD, etc.)
### tickets.member.*
Configuration for member tickets
- `available` (Boolean) - Members can register
- `isFree` (Boolean) - Free for members
- `price` (Number) - Price if not free
- `name` (String) - Display name
- `description` (String) - Additional details
- `circleOverrides` (Object) - Circle-specific pricing
### tickets.public.*
Configuration for public (non-member) tickets
- `available` (Boolean) - Public can register
- `name` (String) - Display name
- `description` (String) - Additional details
- `price` (Number) - Regular price
- `quantity` (Number) - Max tickets (null = unlimited)
- `sold` (Number) - Counter (auto-managed)
- `reserved` (Number) - Temp reservations (auto-managed)
- `earlyBirdPrice` (Number) - Early bird discount price
- `earlyBirdDeadline` (Date) - When early bird ends
### tickets.capacity.*
Overall event capacity
- `total` (Number) - Max attendees across all types
- `reserved` (Number) - Currently reserved (auto-managed)
### tickets.waitlist.*
Waitlist configuration
- `enabled` (Boolean) - Allow waitlist
- `maxSize` (Number) - Max waitlist size (null = unlimited)
- `entries` (Array) - Waitlist entries (auto-managed)
## Tips
### Capacity Planning
- Set `capacity.total` for overall limit
- Set `public.quantity` to reserve spots for members
- Example: 50 total capacity, 30 public tickets = 20 spots for members
### Early Bird Strategy
- Set deadline 1-2 weeks before event
- Discount 20-30% off regular price
- Creates urgency and rewards early commitment
### Member Value
- Always offer member benefit (free or discounted)
- Show savings in ticket card
- Reinforces membership value proposition
### Pricing Psychology
- Round numbers: $25 instead of $24.99
- Early bird: Show regular price crossed out
- Member comparison: Display public price for context
## Testing Events
### Test Mode Configuration
```javascript
{
title: "TEST - Ticket System Demo",
tickets: {
enabled: true,
member: {
isFree: true,
description: "Testing member flow"
},
public: {
available: true,
price: 0.50, // Low price for testing
quantity: 5, // Small capacity for testing
description: "Testing payment flow"
}
}
}
```
### Test Cards (Helcim)
Use Helcim's test card numbers in test mode:
- Visa: 4242 4242 4242 4242
- Mastercard: 5555 5555 5555 4444
- Declined: 4000 0000 0000 0002
## Common Gotchas
1. **Forgot to enable tickets**: Set `tickets.enabled: true`
2. **Members can't register**: Check `tickets.member.available: true`
3. **Payment not processing**: Verify Helcim credentials in .env
4. **Early bird not showing**: Check deadline hasn't passed
5. **Capacity exceeded**: Check both `capacity.total` and `public.quantity`
## Support
If tickets aren't working:
1. Check server logs for errors
2. Verify Helcim API credentials
3. Test with free event first
4. Review event document in MongoDB
5. Check browser console for frontend errors
---
**Need help?** Review HELCIM_TICKET_INTEGRATION.md for full implementation details.

View file

@ -28,6 +28,7 @@ export default defineNuxtConfig({
cloudinaryCloudName: cloudinaryCloudName:
process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr", process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || "divzuumlr",
appUrl: process.env.NUXT_PUBLIC_APP_URL || "http://localhost:3000", appUrl: process.env.NUXT_PUBLIC_APP_URL || "http://localhost:3000",
comingSoon: process.env.NUXT_PUBLIC_COMING_SOON || "false",
}, },
}, },
}); });

View file

@ -0,0 +1,364 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function addCoopValuesSeries() {
try {
// Connect to MongoDB
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Create the series
const seriesData = {
id: 'coop-values-into-practice-2025',
title: 'Cooperative Values into Practice',
description: 'A practical, region-agnostic foundation in cooperative values and governance for game studio founders interested in worker-centric, anti-capitalist models. Structured as a peer-driven workshop emphasizing reciprocal learning and sharing.',
type: 'workshop_series',
isVisible: true,
isActive: true,
targetCircles: ['founder', 'practitioner'],
totalEvents: 6,
createdBy: 'admin@ghostguild.org',
tickets: {
enabled: true,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: 'Free access to all sessions in the Cooperative Values into Practice series for Ghost Guild members.'
},
public: {
available: true,
name: 'Series Pass',
description: 'Access to all 6 sessions in the Cooperative Values into Practice series',
price: 150,
quantity: 20
},
capacity: {
total: 30
},
waitlist: {
enabled: true,
maxSize: 15
}
}
};
// Check if series already exists
let series = await Series.findOne({ id: seriesData.id });
if (series) {
console.log('Series already exists, updating...');
series = await Series.findOneAndUpdate(
{ id: seriesData.id },
seriesData,
{ new: true, runValidators: true }
);
} else {
console.log('Creating new series...');
series = new Series(seriesData);
await series.save();
}
console.log('Series created/updated:', series.title);
// Base date for scheduling (adjust as needed)
const baseDate = new Date('2025-11-01T18:00:00.000Z'); // Starting November 1, 2025, 6 PM UTC
// Create the events
const eventsData = [
{
title: 'Module 0: Orientation',
tagline: 'Welcome to Cooperative Values into Practice',
description: 'Introduce the goals and format of the program, quick power & identity reflections, Baby Ghosts values, and create group agreements.',
content: `## Welcome!
This orientation session kicks off our 6-month journey exploring cooperative values and governance for game studios.
## What We'll Cover
- Program goals and format
- Power & identity reflections
- Baby Ghosts values introduction
- Creating our group agreements together
## Homework
Power Flower exercise to complete before Module 1
## Who Should Attend
This series is designed for:
- Game studio founders interested in worker-centric models
- Practitioners exploring cooperative structures
- Anyone committed to anti-capitalist, democratic workplaces`,
position: 0,
weeks: 0
},
{
title: 'Module 1: Principles',
tagline: 'Understanding cooperative foundations',
description: 'Explore the difference between coops and traditional studios, foundational cooperative principles, historical and cultural context, and how they challenge industry norms.',
content: `## Cooperative Principles
Understanding what makes cooperatives different from traditional game studios.
## Topics
- Coops vs. traditional studios - key differences
- Foundational cooperative principles
- How cooperative values challenge industry norms
- Historical and cultural context
- Introduction to Coops for Creatives
## Activities
- Values Reflection exercise
- Value Mapping workshop
## Between Sessions
Reflect on how cooperative principles align with your studio vision.`,
position: 1,
weeks: 3
},
{
title: 'Module 2: Purpose',
tagline: 'Alignment, agreement, and shared vision',
description: 'Learn the difference between alignment, agreement, and false consensus. Reflect on cooperative origin stories, discuss financial needs and capacity, and map collective visions.',
content: `## Finding Your Cooperative Purpose
Moving beyond false consensus to genuine alignment.
## Topics
- The difference between alignment, agreement, and false consensus
- Reflecting on cooperative origin stories and values
- Discussing financial needs, availability, and capacity
- Mapping collective visions for scale and pace
- Clarifying roles, expectations, and contribution levels
## Activities
- Origin Stories sharing
- Solidarity Press exercise
- "The Talk" - discussing money and capacity
- Scale & Pace Alignment worksheets
## Between Sessions
Continue conversations about purpose and alignment with your team.`,
position: 2,
weeks: 4
},
{
title: 'Module 3: Practices Part 1 - Meetings & Decision-Making',
tagline: 'Democratic processes in action',
description: 'Move from boss-decides or majority-rules to cooperative approaches. Learn to redesign hierarchical meetings and facilitate inclusive, productive discussions.',
content: `## Democratic Practices: Meetings
Redesigning how we make decisions together.
## Topics
- Moving from boss-decides or majority-rules to cooperative approaches
- Redesigning hierarchical meetings
- Tips for facilitating inclusive, productive meetings
- Consensus-building techniques
- Managing power dynamics in meetings
## Activities
- Meeting redesign workshop
- Facilitation practice
- Decision-making simulations
## Between Sessions
Try implementing new meeting practices with your team.`,
position: 3,
weeks: 4
},
{
title: 'Module 4: Practices Part 2 - Finances & Governance',
tagline: 'Transparent systems for cooperative work',
description: 'Design transparent financial practices and systems, reframe disagreement as valuable information, and establish clear governance structures.',
content: `## Democratic Practices: Money & Structure
Building transparent financial and governance systems.
## Topics
- Designing transparent financial practices and systems
- Open book management for cooperatives
- Reframing disagreement from failure to valuable information
- Establishing clear governance systems
- Creating accountability without hierarchy
## Activities
- Financial transparency workshop
- Governance structure design
- Conflict as data reframing
## Between Sessions
Draft governance documents or financial transparency practices for your studio.`,
position: 4,
weeks: 4
},
{
title: 'Module 5: Pathways Forward',
tagline: 'Your cooperative journey continues',
description: 'Integrate learnings, share next steps, create accountability partnerships, and celebrate the work done together.',
content: `## Moving Forward Together
Bringing it all together and planning your next steps.
## Topics
- Integrating everything we've learned
- Individual and collective action plans
- Creating accountability partnerships
- Resources for ongoing learning
- Building a community of practice
## Activities
- Action planning workshop
- Accountability partner matching
- Celebration and reflection
## What's Next
Continue your cooperative journey with Ghost Guild support and your cohort connections.`,
position: 5,
weeks: 4
}
];
let currentDate = new Date(baseDate);
const createdEvents = [];
for (const eventData of eventsData) {
// Calculate dates
const startDate = new Date(currentDate);
const endDate = new Date(startDate);
endDate.setHours(endDate.getHours() + 2); // 2-hour sessions
const eventPayload = {
title: eventData.title,
tagline: eventData.tagline,
description: eventData.description,
content: eventData.content,
startDate,
endDate,
eventType: 'workshop',
location: '#ghost-guild-workshops', // Slack channel
isOnline: true,
isVisible: true,
membersOnly: false,
series: {
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: eventData.position + 1, // 1-indexed for display
totalEvents: 6,
isSeriesEvent: true
},
tickets: {
enabled: true,
requiresSeriesTicket: false,
seriesTicketReference: series._id,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Ticket',
description: 'Free for Ghost Guild members'
},
public: {
available: true,
name: 'Single Session Ticket',
description: 'Attend this individual session',
price: 30,
quantity: 10
},
capacity: {
total: 30
},
waitlist: {
enabled: true,
maxSize: 10
}
},
targetCircles: ['founder', 'practitioner'],
registrationRequired: true,
createdBy: 'admin@ghostguild.org'
};
// Check if event already exists (by title and series)
const existingEvent = await Event.findOne({
title: eventPayload.title,
'series.id': series.id
});
let event;
if (existingEvent) {
console.log(`Updating existing event: ${eventPayload.title}`);
event = await Event.findByIdAndUpdate(
existingEvent._id,
eventPayload,
{ new: true, runValidators: true }
);
} else {
console.log(`Creating event: ${eventPayload.title}`);
event = new Event(eventPayload);
await event.save();
}
createdEvents.push(event);
// Move to next session date (add weeks)
if (eventData.weeks > 0) {
currentDate.setDate(currentDate.getDate() + (eventData.weeks * 7));
}
}
// Update series with date range from events
const firstEvent = createdEvents[0];
const lastEvent = createdEvents[createdEvents.length - 1];
series.startDate = firstEvent.startDate;
series.endDate = lastEvent.endDate;
series.totalEvents = createdEvents.length;
await series.save();
console.log('\n✅ Successfully created/updated series and all events!');
console.log(`\nSeries: ${series.title}`);
console.log(`Events created: ${createdEvents.length}`);
console.log(`Date range: ${series.startDate.toLocaleDateString()} - ${series.endDate.toLocaleDateString()}`);
console.log('\nEvents:');
createdEvents.forEach((event, index) => {
console.log(` ${index + 1}. ${event.title} - ${event.startDate.toLocaleDateString()}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
console.log('\nDatabase connection closed');
}
}
// Run the script
addCoopValuesSeries();

334
scripts/add-to-remote-db.js Normal file
View file

@ -0,0 +1,334 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const MONGODB_URI = process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI;
async function addToRemoteDB() {
try {
console.log('Connecting to remote MongoDB...');
await mongoose.connect(MONGODB_URI);
console.log('✓ Connected');
// Check existing series
const existingSeries = await Series.findOne({ id: 'cooperative-values-into-practice' });
if (existingSeries) {
console.log(`\n✓ Found existing series: ${existingSeries.title}`);
console.log(` Series _id: ${existingSeries._id}`);
} else {
console.log('\nSeries not found, creating new one...');
const newSeries = new Series({
id: 'cooperative-values-into-practice',
title: 'Cooperative Values into Practice',
description: 'A practical, region-agnostic foundation in cooperative values and governance for game studio founders interested in worker-centric, anti-capitalist models. Structured as a peer-driven workshop emphasizing reciprocal learning and sharing.',
type: 'workshop_series',
isVisible: true,
isActive: true,
targetCircles: ['founder', 'practitioner'],
totalEvents: 6,
createdBy: 'admin@ghostguild.org',
tickets: {
enabled: true,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: 'Free access to all sessions for Ghost Guild members.'
},
public: {
available: true,
name: 'Series Pass',
description: 'Access to all 6 sessions',
price: 150,
quantity: 20,
sold: 0,
reserved: 0
},
capacity: {
total: 30,
reserved: 0
},
waitlist: {
enabled: true,
maxSize: 15,
entries: []
}
}
});
await newSeries.save();
console.log('✓ Series created');
}
const series = await Series.findOne({ id: 'cooperative-values-into-practice' });
// Check existing events
const existingEvents = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title').lean();
console.log(`\nCurrent events in series: ${existingEvents.length}`);
existingEvents.forEach(e => console.log(` - ${e.title}`));
// Define the 6 modules
const baseDate = new Date('2025-11-01T18:00:00.000Z');
const modulesData = [
{
title: 'Module 0: Orientation',
tagline: 'Welcome to Cooperative Values into Practice',
description: 'Introduce the goals and format of the program, quick power & identity reflections, Baby Ghosts values, and create group agreements.',
content: `## Welcome!
This orientation session kicks off our 6-month journey exploring cooperative values and governance for game studios.
## What We'll Cover
- Program goals and format
- Power & identity reflections
- Baby Ghosts values introduction
- Creating our group agreements together
## Homework
Power Flower exercise to complete before Module 1`,
position: 1,
weeksFromPrevious: 0
},
{
title: 'Module 1: Principles',
tagline: 'Understanding cooperative foundations',
description: 'Explore the difference between coops and traditional studios, foundational cooperative principles, historical and cultural context, and how they challenge industry norms.',
content: `## Cooperative Principles
Understanding what makes cooperatives different from traditional game studios.
## Topics
- Coops vs. traditional studios
- Foundational cooperative principles
- How cooperative values challenge industry norms
- Historical and cultural context
- Coops for Creatives
## Activities
- Values Reflection exercise
- Value Mapping workshop`,
position: 2,
weeksFromPrevious: 3
},
{
title: 'Module 2: Purpose',
tagline: 'Alignment, agreement, and shared vision',
description: 'Learn the difference between alignment, agreement, and false consensus. Reflect on cooperative origin stories, discuss financial needs and capacity, and map collective visions.',
content: `## Finding Your Cooperative Purpose
Moving beyond false consensus to genuine alignment.
## Topics
- Alignment vs. agreement vs. false consensus
- Reflecting on cooperative origin stories
- Discussing financial needs and capacity
- Mapping collective visions for scale and pace
- Clarifying roles and expectations
## Activities
- Origin Stories sharing
- Solidarity Press exercise
- "The Talk" - discussing money
- Scale & Pace Alignment worksheets`,
position: 3,
weeksFromPrevious: 4
},
{
title: 'Module 3: Practices Part 1 - Meetings & Decision-Making',
tagline: 'Democratic processes in action',
description: 'Move from boss-decides or majority-rules to cooperative approaches. Learn to redesign hierarchical meetings and facilitate inclusive, productive discussions.',
content: `## Democratic Practices: Meetings
Redesigning how we make decisions together.
## Topics
- Moving from boss-decides to cooperative approaches
- Redesigning hierarchical meetings
- Facilitating inclusive, productive meetings
- Consensus-building techniques
- Managing power dynamics
## Activities
- Meeting redesign workshop
- Facilitation practice
- Decision-making simulations`,
position: 4,
weeksFromPrevious: 4
},
{
title: 'Module 4: Practices Part 2 - Finances & Governance',
tagline: 'Transparent systems for cooperative work',
description: 'Design transparent financial practices and systems, reframe disagreement as valuable information, and establish clear governance structures.',
content: `## Democratic Practices: Money & Structure
Building transparent financial and governance systems.
## Topics
- Designing transparent financial practices
- Open book management
- Reframing disagreement as valuable information
- Establishing clear governance systems
- Creating accountability without hierarchy
## Activities
- Financial transparency workshop
- Governance structure design
- Conflict as data reframing`,
position: 5,
weeksFromPrevious: 4
},
{
title: 'Module 5: Pathways Forward',
tagline: 'Your cooperative journey continues',
description: 'Integrate learnings, share next steps, create accountability partnerships, and celebrate the work done together.',
content: `## Moving Forward Together
Bringing it all together and planning your next steps.
## Topics
- Integrating everything we've learned
- Individual and collective action plans
- Creating accountability partnerships
- Resources for ongoing learning
- Building a community of practice
## Activities
- Action planning workshop
- Accountability partner matching
- Celebration and reflection`,
position: 6,
weeksFromPrevious: 4
}
];
let currentDate = new Date(baseDate);
let created = 0;
let updated = 0;
for (const moduleData of modulesData) {
const startDate = new Date(currentDate);
const endDate = new Date(startDate);
endDate.setHours(endDate.getHours() + 2);
const eventPayload = {
title: moduleData.title,
tagline: moduleData.tagline,
description: moduleData.description,
content: moduleData.content,
startDate,
endDate,
eventType: 'workshop',
location: '#ghost-guild-workshops',
isOnline: true,
isVisible: true,
membersOnly: false,
series: {
id: series.id,
title: series.title,
description: series.description,
type: series.type,
position: moduleData.position,
totalEvents: 6,
isSeriesEvent: true
},
tickets: {
enabled: true,
requiresSeriesTicket: false,
seriesTicketReference: series._id,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Ticket',
description: 'Free for Ghost Guild members'
},
public: {
available: true,
name: 'Single Session Ticket',
description: 'Attend this individual session',
price: 30,
quantity: 10
},
capacity: {
total: 30
},
waitlist: {
enabled: true,
maxSize: 10
}
},
targetCircles: ['founder', 'practitioner'],
registrationRequired: true,
createdBy: 'admin@ghostguild.org'
};
const existing = await Event.findOne({
title: moduleData.title,
'series.id': 'cooperative-values-into-practice'
});
if (existing) {
await Event.findByIdAndUpdate(existing._id, eventPayload);
updated++;
console.log(` ✓ Updated: ${moduleData.title}`);
} else {
const newEvent = new Event(eventPayload);
await newEvent.save();
created++;
console.log(` ✓ Created: ${moduleData.title}`);
}
// Move to next date
if (moduleData.weeksFromPrevious > 0) {
currentDate.setDate(currentDate.getDate() + (moduleData.weeksFromPrevious * 7));
}
}
console.log(`\n✅ Success!`);
console.log(` Created: ${created} events`);
console.log(` Updated: ${updated} events`);
// Verify final count
const finalEvents = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title series.position').sort({ 'series.position': 1 }).lean();
console.log(`\n📋 Total events in series: ${finalEvents.length}`);
finalEvents.forEach(e => {
console.log(` ${e.series.position}. ${e.title}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
addToRemoteDB();

32
scripts/check-events.js Normal file
View file

@ -0,0 +1,32 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function checkEvents() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Check events with the series
const events = await Event.find({
'series.id': 'coop-values-into-practice-2025'
}).select('title series').lean();
console.log(`\nFound ${events.length} events for series 'coop-values-into-practice-2025'\n`);
events.forEach((event, index) => {
console.log(`${index + 1}. ${event.title}`);
console.log(` series.id: ${event.series?.id}`);
console.log(` series.isSeriesEvent: ${event.series?.isSeriesEvent}`);
console.log('');
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
checkEvents();

View file

@ -0,0 +1,31 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function check() {
try {
await mongoose.connect(MONGODB_URI);
const oldEvent = await Event.findOne({ title: 'Session 0: Orientation' }).lean();
if (oldEvent) {
console.log('Found old "Session 0: Orientation" event:');
console.log(` ID: ${oldEvent._id}`);
console.log(` series.id: ${oldEvent.series?.id}`);
console.log(` series.title: ${oldEvent.series?.title}`);
console.log('\nDeleting old event...');
await Event.deleteOne({ _id: oldEvent._id });
console.log('✓ Deleted');
} else {
console.log('No old "Session 0: Orientation" event found');
}
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
check();

27
scripts/check-series.js Normal file
View file

@ -0,0 +1,27 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function checkSeries() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
const allSeries = await Series.find({}).lean();
console.log(`\nTotal series: ${allSeries.length}\n`);
allSeries.forEach(s => {
console.log(`ID: ${s.id}`);
console.log(`Title: ${s.title}`);
console.log(`_id: ${s._id}`);
console.log('---');
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
checkSeries();

View file

@ -0,0 +1,27 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
import dotenv from 'dotenv';
dotenv.config();
const MONGODB_URI = process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI;
async function cleanup() {
try {
await mongoose.connect(MONGODB_URI);
// Delete the old Session 0 event (the one without a position)
const result = await Event.deleteOne({
title: 'Session 0: Orientation',
'series.position': { $exists: false }
});
console.log(`✓ Deleted ${result.deletedCount} old event(s)`);
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
cleanup();

View file

@ -0,0 +1,94 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function createCorrectSeries() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Delete the incorrectly named series
await Series.deleteOne({ id: 'coop-values-into-practice-2025' });
console.log('✓ Deleted old series');
// Create series with the correct ID that matches the old event
const series = new Series({
id: 'cooperative-values-into-practice',
title: 'Cooperative Values into Practice',
description: 'A practical, region-agnostic foundation in cooperative values and governance for game studio founders interested in worker-centric, anti-capitalist models. Structured as a peer-driven workshop emphasizing reciprocal learning and sharing.',
type: 'workshop_series',
isVisible: true,
isActive: true,
targetCircles: ['founder', 'practitioner'],
totalEvents: 6,
createdBy: 'admin@ghostguild.org',
tickets: {
enabled: true,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: 'Free access to all sessions for Ghost Guild members.'
},
public: {
available: true,
name: 'Series Pass',
description: 'Access to all 6 sessions',
price: 150,
quantity: 20,
sold: 0,
reserved: 0
},
capacity: {
total: 30,
reserved: 0
},
waitlist: {
enabled: true,
maxSize: 15,
entries: []
}
}
});
await series.save();
console.log('✓ Created series with ID: cooperative-values-into-practice');
// Update all events to use this series ID
const result = await Event.updateMany(
{ 'series.id': 'coop-values-into-practice-2025' },
{
$set: {
'series.id': 'cooperative-values-into-practice',
'tickets.seriesTicketReference': series._id
}
}
);
console.log(`✓ Updated ${result.modifiedCount} events`);
// Verify
const events = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title series.position').sort({ 'series.position': 1 });
console.log(`\n✅ Success! Series has ${events.length} events:`);
events.forEach(e => {
console.log(` ${e.series.position}. ${e.title}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
createCorrectSeries();

34
scripts/debug-series.js Normal file
View file

@ -0,0 +1,34 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function debug() {
try {
await mongoose.connect(MONGODB_URI);
const series = await Series.find({}).lean();
console.log('Series in DB:');
series.forEach(s => console.log(` ${s.id}: ${s.title}`));
console.log('\nAll events with series.id:');
const allEvents = await Event.find({ 'series.id': { $exists: true } })
.select('title series.id series.isSeriesEvent')
.lean();
console.log(`Total: ${allEvents.length}`);
allEvents.forEach(e => {
console.log(` - ${e.title}`);
console.log(` series.id: ${e.series?.id}`);
console.log(` series.isSeriesEvent: ${e.series?.isSeriesEvent}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
debug();

42
scripts/diagnose-query.js Normal file
View file

@ -0,0 +1,42 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function diagnose() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Query 1: Find with series.id AND series.isSeriesEvent');
const events1 = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title series').lean();
console.log(` Found: ${events1.length} events`);
events1.forEach(e => console.log(` - ${e.title} (isSeriesEvent: ${e.series?.isSeriesEvent})`));
console.log('\nQuery 2: Find with just series.id');
const events2 = await Event.find({
'series.id': 'cooperative-values-into-practice'
}).select('title series').lean();
console.log(` Found: ${events2.length} events`);
events2.forEach(e => console.log(` - ${e.title} (isSeriesEvent: ${e.series?.isSeriesEvent})`));
console.log('\nChecking one Module event directly:');
const module0 = await Event.findOne({ title: 'Module 0: Orientation' }).select('series').lean();
if (module0) {
console.log(` series.id: "${module0.series?.id}"`);
console.log(` series.isSeriesEvent: ${module0.series?.isSeriesEvent}`);
console.log(` series.title: "${module0.series?.title}"`);
} else {
console.log(' NOT FOUND');
}
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
diagnose();

96
scripts/fix-series-id.js Normal file
View file

@ -0,0 +1,96 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function fixSeriesId() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Check existing series
const allSeries = await Series.find({}).select('id title').lean();
console.log('\nExisting series:');
allSeries.forEach(s => console.log(` - ${s.id}: ${s.title}`));
// Delete the new series we created
await Series.deleteOne({ id: 'coop-values-into-practice-2025' });
console.log('\n✓ Deleted duplicate series: coop-values-into-practice-2025');
// Update the old series with new data
const updatedSeries = await Series.findOneAndUpdate(
{ id: 'cooperative-values-into-practice' },
{
title: 'Cooperative Values into Practice',
description: 'A practical, region-agnostic foundation in cooperative values and governance for game studio founders interested in worker-centric, anti-capitalist models. Structured as a peer-driven workshop emphasizing reciprocal learning and sharing.',
type: 'workshop_series',
isVisible: true,
isActive: true,
targetCircles: ['founder', 'practitioner'],
totalEvents: 6,
tickets: {
enabled: true,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: 'Free access to all sessions in the Cooperative Values into Practice series for Ghost Guild members.'
},
public: {
available: true,
name: 'Series Pass',
description: 'Access to all 6 sessions in the Cooperative Values into Practice series',
price: 150,
quantity: 20
},
capacity: {
total: 30
},
waitlist: {
enabled: true,
maxSize: 15
}
}
},
{ new: true }
);
console.log('✓ Updated existing series');
// Update all the new events to use the old series ID
const result = await Event.updateMany(
{ 'series.id': 'coop-values-into-practice-2025' },
{
$set: {
'series.id': 'cooperative-values-into-practice',
'seriesTicketReference': updatedSeries._id
}
}
);
console.log(`✓ Updated ${result.modifiedCount} events to use series ID: cooperative-values-into-practice`);
// Verify the events
const events = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title series.position startDate').sort({ 'series.position': 1 });
console.log(`\n✓ Found ${events.length} events in the series:`);
events.forEach(e => {
console.log(` ${e.series.position}. ${e.title} - ${e.startDate.toLocaleDateString()}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
fixSeriesId();

View file

@ -0,0 +1,37 @@
import mongoose from 'mongoose';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function listAll() {
try {
await mongoose.connect(MONGODB_URI);
// Group events by series ID
const allEvents = await Event.find({ 'series.isSeriesEvent': true })
.select('title series.id')
.sort({ 'series.id': 1, 'series.position': 1 })
.lean();
const grouped = {};
allEvents.forEach(e => {
const sid = e.series?.id || 'unknown';
if (!grouped[sid]) grouped[sid] = [];
grouped[sid].push(e.title);
});
console.log('Events grouped by series.id:\n');
Object.entries(grouped).forEach(([sid, events]) => {
console.log(`${sid} (${events.length} events):`);
events.forEach(e => console.log(` - ${e}`));
console.log('');
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
listAll();

99
scripts/merge-series.js Normal file
View file

@ -0,0 +1,99 @@
import mongoose from 'mongoose';
import Series from '../server/models/series.js';
import Event from '../server/models/event.js';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild';
async function mergeSeries() {
try {
await mongoose.connect(MONGODB_URI);
console.log('Connected to MongoDB');
// Get both series
const oldSeries = await Series.findOne({ id: 'cooperative-values-into-practice' });
const newSeries = await Series.findOne({ id: 'coop-values-into-practice-2025' });
console.log(`\nOld series _id: ${oldSeries?._id || 'NOT FOUND'}`);
console.log(`New series _id: ${newSeries?._id || 'NOT FOUND'}`);
if (!oldSeries) {
console.log('\nError: Old series not found!');
return;
}
// Update the old series with new ticketing and metadata
oldSeries.tickets = {
enabled: true,
requiresSeriesTicket: false,
allowIndividualEventTickets: true,
currency: 'CAD',
member: {
available: true,
isFree: true,
price: 0,
name: 'Member Series Pass',
description: 'Free access to all sessions in the Cooperative Values into Practice series for Ghost Guild members.'
},
public: {
available: true,
name: 'Series Pass',
description: 'Access to all 6 sessions in the Cooperative Values into Practice series',
price: 150,
quantity: 20,
sold: 0,
reserved: 0
},
capacity: {
total: 30,
reserved: 0
},
waitlist: {
enabled: true,
maxSize: 15,
entries: []
}
};
oldSeries.targetCircles = ['founder', 'practitioner'];
oldSeries.totalEvents = 6;
await oldSeries.save();
console.log('\n✓ Updated old series with new configuration');
// Update all new events to use the old series ID
const result = await Event.updateMany(
{ 'series.id': 'coop-values-into-practice-2025' },
{
$set: {
'series.id': 'cooperative-values-into-practice',
'tickets.seriesTicketReference': oldSeries._id
}
}
);
console.log(`✓ Updated ${result.modifiedCount} events to use series ID: cooperative-values-into-practice`);
// Delete the new duplicate series
if (newSeries) {
await Series.deleteOne({ id: 'coop-values-into-practice-2025' });
console.log('✓ Deleted duplicate series: coop-values-into-practice-2025');
}
// Verify
const events = await Event.find({
'series.id': 'cooperative-values-into-practice',
'series.isSeriesEvent': true
}).select('title series.position startDate').sort({ 'series.position': 1 });
console.log(`\n✅ Series now has ${events.length} events:`);
events.forEach(e => {
console.log(` ${e.series.position}. ${e.title} - ${e.startDate.toLocaleDateString()}`);
});
} catch (error) {
console.error('Error:', error);
} finally {
await mongoose.connection.close();
}
}
mergeSeries();

View file

@ -1,60 +1,75 @@
import Series from '../../models/series.js' import Series from "../../models/series.js";
import Event from '../../models/event.js' import Event from "../../models/event.js";
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await connectDB() await connectDB();
// Fetch all series // Fetch all series
const series = await Series.find({ isActive: true }) const series = await Series.find({ isActive: true })
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean() .lean();
// For each series, get event count and statistics // For each series, get event count and statistics
const seriesWithStats = await Promise.all( const seriesWithStats = await Promise.all(
series.map(async (s) => { series.map(async (s) => {
const events = await Event.find({ const events = await Event.find({
'series.id': s.id, "series.id": s.id,
'series.isSeriesEvent': true "series.isSeriesEvent": true,
}).select('_id startDate endDate registrations').lean() })
.sort({ "series.position": 1, startDate: 1 })
.lean();
const now = new Date() const now = new Date();
const eventCount = events.length const eventCount = events.length;
const completedEvents = events.filter(e => e.endDate < now).length const completedEvents = events.filter((e) => e.endDate < now).length;
const upcomingEvents = events.filter(e => e.startDate > now).length const upcomingEvents = events.filter((e) => e.startDate > now).length;
const firstEventDate = events.length > 0 ? const firstEventDate =
Math.min(...events.map(e => new Date(e.startDate))) : null events.length > 0
const lastEventDate = events.length > 0 ? ? Math.min(...events.map((e) => new Date(e.startDate)))
Math.max(...events.map(e => new Date(e.endDate))) : null : null;
const lastEventDate =
events.length > 0
? Math.max(...events.map((e) => new Date(e.endDate)))
: null;
let status = 'upcoming' let status = "upcoming";
if (lastEventDate && lastEventDate < now) { if (lastEventDate && lastEventDate < now) {
status = 'completed' status = "completed";
} else if (firstEventDate && firstEventDate <= now && lastEventDate && lastEventDate >= now) { } else if (
status = 'active' firstEventDate &&
firstEventDate <= now &&
lastEventDate &&
lastEventDate >= now
) {
status = "active";
} }
return { return {
...s, ...s,
events, // Include full event objects
eventCount, eventCount,
completedEvents, completedEvents,
upcomingEvents, upcomingEvents,
startDate: firstEventDate, startDate: firstEventDate,
endDate: lastEventDate, endDate: lastEventDate,
status, status,
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) totalRegistrations: events.reduce(
} (sum, e) => sum + (e.registrations?.length || 0),
}) 0,
) ),
};
}),
);
return seriesWithStats return seriesWithStats;
} catch (error) { } catch (error) {
console.error('Error fetching series:', error) console.error("Error fetching series:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch series' statusMessage: "Failed to fetch series",
}) });
} }
}) });

View file

@ -0,0 +1,136 @@
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 body = await readBody(event)
const { id, tickets } = body
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID is required'
})
}
if (!tickets) {
throw createError({
statusCode: 400,
statusMessage: 'Tickets configuration is required'
})
}
// Find the series
const series = await Series.findOne({ id })
if (!series) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Update the series with new ticketing configuration
const updatedSeries = await Series.findOneAndUpdate(
{ id },
{
$set: {
tickets: {
enabled: tickets.enabled || false,
requiresSeriesTicket: tickets.requiresSeriesTicket || false,
allowIndividualEventTickets: tickets.allowIndividualEventTickets !== false,
currency: tickets.currency || 'CAD',
member: {
available: tickets.member?.available !== false,
isFree: tickets.member?.isFree !== false,
price: tickets.member?.price || 0,
name: tickets.member?.name || 'Member Series Pass',
description: tickets.member?.description || '',
circleOverrides: tickets.member?.circleOverrides || {}
},
public: {
available: tickets.public?.available || false,
name: tickets.public?.name || 'Series Pass',
description: tickets.public?.description || '',
price: tickets.public?.price || 0,
quantity: tickets.public?.quantity || null,
sold: tickets.public?.sold || 0,
reserved: tickets.public?.reserved || 0,
earlyBirdPrice: tickets.public?.earlyBirdPrice || null,
earlyBirdDeadline: tickets.public?.earlyBirdDeadline || null
},
capacity: {
total: tickets.capacity?.total || null,
reserved: tickets.capacity?.reserved || 0
},
waitlist: {
enabled: tickets.waitlist?.enabled || false,
maxSize: tickets.waitlist?.maxSize || null,
entries: tickets.waitlist?.entries || []
}
},
updatedAt: new Date()
}
},
{ new: true }
)
// If requiresSeriesTicket is enabled, update all events in the series
if (tickets.enabled && tickets.requiresSeriesTicket) {
await Event.updateMany(
{
'series.id': id,
'series.isSeriesEvent': true
},
{
$set: {
'tickets.enabled': true,
'tickets.requiresSeriesTicket': true,
'tickets.seriesTicketReference': updatedSeries._id
}
}
)
} else if (tickets.enabled && !tickets.requiresSeriesTicket) {
// If series tickets are optional, just mark the reference
await Event.updateMany(
{
'series.id': id,
'series.isSeriesEvent': true
},
{
$set: {
'tickets.seriesTicketReference': updatedSeries._id
}
}
)
} else {
// If series tickets are disabled, remove the requirement from events
await Event.updateMany(
{
'series.id': id,
'series.isSeriesEvent': true
},
{
$set: {
'tickets.requiresSeriesTicket': false,
'tickets.seriesTicketReference': null
}
}
)
}
return {
success: true,
data: updatedSeries
}
} catch (error) {
console.error('Error updating series ticketing:', error)
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Failed to update series ticketing'
})
}
})

View file

@ -0,0 +1,104 @@
import Event from "../../../models/event.js";
import Series from "../../../models/series.js";
import { checkUserSeriesPass } from "../../../utils/tickets.js";
export default defineEventHandler(async (event) => {
try {
const eventId = getRouterParam(event, "id");
const query = getQuery(event);
const email = query.email;
if (!email) {
return {
hasAccess: false,
requiresSeriesPass: false,
message: "Email parameter required",
};
}
// Fetch event
const eventDoc = await Event.findOne({
$or: [{ _id: eventId }, { slug: eventId }],
});
if (!eventDoc) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Check if event requires series ticket
if (
!eventDoc.tickets?.requiresSeriesTicket ||
!eventDoc.tickets?.seriesTicketReference
) {
return {
hasAccess: true,
requiresSeriesPass: false,
message: "Event does not require series pass",
};
}
// Fetch the series
const series = await Series.findById(
eventDoc.tickets.seriesTicketReference,
);
if (!series) {
return {
hasAccess: false,
requiresSeriesPass: true,
message: "Series not found",
};
}
// Check if user has a valid series pass
const { hasPass, registration } = checkUserSeriesPass(series, email);
if (hasPass) {
// Check if already registered for this specific event
const eventRegistration = eventDoc.registrations?.find(
(reg) =>
reg.email.toLowerCase() === email.toLowerCase() && !reg.cancelledAt,
);
return {
hasAccess: true,
requiresSeriesPass: true,
hasSeriesPass: true,
alreadyRegistered: !!eventRegistration,
registration: registration
? {
ticketType: registration.ticketType,
registeredAt: registration.registeredAt,
}
: null,
series: {
id: series.id,
title: series.title,
slug: series.slug,
},
message: "Access granted via series pass",
};
}
return {
hasAccess: false,
requiresSeriesPass: true,
hasSeriesPass: false,
series: {
id: series.id,
title: series.title,
slug: series.slug,
},
message: "Series pass required for this event",
};
} catch (error) {
console.error("Error checking series access:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || "Failed to check series access",
});
}
});

View file

@ -0,0 +1,155 @@
import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import { connectDB } from "../../../../utils/mongoose.js";
import {
calculateTicketPrice,
checkTicketAvailability,
formatPrice,
} from "../../../../utils/tickets.js";
import mongoose from "mongoose";
/**
* GET /api/events/[id]/tickets/available
* Check ticket availability and pricing for current user
* Query params: ?email=user@example.com (optional)
*/
export default defineEventHandler(async (event) => {
try {
await connectDB();
const identifier = getRouterParam(event, "id");
const query = getQuery(event);
const userEmail = query.email;
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: "Event identifier is 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 is cancelled or past
if (eventData.isCancelled) {
return {
available: false,
reason: "Event has been cancelled",
};
}
if (new Date(eventData.startDate) < new Date()) {
return {
available: false,
reason: "Event has already started",
};
}
// Check if user is a member (if email provided)
let member = null;
if (userEmail) {
member = await Member.findOne({ email: userEmail.toLowerCase() });
}
// Calculate ticket pricing for this user
const ticketInfo = calculateTicketPrice(eventData, member);
if (!ticketInfo) {
return {
available: false,
reason: eventData.membersOnly
? "This event is for members only"
: "No tickets available",
membersOnly: eventData.membersOnly,
};
}
// Check availability
const availability = checkTicketAvailability(
eventData,
ticketInfo.ticketType
);
// Build response
const response = {
available: availability.available,
ticketType: ticketInfo.ticketType,
price: ticketInfo.price,
currency: ticketInfo.currency,
formattedPrice: formatPrice(ticketInfo.price, ticketInfo.currency),
isFree: ticketInfo.isFree,
isEarlyBird: ticketInfo.isEarlyBird,
name: ticketInfo.name,
description: ticketInfo.description,
remaining: availability.remaining,
waitlistAvailable: availability.waitlistAvailable,
};
// Add early bird deadline if applicable
if (
ticketInfo.isEarlyBird &&
eventData.tickets?.public?.earlyBirdDeadline
) {
response.earlyBirdDeadline = eventData.tickets.public.earlyBirdDeadline;
response.regularPrice = eventData.tickets.public.price;
response.formattedRegularPrice = formatPrice(
eventData.tickets.public.price,
ticketInfo.currency
);
}
// Add member vs public comparison for transparency
if (member && eventData.tickets?.public?.available) {
response.publicTicket = {
price: eventData.tickets.public.price,
formattedPrice: formatPrice(
eventData.tickets.public.price,
eventData.tickets.currency
),
};
response.memberSavings =
eventData.tickets.public.price - ticketInfo.price;
}
// Check if user is already registered
if (userEmail) {
const alreadyRegistered = eventData.registrations?.some(
(reg) =>
reg.email.toLowerCase() === userEmail.toLowerCase() &&
!reg.cancelledAt
);
if (alreadyRegistered) {
response.alreadyRegistered = true;
response.available = false;
response.reason = "You are already registered for this event";
}
}
return response;
} catch (error) {
console.error("Error checking ticket availability:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to check ticket availability",
});
}
});

View file

@ -0,0 +1,54 @@
import Member from "../../../../models/member.js";
import { connectDB } from "../../../../utils/mongoose.js";
/**
* POST /api/events/[id]/tickets/check-eligibility
* Check if a user is eligible for member pricing
* Body: { email }
*/
export default defineEventHandler(async (event) => {
try {
await connectDB();
const body = await readBody(event);
if (!body.email) {
throw createError({
statusCode: 400,
statusMessage: "Email is required",
});
}
// Check if user is a member
const member = await Member.findOne({
email: body.email.toLowerCase(),
}).select("email name circle contributionTier");
if (!member) {
return {
isMember: false,
eligibleForMemberPricing: false,
};
}
return {
isMember: true,
eligibleForMemberPricing: true,
memberInfo: {
circle: member.circle,
tier: member.contributionTier,
name: member.name,
},
};
} catch (error) {
console.error("Error checking eligibility:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to check eligibility",
});
}
});

View file

@ -0,0 +1,162 @@
import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import { connectDB } from "../../../../utils/mongoose.js";
import {
validateTicketPurchase,
calculateTicketPrice,
completeTicketPurchase,
} from "../../../../utils/tickets.js";
import { sendEventRegistrationEmail } from "../../../../utils/resend.js";
import mongoose from "mongoose";
/**
* POST /api/events/[id]/tickets/purchase
* Purchase a ticket for an event
* Body: { name, email, paymentToken? }
*/
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
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 user is a member
const member = await Member.findOne({ email: body.email.toLowerCase() });
// Validate ticket purchase
const validation = validateTicketPurchase(eventData, {
email: body.email,
name: body.name,
member,
});
if (!validation.valid) {
throw createError({
statusCode: 400,
statusMessage: validation.reason,
data: {
waitlistAvailable: validation.waitlistAvailable,
},
});
}
const { ticketInfo } = validation;
const requiresPayment = ticketInfo.price > 0;
// Handle payment if required
let transactionId = null;
if (requiresPayment) {
// For HelcimPay.js with purchase type, the transaction is already completed
// We just need to verify we received the transaction ID
if (!body.transactionId) {
throw createError({
statusCode: 400,
statusMessage:
"Transaction ID is required. Payment must be completed first.",
});
}
transactionId = body.transactionId;
// Optional: Verify the transaction with Helcim API
// This adds extra security to ensure the transaction is legitimate
// For now, we trust the transaction ID from HelcimPay.js
console.log("Payment completed with transaction ID:", transactionId);
}
// Create registration
const registration = {
memberId: member ? member._id : null,
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: member
? `${member.circle}-${member.contributionTier}`
: "non-member",
isMember: !!member,
ticketType: ticketInfo.ticketType,
ticketPrice: ticketInfo.price,
paymentStatus: requiresPayment ? "completed" : "not_required",
paymentId: transactionId,
amountPaid: ticketInfo.price,
registeredAt: new Date(),
};
// Add registration to event
eventData.registrations.push(registration);
// Complete ticket purchase (updates sold/reserved counts)
await completeTicketPurchase(eventData, ticketInfo.ticketType);
// Save event with registration
await eventData.save();
// Send confirmation email
try {
await sendEventRegistrationEmail(registration, eventData);
} catch (emailError) {
console.error("Failed to send confirmation email:", emailError);
// Don't fail the registration if email fails
}
return {
success: true,
message: "Ticket purchased successfully!",
registration: {
id: eventData.registrations[eventData.registrations.length - 1]._id,
name: registration.name,
email: registration.email,
ticketType: registration.ticketType,
amountPaid: registration.amountPaid,
},
payment: transactionId
? {
transactionId: transactionId,
amount: ticketInfo.price,
currency: ticketInfo.currency,
}
: null,
};
} catch (error) {
console.error("Error purchasing ticket:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to purchase ticket. Please try again.",
});
}
});

View file

@ -0,0 +1,94 @@
import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import { connectDB } from "../../../../utils/mongoose.js";
import {
calculateTicketPrice,
reserveTicket,
} from "../../../../utils/tickets.js";
import mongoose from "mongoose";
/**
* POST /api/events/[id]/tickets/reserve
* Temporarily reserve a ticket during checkout process
* Body: { email }
*/
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",
});
}
if (!body.email) {
throw createError({
statusCode: 400,
statusMessage: "Email is 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 user is a member
const member = await Member.findOne({ email: body.email.toLowerCase() });
// Calculate ticket type
const ticketInfo = calculateTicketPrice(eventData, member);
if (!ticketInfo) {
throw createError({
statusCode: 400,
statusMessage: "No tickets available for your membership status",
});
}
// Reserve the ticket
const reservation = await reserveTicket(eventData, ticketInfo.ticketType);
if (!reservation.success) {
throw createError({
statusCode: 400,
statusMessage: reservation.reason || "Failed to reserve ticket",
});
}
return {
success: true,
reservation: {
id: reservation.reservationId,
expiresAt: reservation.expiresAt,
ticketType: ticketInfo.ticketType,
},
};
} catch (error) {
console.error("Error reserving ticket:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to reserve ticket",
});
}
});

View file

@ -1,62 +1,86 @@
// Initialize HelcimPay.js session // Initialize HelcimPay.js session
const HELCIM_API_BASE = 'https://api.helcim.com/v2' const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event);
const body = await readBody(event) const body = await readBody(event);
// Debug log the request body // Debug log the request body
console.log('Initialize payment request body:', body) console.log("Initialize payment request body:", body);
// Validate required fields const helcimToken =
if (!body.customerId) { config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
throw createError({
statusCode: 400, // Determine payment type based on whether this is for a subscription or one-time payment
statusMessage: 'Customer ID is required' const isEventTicket = body.metadata?.type === "event_ticket";
}) const amount = body.amount || 0;
// For event tickets with amount > 0, we do a purchase
// For subscriptions or card verification, we do verify
const paymentType = isEventTicket && amount > 0 ? "purchase" : "verify";
const requestBody = {
paymentType,
amount: paymentType === "purchase" ? amount : 0,
currency: "CAD",
paymentMethod: "cc",
};
// For subscription setup (verify mode), include customer code if provided
// For one-time purchases (event tickets), don't include customer code
// as the customer may not exist in Helcim yet
if (body.customerCode && paymentType === "verify") {
requestBody.customerCode = body.customerCode;
} }
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN // Add product/event information for better display in Helcim modal
if (body.metadata?.eventTitle) {
// Some Helcim accounts don't support invoice numbers in initialization
// Try multiple fields that might display in the modal
requestBody.description = body.metadata.eventTitle;
requestBody.notes = body.metadata.eventTitle;
requestBody.orderNumber = `${body.metadata.eventId}`;
}
console.log("Helcim request body:", JSON.stringify(requestBody, null, 2));
// Initialize HelcimPay.js session // Initialize HelcimPay.js session
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, { const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
method: 'POST', method: "POST",
headers: { headers: {
'accept': 'application/json', accept: "application/json",
'content-type': 'application/json', "content-type": "application/json",
'api-token': helcimToken "api-token": helcimToken,
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody),
paymentType: 'verify', // For card verification });
amount: 0, // Must be exactly 0 for verification
currency: 'CAD',
customerCode: body.customerCode,
paymentMethod: 'cc'
})
})
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text();
console.error('HelcimPay initialization failed:', response.status, errorText) console.error(
"HelcimPay initialization failed:",
response.status,
errorText,
);
throw createError({ throw createError({
statusCode: response.status, statusCode: response.status,
statusMessage: `Failed to initialize payment: ${errorText}` statusMessage: `Failed to initialize payment: ${errorText}`,
}) });
} }
const paymentData = await response.json() const paymentData = await response.json();
return { return {
success: true, success: true,
checkoutToken: paymentData.checkoutToken, checkoutToken: paymentData.checkoutToken,
secretToken: paymentData.secretToken secretToken: paymentData.secretToken,
} };
} catch (error) { } catch (error) {
console.error('Error initializing HelcimPay:', error) console.error("Error initializing HelcimPay:", error);
throw createError({ throw createError({
statusCode: error.statusCode || 500, statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to initialize payment' statusMessage: error.message || "Failed to initialize payment",
}) });
} }
}) });

View file

@ -1,78 +1,101 @@
import Event from '../../models/event.js' import Event from "../../models/event.js";
import { connectDB } from '../../utils/mongoose.js' import Series from "../../models/series.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await connectDB() await connectDB();
const id = getRouterParam(event, 'id') const id = getRouterParam(event, "id");
if (!id) { if (!id) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Series ID is required' statusMessage: "Series ID is required",
}) });
} }
// Try to fetch the Series model first for full ticketing info
// Build query conditions based on whether id looks like ObjectId or string
const isObjectId = /^[0-9a-fA-F]{24}$/.test(id);
const seriesQuery = isObjectId
? { $or: [{ _id: id }, { id: id }, { slug: id }] }
: { $or: [{ id: id }, { slug: id }] };
const seriesModel = await Series.findOne(seriesQuery)
.select("-registrations") // Don't expose registration details
.lean();
// Fetch all events in this series // Fetch all events in this series
const events = await Event.find({ const events = await Event.find({
'series.id': id, "series.id": id,
'series.isSeriesEvent': true "series.isSeriesEvent": true,
}) })
.sort({ 'series.position': 1, startDate: 1 }) .sort({ "series.position": 1, startDate: 1 })
.select('-registrations') .select("-registrations")
.lean() .lean();
if (events.length === 0) { if (events.length === 0 && !seriesModel) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Event series not found' statusMessage: "Event series not found",
}) });
} }
// Get series metadata from the first event // Get series metadata from the Series model or the first event
const seriesInfo = events[0].series const seriesInfo = seriesModel || events[0]?.series;
// Calculate series statistics // Calculate series statistics
const now = new Date() const now = new Date();
const completedEvents = events.filter(e => e.endDate < now).length const completedEvents = events.filter((e) => e.endDate < now).length;
const upcomingEvents = events.filter(e => e.startDate > now).length const upcomingEvents = events.filter((e) => e.startDate > now).length;
const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length const ongoingEvents = events.filter(
(e) => e.startDate <= now && e.endDate >= now,
).length;
const firstEventDate = events[0].startDate const firstEventDate = events[0]?.startDate;
const lastEventDate = events[events.length - 1].endDate const lastEventDate = events[events.length - 1]?.endDate;
// Return series with additional metadata // Return series with additional metadata
return { return {
id: id, id: id,
_id: seriesModel?._id?.toString(),
title: seriesInfo.title, title: seriesInfo.title,
description: seriesInfo.description, description: seriesInfo.description,
type: seriesInfo.type, type: seriesInfo.type,
totalEvents: seriesInfo.totalEvents, totalEvents: seriesInfo.totalEvents,
startDate: firstEventDate, startDate: firstEventDate,
endDate: lastEventDate, endDate: lastEventDate,
events: events.map(e => ({ // Include ticketing information if it exists
tickets: seriesModel?.tickets || null,
events: events.map((e) => ({
...e, ...e,
id: e._id.toString() id: e._id.toString(),
})), })),
statistics: { statistics: {
totalEvents: events.length, totalEvents: events.length,
completedEvents, completedEvents,
upcomingEvents, upcomingEvents,
ongoingEvents, ongoingEvents,
isOngoing: firstEventDate <= now && lastEventDate >= now, isOngoing:
isUpcoming: firstEventDate > now, firstEventDate && lastEventDate
isCompleted: lastEventDate < now, ? firstEventDate <= now && lastEventDate >= now
totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) : false,
} isUpcoming: firstEventDate ? firstEventDate > now : false,
} isCompleted: lastEventDate ? lastEventDate < now : false,
totalRegistrations: events.reduce(
(sum, e) => sum + (e.registrations?.length || 0),
0,
),
},
};
} catch (error) { } catch (error) {
console.error('Error fetching event series:', error) console.error("Error fetching event series:", error);
if (error.statusCode) throw error if (error.statusCode) throw error;
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch event series' statusMessage: "Failed to fetch event series",
}) });
} }
}) });

View file

@ -0,0 +1,118 @@
import Series from "../../../../models/series.js";
import Member from "../../../../models/member.js";
import {
calculateSeriesTicketPrice,
checkSeriesTicketAvailability,
checkUserSeriesPass,
} from "../../../../utils/tickets.js";
export default defineEventHandler(async (event) => {
try {
const seriesId = getRouterParam(event, "id");
const query = getQuery(event);
const email = query.email;
// Fetch series
// Build query conditions based on whether seriesId looks like ObjectId or string
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] };
const series = await Series.findOne(seriesQuery);
if (!series) {
throw createError({
statusCode: 404,
statusMessage: "Series not found",
});
}
// Check if tickets are enabled
if (!series.tickets?.enabled) {
return {
available: false,
message: "Tickets are not enabled for this series",
};
}
// Check membership if email provided
let member = null;
if (email) {
member = await Member.findOne({ email: email.toLowerCase() });
}
// Check if user already has a series pass
const { hasPass, registration } = checkUserSeriesPass(series, email || "");
if (hasPass) {
return {
available: false,
alreadyRegistered: true,
registration: {
ticketType: registration.ticketType,
registeredAt: registration.registeredAt,
eventsIncluded: registration.eventRegistrations?.length || 0,
},
message: "You already have a pass for this series",
};
}
// Calculate pricing for user
const ticketInfo = calculateSeriesTicketPrice(series, member);
if (!ticketInfo) {
return {
available: false,
message: "No series passes available for your membership status",
};
}
// Check availability
const availability = checkSeriesTicketAvailability(
series,
ticketInfo.ticketType,
);
return {
available: availability.available,
ticket: {
type: ticketInfo.ticketType,
name: ticketInfo.name,
description: ticketInfo.description,
price: ticketInfo.price,
currency: ticketInfo.currency,
isFree: ticketInfo.isFree,
isEarlyBird: ticketInfo.isEarlyBird,
},
availability: {
remaining: availability.remaining,
unlimited: availability.remaining === null,
waitlistAvailable: availability.waitlistAvailable,
},
series: {
id: series.id,
title: series.title,
totalEvents: series.totalEvents,
type: series.type,
},
memberInfo: member
? {
isMember: true,
circle: member.circle,
name: member.name,
}
: {
isMember: false,
},
};
} catch (error) {
console.error("Error checking series ticket availability:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage:
error.statusMessage || "Failed to check series ticket availability",
});
}
});

View file

@ -0,0 +1,38 @@
import Member from "../../../../models/member.js";
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { email } = body;
if (!email) {
return {
isMember: false,
message: "Email is required",
};
}
const member = await Member.findOne({ email: email.toLowerCase() });
if (!member) {
return {
isMember: false,
message: "No membership found",
};
}
return {
isMember: true,
circle: member.circle,
name: member.name,
email: member.email,
message: "Member eligibility confirmed",
};
} catch (error) {
console.error("Error checking series ticket eligibility:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to check member eligibility",
});
}
});

View file

@ -0,0 +1,163 @@
import Series from "../../../../models/series.js";
import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import {
validateSeriesTicketPurchase,
calculateSeriesTicketPrice,
reserveSeriesTicket,
releaseSeriesTicket,
completeSeriesTicketPurchase,
registerForAllSeriesEvents,
} from "../../../../utils/tickets.js";
import { sendSeriesPassConfirmation } from "../../../../utils/resend.js";
export default defineEventHandler(async (event) => {
try {
const seriesId = getRouterParam(event, "id");
const body = await readBody(event);
const { name, email, paymentId } = body;
// Validate input
if (!name || !email) {
throw createError({
statusCode: 400,
statusMessage: "Name and email are required",
});
}
// Fetch series
// Build query conditions based on whether seriesId looks like ObjectId or string
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] };
const series = await Series.findOne(seriesQuery);
if (!series) {
throw createError({
statusCode: 404,
statusMessage: "Series not found",
});
}
// Check membership
let member = null;
member = await Member.findOne({ email: email.toLowerCase() });
// Validate purchase
const validation = validateSeriesTicketPurchase(series, {
email,
name,
member,
});
if (!validation.valid) {
throw createError({
statusCode: 400,
statusMessage: validation.reason,
});
}
const { ticketInfo } = validation;
// For paid tickets, require payment ID
if (!ticketInfo.isFree && !paymentId) {
throw createError({
statusCode: 400,
statusMessage: "Payment is required for this series pass",
});
}
// Create series registration
const registration = {
memberId: member?._id,
name,
email: email.toLowerCase(),
membershipLevel: member?.circle || "non-member",
isMember: !!member,
ticketType: ticketInfo.ticketType,
ticketPrice: ticketInfo.price,
paymentStatus: ticketInfo.isFree ? "not_required" : "completed",
paymentId: paymentId || null,
amountPaid: ticketInfo.price,
registeredAt: new Date(),
eventRegistrations: [],
};
series.registrations.push(registration);
await completeSeriesTicketPurchase(series, ticketInfo.ticketType);
// Get the newly created registration
const newRegistration =
series.registrations[series.registrations.length - 1];
// Fetch all events in this series
const seriesEvents = await Event.find({
"series.id": series.id,
isCancelled: false,
}).sort({ "series.position": 1, startDate: 1 });
// Register user for all events
const eventRegistrations = await registerForAllSeriesEvents(
series,
seriesEvents,
newRegistration,
);
// Send confirmation email
try {
await sendSeriesPassConfirmation({
to: email,
name,
series: {
title: series.title,
description: series.description,
type: series.type,
},
ticket: {
type: ticketInfo.ticketType,
price: ticketInfo.price,
currency: ticketInfo.currency,
isFree: ticketInfo.isFree,
},
events: seriesEvents.map((e) => ({
title: e.title,
startDate: e.startDate,
endDate: e.endDate,
location: e.location,
})),
paymentId,
});
} catch (emailError) {
console.error(
"Failed to send series pass confirmation email:",
emailError,
);
// Don't fail the registration if email fails
}
return {
success: true,
message: "Series pass purchased successfully",
registration: {
id: newRegistration._id,
ticketType: newRegistration.ticketType,
amountPaid: newRegistration.amountPaid,
eventsRegistered: eventRegistrations.filter((r) => r.success).length,
totalEvents: seriesEvents.length,
},
events: eventRegistrations.map((r) => ({
eventId: r.eventId,
success: r.success,
reason: r.reason,
})),
};
} catch (error) {
console.error("Error purchasing series pass:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || "Failed to purchase series pass",
});
}
});

View file

@ -70,16 +70,66 @@ const eventSchema = new mongoose.Schema({
// Ticket configuration // Ticket configuration
tickets: { tickets: {
enabled: { type: Boolean, default: false }, enabled: { type: Boolean, default: false },
requiresSeriesTicket: { type: Boolean, default: false }, // If true, must buy series pass instead
seriesTicketReference: {
type: mongoose.Schema.Types.ObjectId,
ref: "Series",
}, // Reference to series with tickets
currency: { type: String, default: "CAD" },
// Member ticket configuration
member: {
available: { type: Boolean, default: true }, // Members can always register if tickets enabled
isFree: { type: Boolean, default: true }, // Most events free for members
price: { type: Number, default: 0 }, // Optional member price (discounted)
name: { type: String, default: "Member Ticket" },
description: String,
// Circle-specific overrides (optional)
circleOverrides: {
community: {
isFree: { type: Boolean },
price: { type: Number },
},
founder: {
isFree: { type: Boolean },
price: { type: Number },
},
practitioner: {
isFree: { type: Boolean },
price: { type: Number },
},
},
},
// Public (non-member) ticket configuration
public: { public: {
available: { type: Boolean, default: false }, available: { type: Boolean, default: false },
name: { type: String, default: "Public Ticket" }, name: { type: String, default: "Public Ticket" },
description: String, description: String,
price: { type: Number, default: 0 }, price: { type: Number, default: 0 },
quantity: Number, // null = unlimited quantity: Number, // null/undefined = unlimited
sold: { type: Number, default: 0 }, sold: { type: Number, default: 0 },
reserved: { type: Number, default: 0 }, // Temporarily reserved during checkout
earlyBirdPrice: Number, earlyBirdPrice: Number,
earlyBirdDeadline: Date, earlyBirdDeadline: Date,
}, },
// Capacity management (applies to all ticket types combined)
capacity: {
total: Number, // null/undefined = unlimited
reserved: { type: Number, default: 0 }, // Currently reserved across all types
},
// Waitlist configuration
waitlist: {
enabled: { type: Boolean, default: false },
maxSize: Number, // null = unlimited waitlist
entries: [
{
name: String,
email: String,
membershipLevel: String,
addedAt: { type: Date, default: Date.now },
notified: { type: Boolean, default: false },
},
],
},
}, },
// Circle targeting // Circle targeting
targetCircles: [ targetCircles: [
@ -107,14 +157,29 @@ const eventSchema = new mongoose.Schema({
email: String, email: String,
membershipLevel: String, membershipLevel: String,
isMember: { type: Boolean, default: false }, isMember: { type: Boolean, default: false },
// Ticket information
ticketType: {
type: String,
enum: ["member", "public", "guest", "series_pass"],
default: "guest",
},
ticketPrice: { type: Number, default: 0 }, // Actual price paid (may be early bird, member, etc.)
// Series ticket info
isSeriesTicketHolder: { type: Boolean, default: false }, // Registered via series pass
seriesTicketId: { type: mongoose.Schema.Types.ObjectId, ref: "Series" }, // Reference to series registration
// Payment information
paymentStatus: { paymentStatus: {
type: String, type: String,
enum: ["pending", "completed", "failed", "not_required"], enum: ["pending", "completed", "failed", "refunded", "not_required"],
default: "not_required", default: "not_required",
}, },
paymentId: String, // Helcim transaction ID paymentId: String, // Helcim transaction ID
amountPaid: { type: Number, default: 0 }, amountPaid: { type: Number, default: 0 },
// Metadata
registeredAt: { type: Date, default: Date.now }, registeredAt: { type: Date, default: Date.now },
cancelledAt: Date,
refundedAt: Date,
refundAmount: Number,
}, },
], ],
createdBy: { type: String, required: true }, createdBy: { type: String, required: true },

View file

@ -1,35 +1,173 @@
import mongoose from 'mongoose' import mongoose from "mongoose";
const seriesSchema = new mongoose.Schema({ const seriesSchema = new mongoose.Schema({
id: { id: { type: String, required: true, unique: true }, // Simple string ID (e.g., "coop-game-dev-2025")
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 }, title: { type: String, required: true },
description: { type: String, required: true }, slug: { type: String, unique: true }, // Auto-generated in pre-save hook
description: String,
type: { type: {
type: String, type: String,
enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'], enum: [
default: 'workshop_series' "workshop_series",
"recurring_meetup",
"multi_day",
"course",
"tournament",
],
default: "workshop_series",
}, },
totalEvents: Number, // Visibility and status
isVisible: { type: Boolean, default: true },
isActive: { type: Boolean, default: true }, isActive: { type: Boolean, default: true },
// Date range (calculated from events or set manually)
startDate: Date,
endDate: Date,
// Series ticketing configuration
tickets: {
enabled: { type: Boolean, default: false },
requiresSeriesTicket: { type: Boolean, default: false }, // If true, must buy series pass
allowIndividualEventTickets: { type: Boolean, default: true }, // Allow drop-in for individual events
currency: { type: String, default: "CAD" },
// Member series pass configuration
member: {
available: { type: Boolean, default: true },
isFree: { type: Boolean, default: true },
price: { type: Number, default: 0 },
name: { type: String, default: "Member Series Pass" },
description: String,
// Circle-specific overrides
circleOverrides: {
community: {
isFree: { type: Boolean },
price: { type: Number },
},
founder: {
isFree: { type: Boolean },
price: { type: Number },
},
practitioner: {
isFree: { type: Boolean },
price: { type: Number },
},
},
},
// Public (non-member) series pass configuration
public: {
available: { type: Boolean, default: false },
name: { type: String, default: "Series Pass" },
description: String,
price: { type: Number, default: 0 },
quantity: Number, // null/undefined = unlimited
sold: { type: Number, default: 0 },
reserved: { type: Number, default: 0 },
earlyBirdPrice: Number,
earlyBirdDeadline: Date,
},
// Series-wide capacity
capacity: {
total: Number, // null/undefined = unlimited
reserved: { type: Number, default: 0 },
},
// Waitlist configuration
waitlist: {
enabled: { type: Boolean, default: false },
maxSize: Number,
entries: [
{
name: String,
email: String,
membershipLevel: String,
addedAt: { type: Date, default: Date.now },
notified: { type: Boolean, default: false },
},
],
},
},
// Series pass purchases (registrations)
registrations: [
{
memberId: { type: mongoose.Schema.Types.ObjectId, ref: "Member" },
name: String,
email: String,
membershipLevel: String,
isMember: { type: Boolean, default: false },
// Ticket information
ticketType: {
type: String,
enum: ["member", "public", "guest"],
default: "guest",
},
ticketPrice: { type: Number, default: 0 },
// Payment information
paymentStatus: {
type: String,
enum: ["pending", "completed", "failed", "refunded", "not_required"],
default: "not_required",
},
paymentId: String, // Helcim transaction ID
amountPaid: { type: Number, default: 0 },
// Metadata
registeredAt: { type: Date, default: Date.now },
cancelledAt: Date,
refundedAt: Date,
refundAmount: Number,
// Events they've been registered for (references)
eventRegistrations: [
{
eventId: { type: mongoose.Schema.Types.ObjectId, ref: "Event" },
registrationId: mongoose.Schema.Types.ObjectId, // ID of registration in event.registrations
},
],
},
],
// Target circles
targetCircles: [
{
type: String,
enum: ["community", "founder", "practitioner"],
},
],
// Metadata
totalEvents: { type: Number, default: 0 }, // Number of events in this series
createdBy: { type: String, required: true }, createdBy: { type: String, required: true },
createdAt: { type: Date, default: Date.now }, createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now } updatedAt: { type: Date, default: Date.now },
}) });
// Update the updatedAt field on save // Generate slug from title
seriesSchema.pre('save', function(next) { function generateSlug(title) {
this.updatedAt = new Date() return title
next() .toLowerCase()
}) .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export default mongoose.models.Series || mongoose.model('Series', seriesSchema) // Pre-save hook to generate slug
seriesSchema.pre("save", async function (next) {
try {
// Always generate slug if it doesn't exist or if title has changed
if (!this.slug || this.isNew || this.isModified("title")) {
let baseSlug = generateSlug(this.title);
let slug = baseSlug;
let counter = 1;
// Ensure slug is unique
while (await this.constructor.findOne({ slug, _id: { $ne: this._id } })) {
slug = `${baseSlug}-${counter}`;
counter++;
}
this.slug = slug;
}
// Update timestamps
this.updatedAt = new Date();
next();
} catch (error) {
console.error("Error in pre-save hook:", error);
next(error);
}
});
export default mongoose.models.Series || mongoose.model("Series", seriesSchema);

View file

@ -128,6 +128,45 @@ export async function sendEventRegistrationEmail(registration, eventData) {
${eventData.description ? `<p>${eventData.description}</p>` : ""} ${eventData.description ? `<p>${eventData.description}</p>` : ""}
${
registration.ticketType &&
registration.ticketType !== "guest" &&
registration.amountPaid > 0
? `
<div class="event-details" style="margin-top: 20px;">
<h3 style="margin-top: 0; color: #1a1a2e;">Ticket Information</h3>
<div class="detail-row">
<div class="label">Ticket Type</div>
<div class="value">${registration.ticketType === "member" ? "Member Ticket" : "Public Ticket"}</div>
</div>
<div class="detail-row">
<div class="label">Amount Paid</div>
<div class="value">$${registration.amountPaid.toFixed(2)} CAD</div>
</div>
${
registration.paymentId
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div>
</div>
`
: ""
}
</div>
`
: registration.ticketType === "member" &&
registration.amountPaid === 0
? `
<div style="background-color: #e0e7ff; border-left: 4px solid #6366f1; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0; color: #4338ca;">
<strong> Member Benefit:</strong> This event is free for Ghost Guild members!
</p>
</div>
`
: ""
}
<center> <center>
<a href="${process.env.BASE_URL || "https://ghostguild.org"}/events/${eventData.slug || eventData._id}" class="button"> <a href="${process.env.BASE_URL || "https://ghostguild.org"}/events/${eventData.slug || eventData._id}" class="button">
View Event Details View Event Details
@ -261,3 +300,282 @@ export async function sendEventCancellationEmail(registration, eventData) {
return { success: false, error }; return { success: false, error };
} }
} }
/**
* Send series pass confirmation email
*/
export async function sendSeriesPassConfirmation(options) {
const { to, name, series, ticket, events, paymentId } = options;
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
const seriesTypeLabels = {
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
tournament: "Tournament",
};
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [to],
subject: `Your Series Pass for ${series.title}`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #fff;
padding: 30px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.pass-details {
background-color: #fff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #7c3aed;
}
.event-list {
background-color: #fff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.event-item {
padding: 15px;
margin: 10px 0;
background-color: #f9f9f9;
border-radius: 6px;
border-left: 3px solid #7c3aed;
}
.detail-row {
margin: 10px 0;
}
.label {
font-weight: 600;
color: #666;
font-size: 14px;
}
.value {
color: #1a1a2e;
font-size: 16px;
}
.member-benefit {
background-color: #dcfce7;
border: 2px solid #86efac;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
}
.footer {
text-align: center;
padding: 20px;
color: #666;
font-size: 14px;
}
.button {
display: inline-block;
background-color: #7c3aed;
color: #fff;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="header">
<h1 style="margin: 0; font-size: 28px;">🎫 Your Series Pass is Ready!</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">You're registered for all events</p>
</div>
<div class="content">
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p>
<p>
Great news! Your series pass for <strong>${series.title}</strong> is confirmed.
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
</p>
${
ticket.isFree && ticket.type === "member"
? `
<div class="member-benefit">
<p style="margin: 0; font-weight: 600; color: #166534;">
Member Benefit
</p>
<p style="margin: 5px 0 0 0; color: #166534;">
This series pass is free for Ghost Guild members. Thank you for being part of our community!
</p>
</div>
`
: ""
}
<div class="pass-details">
<h2 style="margin: 0 0 15px 0; color: #7c3aed; font-size: 20px;">Series Pass Details</h2>
<div class="detail-row">
<div class="label">Series</div>
<div class="value">${series.title}</div>
</div>
${
series.description
? `
<div class="detail-row">
<div class="label">About</div>
<div class="value">${series.description}</div>
</div>
`
: ""
}
<div class="detail-row">
<div class="label">Pass Type</div>
<div class="value">${ticket.type === "member" ? "Member Pass" : "Public Pass"}</div>
</div>
<div class="detail-row">
<div class="label">Amount Paid</div>
<div class="value">${formatPrice(ticket.price, ticket.currency)}</div>
</div>
${
paymentId
? `
<div class="detail-row">
<div class="label">Transaction ID</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div>
</div>
`
: ""
}
<div class="detail-row">
<div class="label">Total Events</div>
<div class="value">${events.length} events included</div>
</div>
</div>
<div class="event-list">
<h3 style="margin: 0 0 15px 0; color: #1a1a2e; font-size: 18px;">Your Event Schedule</h3>
<p style="color: #666; font-size: 14px; margin-bottom: 15px;">
You're automatically registered for all of these events:
</p>
${events
.map(
(event, index) => `
<div class="event-item">
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
Event ${index + 1}: ${event.title}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📅 ${formatDate(event.startDate)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
🕐 ${formatTime(event.startDate, event.endDate)}
</div>
<div style="font-size: 14px; color: #666; margin: 5px 0;">
📍 ${event.location}
</div>
</div>
`,
)
.join("")}
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="https://ghostguild.org/dashboard" class="button">
View in Dashboard
</a>
</div>
<div style="background-color: #fff; padding: 20px; border-radius: 8px; border: 1px solid #e5e7eb;">
<h3 style="margin: 0 0 10px 0; font-size: 16px;">What's Next?</h3>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>Mark these dates in your calendar</li>
<li>You'll receive individual reminders before each event</li>
<li>Event links and materials will be shared closer to each date</li>
<li>Join the conversation in our Slack community</li>
</ul>
</div>
</div>
<div class="footer">
<p style="margin: 10px 0;">
Questions? Email us at
<a href="mailto:events@babyghosts.org">events@babyghosts.org</a>
</p>
<p style="margin: 10px 0; color: #999; font-size: 12px;">
Ghost Guild Building solidarity economies in gaming
</p>
</div>
</body>
</html>
`,
});
if (error) {
console.error("Failed to send series pass confirmation email:", error);
return { success: false, error };
}
return { success: true, data };
} catch (error) {
console.error("Error sending series pass confirmation email:", error);
return { success: false, error };
}
}

750
server/utils/tickets.js Normal file
View file

@ -0,0 +1,750 @@
// Ticket business logic utilities
/**
* Calculate the applicable ticket price for a user
* @param {Object} event - Event document
* @param {Object} member - Member document (null if not a member)
* @returns {Object} { ticketType, price, currency, isEarlyBird }
*/
export const calculateTicketPrice = (event, member = null) => {
if (!event.tickets?.enabled) {
// Legacy pricing model
if (event.pricing?.paymentRequired && !event.pricing?.isFree) {
return {
ticketType: member ? "member" : "public",
price: member ? 0 : event.pricing.publicPrice,
currency: event.pricing.currency || "CAD",
isEarlyBird: false,
isFree: member ? true : event.pricing.publicPrice === 0,
};
}
return {
ticketType: "guest",
price: 0,
currency: "CAD",
isEarlyBird: false,
isFree: true,
};
}
const now = new Date();
// Member pricing
if (member && event.tickets.member?.available) {
const memberTicket = event.tickets.member;
let price = memberTicket.price || 0;
let isFree = memberTicket.isFree;
// Check for circle-specific overrides
if (memberTicket.circleOverrides && member.circle) {
const circleOverride = memberTicket.circleOverrides[member.circle];
if (circleOverride) {
if (circleOverride.isFree !== undefined) {
isFree = circleOverride.isFree;
}
if (circleOverride.price !== undefined) {
price = circleOverride.price;
}
}
}
return {
ticketType: "member",
price: isFree ? 0 : price,
currency: event.tickets.currency || "CAD",
isEarlyBird: false,
isFree,
name: memberTicket.name || "Member Ticket",
description: memberTicket.description,
};
}
// Public pricing
if (event.tickets.public?.available) {
const publicTicket = event.tickets.public;
let price = publicTicket.price || 0;
let isEarlyBird = false;
// Check for early bird pricing
if (
publicTicket.earlyBirdPrice !== undefined &&
publicTicket.earlyBirdDeadline &&
now < new Date(publicTicket.earlyBirdDeadline)
) {
price = publicTicket.earlyBirdPrice;
isEarlyBird = true;
}
return {
ticketType: "public",
price,
currency: event.tickets.currency || "CAD",
isEarlyBird,
isFree: price === 0,
name: publicTicket.name || "Public Ticket",
description: publicTicket.description,
};
}
// No tickets available (members only event, user not a member)
return null;
};
/**
* Check if tickets are available for an event
* @param {Object} event - Event document
* @param {String} ticketType - 'member' or 'public'
* @returns {Object} { available, remaining, waitlistAvailable }
*/
export const checkTicketAvailability = (event, ticketType = "public") => {
if (!event.tickets?.enabled) {
// Legacy registration system
if (!event.maxAttendees) {
return { available: true, remaining: null, waitlistAvailable: false };
}
const registered = event.registrations?.length || 0;
const remaining = event.maxAttendees - registered;
return {
available: remaining > 0,
remaining: Math.max(0, remaining),
waitlistAvailable: event.tickets?.waitlist?.enabled || false,
};
}
const registered = event.registrations?.length || 0;
// Check overall capacity first
if (event.tickets.capacity?.total) {
const totalRemaining = event.tickets.capacity.total - registered;
if (totalRemaining <= 0) {
return {
available: false,
remaining: 0,
waitlistAvailable: event.tickets.waitlist?.enabled || false,
};
}
}
// Check ticket-type specific availability
if (ticketType === "public" && event.tickets.public?.available) {
if (event.tickets.public.quantity) {
const sold = event.tickets.public.sold || 0;
const reserved = event.tickets.public.reserved || 0;
const remaining = event.tickets.public.quantity - sold - reserved;
return {
available: remaining > 0,
remaining: Math.max(0, remaining),
waitlistAvailable: event.tickets.waitlist?.enabled || false,
};
}
// Unlimited public tickets
return { available: true, remaining: null, waitlistAvailable: false };
}
if (ticketType === "member" && event.tickets.member?.available) {
// Members typically have unlimited access unless capacity is reached
if (event.tickets.capacity?.total) {
const totalRemaining = event.tickets.capacity.total - registered;
return {
available: totalRemaining > 0,
remaining: Math.max(0, totalRemaining),
waitlistAvailable: event.tickets.waitlist?.enabled || false,
};
}
// Unlimited member tickets
return { available: true, remaining: null, waitlistAvailable: false };
}
return { available: false, remaining: 0, waitlistAvailable: false };
};
/**
* Validate if a user can purchase a ticket
* @param {Object} event - Event document
* @param {Object} user - User data { email, name, member }
* @returns {Object} { valid, reason, ticketInfo }
*/
export const validateTicketPurchase = (event, user) => {
// Check if event is cancelled
if (event.isCancelled) {
return { valid: false, reason: "Event has been cancelled" };
}
// Check if event has passed
if (new Date(event.startDate) < new Date()) {
return { valid: false, reason: "Event has already started" };
}
// Check if registration deadline has passed
if (
event.registrationDeadline &&
new Date(event.registrationDeadline) < new Date()
) {
return { valid: false, reason: "Registration deadline has passed" };
}
// Check if user is already registered
const alreadyRegistered = event.registrations?.some(
(reg) =>
reg.email.toLowerCase() === user.email.toLowerCase() && !reg.cancelledAt,
);
if (alreadyRegistered) {
return {
valid: false,
reason: "You are already registered for this event",
};
}
// Check member-only restrictions
if (event.membersOnly && !user.member) {
return {
valid: false,
reason: "This event is for members only. Please join to register.",
};
}
// Calculate ticket price and check availability
const ticketInfo = calculateTicketPrice(event, user.member);
if (!ticketInfo) {
return {
valid: false,
reason: "No tickets available for your membership status",
};
}
const availability = checkTicketAvailability(event, ticketInfo.ticketType);
if (!availability.available) {
return {
valid: false,
reason: "Event is sold out",
waitlistAvailable: availability.waitlistAvailable,
};
}
return {
valid: true,
ticketInfo,
availability,
};
};
/**
* Reserve a ticket temporarily during checkout (prevents race conditions)
* @param {Object} event - Event document
* @param {String} ticketType - 'member' or 'public'
* @param {Number} ttl - Time to live in seconds (default 10 minutes)
* @returns {Object} { success, reservationId, expiresAt }
*/
export const reserveTicket = async (event, ticketType, ttl = 600) => {
const availability = checkTicketAvailability(event, ticketType);
if (!availability.available) {
return {
success: false,
reason: "No tickets available",
};
}
// Increment reserved count
if (ticketType === "public" && event.tickets.public) {
event.tickets.public.reserved = (event.tickets.public.reserved || 0) + 1;
}
if (event.tickets.capacity) {
event.tickets.capacity.reserved =
(event.tickets.capacity.reserved || 0) + 1;
}
await event.save();
const expiresAt = new Date(Date.now() + ttl * 1000);
return {
success: true,
reservationId: `${event._id}-${ticketType}-${Date.now()}`,
expiresAt,
};
};
/**
* Release a reserved ticket (if payment fails or user abandons checkout)
* @param {Object} event - Event document
* @param {String} ticketType - 'member' or 'public'
*/
export const releaseTicket = async (event, ticketType) => {
if (ticketType === "public" && event.tickets.public) {
event.tickets.public.reserved = Math.max(
0,
(event.tickets.public.reserved || 0) - 1,
);
}
if (event.tickets.capacity) {
event.tickets.capacity.reserved = Math.max(
0,
(event.tickets.capacity.reserved || 0) - 1,
);
}
await event.save();
};
/**
* Complete ticket purchase (after successful payment)
* @param {Object} event - Event document
* @param {String} ticketType - 'member' or 'public'
*/
export const completeTicketPurchase = async (event, ticketType) => {
// Decrement reserved count
await releaseTicket(event, ticketType);
// Increment sold count for public tickets
if (ticketType === "public" && event.tickets.public) {
event.tickets.public.sold = (event.tickets.public.sold || 0) + 1;
}
await event.save();
};
/**
* Add user to waitlist
* @param {Object} event - Event document
* @param {Object} userData - { name, email, membershipLevel }
* @returns {Object} { success, position }
*/
export const addToWaitlist = async (event, userData) => {
if (!event.tickets?.waitlist?.enabled) {
return { success: false, reason: "Waitlist is not enabled for this event" };
}
// Check if already on waitlist
const alreadyOnWaitlist = event.tickets.waitlist.entries?.some(
(entry) => entry.email.toLowerCase() === userData.email.toLowerCase(),
);
if (alreadyOnWaitlist) {
return { success: false, reason: "You are already on the waitlist" };
}
// Check waitlist capacity
const maxSize = event.tickets.waitlist.maxSize;
const currentSize = event.tickets.waitlist.entries?.length || 0;
if (maxSize && currentSize >= maxSize) {
return { success: false, reason: "Waitlist is full" };
}
// Add to waitlist
if (!event.tickets.waitlist.entries) {
event.tickets.waitlist.entries = [];
}
event.tickets.waitlist.entries.push({
name: userData.name,
email: userData.email.toLowerCase(),
membershipLevel: userData.membershipLevel || "non-member",
addedAt: new Date(),
notified: false,
});
await event.save();
return {
success: true,
position: event.tickets.waitlist.entries.length,
};
};
/**
* Format price for display
* @param {Number} price - Price in cents or dollars
* @param {String} currency - Currency code (default CAD)
* @returns {String} Formatted price string
*/
export const formatPrice = (price, currency = "CAD") => {
if (price === 0) return "Free";
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(price);
};
// ============================================================================
// SERIES TICKET FUNCTIONS
// ============================================================================
/**
* Calculate the applicable series pass price for a user
* @param {Object} series - Series document
* @param {Object} member - Member document (null if not a member)
* @returns {Object} { ticketType, price, currency, isEarlyBird }
*/
export const calculateSeriesTicketPrice = (series, member = null) => {
if (!series.tickets?.enabled) {
return {
ticketType: "guest",
price: 0,
currency: "CAD",
isEarlyBird: false,
isFree: true,
};
}
const now = new Date();
// Member pricing
if (member && series.tickets.member?.available) {
const memberTicket = series.tickets.member;
let price = memberTicket.price || 0;
let isFree = memberTicket.isFree;
// Check for circle-specific overrides
if (memberTicket.circleOverrides && member.circle) {
const circleOverride = memberTicket.circleOverrides[member.circle];
if (circleOverride) {
if (circleOverride.isFree !== undefined) {
isFree = circleOverride.isFree;
}
if (circleOverride.price !== undefined) {
price = circleOverride.price;
}
}
}
return {
ticketType: "member",
price: isFree ? 0 : price,
currency: series.tickets.currency || "CAD",
isEarlyBird: false,
isFree,
name: memberTicket.name || "Member Series Pass",
description: memberTicket.description,
};
}
// Public pricing
if (series.tickets.public?.available) {
const publicTicket = series.tickets.public;
let price = publicTicket.price || 0;
let isEarlyBird = false;
// Check for early bird pricing
if (
publicTicket.earlyBirdPrice !== undefined &&
publicTicket.earlyBirdDeadline &&
now < new Date(publicTicket.earlyBirdDeadline)
) {
price = publicTicket.earlyBirdPrice;
isEarlyBird = true;
}
return {
ticketType: "public",
price,
currency: series.tickets.currency || "CAD",
isEarlyBird,
isFree: price === 0,
name: publicTicket.name || "Series Pass",
description: publicTicket.description,
};
}
// No tickets available
return null;
};
/**
* Check if series passes are available
* @param {Object} series - Series document
* @param {String} ticketType - 'member' or 'public'
* @returns {Object} { available, remaining, waitlistAvailable }
*/
export const checkSeriesTicketAvailability = (
series,
ticketType = "public",
) => {
if (!series.tickets?.enabled) {
return { available: false, remaining: 0, waitlistAvailable: false };
}
const registered =
series.registrations?.filter((r) => !r.cancelledAt).length || 0;
// Check overall capacity first
if (series.tickets.capacity?.total) {
const totalRemaining = series.tickets.capacity.total - registered;
if (totalRemaining <= 0) {
return {
available: false,
remaining: 0,
waitlistAvailable: series.tickets.waitlist?.enabled || false,
};
}
}
// Check ticket-type specific availability
if (ticketType === "public" && series.tickets.public?.available) {
if (series.tickets.public.quantity) {
const sold = series.tickets.public.sold || 0;
const reserved = series.tickets.public.reserved || 0;
const remaining = series.tickets.public.quantity - sold - reserved;
return {
available: remaining > 0,
remaining: Math.max(0, remaining),
waitlistAvailable: series.tickets.waitlist?.enabled || false,
};
}
// Unlimited public tickets
return { available: true, remaining: null, waitlistAvailable: false };
}
if (ticketType === "member" && series.tickets.member?.available) {
// Members typically have unlimited access unless capacity is reached
if (series.tickets.capacity?.total) {
const totalRemaining = series.tickets.capacity.total - registered;
return {
available: totalRemaining > 0,
remaining: Math.max(0, totalRemaining),
waitlistAvailable: series.tickets.waitlist?.enabled || false,
};
}
// Unlimited member tickets
return { available: true, remaining: null, waitlistAvailable: false };
}
return { available: false, remaining: 0, waitlistAvailable: false };
};
/**
* Validate if a user can purchase a series pass
* @param {Object} series - Series document
* @param {Object} user - User data { email, name, member }
* @returns {Object} { valid, reason, ticketInfo }
*/
export const validateSeriesTicketPurchase = (series, user) => {
// Check if series is active
if (!series.isActive) {
return { valid: false, reason: "This series is not currently available" };
}
// Check if user is already registered for the series
const alreadyRegistered = series.registrations?.some(
(reg) =>
reg.email.toLowerCase() === user.email.toLowerCase() && !reg.cancelledAt,
);
if (alreadyRegistered) {
return { valid: false, reason: "You already have a pass for this series" };
}
// Calculate ticket price and check availability
const ticketInfo = calculateSeriesTicketPrice(series, user.member);
if (!ticketInfo) {
return {
valid: false,
reason: "No series passes available for your membership status",
};
}
const availability = checkSeriesTicketAvailability(
series,
ticketInfo.ticketType,
);
if (!availability.available) {
return {
valid: false,
reason: "Series passes are sold out",
waitlistAvailable: availability.waitlistAvailable,
};
}
return {
valid: true,
ticketInfo,
availability,
};
};
/**
* Reserve a series pass temporarily during checkout
* @param {Object} series - Series document
* @param {String} ticketType - 'member' or 'public'
* @param {Number} ttl - Time to live in seconds (default 10 minutes)
* @returns {Object} { success, reservationId, expiresAt }
*/
export const reserveSeriesTicket = async (series, ticketType, ttl = 600) => {
const availability = checkSeriesTicketAvailability(series, ticketType);
if (!availability.available) {
return {
success: false,
reason: "No series passes available",
};
}
// Increment reserved count
if (ticketType === "public" && series.tickets.public) {
series.tickets.public.reserved = (series.tickets.public.reserved || 0) + 1;
}
if (series.tickets.capacity) {
series.tickets.capacity.reserved =
(series.tickets.capacity.reserved || 0) + 1;
}
await series.save();
const expiresAt = new Date(Date.now() + ttl * 1000);
return {
success: true,
reservationId: `${series._id}-${ticketType}-${Date.now()}`,
expiresAt,
};
};
/**
* Release a reserved series pass
* @param {Object} series - Series document
* @param {String} ticketType - 'member' or 'public'
*/
export const releaseSeriesTicket = async (series, ticketType) => {
if (ticketType === "public" && series.tickets.public) {
series.tickets.public.reserved = Math.max(
0,
(series.tickets.public.reserved || 0) - 1,
);
}
if (series.tickets.capacity) {
series.tickets.capacity.reserved = Math.max(
0,
(series.tickets.capacity.reserved || 0) - 1,
);
}
await series.save();
};
/**
* Complete series pass purchase (after successful payment)
* @param {Object} series - Series document
* @param {String} ticketType - 'member' or 'public'
*/
export const completeSeriesTicketPurchase = async (series, ticketType) => {
// Decrement reserved count
await releaseSeriesTicket(series, ticketType);
// Increment sold count for public tickets
if (ticketType === "public" && series.tickets.public) {
series.tickets.public.sold = (series.tickets.public.sold || 0) + 1;
}
await series.save();
};
/**
* Check if a user has a valid series pass for a series
* @param {Object} series - Series document
* @param {String} userEmail - User email to check
* @returns {Object} { hasPass, registration }
*/
export const checkUserSeriesPass = (series, userEmail) => {
const registration = series.registrations?.find(
(reg) =>
reg.email.toLowerCase() === userEmail.toLowerCase() &&
!reg.cancelledAt &&
reg.paymentStatus !== "failed",
);
return {
hasPass: !!registration,
registration: registration || null,
};
};
/**
* Register user for all events in a series using their series pass
* @param {Object} series - Series document
* @param {Array} events - Array of event documents in the series
* @param {Object} seriesRegistration - The series pass registration
* @returns {Array} Array of event registration results
*/
export const registerForAllSeriesEvents = async (
series,
events,
seriesRegistration,
) => {
const results = [];
for (const event of events) {
try {
// Check if already registered for this event
const alreadyRegistered = event.registrations?.some(
(reg) =>
reg.email.toLowerCase() === seriesRegistration.email.toLowerCase() &&
!reg.cancelledAt,
);
if (alreadyRegistered) {
results.push({
eventId: event._id,
success: false,
reason: "Already registered",
});
continue;
}
// Add registration to event
event.registrations.push({
memberId: seriesRegistration.memberId,
name: seriesRegistration.name,
email: seriesRegistration.email,
membershipLevel: seriesRegistration.membershipLevel,
isMember: seriesRegistration.isMember,
ticketType: "series_pass",
ticketPrice: 0, // Already paid at series level
isSeriesTicketHolder: true,
seriesTicketId: series._id,
paymentStatus: "not_required", // Paid at series level
registeredAt: new Date(),
});
await event.save();
// Update series registration with event reference
const eventReg = event.registrations[event.registrations.length - 1];
seriesRegistration.eventRegistrations.push({
eventId: event._id,
registrationId: eventReg._id,
});
results.push({
eventId: event._id,
success: true,
registrationId: eventReg._id,
});
} catch (error) {
results.push({
eventId: event._id,
success: false,
reason: error.message,
});
}
}
// Save series with updated event registrations
await series.save();
return results;
};