Add landing page
This commit is contained in:
parent
3fea484585
commit
bce86ee840
47 changed files with 7119 additions and 439 deletions
149
HELCIM_PAYMENT_FIX.md
Normal file
149
HELCIM_PAYMENT_FIX.md
Normal 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
|
||||||
284
HELCIM_TICKET_INTEGRATION.md
Normal file
284
HELCIM_TICKET_INTEGRATION.md
Normal 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)
|
||||||
400
SERIES_TICKETING_IMPLEMENTATION.md
Normal file
400
SERIES_TICKETING_IMPLEMENTATION.md
Normal 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)
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
264
app/components/EventSeriesTicketCard.vue
Normal file
264
app/components/EventSeriesTicketCard.vue
Normal 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>
|
||||||
192
app/components/EventTicketCard.vue
Normal file
192
app/components/EventTicketCard.vue
Normal 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>
|
||||||
412
app/components/EventTicketPurchase.vue
Normal file
412
app/components/EventTicketPurchase.vue
Normal 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>
|
||||||
333
app/components/SeriesPassPurchase.vue
Normal file
333
app/components/SeriesPassPurchase.vue
Normal 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>
|
||||||
|
|
@ -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,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
5
app/layouts/coming-soon.vue
Normal file
5
app/layouts/coming-soon.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-ghost-900">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
18
app/middleware/coming-soon.global.js
Normal file
18
app/middleware/coming-soon.global.js
Normal 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");
|
||||||
|
});
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
14
app/pages/coming-soon.vue
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
251
docs/TICKET_SETUP_GUIDE.md
Normal 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.
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
364
scripts/add-coop-values-series.js
Normal file
364
scripts/add-coop-values-series.js
Normal 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
334
scripts/add-to-remote-db.js
Normal 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
32
scripts/check-events.js
Normal 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();
|
||||||
31
scripts/check-old-event.js
Normal file
31
scripts/check-old-event.js
Normal 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
27
scripts/check-series.js
Normal 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();
|
||||||
27
scripts/cleanup-old-event.js
Normal file
27
scripts/cleanup-old-event.js
Normal 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();
|
||||||
94
scripts/create-correct-series.js
Normal file
94
scripts/create-correct-series.js
Normal 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
34
scripts/debug-series.js
Normal 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
42
scripts/diagnose-query.js
Normal 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
96
scripts/fix-series-id.js
Normal 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();
|
||||||
37
scripts/list-all-series-events.js
Normal file
37
scripts/list-all-series-events.js
Normal 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
99
scripts/merge-series.js
Normal 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();
|
||||||
|
|
@ -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",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
|
||||||
136
server/api/admin/series/tickets.put.js
Normal file
136
server/api/admin/series/tickets.put.js
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
104
server/api/events/[id]/check-series-access.get.js
Normal file
104
server/api/events/[id]/check-series-access.get.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
155
server/api/events/[id]/tickets/available.get.js
Normal file
155
server/api/events/[id]/tickets/available.get.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
54
server/api/events/[id]/tickets/check-eligibility.post.js
Normal file
54
server/api/events/[id]/tickets/check-eligibility.post.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
162
server/api/events/[id]/tickets/purchase.post.js
Normal file
162
server/api/events/[id]/tickets/purchase.post.js
Normal 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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
94
server/api/events/[id]/tickets/reserve.post.js
Normal file
94
server/api/events/[id]/tickets/reserve.post.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
|
||||||
118
server/api/series/[id]/tickets/available.get.js
Normal file
118
server/api/series/[id]/tickets/available.get.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
38
server/api/series/[id]/tickets/check-eligibility.post.js
Normal file
38
server/api/series/[id]/tickets/check-eligibility.post.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
163
server/api/series/[id]/tickets/purchase.post.js
Normal file
163
server/api/series/[id]/tickets/purchase.post.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
750
server/utils/tickets.js
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue